Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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:
- Patching tsconnect: the
tailscalesubmodule tracks a fork on thewebnetbranch 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. @webnet/transport: declares theRawTransport,RawListener, andRawDialerinterfaces, plus buffer utilities and transport implementations that have no external dependencies — a loopback transport and a Node.js streams adapter.@webnet/tsconnect: builds the WASM artifact, ships it alongside a Mozilla CA bundle andwasm_exec.js, and wraps the raw JS bridge in typed TypeScript classes. ItsConn,TCPListener, andIPNDialerimplement the@webnet/transportinterfaces, making it a drop-in transport source for the rest of the stack.@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.@webnet/http: a full HTTP/1.1 client and server over any@webnet/transportimplementation. Features include request/response streaming, chunked transfer encoding, keep-alive, a client connection pool, automatic redirect following, and a Koa-inspired middleware router.@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.@webnet/drive: WebDAV Level 1 (and optionally Level 2) client and server built on@webnet/http. Includes an async VFS abstraction withMemoryVFS,NodeVFS, andFsaVFS(with an OPFS factory method) implementations, acreateDAVHandler()server handler, and aDAVClientthat itself implementsAsyncVFS.
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
tailscalesubmodule fork and its Go-sidetsconnectpatches (thewebnetbranch) - The
packages/tsconnectTypeScript SDK - The
packages/test-appVite 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/httpHTTP/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,ServerConnectiondraining the body after closing the connection,PooledDialerthrowing instead of queuing waiters,shouldClose()doing a case-sensitive header comparison) and adding transport primitives (halfClose,readEnded,whenClosed,remoteAddr,localAddr)packages/http— timeout support: theheadersTimeout,keepAliveTimeout, andbodyTimeoutoptions across both client and server were implemented by Claude Codepackages/http— parse error messages: improved by Claude Code to include the offending valuespackages/http— package and build scaffolding: initialpackage.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/http—hijack()on server and client responses: thehijack()method onServerResponseandClientResponse, theReadBuffer.drain()helper, and theprependTransport()utility were implemented by Claude Codepackages/http— 1xx informational response support: implemented by Claude Code. Server side: automatic100 Continue(sent lazily when the handler reads the body) andres.sendInformational()for 103 Early Hints etc. Client side: default skip mode,interim: "collect"to capture 1xx intores.informational[],conn.requestStream()async generator that yields each interim response and the final one as they arrive, andfetchStream()/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, orconnectWebSocket(res, key)to promote an existingfetch()/fetchStream()101 response — both return aWebSocketConnectionasync 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.digestfor SHA-1,crypto.getRandomValuesfor mask keys) with no external dependencies. Two fixed bugs infetch.tswere required for pool safety: a case-insensitiveConnection: upgradecheck 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"), andredirect.collectto gather followed 3xx intores.redirects[]. In streaming mode all redirects and interims are yielded as they arrive; adrain()method was added toClientConnectionto support clean connection hand-off between redirect hops.packages/http— lint fix and WebSocket test coverage: Claude Code fixed aprefer-constlint error inServerConnectionby refactoringHijackFnto acceptresas a parameter (eliminating a forward-referencelet 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-chunkReadBufferslicing, empty close frames, unsolicited PONG frames, invalid port URLs, and thefetchStream()101 early-break path.packages/http— typed context extension vianext(extra): theRouter<T>generic, theNext<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'sServerclass was refactored to take aHandlerargument instead of extendingRouter;Routerwas moved to a./routersub-export.@webnet/tsconnect'sConnandTCPListenerwere updated to formally implementRawTransportandRawListener;IPNDialerimplementingRawDialerwas added. @webnet/tsconnect— drop pre-compression of assets: Claude Code removed the gzip/brotli pre-compression ofmain.wasm(from thetsconnectfork's build-pkg) andcacert.pem(frompackages/tsconnect/build.sh), dropping the corresponding.br/.gzpackage exports and addingscripts/asset-sizes.sh(exposed asnpm 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 nativeDOMParser, Node.js builds use@xmldom/xmldom. Authored by Claude Code.packages/drive: WebDAV client and server with an async VFS abstraction. IncludesMemoryVFS,NodeVFS(node:fs),FsaVFS(browser File System Access API, with an OPFS factory method),createDAVHandler()(server-side HTTP handler compatible with@webnet/http), andDAVClient(which implementsAsyncVFSso it can be used as a backing store for another server instance). Full WebDAV Level 2 locking support (LockStoreinterface,InMemoryLockStore, LOCK/UNLOCK methods,If:header enforcement,lockdiscovery/supportedlockproperties, and client-sidelock()/unlock()/refreshLock()with optionallockTokenon 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.sendFilenow accepts aReadableStream<Uint8Array>+declaredSize;IPN.openWaitingFilereturnsPromise<ReadableStream<Uint8Array>>. On the Go/WASM side, a newjsStreamReader(io.ReadCloser) pulls chunks from a JSReadableStreamDefaultReadervia awaited.read()Promises (channel+js.FuncOfpattern), andjsReadableStreamwraps a Goio.ReadCloserin a pull-based JSReadableStream.UserIPNFileOps.openReadernow returns aReadableStreaminstead of aUint8Array. A newFsaFileOpsclass (withFsaFileOps.createFromOpfs()) provides an OPFS-backedUserIPNFileOpswhere received chunks land directly on disk and downloads stream back through Go without buffering.InMemoryFileOps.openReaderwas updated to emit stored chunks one-by-one via aReadableStream.@webnet/tsconnect—IPN.shutdown(): Claude Code added ashutdown()method toIPN(TypeScript) andjsIPN(Go/WASM). Calling it stops theLocalBackend, closes the safesocket listener to unblocksrv.Run, and signalsmain()to return so the Go runtime exits. The TypeScript side awaits thego.run()Promise (captured ininitIPNand threaded into eachIPN) as the authoritative "Go runtime has exited" signal, avoiding a race where Go deletes_instbefore a callback-based resolve could fire. Go-side nil guards were added forlbandlnsoshutdown()is safe to call even whenrun()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 andwasm_exec.jsare already Worker-compatible (js.Global()maps to the Worker'sglobalThis;wasm_exec.jsstubsfs/process/pathwhen absent). Three issues were found and fixed for Node.js:initIPNaccepted only a URL string and calledfetch(), which does not supportfile://URLs in Node.js. Claude Code added aWasmSourceunion type (string | URL | ArrayBuffer | ArrayBufferView | Response | ReadableStream<Uint8Array>) dispatching toWebAssembly.instantiatefor binary sources andWebAssembly.instantiateStreamingfor URL/Response/ReadableStream inputs.wasm_exec.jsinstalls ENOSYS stubs forglobalThis.fswhen it is falsy. In Node.jsglobalThis.fsis undefined, so the stubs are installed — and this is the correct behaviour: tsconnect's WASM routes all network calls through JavaScript'sfetch()and WebSocket APIs (which use Node.js's own DNS resolver), so the Go net package's/etc/resolv.confread should remain a no-op. An earlier iteration setglobalThis.fsto Node.js's realfs, which caused Go to read the host's/etc/resolv.confdirectly and attempt to use those nameservers, breaking in environments where they are unreachable (e.g. Tailscale-managed entries without Tailscale running).wasm_exec.jsis now imported directly inindex.tsandGois extracted fromglobalThisinline — a separateenv-node.ts/env-web.tssplit was explored but both files were identical, so the indirection was removed.- In the
tailscalesubmodule, two Go-side bugs were fixed:safesocket_js.goused a hardcoded memconn address"Tailscale-IPN", so a secondnewIPN()call in the same WASM process wouldlog.Fatal; an atomic counter now gives each instance a unique address. Andwasm_js.go'slisten()rejected the standard":0"(any-interface) address form that netstack does not accept; it now normalises:portto0.0.0.0:port. Claude Code also wrote the full automated test suite for@webnet/tsconnect: unit tests forInMemoryFileOpsandInMemoryState(no WASM required), and integration tests that spin up real Tailscale nodes against a headscale control server, verifyinginitIPNWASM loading,/localapi/v0/status, and two-node TCP dial/listen.
@webnet/tsconnect—getIceServers(): Claude Code implementedgetIceServers(ipn), which fetches the tailnet's DERPMap via LocalAPI and converts it to anRTCIceServer[]list for use withnew 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 addedDERPMap,DERPRegion, andDERPNodetypes. Unit and integration tests included.@webnet/tsconnect— service advertisement: Claude Code addedSetExplicitServicestoLocalBackendin the tailscale fork (bypassing the OS portlist gate), wired asetServices(services)WASM binding, and added aservicesfield to the netmap JSON for both self and peers (stripping internal peerapi entries). On the TypeScript side:IPNServicetype,services: IPNService[]onIPNNetMapNode, andIPN.setServices(). In@webnet/tsconnect-redux:getPeersByService,getPeersByServiceDescription(both memoized withcreateSelector), andgetSelfServices(referentially stable empty-array fallback).