feat(tsconnect): use #env import condition to isolate Node.js fs setup from web bundlers
Move Node.js-specific wasm_exec.js setup to env-node-setup.ts (imported via
the "node" condition on the #env internal package import), so web bundlers get
env-web.ts instead and never see the import('node:fs') that was breaking them.
Static imports replace the dynamic ones, eliminating the small race condition
and simplifying index.ts. Restructure integration tests to share one Go WASM
instance across all three IPNs in a top-level before()/after() pair (shutdown()
kills the entire Go runtime, making per-test cleanup impossible).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -99,6 +99,6 @@ The test suite for `packages/http` was mostly generated by Claude Code, which al
|
||||
- **`@webnet/tsconnect` — `IPN.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, releasing all goroutines, timers, and JS object references. This allows the host process (Node.js or browser service worker) to terminate cleanly instead of being kept alive indefinitely.
|
||||
- **`@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 an ENOSYS stub for `globalThis.fs` when it is falsy. In Node.js `globalThis.fs` is undefined, so Go's net package could not read `/etc/resolv.conf` and fell back to `[::1]:53` for DNS. The fix lazily loads `wasm_exec.js` inside `initIPN` and sets `globalThis.fs` to Node.js's real `fs` module first — with `write()` wrapped to call back synchronously (via `writeSync`) because Go calls `fs.write()` for stdout/stderr inside synchronous JS event handlers where an async callback would deadlock.
|
||||
2. `wasm_exec.js` installs an ENOSYS stub for `globalThis.fs` when it is falsy. In Node.js `globalThis.fs` is undefined, so Go's net package could not read `/etc/resolv.conf` and fell back to `[::1]:53` for DNS. The fix uses `package.json` `imports` conditions (`#env`) to select a Node.js-specific entry point (`env-node.ts`) that statically imports `env-node-setup.ts` (which sets `globalThis.fs` to Node.js's real `fs`) before statically importing `wasm_exec.js`. ESM's depth-first evaluation order ensures the fs assignment runs before `wasm_exec.js` checks for it. `write()` is wrapped to call back synchronously (via `writeSync`) because Go calls `fs.write()` for stdout/stderr inside synchronous JS event handlers where an async callback would deadlock. Bundlers targeting web get `env-web.ts` (no fs setup) via the `default` condition, eliminating the `import('node:fs')` that was breaking web bundlers.
|
||||
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`: 36 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.
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
"type": "module",
|
||||
"main": "out/index.js",
|
||||
"types": "out/index.d.ts",
|
||||
"imports": {
|
||||
"#env": {
|
||||
"node": "./out/env-node.js",
|
||||
"default": "./out/env-web.js"
|
||||
}
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./out/index.d.ts",
|
||||
@@ -23,7 +29,7 @@
|
||||
],
|
||||
"scripts": {
|
||||
"build": "bash build.sh && tsc --project tsconfig.json",
|
||||
"test": "tsx --env-file-if-exists=../../.env.local --test --test-timeout=60000 'src/**/*.test.ts'",
|
||||
"test": "tsx --env-file-if-exists=../../.env.local --test --test-timeout=180000 'src/**/*.test.ts'",
|
||||
"asset-sizes": "./scripts/asset-sizes.sh",
|
||||
"update-ca-bundle": "./scripts/update-ca-bundle.sh",
|
||||
"typecheck": "tsc --project tsconfig.json --noEmit"
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Sets up globalThis.fs from Node.js's real fs module before wasm_exec.js
|
||||
// checks for it. This file is the first import in env-node.ts; ESM evaluates
|
||||
// imports depth-first, so its module body runs before wasm_exec.js is loaded,
|
||||
// ensuring wasm_exec.js finds globalThis.fs already set and skips the ENOSYS stub.
|
||||
|
||||
// @ts-expect-error - node:fs is not in the lib declarations; this file is only
|
||||
// evaluated in Node.js via the "node" condition in package.json#imports
|
||||
import * as nodeFsRaw from "node:fs"
|
||||
|
||||
const nodeFs = nodeFsRaw as unknown as Record<string, unknown> & {
|
||||
writeSync(fd: number, buf: Uint8Array): number
|
||||
}
|
||||
|
||||
// fs.write() must call back synchronously. Go invokes it for stdout/stderr
|
||||
// inside synchronous JS event handlers (js.handleEvent). An async libuv
|
||||
// callback from the real node fs.write() would deadlock: Go blocks on the
|
||||
// channel waiting for the callback but cannot yield the thread back to the
|
||||
// event loop while still serving the handler. writeSync avoids the yield.
|
||||
const syncWrite = (
|
||||
fd: number,
|
||||
buf: Uint8Array,
|
||||
_off: number,
|
||||
_len: number,
|
||||
_pos: unknown,
|
||||
cb: (err: unknown, n?: number) => void,
|
||||
) => {
|
||||
try {
|
||||
cb(null, nodeFs.writeSync(fd, buf))
|
||||
} catch (e) {
|
||||
cb(e)
|
||||
}
|
||||
}
|
||||
|
||||
;(globalThis as Record<string, unknown>).fs = { ...nodeFs, write: syncWrite }
|
||||
@@ -0,0 +1,22 @@
|
||||
// Node.js environment entry point for @webnet/tsconnect.
|
||||
// Selected by the "node" condition in package.json#imports.
|
||||
//
|
||||
// Import order is load-bearing: env-node-setup.js installs the real Node.js fs
|
||||
// on globalThis first, then wasm_exec.js sees it already set and skips the
|
||||
// ENOSYS stub it would otherwise install.
|
||||
|
||||
import "./env-node-setup.js"
|
||||
import "../dist/wasm_exec.js"
|
||||
|
||||
interface GoInstance {
|
||||
importObject: WebAssembly.Imports
|
||||
run(instance: WebAssembly.Instance): Promise<void>
|
||||
}
|
||||
|
||||
interface GoConstructor {
|
||||
new (): GoInstance
|
||||
}
|
||||
|
||||
const g = globalThis as typeof globalThis & { Go?: GoConstructor }
|
||||
export const Go = g.Go!
|
||||
delete g.Go
|
||||
@@ -0,0 +1,17 @@
|
||||
// Web/bundler environment entry point for @webnet/tsconnect.
|
||||
// Selected by the "default" condition in package.json#imports.
|
||||
|
||||
import "../dist/wasm_exec.js"
|
||||
|
||||
interface GoInstance {
|
||||
importObject: WebAssembly.Imports
|
||||
run(instance: WebAssembly.Instance): Promise<void>
|
||||
}
|
||||
|
||||
interface GoConstructor {
|
||||
new (): GoInstance
|
||||
}
|
||||
|
||||
const g = globalThis as typeof globalThis & { Go?: GoConstructor }
|
||||
export const Go = g.Go!
|
||||
delete g.Go
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Go } from "#env"
|
||||
import { IPN } from "./ipn.js"
|
||||
import type { IPN as RawIPN, IPNConfig, UserIPNFileOps, IPNFileOps } from "./types.js"
|
||||
|
||||
@@ -34,66 +35,10 @@ export type {
|
||||
UserIPNFileOps,
|
||||
} from "./types.js"
|
||||
|
||||
interface GoInstance {
|
||||
importObject: WebAssembly.Imports
|
||||
run(instance: WebAssembly.Instance): Promise<void>
|
||||
}
|
||||
|
||||
interface GoConstructor {
|
||||
new (): GoInstance
|
||||
}
|
||||
|
||||
type GlobalWithIPN = typeof globalThis & {
|
||||
newIPN?: (config: IPNConfig) => RawIPN
|
||||
}
|
||||
|
||||
// Lazily loaded on first initIPN call. Module-level loading was moved here so
|
||||
// that wasm_exec.js (a side-effect import) is not evaluated until needed, and
|
||||
// so that the Node.js fs shim can be installed before wasm_exec.js runs.
|
||||
let _Go: GoConstructor | undefined
|
||||
|
||||
async function ensureWasmEnv(): Promise<void> {
|
||||
if (_Go) return
|
||||
|
||||
// wasm_exec.js installs an ENOSYS stub for the fs global only when
|
||||
// globalThis.fs is falsy. In Node.js we provide the real module first so
|
||||
// that Go's net package can read /etc/resolv.conf for DNS resolution.
|
||||
const proc = (globalThis as Record<string, unknown>).process as
|
||||
| { versions?: { node?: string } }
|
||||
| undefined
|
||||
if (!globalThis.fs && proc?.versions?.node) {
|
||||
// @ts-expect-error — node:fs is only available at runtime in Node.js
|
||||
const nodeFs = await import("node:fs")
|
||||
// fs.write() must call back synchronously. Go invokes it for stdout/stderr
|
||||
// inside synchronous JS event handlers (e.g. newIPN()). An async callback
|
||||
// from Node.js's real fs.write() would deadlock: Go blocks on the channel
|
||||
// waiting for the callback, but Go cannot yield back to the JS event loop
|
||||
// while still serving the handler. writeSync avoids the yield entirely.
|
||||
const syncWrite = (
|
||||
fd: number,
|
||||
buf: Uint8Array,
|
||||
_off: number,
|
||||
_len: number,
|
||||
_pos: unknown,
|
||||
cb: (err: unknown, n?: number) => void,
|
||||
) => {
|
||||
try {
|
||||
cb(null, nodeFs.writeSync(fd, buf))
|
||||
} catch (e) {
|
||||
cb(e)
|
||||
}
|
||||
}
|
||||
;(globalThis as Record<string, unknown>).fs = { ...nodeFs, write: syncWrite }
|
||||
}
|
||||
|
||||
await import("../dist/wasm_exec.js")
|
||||
|
||||
const g = globalThis as typeof globalThis & { Go?: GoConstructor }
|
||||
_Go = g.Go
|
||||
if (!_Go) throw new Error("wasm_exec.js did not install Go on globalThis")
|
||||
delete g.Go
|
||||
}
|
||||
|
||||
/**
|
||||
* Source for the Tailscale WASM module passed to {@link initIPN}.
|
||||
*
|
||||
@@ -151,8 +96,7 @@ async function instantiateWasm(
|
||||
export async function initIPN(
|
||||
wasm: WasmSource,
|
||||
): Promise<(config: Omit<IPNConfig, "fileOps"> & { fileOps?: UserIPNFileOps }) => IPN> {
|
||||
await ensureWasmEnv()
|
||||
const go = new _Go!()
|
||||
const go = new Go()
|
||||
|
||||
const result = await instantiateWasm(wasm, go.importObject)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import test, { suite } from "node:test"
|
||||
import test, { suite, before, after } from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
import { existsSync } from "node:fs"
|
||||
import { readFile } from "node:fs/promises"
|
||||
@@ -16,12 +16,15 @@ const CONTROL_URL = process.env.TSCONNECT_TEST_CONTROL_URL
|
||||
const AUTH_KEY = process.env.TSCONNECT_TEST_AUTH_KEY
|
||||
const NETWORK_OK = WASM_BUILT && !!CONTROL_URL && !!AUTH_KEY
|
||||
|
||||
// initIPN is not safe to call concurrently (both instances race to set/read
|
||||
// globalThis.newIPN). Load the WASM once and reuse the factory for all tests.
|
||||
// index.js is imported dynamically so that wasm_exec.js (a side-effect import
|
||||
// inside index.ts) is not loaded at module evaluation time — which would crash
|
||||
// when dist/wasm_exec.js does not exist yet (i.e. before the package is built).
|
||||
// index.js is imported dynamically so that env-node.js / env-web.js (which
|
||||
// statically import wasm_exec.js) are not loaded at module evaluation time —
|
||||
// which would crash when dist/wasm_exec.js does not exist yet (before build).
|
||||
type Factory = (config: Omit<IPNConfig, "fileOps"> & { fileOps?: UserIPNFileOps }) => IPN
|
||||
|
||||
// Shared factory for all tests. initIPN compiles the 32 MB WASM once; doing
|
||||
// it multiple times per process would be prohibitively slow (~30-50 s each).
|
||||
// All IPN instances created from this factory share one Go runtime, so
|
||||
// shutdown() on any one of them exits the runtime for all.
|
||||
let _factory: Factory | undefined
|
||||
async function getFactory() {
|
||||
if (!_factory) {
|
||||
@@ -33,8 +36,6 @@ async function getFactory() {
|
||||
}
|
||||
|
||||
// Connect an IPN to the test tailnet and wait for it to reach Running.
|
||||
// Each call to factory() creates an independent Go backend inside the shared
|
||||
// WASM instance; they are fully isolated from each other.
|
||||
async function connectIPN(hostname: string): Promise<IPN> {
|
||||
const factory = await getFactory()
|
||||
const ipn = factory({
|
||||
@@ -87,8 +88,8 @@ async function readUntilEOF(conn: Conn): Promise<Uint8Array> {
|
||||
|
||||
suite("initIPN — WASM loading", { skip: WASM_BUILT ? false : "dist/main.wasm not built" }, () => {
|
||||
test("initIPN accepts a Buffer (ArrayBufferView) and returns a factory", async () => {
|
||||
// This is the primary smoke-test for the WasmSource dispatch: a Buffer is
|
||||
// an ArrayBufferView, so it should take the WebAssembly.instantiate path.
|
||||
// Primary smoke-test for WasmSource dispatch: a Buffer is an ArrayBufferView
|
||||
// so it takes the WebAssembly.instantiate path (not instantiateStreaming).
|
||||
const factory = await getFactory()
|
||||
assert.equal(typeof factory, "function")
|
||||
})
|
||||
@@ -96,11 +97,15 @@ suite("initIPN — WASM loading", { skip: WASM_BUILT ? false : "dist/main.wasm n
|
||||
test("factory produces an IPN with expected shape before run()", async () => {
|
||||
const factory = await getFactory()
|
||||
const ipn = factory({ stateStorage: new InMemoryState() })
|
||||
assert.equal(typeof ipn.run, "function")
|
||||
assert.equal(typeof ipn.dial, "function")
|
||||
assert.equal(typeof ipn.listen, "function")
|
||||
assert.equal(ipn.state, "NoState")
|
||||
assert.equal(ipn.running, false)
|
||||
try {
|
||||
assert.equal(typeof ipn.run, "function")
|
||||
assert.equal(typeof ipn.dial, "function")
|
||||
assert.equal(typeof ipn.listen, "function")
|
||||
assert.equal(ipn.state, "NoState")
|
||||
assert.equal(ipn.running, false)
|
||||
} finally {
|
||||
await ipn.shutdown()
|
||||
}
|
||||
})
|
||||
|
||||
test("globalThis.newIPN is removed after initIPN", async () => {
|
||||
@@ -118,16 +123,31 @@ suite(
|
||||
: "requires TSCONNECT_TEST_CONTROL_URL + TSCONNECT_TEST_AUTH_KEY in .env.local and a built dist/main.wasm",
|
||||
},
|
||||
() => {
|
||||
// All integration IPNs share one Go WASM runtime (from the shared factory).
|
||||
// shutdown() exits the entire Go runtime, so we spin up all IPNs concurrently
|
||||
// in before() and shut down once in after() rather than per sub-suite.
|
||||
let ipn!: IPN, server!: IPN, client!: IPN
|
||||
before(async (t) => {
|
||||
t.diagnostic(`control: ${CONTROL_URL}`)
|
||||
;[ipn, server, client] = await Promise.all([
|
||||
connectIPN("tsconnect-test-single"),
|
||||
connectIPN("tsconnect-test-server"),
|
||||
connectIPN("tsconnect-test-client"),
|
||||
])
|
||||
})
|
||||
after(async () => {
|
||||
// Shutting down any one IPN exits the shared Go runtime; server and client
|
||||
// are dead immediately after. Calling shutdown() on them would throw.
|
||||
await ipn.shutdown()
|
||||
})
|
||||
|
||||
suite("single node", () => {
|
||||
test("reaches Running state", async (t) => {
|
||||
t.diagnostic(`control: ${CONTROL_URL}`)
|
||||
const ipn = await connectIPN("tsconnect-test-single")
|
||||
test("reaches Running state", () => {
|
||||
assert.equal(ipn.state, "Running")
|
||||
assert.equal(ipn.running, true)
|
||||
})
|
||||
|
||||
test("localAPI /localapi/v0/status returns 200 with self info", async () => {
|
||||
const ipn = await connectIPN("tsconnect-test-status")
|
||||
const { status, body } = await ipn.localAPI("GET", "/localapi/v0/status")
|
||||
assert.equal(status, 200)
|
||||
const parsed = JSON.parse(body) as { Self?: { TailscaleIPs?: string[] } }
|
||||
@@ -137,13 +157,6 @@ suite(
|
||||
|
||||
suite("two-node dial / listen", () => {
|
||||
test("client can send data to a listener on the server", async () => {
|
||||
// Stand up both nodes concurrently — they each call factory() which is
|
||||
// safe since factory was already resolved; no initIPN race here.
|
||||
const [server, client] = await Promise.all([
|
||||
connectIPN("tsconnect-test-server"),
|
||||
connectIPN("tsconnect-test-client"),
|
||||
])
|
||||
|
||||
// Bind a TCP listener on the server node.
|
||||
const listener = await server.listen("tcp", ":0")
|
||||
const port = listener.addr.split(":").at(-1)!
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
"outDir": "out",
|
||||
"rootDir": "src",
|
||||
"sourceMap": true,
|
||||
"lib": ["ES2018", "DOM", "DOM.AsyncIterable"]
|
||||
"lib": ["ES2018", "DOM", "DOM.AsyncIterable"],
|
||||
"paths": {
|
||||
"#env": ["./src/env-web.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
|
||||
Reference in New Issue
Block a user