2026-06-15 22:29:12 +00:00

webnet

A TypeScript monorepo for transport-based networking, anchored by a WebAssembly Tailscale SDK. It provides a layered stack of packages — from raw transport abstractions up through HTTP, WebSocket, and WebDAV — that work in browsers, Node.js, and any environment that can supply a transport.

Inspiration

This is heavily inspired by the WebVM networking stack that levrages Tailscale in the browser. Note that this doesn't lift any code from that project, only ideas.

Another inspiration is the ElysiaJS documentation that seemingly allows running a webserver from the docs directly in the browser (even if it is just a feature of the framework itself and not real networking).

How it works

Tailscale already ships a tsconnect package that compiles the IPN (in-process networking) stack to WASM via GOARCH=wasm. This repo builds on top of that with a layered set of packages:

  1. Patching tsconnect: the tailscale submodule tracks a fork on the webnet branch that extends the Go-to-JS bridge (wasm_js.go) to expose lower-level networking primitives: raw TCP/UDP connections, ICMP, TLS dialing, and TCP listening.
  2. @webnet/transport: declares the RawTransport, RawListener, and RawDialer interfaces, plus buffer utilities and transport implementations that have no external dependencies — a loopback transport and a Node.js streams adapter.
  3. @webnet/tsconnect: builds the WASM artifact, ships it alongside a Mozilla CA bundle and wasm_exec.js, and wraps the raw JS bridge in typed TypeScript classes. Its Conn, TCPListener, and IPNDialer implement the @webnet/transport interfaces, making it a drop-in transport source for the rest of the stack.
  4. @webnet/tsconnect-redux / @webnet/tsconnect-react: Redux Toolkit (RTK) slice and React hooks/context for IPN state management and control, extracted from the core SDK so consumers can bring their own UI framework.
  5. @webnet/http: a full HTTP/1.1 client and server over any @webnet/transport implementation. Features include request/response streaming, chunked transfer encoding, keep-alive, a client connection pool, automatic redirect following, and a Koa-inspired middleware router.
  6. @webnet/websocket: WebSocket client and server built on @webnet/http. Handles the upgrade handshake, frame codec, masking, fragmented-message reassembly, ping/pong, and the close handshake — no external dependencies.
  7. @webnet/drive: WebDAV Level 1 (and optionally Level 2) client and server built on @webnet/http. Includes an async VFS abstraction with MemoryVFS, NodeVFS, and FsaVFS (with an OPFS factory method) implementations, a createDAVHandler() server handler, and a DAVClient that itself implements AsyncVFS.

Packages

Package Description
packages/transport Transport interfaces (RawTransport, RawListener, RawDialer), loopback and Node.js implementations
packages/tsconnect Tailscale WASM SDK — IPN lifecycle, typed TS wrappers, transport implementation
packages/tsconnect-redux RTK slice and thunks for IPN state management and control
packages/tsconnect-react React hooks and context for IPN state management and control
packages/http HTTP/1.1 client and server, connection pool, redirect following, Koa-inspired router
packages/websocket WebSocket client and server based on @webnet/http
packages/drive WebDAV (Level 1 + optional Level 2) client and server based on @webnet/http
packages/xml Thin XML parse/serialize with conditional exports (native DOM / @xmldom/xmldom)
packages/test-app Vite dev app for manual browser testing
packages/example-app Example app demonstrating the full stack

Submodules

The tailscale/ directory is a git submodule pointing to a fork of the Tailscale repository. The webnet branch on that fork contains the Go-side patches to tsconnect.

git submodule update --init tailscale

Development

# Build the WASM and TypeScript declarations
npm run build --workspace=packages/tsconnect

# Start the test app
npm run dev --workspace=packages/test-app

# Lint and format
npm run lint
npm run format

Commits must follow the Conventional Commits spec. ESLint and Prettier are also required to pass. This is enforced at commit time by husky with commitlint and lint-staged.

AI disclosure

This project was set up with the assistance of Claude Code (Anthropic). The following were written by Claude Code:

  • The tailscale submodule fork and its Go-side tsconnect patches (the webnet branch)
  • The packages/tsconnect TypeScript SDK
  • The packages/test-app Vite test application
  • The repo tooling setup (ESLint, Prettier, lint-staged, commitlint, dpdm, TypeScript 6)
  • AGENTS.md — the agent guidelines file (worktree/submodule rules, commit-trailer convention, PR finalisation checklist)

The following were hand-written:

  • The packages/http HTTP/1.1 server library (HTTP parsing, server/client connection management, chunked transfer encoding, router/middleware system, response serialisation)

The following were hand-written but with substantial Claude Code contributions:

  • packages/http — low-level transport layer: reviewed and significantly reworked by Claude Code, fixing multiple bugs (errcallback not cleared after a successful read, ServerConnection draining the body after closing the connection, PooledDialer throwing instead of queuing waiters, shouldClose() doing a case-sensitive header comparison) and adding transport primitives (halfClose, readEnded, whenClosed, remoteAddr, localAddr)
  • packages/http — timeout support: the headersTimeout, keepAliveTimeout, and bodyTimeout options across both client and server were implemented by Claude Code
  • packages/http — parse error messages: improved by Claude Code to include the offending values
  • packages/http — package and build scaffolding: initial package.json, tsconfig.json, and build configuration were set up by Claude Code

