5 Commits
Author SHA1 Message Date
codingetandClaude a502735fd3 docs(readme): update AI disclosure for WasmSource, Node.js compat, shutdown signal, test suite
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 20:44:16 +00:00
codingetandClaude 73a74dcb15 test(tsconnect): add unit and integration test suite
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>
2026-06-15 20:44:04 +00:00
codingetandClaude 9dbce83f9f fix(tsconnect): resolve shutdown() race using go.run() as exit signal
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>
2026-06-15 20:43:46 +00:00
codingetandClaude 29d82feaca fix(tsconnect): node.js compat — safesocket unique addr, listen addr normalisation
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>
2026-06-15 20:43:33 +00:00
codingetandClaude 7be31df1f0 feat(tsconnect): accept WasmSource — multi-environment WASM loading
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>
2026-06-15 20:43:17 +00:00
10 changed files with 612 additions and 36 deletions
+11
View File
@@ -0,0 +1,11 @@
# Copy this file to .env.local and fill in real values to enable integration tests.
# .env.local is gitignored and loaded automatically by `npm test` via
# --env-file-if-exists, so integration tests skip gracefully when it is absent.
# URL of the headscale (or Tailscale) control server.
TSCONNECT_TEST_CONTROL_URL=https://headscale.example.com
# Reusable ephemeral authkey for the test tailnet.
# Ephemeral nodes clean themselves up on disconnect, so a single key is enough
# for all tests even when they spin up multiple IPN instances per run.
TSCONNECT_TEST_AUTH_KEY=hskey-auth-...
+1
View File
@@ -4,3 +4,4 @@ out/
coverage/
.turbo/
tmp/
.env.local
+6 -1
View File
@@ -96,4 +96,9 @@ The test suite for `packages/http` was mostly generated by Claude Code, which al
- **`packages/xml`**: a thin XML parse/serialize package with conditional exports — browser builds use the native `DOMParser`, Node.js builds use `@xmldom/xmldom`. Authored by Claude Code.
- **`packages/drive`**: WebDAV client and server with an async VFS abstraction. Includes `MemoryVFS`, `NodeVFS` (node:fs), `FsaVFS` (browser File System Access API, with an OPFS factory method), `createDAVHandler()` (server-side HTTP handler compatible with `@webnet/http`), and `DAVClient` (which implements `AsyncVFS` so it can be used as a backing store for another server instance). Full WebDAV Level 2 locking support (`LockStore` interface, `InMemoryLockStore`, LOCK/UNLOCK methods, `If:` header enforcement, `lockdiscovery`/`supportedlock` properties, and client-side `lock()`/`unlock()`/`refreshLock()` with optional `lockToken` on 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.sendFile` now accepts a `ReadableStream<Uint8Array>` + `declaredSize`; `IPN.openWaitingFile` returns `Promise<ReadableStream<Uint8Array>>`. On the Go/WASM side, a new `jsStreamReader` (`io.ReadCloser`) pulls chunks from a JS `ReadableStreamDefaultReader` via awaited `.read()` Promises (channel+`js.FuncOf` pattern), and `jsReadableStream` wraps a Go `io.ReadCloser` in a pull-based JS `ReadableStream`. `UserIPNFileOps.openReader` now returns a `ReadableStream` instead of a `Uint8Array`. A new `FsaFileOps` class (with `FsaFileOps.createFromOpfs()`) provides an OPFS-backed `UserIPNFileOps` where received chunks land directly on disk and downloads stream back through Go without buffering. `InMemoryFileOps.openReader` was updated to emit stored chunks one-by-one via a `ReadableStream`.
- **`@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``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. The TypeScript side awaits the `go.run()` Promise (captured in `initIPN` and threaded into each `IPN`) as the authoritative "Go runtime has exited" 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 to call even when `run()` 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 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 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), so the Go net package's `/etc/resolv.conf` read should remain a no-op. An earlier iteration set `globalThis.fs` to Node.js's real `fs`, which caused Go to read the host's `/etc/resolv.conf` directly and attempt to use those nameservers, breaking in environments where they are unreachable (e.g. Tailscale-managed entries without Tailscale running). `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 was explored but both files were identical, so the indirection was removed.
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`: 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.
+2
View File
@@ -23,6 +23,8 @@
],
"scripts": {
"build": "bash build.sh && tsc --project tsconfig.json",
"test": "tsx --env-file-if-exists=../../.env.local --test --test-force-exit --test-timeout=180000 'src/**/*.test.ts'",
"test:coverage": "c8 --src src --exclude 'src/**/*.test.ts' --exclude 'dist/*.js' --reporter text --reporter lcov node --enable-source-maps --import tsx --test-timeout=180000 --test-force-exit --test --env-file-if-exists=../../.env.local 'src/**/*.test.ts'",
"asset-sizes": "./scripts/asset-sizes.sh",
"update-ca-bundle": "./scripts/update-ca-bundle.sh",
"typecheck": "tsc --project tsconfig.json --noEmit"
+317
View File
@@ -0,0 +1,317 @@
import test, { suite } from "node:test"
import assert from "node:assert/strict"
import { InMemoryFileOps, InMemoryState } from "./helpers.js"
async function readAll(stream: ReadableStream<Uint8Array>): Promise<Uint8Array> {
const reader = stream.getReader()
const chunks: Uint8Array[] = []
for (;;) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
}
const size = chunks.reduce((n, c) => n + c.length, 0)
const out = new Uint8Array(size)
let off = 0
for (const c of chunks) {
out.set(c, off)
off += c.length
}
return out
}
suite("InMemoryState", () => {
test("returns empty string for unknown key", () => {
assert.equal(new InMemoryState().getState("missing"), "")
})
test("round-trips a value", () => {
const s = new InMemoryState()
s.setState("k", "v")
assert.equal(s.getState("k"), "v")
})
test("second setState overwrites first", () => {
const s = new InMemoryState()
s.setState("k", "first")
s.setState("k", "second")
assert.equal(s.getState("k"), "second")
})
test("constructor seed is copied; mutations to source map do not bleed in", () => {
const seed = new Map([["a", "1"]])
const s = new InMemoryState(seed)
seed.set("a", "mutated")
assert.equal(s.getState("a"), "1")
})
})
suite("InMemoryFileOps", () => {
suite("basic write / read roundtrip", () => {
test("single chunk", async () => {
const ops = new InMemoryFileOps()
ops.openWriter("f", 0)
ops.write("f", new Uint8Array([1, 2, 3]))
ops.closeWriter("f")
const got = await readAll(ops.openReader("f") as ReadableStream<Uint8Array>)
assert.deepEqual(got, new Uint8Array([1, 2, 3]))
})
test("multiple chunks are concatenated in stream order", async () => {
const ops = new InMemoryFileOps()
ops.openWriter("f", 0)
ops.write("f", new Uint8Array([1, 2]))
ops.write("f", new Uint8Array([3, 4]))
ops.closeWriter("f")
const got = await readAll(ops.openReader("f") as ReadableStream<Uint8Array>)
assert.deepEqual(got, new Uint8Array([1, 2, 3, 4]))
})
test("empty file produces empty stream", async () => {
const ops = new InMemoryFileOps()
ops.openWriter("f", 0)
ops.closeWriter("f")
const got = await readAll(ops.openReader("f") as ReadableStream<Uint8Array>)
assert.deepEqual(got, new Uint8Array(0))
})
test("overwrite (offset 0 on existing file) discards previous content", async () => {
const ops = new InMemoryFileOps()
ops.openWriter("f", 0)
ops.write("f", new Uint8Array([9, 9, 9]))
ops.closeWriter("f")
ops.openWriter("f", 0)
ops.write("f", new Uint8Array([1]))
ops.closeWriter("f")
const got = await readAll(ops.openReader("f") as ReadableStream<Uint8Array>)
assert.deepEqual(got, new Uint8Array([1]))
})
})
suite("openReader", () => {
test("returns ENOENT for a file that was never written", () => {
assert.equal(new InMemoryFileOps().openReader("nope"), "ENOENT")
})
test("two concurrent readers are independent", async () => {
const ops = new InMemoryFileOps()
ops.openWriter("f", 0)
ops.write("f", new Uint8Array([7]))
ops.closeWriter("f")
const [got1, got2] = await Promise.all([
readAll(ops.openReader("f") as ReadableStream<Uint8Array>),
readAll(ops.openReader("f") as ReadableStream<Uint8Array>),
])
assert.deepEqual(got1, new Uint8Array([7]))
assert.deepEqual(got2, new Uint8Array([7]))
})
})
suite("openWriter with non-zero offset (resume / truncate)", () => {
test("returns ENOENT when the file does not exist", () => {
assert.equal(new InMemoryFileOps().openWriter("nope", 5), "ENOENT")
})
test("truncates at an exact chunk boundary", async () => {
const ops = new InMemoryFileOps()
ops.openWriter("f", 0)
ops.write("f", new Uint8Array([1, 2, 3]))
ops.write("f", new Uint8Array([4, 5, 6]))
ops.closeWriter("f")
// resume at offset 3 — the boundary between the two chunks
ops.openWriter("f", 3)
ops.write("f", new Uint8Array([7, 8, 9]))
ops.closeWriter("f")
const got = await readAll(ops.openReader("f") as ReadableStream<Uint8Array>)
assert.deepEqual(got, new Uint8Array([1, 2, 3, 7, 8, 9]))
})
test("truncates mid-chunk", async () => {
const ops = new InMemoryFileOps()
ops.openWriter("f", 0)
ops.write("f", new Uint8Array([1, 2, 3, 4, 5]))
ops.closeWriter("f")
// truncate at offset 2 inside the single chunk
ops.openWriter("f", 2)
ops.write("f", new Uint8Array([9]))
ops.closeWriter("f")
const got = await readAll(ops.openReader("f") as ReadableStream<Uint8Array>)
assert.deepEqual(got, new Uint8Array([1, 2, 9]))
})
test("offset past end of file leaves existing content intact", async () => {
const ops = new InMemoryFileOps()
ops.openWriter("f", 0)
ops.write("f", new Uint8Array([1, 2, 3]))
ops.closeWriter("f")
// offset beyond the file size — nothing to truncate, appends
ops.openWriter("f", 10)
ops.write("f", new Uint8Array([4]))
ops.closeWriter("f")
const got = await readAll(ops.openReader("f") as ReadableStream<Uint8Array>)
assert.deepEqual(got, new Uint8Array([1, 2, 3, 4]))
})
})
suite("stat", () => {
test("ENOENT for a file that was never written", () => {
assert.equal(new InMemoryFileOps().stat("nope"), "ENOENT")
})
test("0 for an empty file", () => {
const ops = new InMemoryFileOps()
ops.openWriter("f", 0)
ops.closeWriter("f")
assert.equal(ops.stat("f"), 0)
})
test("total byte count across all chunks", () => {
const ops = new InMemoryFileOps()
ops.openWriter("f", 0)
ops.write("f", new Uint8Array(3))
ops.write("f", new Uint8Array(5))
ops.closeWriter("f")
assert.equal(ops.stat("f"), 8)
})
test("single-chunk fast path", () => {
const ops = new InMemoryFileOps()
ops.openWriter("f", 0)
ops.write("f", new Uint8Array(7))
ops.closeWriter("f")
assert.equal(ops.stat("f"), 7)
})
})
suite("listFiles", () => {
test("empty on a fresh instance", () => {
assert.deepEqual(new InMemoryFileOps().listFiles(), [])
})
test("includes all written files", () => {
const ops = new InMemoryFileOps()
ops.openWriter("a", 0)
ops.closeWriter("a")
ops.openWriter("b", 0)
ops.closeWriter("b")
assert.deepEqual(ops.listFiles().sort(), ["a", "b"])
})
test("excludes files after they are removed", () => {
const ops = new InMemoryFileOps()
ops.openWriter("a", 0)
ops.closeWriter("a")
ops.remove("a")
assert.deepEqual(ops.listFiles(), [])
})
test("reflects rename", () => {
const ops = new InMemoryFileOps()
ops.openWriter("old", 0)
ops.closeWriter("old")
ops.rename("old", "new")
assert.deepEqual(ops.listFiles(), ["new"])
})
})
suite("remove", () => {
test("ENOENT for a file that was never written", () => {
assert.equal(new InMemoryFileOps().remove("nope"), "ENOENT")
})
test("file is gone after removal", () => {
const ops = new InMemoryFileOps()
ops.openWriter("f", 0)
ops.closeWriter("f")
ops.remove("f")
assert.equal(ops.openReader("f"), "ENOENT")
})
test("throws when the file is currently open for writing", () => {
const ops = new InMemoryFileOps()
ops.openWriter("f", 0)
assert.throws(() => ops.remove("f"), /open/)
})
})
suite("rename", () => {
test("ENOENT when source does not exist", () => {
assert.equal(new InMemoryFileOps().rename("nope", "other"), "ENOENT")
})
test("data is accessible under the new name only", async () => {
const ops = new InMemoryFileOps()
ops.openWriter("old", 0)
ops.write("old", new Uint8Array([42]))
ops.closeWriter("old")
ops.rename("old", "new")
assert.equal(ops.openReader("old"), "ENOENT")
const got = await readAll(ops.openReader("new") as ReadableStream<Uint8Array>)
assert.deepEqual(got, new Uint8Array([42]))
})
test("throws when the source is open", () => {
const ops = new InMemoryFileOps()
ops.openWriter("f", 0)
assert.throws(() => ops.rename("f", "g"), /open/)
})
test("throws when the destination is open", () => {
const ops = new InMemoryFileOps()
ops.openWriter("f", 0)
ops.closeWriter("f")
ops.openWriter("g", 0)
assert.throws(() => ops.rename("f", "g"), /open/)
})
})
suite("misuse guards", () => {
test("write without openWriter throws", () => {
assert.throws(() => new InMemoryFileOps().write("f", new Uint8Array([1])), /not open/)
})
test("closeWriter without openWriter throws", () => {
assert.throws(() => new InMemoryFileOps().closeWriter("f"), /not open/)
})
test("openWriter on an already-open file throws", () => {
const ops = new InMemoryFileOps()
ops.openWriter("f", 0)
assert.throws(() => ops.openWriter("f", 0), /already open/)
})
})
suite("constructor seed data", () => {
test("Uint8Array seed is readable as a stream", async () => {
const ops = new InMemoryFileOps(new Map([["f", new Uint8Array([1, 2, 3])]]))
const got = await readAll(ops.openReader("f") as ReadableStream<Uint8Array>)
assert.deepEqual(got, new Uint8Array([1, 2, 3]))
})
test("array-of-chunks seed is readable as a stream", async () => {
const ops = new InMemoryFileOps(
new Map([["f", [new Uint8Array([1, 2]), new Uint8Array([3, 4])]]]),
)
const got = await readAll(ops.openReader("f") as ReadableStream<Uint8Array>)
assert.deepEqual(got, new Uint8Array([1, 2, 3, 4]))
})
test("seed files appear in listFiles", () => {
const ops = new InMemoryFileOps(
new Map([
["a", new Uint8Array()],
["b", new Uint8Array()],
]),
)
assert.deepEqual(ops.listFiles().sort(), ["a", "b"])
})
test("seed data is independent of the source map", async () => {
const seed = new Map<string, Uint8Array>([["f", new Uint8Array([1])]])
const ops = new InMemoryFileOps(seed)
seed.delete("f")
const got = await readAll(ops.openReader("f") as ReadableStream<Uint8Array>)
assert.deepEqual(got, new Uint8Array([1]))
})
})
})
+63 -28
View File
@@ -1,8 +1,15 @@
import "../dist/wasm_exec.js"
import { IPN } from "./ipn.js"
import type { IPN as RawIPN, IPNConfig, UserIPNFileOps, IPNFileOps } from "./types.js"
interface GoInstance {
importObject: WebAssembly.Imports
run(instance: WebAssembly.Instance): Promise<void>
}
const g0 = globalThis as typeof globalThis & { Go?: new () => GoInstance }
const Go = g0.Go!
delete g0.Go
export {
IPN,
Conn,
@@ -36,48 +43,76 @@ export type {
UserIPNFileOps,
} from "./types.js"
interface GoInstance {
importObject: WebAssembly.Imports
run(instance: WebAssembly.Instance): Promise<void>
}
interface GoConstructor {
new (): GoInstance
}
// Capture the Go runtime installed by wasm_exec.js and immediately remove it
// from the global scope to avoid polluting it.
const g = globalThis as typeof globalThis & { Go?: GoConstructor }
const Go = g.Go
if (!Go) {
throw new Error("wasm_exec.js did not install Go on globalThis")
}
delete g.Go
type GlobalWithIPN = typeof globalThis & {
newIPN?: (config: IPNConfig) => RawIPN
}
/**
* Source for the Tailscale WASM module passed to {@link initIPN}.
*
* - `string | URL` — fetched with `fetch()` and streamed into
* `WebAssembly.instantiateStreaming`. Use this in browsers and in runtimes
* that can fetch the file over HTTP.
* - `ArrayBuffer | ArrayBufferView` — compiled with `WebAssembly.instantiate`.
* Use this in Node.js / Bun where you can read the file with `fs.readFile`.
* - `Response` — passed directly to `WebAssembly.instantiateStreaming`. Use
* this when you already hold a fetched response (e.g. a Cloudflare Worker
* that imports the wasm as a module binding).
* - `ReadableStream<Uint8Array>` — wrapped in a `Response` with the correct
* MIME type and passed to `WebAssembly.instantiateStreaming`.
*/
export type WasmSource =
| string
| URL
| ArrayBuffer
| ArrayBufferView
| Response
| ReadableStream<Uint8Array>
async function instantiateWasm(
wasm: WasmSource,
imports: WebAssembly.Imports,
): Promise<WebAssembly.WebAssemblyInstantiatedSource> {
if (typeof wasm === "string" || wasm instanceof URL) {
return WebAssembly.instantiateStreaming(fetch(wasm as RequestInfo), imports)
}
if (wasm instanceof Response) {
return WebAssembly.instantiateStreaming(wasm, imports)
}
if (wasm instanceof ReadableStream) {
return WebAssembly.instantiateStreaming(
new Response(wasm, { headers: { "Content-Type": "application/wasm" } }),
imports,
)
}
// ArrayBuffer or any ArrayBufferView (Uint8Array, Buffer, …)
return WebAssembly.instantiate(wasm as ArrayBuffer, imports)
}
/**
* Loads and initializes the Tailscale WASM module.
*
* @param wasmURL - URL to main.wasm (available at dist/main.wasm in this package)
* @param wasm - The WASM source. See {@link WasmSource} for all accepted forms.
* In a browser or Worker, pass the URL string (e.g. the `./main.wasm` export
* of this package). In Node.js or Bun, read the file first and pass the
* resulting `Buffer` / `Uint8Array` / `ArrayBuffer`.
* @returns A factory function that, given an {@link IPNConfig}, returns a
* fully-wrapped {@link IPN} instance. The underlying `newIPN` global
* installed by the wasm module is captured and removed to avoid
* polluting the global scope.
*/
export async function initIPN(
wasmURL: string,
wasm: WasmSource,
): Promise<(config: Omit<IPNConfig, "fileOps"> & { fileOps?: UserIPNFileOps }) => IPN> {
const go = new Go!()
const go = new Go()
const result = await WebAssembly.instantiateStreaming(fetch(wasmURL), go.importObject)
const result = await instantiateWasm(wasm, go.importObject)
// Do not await: go.run() resolves only when the Go program exits, which
// under normal operation never happens. The wasm module sets newIPN on
// globalThis synchronously during startup before yielding to JS.
go.run(result.instance)
// Do not await: go.run() resolves only when the Go program exits. The wasm
// module sets newIPN on globalThis synchronously during startup before
// yielding to JS. We keep the promise so IPN.shutdown() can await it as the
// authoritative "runtime has fully exited" signal.
const goRun = go.run(result.instance)
const g2 = globalThis as GlobalWithIPN
const rawFactory = g2.newIPN
@@ -215,7 +250,7 @@ export async function initIPN(
},
}
: undefined
return new IPN(rawFactory({ ...config, fileOps }), !!config.fileOps)
return new IPN(rawFactory({ ...config, fileOps }), !!config.fileOps, goRun)
}
}
+192
View File
@@ -0,0 +1,192 @@
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"
import { fileURLToPath } from "node:url"
import { join, dirname } from "node:path"
import { InMemoryState } from "./helpers.js"
import type { IPN, Conn } from "./ipn.js"
import type { IPNConfig, UserIPNFileOps } from "./types.js"
const pkg = dirname(fileURLToPath(import.meta.url))
const WASM_PATH = join(pkg, "../dist/main.wasm")
const WASM_BUILT = existsSync(WASM_PATH)
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
// 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) {
const bytes = await readFile(WASM_PATH)
const { initIPN } = await import("./index.js")
_factory = await initIPN(bytes)
}
return _factory
}
// Connect an IPN to the test tailnet and wait for it to reach Running.
async function connectIPN(hostname: string): Promise<IPN> {
const factory = await getFactory()
const ipn = factory({
controlURL: CONTROL_URL!,
authKey: AUTH_KEY!,
hostname,
stateStorage: new InMemoryState(),
})
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error(`${hostname}: timed out waiting for Running`)),
55_000,
)
ipn.run({
notifyState(state) {
if (state === "NeedsLogin") {
ipn.login()
} else if (state === "Running") {
clearTimeout(timer)
resolve()
}
},
notifyPanicRecover(err) {
clearTimeout(timer)
reject(new Error(`WASM panic in ${hostname}: ${err}`))
},
})
})
return ipn
}
// Collect all bytes from a Conn until the remote end closes it.
async function readUntilEOF(conn: Conn): Promise<Uint8Array> {
const chunks: Uint8Array[] = []
try {
for (;;) chunks.push(await conn.read())
} catch {
// EOF — remote closed the connection
}
const out = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0))
let off = 0
for (const c of chunks) {
out.set(c, off)
off += c.length
}
return out
}
suite("initIPN — WASM loading", { skip: WASM_BUILT ? false : "dist/main.wasm not built" }, () => {
test("initIPN accepts a Buffer (ArrayBufferView) and returns a factory", async () => {
// 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")
})
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)
// Do not call ipn.shutdown() here: shutdown() now kills the entire shared
// Go runtime, which the integration suite below still needs.
})
test("globalThis.newIPN is removed after initIPN", async () => {
// The factory should have been captured and deleted from globalThis.
await getFactory()
assert.equal((globalThis as Record<string, unknown>)["newIPN"], undefined)
})
})
suite(
"IPN — network integration",
{
skip: NETWORK_OK
? false
: "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 () => {
console.log(`# 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", () => {
assert.equal(ipn.state, "Running")
assert.equal(ipn.running, true)
})
test("localAPI /localapi/v0/status returns 200 with self info", async () => {
const { status, body } = await ipn.localAPI("GET", "/localapi/v0/status")
assert.equal(status, 200)
const parsed = JSON.parse(body) as { Self?: { TailscaleIPs?: string[] } }
assert.ok(parsed.Self?.TailscaleIPs?.length, "status has no self TailscaleIPs")
})
})
suite("two-node dial / listen", () => {
test("client can send data to a listener on the server", async () => {
// Bind a TCP listener on the server node.
const listener = await server.listen("tcp", ":0")
const port = listener.addr.split(":").at(-1)!
// Find the server's Tailscale IP so the client can address it.
const { body } = await server.localAPI("GET", "/localapi/v0/status")
const {
Self: { TailscaleIPs },
} = JSON.parse(body) as { Self: { TailscaleIPs: string[] } }
const serverIP = TailscaleIPs[0]
assert.ok(serverIP, "server has no TailscaleIP")
const message = new TextEncoder().encode("hello from client")
// Accept on the server while the client dials — both block until the
// TCP handshake completes, so Promise.all is the right shape.
const [serverConn, clientConn] = await Promise.all([
listener.accept(),
client.dial("tcp", `${serverIP}:${port}`),
])
// Client sends data then closes its end.
await clientConn.write(message)
clientConn.close()
// Server reads until the client's FIN arrives.
const received = await readUntilEOF(serverConn)
serverConn.close()
listener.close()
assert.deepEqual(received, message)
})
})
},
)
+17 -5
View File
@@ -110,12 +110,14 @@ const ICMP_NETWORKS = new Set(["icmp", "icmp4", "icmp6"])
*/
export class IPN {
readonly #raw: RawIPN
readonly #goRun: Promise<void>
#state: IPNState = "NoState"
#running = false
#fileOps = false
constructor(raw: RawIPN, fileOps: boolean) {
constructor(raw: RawIPN, fileOps: boolean, goRun: Promise<void>) {
this.#raw = raw
this.#goRun = goRun
this.#fileOps = fileOps
}
@@ -191,13 +193,23 @@ export class IPN {
}
/**
* Shut down this IPN cleanly. Idempotent; resolves immediately if not
* running. After this call, other methods throw {@link NotRunningError}.
* Shut down this IPN and wait for the Go WASM runtime to exit. Idempotent;
* safe to call even if {@link run} was never invoked. After this call, all
* other methods on every IPN from the same factory will throw.
*/
async shutdown(): Promise<void> {
if (!this.#running) return
this.#running = false
await this.#raw.shutdown()
// Trigger Go's cleanup. shutdownOnce in Go makes this idempotent; the
// Promise it returns has a race with Go's own exit (resolve fires after
// _inst is deleted), so we ignore it and instead await the go.run()
// promise, which resolves exactly when the Go runtime has fully exited.
try {
this.#raw.shutdown()
} catch {
// Go may have already exited (e.g. a second IPN calling shutdown after
// the first already closed the shared shutdownCh).
}
await this.#goRun
}
login(): void {
+2 -1
View File
@@ -10,5 +10,6 @@
"sourceMap": true,
"lib": ["ES2018", "DOM", "DOM.AsyncIterable"]
},
"include": ["src/**/*"]
"include": ["src/**/*"],
"exclude": ["src/**/*.test.ts"]
}