initIPN previously only accepted a URL string and called fetch(), which does not work with file:// paths in Node.js. The parameter type is now a WasmSource union:
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. The Go net package's attempt to read /etc/resolv.conf should remain a no-op via the ENOSYS stub. An earlier iteration of this branch set globalThis.fs to Node.js's real fs, which caused Go to pick up nameservers from the host's resolv.conf and try them directly — breaking in environments where those servers are unreachable.
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 with a #envpackage.json imports condition was explored, but both files were identical so the indirection was removed.
3. IPN.shutdown()
A shutdown() method on IPN (TypeScript) and jsIPN (Go/WASM) stops the LocalBackend, closes the safesocket listener, and signals main() to return so the Go runtime exits cleanly. The TypeScript side awaits the go.run() Promise as the authoritative exit 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 even when run() was never called.
4. Two Go-side bug fixes in the tailscale submodule
safesocket_js.go: hardcoded "Tailscale-IPN" memconn address caused log.Fatal on a second newIPN() call in the same WASM process. An atomic counter now gives each instance a unique name.
wasm_js.go: listen() rejected the standard ":port" (any-interface) address form; netstack.ListenTCP requires an explicit bind address. Now normalises :port → 0.0.0.0:port.
5. Test suite
Unit tests for InMemoryFileOps and InMemoryState (no WASM needed)
Integration tests that spin up real Tailscale nodes against a headscale control server, verifying WASM loading, /localapi/v0/status, and two-node TCP dial/listen
Tests use a shared getFactory() singleton (WASM compiled once) and a top-level before()/after() pair — shutdown() kills the entire Go WASM runtime so per-test teardown is not possible
--test-force-exit keeps the runner from hanging on tsx's open handles after Go exits
--test-timeout=180000 to accommodate the time nodes need to reach Running state
test:coverage script added for consistency with other packages
Running the integration tests
# Requires a headscale control server and auth key:TSCONNECT_TEST_CONTROL_URL=https://your-headscale.example.com \
TSCONNECT_TEST_AUTH_KEY=hskey-auth-... \
npm test -w packages/tsconnect
Or set them in .env.local at the repo root (gitignored).
## What this PR does
Extends `@webnet/tsconnect` with full Node.js support, covering three areas.
### 1. `WasmSource` — multi-environment WASM loading (`initIPN`)
`initIPN` previously only accepted a URL string and called `fetch()`, which does not work with `file://` paths in Node.js. The parameter type is now a `WasmSource` union:
- `string | URL` → `fetch()` + `instantiateStreaming` (browser / CDN)
- `ArrayBuffer | ArrayBufferView` → `instantiate` (Node.js `fs.readFile`, Bun)
- `Response` → `instantiateStreaming` (Cloudflare Workers module binding)
- `ReadableStream<Uint8Array>` → wrapped `Response` + `instantiateStreaming`
### 2. `wasm_exec.js` / Node.js environment
`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. The Go net package's attempt to read `/etc/resolv.conf` should remain a no-op via the ENOSYS stub. An earlier iteration of this branch set `globalThis.fs` to Node.js's real `fs`, which caused Go to pick up nameservers from the host's `resolv.conf` and try them directly — breaking in environments where those servers are unreachable.
`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 with a `#env` `package.json` imports condition was explored, but both files were identical so the indirection was removed.
### 3. `IPN.shutdown()`
A `shutdown()` method on `IPN` (TypeScript) and `jsIPN` (Go/WASM) stops the `LocalBackend`, closes the safesocket listener, and signals `main()` to return so the Go runtime exits cleanly. The TypeScript side awaits the `go.run()` Promise as the authoritative exit 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 even when `run()` was never called.
### 4. Two Go-side bug fixes in the `tailscale` submodule
- **`safesocket_js.go`**: hardcoded `"Tailscale-IPN"` memconn address caused `log.Fatal` on a second `newIPN()` call in the same WASM process. An atomic counter now gives each instance a unique name.
- **`wasm_js.go`**: `listen()` rejected the standard `":port"` (any-interface) address form; `netstack.ListenTCP` requires an explicit bind address. Now normalises `:port` → `0.0.0.0:port`.
### 5. Test suite
- Unit tests for `InMemoryFileOps` and `InMemoryState` (no WASM needed)
- Integration tests that spin up real Tailscale nodes against a headscale control server, verifying WASM loading, `/localapi/v0/status`, and two-node TCP dial/listen
- Tests use a shared `getFactory()` singleton (WASM compiled once) and a top-level `before()`/`after()` pair — `shutdown()` kills the entire Go WASM runtime so per-test teardown is not possible
- `--test-force-exit` keeps the runner from hanging on tsx's open handles after Go exits
- `--test-timeout=180000` to accommodate the time nodes need to reach Running state
- `test:coverage` script added for consistency with other packages
## Running the integration tests
```sh
# Requires a headscale control server and auth key:
TSCONNECT_TEST_CONTROL_URL=https://your-headscale.example.com \
TSCONNECT_TEST_AUTH_KEY=hskey-auth-... \
npm test -w packages/tsconnect
```
Or set them in `.env.local` at the repo root (gitignored).
codinget
changed title from WIP: feat(tsconnect): accept ArrayBuffer/Uint8Array/Response/ReadableStream in initIPN to feat(tsconnect): Node.js support — WasmSource, fs shim, safesocket fix, test suite2026-06-13 22:16:58 +02:00
Two Go-side bugs fixed in the tailscale submodule:
- safesocket_js.go: hardcoded "Tailscale-IPN" memconn addr caused log.Fatal
on a second newIPN() call; atomic counter now gives each instance a unique name.
- wasm_js.go: listen() rejected ":port" (any-interface form); netstack requires
an explicit bind addr; now normalises :port → 0.0.0.0:port.
wasm_exec.js ENOSYS stubs (installed when globalThis.fs is falsy) are the correct
behaviour: all network calls go through JS fetch()/WebSocket. Setting globalThis.fs
to Node.js's real fs caused Go to read /etc/resolv.conf and use host nameservers.
tsconfig.json: add test-file exclude; add DOM.AsyncIterable to lib.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
go.run() returns a Promise that resolves exactly when the Go runtime exits.
Storing it in initIPN and awaiting it in shutdown() eliminates a race where
Go deletes _inst before a callback-based resolve fires.
Go side: add nil guards on lb and ln so shutdown() is safe even when run()
was never called (e.g. testing IPN construction without connecting).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Unit tests cover InMemoryFileOps (read/write/stat/list/rename/remove, seek,
misuse guards, constructor seed) and InMemoryState. No WASM needed.
Integration tests spin up three real Tailscale nodes against a headscale
control server, verifying initIPN WASM loading, /localapi/v0/status, and
two-node TCP dial/listen. Credentials loaded from .env.local (gitignored).
All IPN instances share one Go WASM runtime (factory compiled once); shutdown()
on any one exits the runtime, so a single before()/after() pair wraps the suite.
test:coverage script added for consistency with other packages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
What this PR does
Extends
@webnet/tsconnectwith full Node.js support, covering three areas.1.
WasmSource— multi-environment WASM loading (initIPN)initIPNpreviously only accepted a URL string and calledfetch(), which does not work withfile://paths in Node.js. The parameter type is now aWasmSourceunion:string | URL→fetch()+instantiateStreaming(browser / CDN)ArrayBuffer | ArrayBufferView→instantiate(Node.jsfs.readFile, Bun)Response→instantiateStreaming(Cloudflare Workers module binding)ReadableStream<Uint8Array>→ wrappedResponse+instantiateStreaming2.
wasm_exec.js/ Node.js environmentwasm_exec.jsinstalls ENOSYS stubs forglobalThis.fswhen it is falsy. In Node.js,globalThis.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. The Go net package's attempt to read/etc/resolv.confshould remain a no-op via the ENOSYS stub. An earlier iteration of this branch setglobalThis.fsto Node.js's realfs, which caused Go to pick up nameservers from the host'sresolv.confand try them directly — breaking in environments where those servers are unreachable.wasm_exec.jsis now imported directly inindex.tsandGois extracted fromglobalThisinline. A separateenv-node.ts/env-web.tssplit with a#envpackage.jsonimports condition was explored, but both files were identical so the indirection was removed.3.
IPN.shutdown()A
shutdown()method onIPN(TypeScript) andjsIPN(Go/WASM) stops theLocalBackend, closes the safesocket listener, and signalsmain()to return so the Go runtime exits cleanly. The TypeScript side awaits thego.run()Promise as the authoritative exit signal, avoiding a race where Go deletes_instbefore a callback-based resolve could fire. Go-side nil guards were added forlbandlnsoshutdown()is safe even whenrun()was never called.4. Two Go-side bug fixes in the
tailscalesubmodulesafesocket_js.go: hardcoded"Tailscale-IPN"memconn address causedlog.Fatalon a secondnewIPN()call in the same WASM process. An atomic counter now gives each instance a unique name.wasm_js.go:listen()rejected the standard":port"(any-interface) address form;netstack.ListenTCPrequires an explicit bind address. Now normalises:port→0.0.0.0:port.5. Test suite
InMemoryFileOpsandInMemoryState(no WASM needed)/localapi/v0/status, and two-node TCP dial/listengetFactory()singleton (WASM compiled once) and a top-levelbefore()/after()pair —shutdown()kills the entire Go WASM runtime so per-test teardown is not possible--test-force-exitkeeps the runner from hanging on tsx's open handles after Go exits--test-timeout=180000to accommodate the time nodes need to reach Running statetest:coveragescript added for consistency with other packagesRunning the integration tests
Or set them in
.env.localat the repo root (gitignored).WIP: feat(tsconnect): accept ArrayBuffer/Uint8Array/Response/ReadableStream in initIPNto feat(tsconnect): Node.js support — WasmSource, fs shim, safesocket fix, test suiteca10c7a026to1a660613160501c2fe64toa9d26d965ba9d26d965bto04ff2aa76004ff2aa760toa502735fd3