feat(tsconnect): Node.js support — WasmSource, fs shim, safesocket fix, test suite #33

Merged
codinget merged 5 commits from worktree-bridge-cse_01CETAfxGfBA7CRyPrbuDYv1 into main 2026-06-15 22:46:10 +02:00
Owner

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 | URLfetch() + instantiateStreaming (browser / CDN)
  • ArrayBuffer | ArrayBufferViewinstantiate (Node.js fs.readFile, Bun)
  • ResponseinstantiateStreaming (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 :port0.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 suite 2026-06-13 22:16:58 +02:00
codinget force-pushed worktree-bridge-cse_01CETAfxGfBA7CRyPrbuDYv1 from ca10c7a026 to 1a66061316 2026-06-14 03:09:04 +02:00 Compare
codinget force-pushed worktree-bridge-cse_01CETAfxGfBA7CRyPrbuDYv1 from 0501c2fe64 to a9d26d965b 2026-06-15 09:25:26 +02:00 Compare
codinget force-pushed worktree-bridge-cse_01CETAfxGfBA7CRyPrbuDYv1 from a9d26d965b to 04ff2aa760 2026-06-15 22:08:20 +02:00 Compare
codinget added 5 commits 2026-06-15 22:44:36 +02:00
initIPN now accepts a WasmSource union instead of a plain URL string:

  - string | URL          → fetch() + instantiateStreaming (browser / CDN)
  - ArrayBuffer | View    → instantiate (Node.js fs.readFile, Bun)
  - Response              → instantiateStreaming (Cloudflare Worker bindings)
  - ReadableStream        → Response-wrapped instantiateStreaming

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
codinget force-pushed worktree-bridge-cse_01CETAfxGfBA7CRyPrbuDYv1 from 04ff2aa760 to a502735fd3 2026-06-15 22:44:36 +02:00 Compare
codinget merged commit a502735fd3 into main 2026-06-15 22:46:10 +02:00
codinget deleted branch worktree-bridge-cse_01CETAfxGfBA7CRyPrbuDYv1 2026-06-15 22:46:13 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: webnet/webnet#33