The test suite for packages/http was mostly generated by Claude Code, which also identified and fixed several further bugs: a body-size-limit error being swallowed on the final body chunk, a stale idle-connection entry left behind by rejectConnection, and a read() call hanging indefinitely after a socket close.

  • packages/httphijack() on server and client responses: the hijack() method on ServerResponse and ClientResponse, the ReadBuffer.drain() helper, and the prependTransport() utility were implemented by Claude Code
  • packages/http — 1xx informational response support: implemented by Claude Code. Server side: automatic 100 Continue (sent lazily when the handler reads the body) and res.sendInformational() for 103 Early Hints etc. Client side: default skip mode, interim: "collect" to capture 1xx into res.informational[], conn.requestStream() async generator that yields each interim response and the final one as they arrive, and fetchStream() / f.stream() to expose the same streaming behaviour through the fetch API with proper connection pool management.
  • packages/http — WebSocket support: implemented by Claude Code. upgradeWebSocket(req, res) for server-side handshake; connectWebSocket(dialer, url, options?) to open a new WebSocket connection, or connectWebSocket(res, key) to promote an existing fetch()/fetchStream() 101 response — both return a WebSocketConnection async iterable. Frame codec (read/write), masking, fragmented-message reassembly, ping/pong, and the close handshake are all implemented from scratch using the Web Crypto API (crypto.subtle.digest for SHA-1, crypto.getRandomValues for mask keys) with no external dependencies. Two fixed bugs in fetch.ts were required for pool safety: a case-insensitive Connection: upgrade check and immediate pool ejection on 101 to prevent a microtask race before hijack. Exported as three tree-shakeable entry points: @webnet/http/websocket (combined), @webnet/http/websocket/client, and @webnet/http/websocket/server.
  • packages/http — 3xx redirect support: implemented by Claude Code. redirect.mode ("manual" / "same-connection" / "same-origin" / "follow"), redirect.max, redirect.filter (string array / Set / callback), redirect.credentials ("keep" / "strip-cross-origin" / "strip"), redirect.body ("resubmit" / "strip-non-resubmit" / "strip"), and redirect.collect to gather followed 3xx into res.redirects[]. In streaming mode all redirects and interims are yielded as they arrive; a drain() method was added to ClientConnection to support clean connection hand-off between redirect hops.
  • packages/http — lint fix and WebSocket test coverage: Claude Code fixed a prefer-const lint error in ServerConnection by refactoring HijackFn to accept res as a parameter (eliminating a forward-reference let res! pattern), updated the ESLint config to recognise _-prefixed variables as intentionally unused (varsIgnorePattern), and added test coverage for extended-length WebSocket frames (2-byte and 8-byte), multi-chunk ReadBuffer slicing, empty close frames, unsolicited PONG frames, invalid port URLs, and the fetchStream() 101 early-break path.
  • packages/http — typed context extension via next(extra): the Router<T> generic, the Next<TAdd> conditional type, and the context-passing chain were designed and implemented by Claude Code
  • Monorepo restructuring: the following packages were created or substantially reworked by Claude Code — @webnet/transport (transport abstractions, buffer utilities, node and loopback implementations extracted from @webnet/http); @webnet/websocket (WebSocket support extracted from @webnet/http); @webnet/tsconnect-redux (Redux state bindings extracted from @webnet/tsconnect); @webnet/tsconnect-react (React hooks and context extracted from @webnet/tsconnect). @webnet/http's Server class was refactored to take a Handler argument instead of extending Router; Router was moved to a ./router sub-export. @webnet/tsconnect's Conn and TCPListener were updated to formally implement RawTransport and RawListener; IPNDialer implementing RawDialer was added.
  • @webnet/tsconnect — drop pre-compression of assets: Claude Code removed the gzip/brotli pre-compression of main.wasm (from the tsconnect fork's build-pkg) and cacert.pem (from packages/tsconnect/build.sh), dropping the corresponding .br/.gz package exports and adding scripts/asset-sizes.sh (exposed as npm run asset-sizes) to let consumers measure raw, gzip, and brotli sizes of the built assets.
  • packages/xml: a thin XML parse/serialize package with conditional exports — browser builds use the native DOMParser, Node.js builds use @xmldom/xmldom. Authored by Claude Code.
  • packages/drive: WebDAV client and server with an async VFS abstraction. Includes MemoryVFS, NodeVFS (node:fs), FsaVFS (browser File System Access API, with an OPFS factory method), createDAVHandler() (server-side HTTP handler compatible with @webnet/http), and DAVClient (which implements AsyncVFS so it can be used as a backing store for another server instance). Full WebDAV Level 2 locking support (LockStore interface, InMemoryLockStore, LOCK/UNLOCK methods, If: header enforcement, lockdiscovery/supportedlock properties, and client-side lock()/unlock()/refreshLock() with optional lockToken on all mutating methods). Authored by Claude Code.
  • @webnet/tsconnect — streaming Taildrop: Claude Code removed all full-file buffering from the Taildrop send and receive paths. IPN.sendFile now accepts a ReadableStream<Uint8Array> + declaredSize; IPN.openWaitingFile returns Promise<ReadableStream<Uint8Array>>. On the Go/WASM side, a new jsStreamReader (io.ReadCloser) pulls chunks from a JS ReadableStreamDefaultReader via awaited .read() Promises (channel+js.FuncOf pattern), and jsReadableStream wraps a Go io.ReadCloser in a pull-based JS ReadableStream. UserIPNFileOps.openReader now returns a ReadableStream instead of a Uint8Array. A new FsaFileOps class (with FsaFileOps.createFromOpfs()) provides an OPFS-backed UserIPNFileOps where received chunks land directly on disk and downloads stream back through Go without buffering. InMemoryFileOps.openReader was updated to emit stored chunks one-by-one via a ReadableStream.
  • @webnet/tsconnectIPN.shutdown(): Claude Code added a shutdown() method to IPN (TypeScript) and jsIPN (Go/WASM). Calling it stops the LocalBackend, closes the safesocket listener to unblock srv.Run, and signals main() to return so the Go runtime exits. The TypeScript side awaits the go.run() Promise (captured in initIPN and threaded into each IPN) as the authoritative "Go runtime has exited" signal, avoiding a race where Go deletes _inst before a callback-based resolve could fire. Go-side nil guards were added for lb and ln so shutdown() is safe to call even when run() was never invoked.
  • @webnet/tsconnect — multi-environment WASM loading and Node.js fixes: Claude Code investigated Worker/SharedWorker and Node.js/Bun compatibility. The Go WASM and wasm_exec.js are already Worker-compatible (js.Global() maps to the Worker's globalThis; wasm_exec.js stubs fs/process/path when absent). Three issues were found and fixed for Node.js:
    1. initIPN accepted only a URL string and called fetch(), which does not support file:// URLs in Node.js. Claude Code added a WasmSource union type (string | URL | ArrayBuffer | ArrayBufferView | Response | ReadableStream<Uint8Array>) dispatching to WebAssembly.instantiate for binary sources and WebAssembly.instantiateStreaming for URL/Response/ReadableStream inputs.
    2. wasm_exec.js installs ENOSYS stubs for globalThis.fs when it is falsy. In Node.js globalThis.fs is undefined, so the stubs are installed — and this is the correct behaviour: tsconnect's WASM routes all network calls through JavaScript's fetch() and WebSocket APIs (which use Node.js's own DNS resolver), so the Go net package's /etc/resolv.conf read should remain a no-op. An earlier iteration set globalThis.fs to Node.js's real fs, which caused Go to read the host's /etc/resolv.conf directly and attempt to use those nameservers, breaking in environments where they are unreachable (e.g. Tailscale-managed entries without Tailscale running). wasm_exec.js is now imported directly in index.ts and Go is extracted from globalThis inline — a separate env-node.ts/env-web.ts split was explored but both files were identical, so the indirection was removed.
    3. In the tailscale submodule, two Go-side bugs were fixed: safesocket_js.go used a hardcoded memconn address "Tailscale-IPN", so a second newIPN() call in the same WASM process would log.Fatal; an atomic counter now gives each instance a unique address. And wasm_js.go's listen() rejected the standard ":0" (any-interface) address form that netstack does not accept; it now normalises :port to 0.0.0.0:port. Claude Code also wrote the full automated test suite for @webnet/tsconnect: unit tests for InMemoryFileOps and InMemoryState (no WASM required), and integration tests that spin up real Tailscale nodes against a headscale control server, verifying initIPN WASM loading, /localapi/v0/status, and two-node TCP dial/listen.
  • @webnet/tsconnectgetIceServers(): Claude Code implemented getIceServers(ipn), which fetches the tailnet's DERPMap via LocalAPI and converts it to an RTCIceServer[] list for use with new RTCPeerConnection({ iceServers }). DERP servers run an integrated RFC 5389-compliant STUN server on UDP 3478 by default; since tsconnect in the browser cannot use raw UDP (all traffic goes through DERP over WebSocket), WebRTC is the only way to establish a direct peer-to-peer connection. Also added DERPMap, DERPRegion, and DERPNode types. Unit and integration tests included.
  • @webnet/tsconnect — service advertisement: Claude Code added SetExplicitServices to LocalBackend in the tailscale fork (bypassing the OS portlist gate), wired a setServices(services) WASM binding, and added a services field to the netmap JSON for both self and peers (stripping internal peerapi entries). On the TypeScript side: IPNService type, services: IPNService[] on IPNNetMapNode, and IPN.setServices(). In @webnet/tsconnect-redux: getPeersByService, getPeersByServiceDescription (both memoized with createSelector), and getSelfServices (referentially stable empty-array fallback).
S
Description
No description provided
Readme
3.3 MiB
Languages
TypeScript 98.8%
JavaScript 0.6%
Shell 0.2%
SCSS 0.2%
HTML 0.1%