Two bugs in fetch.ts caused the upgraded connection to be closed or reassigned before connectWebSocket() could hijack it: 1. prepareRequest() checked Connection !== "upgrade" case-sensitively. A request with Connection: Upgrade (capital U) was silently rewritten to Connection: close, making ClientConnection auto-close the transport after receiving the 101 (shouldClose returned true, no body → immediate close). Fixed by lowercasing before the comparison. 2. rawFetch() called onPrevBodyFinished().then(done(false)) even for 101 responses. A 101 has no body so the promise resolves as a microtask, firing done(false) — and thus pool.releaseConnection() — before the caller can call connectWebSocket(). With keepAlive:false the pool closes the connection immediately; with keepAlive:N it puts it back in the idle pool where a concurrent waiter can grab it. Fixed by calling done(true) synchronously for 101, which calls rejectConnection() instead, removing the connection from the pool without closing it. rawFetchStream() had the same issue in its finally block when the caller breaks before calling connectWebSocket(); fixed likewise. Tests added in websocket.test.ts covering all four affected paths: - fetch() + connectWebSocket(res, key) - makeFetch(keepAlive:false) + connectWebSocket(res, key) - pool race: second request after WebSocket hijack gets a fresh conn - fetchStream() + connectWebSocket(r, key) inside the loop - makeFetch().stream + connectWebSocket(r, key) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
webnet
A TypeScript/WebAssembly SDK for running Tailscale inside a browser. It wraps the existing tsconnect Go package from the Tailscale repository, compiles it to WASM, and exposes a typed JavaScript API for joining a tailnet, opening TCP connections, dialing TLS, and listening, all from within a web page.
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 by:
- 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/tsconnect: an npm package (packages/tsconnect) that builds the WASM artifact, ships it alongside a Mozilla CA bundle andwasm_exec.js, and wraps the raw JS bridge in typed TypeScript classes with argument validation to avoid a Go panic that kills the WASM instance.@webnet/test-app: a Vite dev app (packages/test-app) for manual browser testing. It exposesinitIPN,wasmURL,cacertURL, andloadCACertsonwindowso the full stack can be exercised from the browser console without writing any test code.@webnet/http: an HTTP/1.1 server library (packages/http) built on top of theRawTransport/RawListenerprimitives exposed by@webnet/tsconnect. Implements request parsing, chunked transfer encoding, keep-alive, a middleware/router system, and response serialisation. Supports any stream transport that can implement these interfaces.
Packages
| Package | Description |
|---|---|
packages/tsconnect |
The SDK: builds the WASM, exports typed TS wrappers |
packages/http |
HTTP/1.1 server library over any stream transport |
packages/test-app |
Vite dev app for manual browser testing |
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)
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 (@webnet/http/websocket): implemented by Claude Code.upgradeWebSocket(req, res)for server-side handshake andconnectWebSocket(conn, target)for client-side, both returning 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. Exported as a separate entry point so it is fully tree-shakeable.