From abc84f7fd1321df571bbf1918362e4c50692df5e Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Mon, 18 May 2026 18:31:47 +0100 Subject: [PATCH 01/43] tweaks --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index c606fb5..01c69ae 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ pnpm run deploy python3 -m http.server 3000 CAPTUN_SERVER_URL=https://captun..workers.dev pnpm run cli -- --name demo 3000 + curl https://captun..workers.dev/demo/ ``` From 4872c18f24869232e58d5235c53bfdc54717abb5 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Mon, 18 May 2026 18:32:19 +0100 Subject: [PATCH 02/43] rm old cli --- README.md | 4 ++++ src/cli.ts | 35 ----------------------------------- 2 files changed, 4 insertions(+), 35 deletions(-) delete mode 100644 src/cli.ts diff --git a/README.md b/README.md index 01c69ae..66c0304 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ Captun is a tiny reference implementation of a self-hosted ngrok or Cloudflare Tunnel alternative. It runs the public side on Cloudflare Workers and sends matching HTTP requests back to a Node process over [Cap'n Web](https://github.com/cloudflare/capnweb). +```bash +npx captun deploy +``` + ```bash pnpm install pnpm run deploy diff --git a/src/cli.ts b/src/cli.ts deleted file mode 100644 index d8f84ad..0000000 --- a/src/cli.ts +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env node -import { createCaptunTunnel } from "./client.ts"; - -const words = [ - "apple amber bright cedar copper daisy ember forest ginger harbor indigo jolly kiwi lemon maple nova olive pearl quartz ruby".split(" "), - "fast swift quick rapid zippy brisk fleet nimble snappy speedy lively eager sharp ready active bold crisp fresh keen spry".split(" "), - "tree river stone cloud field bridge spark meadow tower trail garden island planet signal anchor valley window canyon summit harvest".split(" "), -]; - -const args = process.argv.slice(2); -const name = args.includes("--name") - ? args[args.indexOf("--name") + 1] - : words.map((list) => list[Math.floor(Math.random() * list.length)]).join("-"); -const secret = args.includes("--secret") - ? args[args.indexOf("--secret") + 1] - : process.env.CAPTUN_SECRET; -const port = - args.find( - (arg, index) => - !arg.startsWith("--") && args[index - 1] !== "--name" && args[index - 1] !== "--secret", - ) ?? "3000"; -const base = new URL(process.env.CAPTUN_SERVER_URL ?? "http://localhost:8787"); -base.pathname = base.hostname.match(/^[^.]+\.tunnels\./) ? "/" : `/${name}/`; - -using _tunnel = await createCaptunTunnel({ - url: new URL("__connect", base), - headers: secret ? { authorization: `Bearer ${secret}` } : undefined, - fetch: (request) => { - const url = new URL(request.url); - return fetch(new URL(url.pathname + url.search, `http://localhost:${port}`), request); - }, -}); - -console.log(`tunneling ${base.href} -> http://localhost:${port}`); -await new Promise(() => {}); From a1c8440df9a807d0f3f85dcb252866b785c9f947 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Mon, 18 May 2026 19:04:00 +0100 Subject: [PATCH 03/43] Rename CLI entrypoint --- src/{capnweb-tunnel-cli.ts => cli.ts} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/{capnweb-tunnel-cli.ts => cli.ts} (99%) diff --git a/src/capnweb-tunnel-cli.ts b/src/cli.ts similarity index 99% rename from src/capnweb-tunnel-cli.ts rename to src/cli.ts index c59f5a4..7e0d43a 100644 --- a/src/capnweb-tunnel-cli.ts +++ b/src/cli.ts @@ -36,7 +36,7 @@ const router = os.router({ }) .input( z.object({ - port: z.coerce + port: z .number() .int() .positive() From 3e3e6eac4e2da35778db4b46b1dc559343506d2b Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Mon, 18 May 2026 20:00:23 +0100 Subject: [PATCH 04/43] Align Captun CLI config handling --- README.md | 18 ++---- ...s-tunnels-templestein-20260518-171355.json | 4 +- .../streams-workers-dev-20260518-170417.json | 4 +- examples/weather-reporter/worker.ts | 4 +- src/cli.ts | 64 ++++++++++++------- src/client.ts | 5 +- 6 files changed, 55 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 66c0304..f8854bf 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,9 @@ Captun is a tiny reference implementation of a self-hosted ngrok or Cloudflare T ```bash npx captun deploy -``` - -```bash -pnpm install -pnpm run deploy python3 -m http.server 3000 -CAPTUN_SERVER_URL=https://captun..workers.dev pnpm run cli -- --name demo 3000 +npx captun --name demo 3000 curl https://captun..workers.dev/demo/ ``` @@ -34,26 +29,25 @@ The core client/server pieces are small TypeScript modules around [Cap'n Web](ht Deploy the Worker first: ```bash -pnpm install -pnpm run deploy +npx captun deploy ``` Then expose a local port through a named folder tunnel: ```bash python3 -m http.server 3000 -CAPTUN_SERVER_URL=https://captun..workers.dev pnpm run cli -- --name demo 3000 +captun --name demo 3000 curl https://captun..workers.dev/demo/ ``` -If you omit `--name`, the CLI generates a random hyphenated tunnel name. If you set `CAPTUN_SECRET` on the Worker, pass the same value to the CLI through `CAPTUN_SECRET` or `--secret`: +If you omit `--name`, the CLI generates a random hyphenated tunnel name. If you set `CAPTUN_SECRET` on the Worker manually, pass the same value to the CLI with `--secret`: ```bash pnpm exec wrangler secret put CAPTUN_SECRET -CAPTUN_SECRET=secret CAPTUN_SERVER_URL=https://captun..workers.dev pnpm run cli -- --name demo 3000 +captun --server-url https://captun..workers.dev --secret secret --name demo 3000 ``` -The repo script runs the source CLI. The packaged command is `captun`, so installed consumers can run the same tunnel with `CAPTUN_SERVER_URL=... captun --name demo 3000`. +`captun deploy` stores the deployed Worker URL and generated secret in `$XDG_CONFIG_HOME/captun/config.json`, or `~/.config/captun/config.json` when `XDG_CONFIG_HOME` is not set. `--server-url` and `--secret` override the saved config. The repo script runs the same source CLI with `pnpm run cli --`. Folder tunnels are the golden path. The Worker routes `/:name/__connect` to the Cap'n Web session and `/:name/*` to normal proxied HTTP requests, stripping `/:name` before calling your local fetcher. diff --git a/docs/performance/streams-tunnels-templestein-20260518-171355.json b/docs/performance/streams-tunnels-templestein-20260518-171355.json index 59d09fd..fa7e1d0 100644 --- a/docs/performance/streams-tunnels-templestein-20260518-171355.json +++ b/docs/performance/streams-tunnels-templestein-20260518-171355.json @@ -2,9 +2,7 @@ "serverUrl": "https://{name}.tunnels.templestein.com", "bytes": 2097152, "chunkBytes": 65536, - "modes": [ - "stream" - ], + "modes": ["stream"], "readMode": "stream", "timeoutMs": 60000, "results": [ diff --git a/docs/performance/streams-workers-dev-20260518-170417.json b/docs/performance/streams-workers-dev-20260518-170417.json index fe8f58f..fe66306 100644 --- a/docs/performance/streams-workers-dev-20260518-170417.json +++ b/docs/performance/streams-workers-dev-20260518-170417.json @@ -2,9 +2,7 @@ "serverUrl": "https://captun.garple-pretend-customer-should-be-iterate-dev-stg-will-chan.workers.dev", "bytes": 2097152, "chunkBytes": 65536, - "modes": [ - "stream" - ], + "modes": ["stream"], "readMode": "stream", "timeoutMs": 60000, "results": [ diff --git a/examples/weather-reporter/worker.ts b/examples/weather-reporter/worker.ts index 9d2baa9..051b9ee 100644 --- a/examples/weather-reporter/worker.ts +++ b/examples/weather-reporter/worker.ts @@ -15,7 +15,9 @@ export class WeatherReporterEgressTunnel extends DurableObject { const config = await readConfig(); - const serverUrl = - input.serverUrl ?? process.env.CAPTUN_SERVER_URL ?? process.env.TUNNEL_SERVER_URL ?? config?.serverUrl; + const serverUrl = input.serverUrl || config?.serverUrl; if (!serverUrl) { - throw new Error(`No tunnel server configured. Run "capnweb-tunnel deploy" first or pass --server-url.`); + throw new Error( + `No tunnel server configured. Run "captun deploy" first or pass --server-url.`, + ); } - const secret = input.secret ?? process.env.CAPTUN_SECRET ?? process.env.TUNNEL_SECRET ?? config?.secret; - const name = input.name ?? randomName(); + const secret = input.secret || config?.secret; + const name = input.name || randomName(); const tunnel = tunnelUrl(serverUrl, name); const origin = `http://127.0.0.1:${input.port}`; const tunnelSession = await createCaptunTunnel({ url: new URL("__connect", tunnel), headers: secret ? { authorization: `Bearer ${secret}` } : undefined, - fetch: ((request) => { + fetch: (request) => { const url = new URL(request.url); return fetch(new Request(new URL(url.pathname + url.search, origin), request)); - }) satisfies Fetcher["fetch"], + }, }); console.log(`tunneling ${tunnel} -> ${origin}`); @@ -80,29 +90,33 @@ const router = os.router({ deploy: os .meta({ - description: "Deploy the Capnweb tunnel Worker with Wrangler and save local CLI config.", + description: "Deploy the Captun tunnel Worker with Wrangler and save local CLI config.", prompt: true, - examples: ["capnweb-tunnel deploy", "capnweb-tunnel deploy --route '*.tunnels.example.com/*'"], + examples: ["captun deploy", "captun deploy --route '*.tunnels.example.com/*'"], }) .input( z.object({ - route: z.string().optional().describe("Optional Worker route, for example *.tunnels.example.com/*"), + route: z + .string() + .optional() + .describe("Optional Worker route, for example *.tunnels.example.com/*"), secret: z .string() - .default(() => randomSecret()) - .describe("Secret required by tunnel clients"), + .optional() + .describe("Secret required by tunnel clients; generated when omitted"), }), ) .handler(async ({ input }) => { - const serverUrl = await deployWorker(input); - await writeConfig({ serverUrl, secret: input.secret }); + const secret = input.secret || randomSecret(); + const serverUrl = await deployWorker({ route: input.route, secret }); + await writeConfig({ serverUrl, secret }); return { serverUrl, configPath }; }), }); const cli = createCli({ router, - name: "capnweb-tunnel", + name: "captun", version: "0.0.0", description: "Expose local HTTP servers through a tiny Cloudflare Worker tunnel.", }); @@ -110,7 +124,7 @@ const cli = createCli({ await cli.run({ prompts }); async function deployWorker(input: { route?: string; secret: string }) { - const tempDir = await mkdtemp(resolve(tmpdir(), "capnweb-tunnel-")); + const tempDir = await mkdtemp(resolve(tmpdir(), "captun-")); const secretsFile = resolve(tempDir, "secrets.json"); try { await writeFile(secretsFile, JSON.stringify({ CAPTUN_SECRET: input.secret }), { mode: 0o600 }); @@ -131,7 +145,9 @@ async function deployWorker(input: { route?: string; secret: string }) { if (input.route) args.push("--route", input.route); const output = await runWrangler(args); - const serverUrl = input.route ? serverUrlFromRoute(input.route) : serverUrlFromWranglerOutput(output); + const serverUrl = input.route + ? serverUrlFromRoute(input.route) + : serverUrlFromWranglerOutput(output); if (!serverUrl) { throw new Error("Wrangler deploy succeeded, but the Worker URL was not found in its output."); } diff --git a/src/client.ts b/src/client.ts index 6175f47..b73957d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -23,8 +23,11 @@ export async function createCaptunTunnel( } class LocalFetcher extends RpcTarget implements CaptunClientRemoteFetcher { - constructor(private readonly options: CaptunClientCreateTunnelOptions) { + private options: CaptunClientCreateTunnelOptions; + + constructor(options: CaptunClientCreateTunnelOptions) { super(); + this.options = options; } fetch(request: Request) { From 68292f62c1071043f3021bc823a7fc2963ac3406 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Mon, 18 May 2026 20:09:47 +0100 Subject: [PATCH 05/43] Use disposal syntax in CLI --- package.json | 3 +- pnpm-lock.yaml | 308 +++++++++++++++++++++++++++++++++++++++++++++++-- src/cli.ts | 12 +- tsconfig.json | 2 +- vite.config.ts | 1 + 5 files changed, 304 insertions(+), 22 deletions(-) diff --git a/package.json b/package.json index 4be7409..a28dba4 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "dev": "wrangler dev", "test": "vp test", "test:unit": "vp test test/worker.test.ts", - "cli": "node src/cli.ts", + "cli": "tsx src/cli.ts", "prepack": "pnpm run build", "prepublishOnly": "pnpm run check && pnpm run test", "publish:dry-run": "pnpm publish --dry-run --no-git-checks" @@ -56,6 +56,7 @@ "devDependencies": { "@cloudflare/workers-types": "^4.20260515.1", "@types/node": "^25.8.0", + "tsx": "^4.22.2", "typescript": "^6.0.3", "vite-plus": "^0.1.21", "wrangler": "^4.92.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f4c7d10..be1a9e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,12 +30,15 @@ importers: '@types/node': specifier: ^25.8.0 version: 25.8.0 + tsx: + specifier: ^4.22.2 + version: 4.22.2 typescript: specifier: ^6.0.3 version: 6.0.3 vite-plus: specifier: ^0.1.21 - version: 0.1.21(@types/node@25.8.0)(esbuild@0.27.3)(typescript@6.0.3)(vite@8.0.13(@types/node@25.8.0)(esbuild@0.27.3)) + version: 0.1.21(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.2)(typescript@6.0.3)(vite@8.0.13(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.2)) wrangler: specifier: ^4.92.0 version: 4.92.0(@cloudflare/workers-types@4.20260518.1) @@ -51,7 +54,7 @@ importers: version: 6.0.3 vite-plus: specifier: ^0.1.21 - version: 0.1.21(@types/node@25.8.0)(esbuild@0.27.3)(typescript@6.0.3)(vite@8.0.13(@types/node@25.8.0)(esbuild@0.27.3)) + version: 0.1.21(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.2)(typescript@6.0.3)(vite@8.0.13(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.2)) packages: @@ -120,156 +123,312 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.0': + resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.27.3': resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.0': + resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.27.3': resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.0': + resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.27.3': resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.0': + resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.27.3': resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.0': + resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.27.3': resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.0': + resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.27.3': resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.0': + resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.27.3': resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.0': + resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.27.3': resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.0': + resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.27.3': resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.0': + resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.27.3': resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.0': + resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.27.3': resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.0': + resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.27.3': resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.0': + resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.27.3': resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.0': + resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.27.3': resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.0': + resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.27.3': resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.0': + resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.27.3': resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.0': + resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.27.3': resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.0': + resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.27.3': resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.0': + resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.27.3': resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.0': + resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.27.3': resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.0': + resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.27.3': resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.0': + resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.27.3': resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.0': + resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.27.3': resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.0': + resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.27.3': resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.0': + resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.27.3': resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} engines: {node: '>=18'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.0': + resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -1184,6 +1343,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.0: + resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} + engines: {node: '>=18'} + hasBin: true + fast-string-truncated-width@3.0.3: resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} @@ -1444,6 +1608,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.22.2: + resolution: {integrity: sha512-6w9FwtT8WQqRAyTNR+Z+86kghRqpmOLjXUrBlBT6T+CQGDuIMm0VmAqaFUFBIeKDTGobE6/YSigZYLeomzBaRg==} + engines: {node: '>=18.0.0'} + hasBin: true + type-fest@5.6.0: resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==} engines: {node: '>=20'} @@ -1609,81 +1778,159 @@ snapshots: '@esbuild/aix-ppc64@0.27.3': optional: true + '@esbuild/aix-ppc64@0.28.0': + optional: true + '@esbuild/android-arm64@0.27.3': optional: true + '@esbuild/android-arm64@0.28.0': + optional: true + '@esbuild/android-arm@0.27.3': optional: true + '@esbuild/android-arm@0.28.0': + optional: true + '@esbuild/android-x64@0.27.3': optional: true + '@esbuild/android-x64@0.28.0': + optional: true + '@esbuild/darwin-arm64@0.27.3': optional: true + '@esbuild/darwin-arm64@0.28.0': + optional: true + '@esbuild/darwin-x64@0.27.3': optional: true + '@esbuild/darwin-x64@0.28.0': + optional: true + '@esbuild/freebsd-arm64@0.27.3': optional: true + '@esbuild/freebsd-arm64@0.28.0': + optional: true + '@esbuild/freebsd-x64@0.27.3': optional: true + '@esbuild/freebsd-x64@0.28.0': + optional: true + '@esbuild/linux-arm64@0.27.3': optional: true + '@esbuild/linux-arm64@0.28.0': + optional: true + '@esbuild/linux-arm@0.27.3': optional: true + '@esbuild/linux-arm@0.28.0': + optional: true + '@esbuild/linux-ia32@0.27.3': optional: true + '@esbuild/linux-ia32@0.28.0': + optional: true + '@esbuild/linux-loong64@0.27.3': optional: true + '@esbuild/linux-loong64@0.28.0': + optional: true + '@esbuild/linux-mips64el@0.27.3': optional: true + '@esbuild/linux-mips64el@0.28.0': + optional: true + '@esbuild/linux-ppc64@0.27.3': optional: true + '@esbuild/linux-ppc64@0.28.0': + optional: true + '@esbuild/linux-riscv64@0.27.3': optional: true + '@esbuild/linux-riscv64@0.28.0': + optional: true + '@esbuild/linux-s390x@0.27.3': optional: true + '@esbuild/linux-s390x@0.28.0': + optional: true + '@esbuild/linux-x64@0.27.3': optional: true + '@esbuild/linux-x64@0.28.0': + optional: true + '@esbuild/netbsd-arm64@0.27.3': optional: true + '@esbuild/netbsd-arm64@0.28.0': + optional: true + '@esbuild/netbsd-x64@0.27.3': optional: true + '@esbuild/netbsd-x64@0.28.0': + optional: true + '@esbuild/openbsd-arm64@0.27.3': optional: true + '@esbuild/openbsd-arm64@0.28.0': + optional: true + '@esbuild/openbsd-x64@0.27.3': optional: true + '@esbuild/openbsd-x64@0.28.0': + optional: true + '@esbuild/openharmony-arm64@0.27.3': optional: true + '@esbuild/openharmony-arm64@0.28.0': + optional: true + '@esbuild/sunos-x64@0.27.3': optional: true + '@esbuild/sunos-x64@0.28.0': + optional: true + '@esbuild/win32-arm64@0.27.3': optional: true + '@esbuild/win32-arm64@0.28.0': + optional: true + '@esbuild/win32-ia32@0.27.3': optional: true + '@esbuild/win32-ia32@0.28.0': + optional: true + '@esbuild/win32-x64@0.27.3': optional: true + '@esbuild/win32-x64@0.28.0': + optional: true + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.34.5': @@ -2229,7 +2476,7 @@ snapshots: dependencies: undici-types: 7.24.6 - '@voidzero-dev/vite-plus-core@0.1.21(@types/node@25.8.0)(esbuild@0.27.3)(typescript@6.0.3)': + '@voidzero-dev/vite-plus-core@0.1.21(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.2)(typescript@6.0.3)': dependencies: '@oxc-project/runtime': 0.129.0 '@oxc-project/types': 0.129.0 @@ -2237,8 +2484,9 @@ snapshots: postcss: 8.5.14 optionalDependencies: '@types/node': 25.8.0 - esbuild: 0.27.3 + esbuild: 0.28.0 fsevents: 2.3.3 + tsx: 4.22.2 typescript: 6.0.3 '@voidzero-dev/vite-plus-darwin-arm64@0.1.21': @@ -2259,11 +2507,11 @@ snapshots: '@voidzero-dev/vite-plus-linux-x64-musl@0.1.21': optional: true - '@voidzero-dev/vite-plus-test@0.1.21(@types/node@25.8.0)(esbuild@0.27.3)(typescript@6.0.3)(vite@8.0.13(@types/node@25.8.0)(esbuild@0.27.3))': + '@voidzero-dev/vite-plus-test@0.1.21(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.2)(typescript@6.0.3)(vite@8.0.13(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.2))': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@voidzero-dev/vite-plus-core': 0.1.21(@types/node@25.8.0)(esbuild@0.27.3)(typescript@6.0.3) + '@voidzero-dev/vite-plus-core': 0.1.21(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.2)(typescript@6.0.3) es-module-lexer: 1.7.0 obug: 2.1.1 pixelmatch: 7.2.0 @@ -2273,7 +2521,7 @@ snapshots: tinybench: 2.9.0 tinyexec: 1.1.2 tinyglobby: 0.2.16 - vite: 8.0.13(@types/node@25.8.0)(esbuild@0.27.3) + vite: 8.0.13(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.2) ws: 8.20.1 optionalDependencies: '@types/node': 25.8.0 @@ -2354,6 +2602,35 @@ snapshots: '@esbuild/win32-ia32': 0.27.3 '@esbuild/win32-x64': 0.27.3 + esbuild@0.28.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.0 + '@esbuild/android-arm': 0.28.0 + '@esbuild/android-arm64': 0.28.0 + '@esbuild/android-x64': 0.28.0 + '@esbuild/darwin-arm64': 0.28.0 + '@esbuild/darwin-x64': 0.28.0 + '@esbuild/freebsd-arm64': 0.28.0 + '@esbuild/freebsd-x64': 0.28.0 + '@esbuild/linux-arm': 0.28.0 + '@esbuild/linux-arm64': 0.28.0 + '@esbuild/linux-ia32': 0.28.0 + '@esbuild/linux-loong64': 0.28.0 + '@esbuild/linux-mips64el': 0.28.0 + '@esbuild/linux-ppc64': 0.28.0 + '@esbuild/linux-riscv64': 0.28.0 + '@esbuild/linux-s390x': 0.28.0 + '@esbuild/linux-x64': 0.28.0 + '@esbuild/netbsd-arm64': 0.28.0 + '@esbuild/netbsd-x64': 0.28.0 + '@esbuild/openbsd-arm64': 0.28.0 + '@esbuild/openbsd-x64': 0.28.0 + '@esbuild/openharmony-arm64': 0.28.0 + '@esbuild/sunos-x64': 0.28.0 + '@esbuild/win32-arm64': 0.28.0 + '@esbuild/win32-ia32': 0.28.0 + '@esbuild/win32-x64': 0.28.0 + fast-string-truncated-width@3.0.3: {} fast-string-width@3.0.2: @@ -2621,6 +2898,12 @@ snapshots: tslib@2.8.1: optional: true + tsx@4.22.2: + dependencies: + esbuild: 0.28.0 + optionalDependencies: + fsevents: 2.3.3 + type-fest@5.6.0: dependencies: tagged-tag: 1.0.0 @@ -2635,11 +2918,11 @@ snapshots: dependencies: pathe: 2.0.3 - vite-plus@0.1.21(@types/node@25.8.0)(esbuild@0.27.3)(typescript@6.0.3)(vite@8.0.13(@types/node@25.8.0)(esbuild@0.27.3)): + vite-plus@0.1.21(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.2)(typescript@6.0.3)(vite@8.0.13(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.2)): dependencies: '@oxc-project/types': 0.129.0 - '@voidzero-dev/vite-plus-core': 0.1.21(@types/node@25.8.0)(esbuild@0.27.3)(typescript@6.0.3) - '@voidzero-dev/vite-plus-test': 0.1.21(@types/node@25.8.0)(esbuild@0.27.3)(typescript@6.0.3)(vite@8.0.13(@types/node@25.8.0)(esbuild@0.27.3)) + '@voidzero-dev/vite-plus-core': 0.1.21(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.2)(typescript@6.0.3) + '@voidzero-dev/vite-plus-test': 0.1.21(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.2)(typescript@6.0.3)(vite@8.0.13(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.2)) oxfmt: 0.48.0 oxlint: 1.63.0(oxlint-tsgolint@0.22.1) oxlint-tsgolint: 0.22.1 @@ -2683,7 +2966,7 @@ snapshots: - vite - yaml - vite@8.0.13(@types/node@25.8.0)(esbuild@0.27.3): + vite@8.0.13(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.2): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -2692,8 +2975,9 @@ snapshots: tinyglobby: 0.2.16 optionalDependencies: '@types/node': 25.8.0 - esbuild: 0.27.3 + esbuild: 0.28.0 fsevents: 2.3.3 + tsx: 4.22.2 workerd@1.20260515.1: optionalDependencies: diff --git a/src/cli.ts b/src/cli.ts index d9da2fc..b382d83 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -50,8 +50,8 @@ const router = os.router({ .int() .positive() .default(3000) - .meta({ positional: true }) - .describe("Local port to expose"), + .describe("Local port to expose") + .meta({ positional: true }), name: z.string().optional().describe("Tunnel name"), serverUrl: z.url().optional().describe("Tunnel Worker base URL"), secret: z.string().optional().describe("Tunnel connection secret"), @@ -71,7 +71,7 @@ const router = os.router({ const tunnel = tunnelUrl(serverUrl, name); const origin = `http://127.0.0.1:${input.port}`; - const tunnelSession = await createCaptunTunnel({ + using _tunnelSession = await createCaptunTunnel({ url: new URL("__connect", tunnel), headers: secret ? { authorization: `Bearer ${secret}` } : undefined, fetch: (request) => { @@ -81,11 +81,7 @@ const router = os.router({ }); console.log(`tunneling ${tunnel} -> ${origin}`); - try { - await waitForShutdown(); - } finally { - tunnelSession[Symbol.dispose](); - } + await waitForShutdown(); }), deploy: os diff --git a/tsconfig.json b/tsconfig.json index 1e79186..4a736dd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "outDir": "dist", - "target": "ESNext", + "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", "lib": ["ESNext"], diff --git a/vite.config.ts b/vite.config.ts index a25da81..b5420e8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -16,6 +16,7 @@ export default defineConfig({ cli: "src/cli.ts", }, dts: { build: true }, + target: "es2022", deps: { neverBundle: ["cloudflare:workers"], }, From 14778124628510ee67f636cf1431f236d5464566 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Mon, 18 May 2026 20:37:24 +0100 Subject: [PATCH 06/43] Replace Vite build with tsc and Miniflare tests --- README.md | 2 +- examples/weather-reporter/e2e.test.ts | 5 +- examples/weather-reporter/package.json | 4 +- package.json | 52 +- pnpm-lock.yaml | 984 +++++-------------------- scripts/rewrite-dts-imports.mjs | 23 + src/cli.ts | 0 src/client.ts | 2 +- src/server.ts | 4 +- src/worker.ts | 36 +- test/cloudflare-workers-shim.ts | 7 - test/e2e.test.ts | 105 ++- test/worker.test.ts | 152 +++- tsconfig.json | 12 +- tsconfig.lib.json | 6 + vite.config.ts | 38 - 16 files changed, 453 insertions(+), 979 deletions(-) create mode 100644 scripts/rewrite-dts-imports.mjs mode change 100644 => 100755 src/cli.ts delete mode 100644 test/cloudflare-workers-shim.ts delete mode 100644 vite.config.ts diff --git a/README.md b/README.md index f8854bf..82ffff4 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,7 @@ sequenceDiagram Server-->>HTTP: Response ``` -See [examples/weather-reporter](./examples/weather-reporter) for a small workspace package that imports `captun/server` and has its own `vite-plus` e2e tests. +See [examples/weather-reporter](./examples/weather-reporter) for a small workspace package that imports `captun/server` and has its own e2e tests. ## 3. Development diff --git a/examples/weather-reporter/e2e.test.ts b/examples/weather-reporter/e2e.test.ts index b9bd683..4740069 100644 --- a/examples/weather-reporter/e2e.test.ts +++ b/examples/weather-reporter/e2e.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, test, vi } from "vite-plus/test"; -import { createCaptunTunnel } from "../../src/client"; +import { describe, expect, test, vi } from "vitest"; + +import { createCaptunTunnel } from "../../src/client.ts"; vi.setConfig({ testTimeout: 15_000 }); diff --git a/examples/weather-reporter/package.json b/examples/weather-reporter/package.json index 48af2f4..c885238 100644 --- a/examples/weather-reporter/package.json +++ b/examples/weather-reporter/package.json @@ -4,13 +4,13 @@ "type": "module", "scripts": { "typecheck": "tsc --noEmit", - "test": "vp test" + "test": "vitest run" }, "dependencies": { "captun": "workspace:*" }, "devDependencies": { "typescript": "^6.0.3", - "vite-plus": "^0.1.21" + "vitest": "^4.1.6" } } diff --git a/package.json b/package.json index a28dba4..4784bb0 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "url": "https://github.com/iterate/captun.git" }, "bin": { - "captun": "./dist/cli.mjs" + "captun": "./src/cli.ts" }, "files": [ "dist", @@ -16,31 +16,49 @@ "wrangler.toml" ], "type": "module", - "main": "./dist/index.mjs", - "types": "./dist/index.d.mts", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", "exports": { ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" + "types": "./src/index.ts", + "import": "./src/index.ts" }, "./client": { - "types": "./dist/client.d.mts", - "import": "./dist/client.mjs" + "types": "./src/client.ts", + "import": "./src/client.ts" }, "./server": { - "types": "./dist/server.d.mts", - "import": "./dist/server.mjs" + "types": "./src/server.ts", + "import": "./src/server.ts" + } + }, + "publishConfig": { + "bin": { + "captun": "./dist/cli.js" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./client": { + "types": "./dist/client.d.ts", + "import": "./dist/client.js" + }, + "./server": { + "types": "./dist/server.d.ts", + "import": "./dist/server.js" + } } }, "scripts": { - "build": "vp pack", - "check": "vp check", - "check:fix": "vp check --fix", - "typecheck": "tsc -p tsconfig.lib.json && tsc -p tsconfig.json && pnpm --filter @captun/weather-reporter typecheck", + "build": "rm -rf dist && tsc -p tsconfig.lib.json && node scripts/rewrite-dts-imports.mjs && chmod +x dist/cli.js", + "check": "pnpm run typecheck", + "typecheck": "tsc -p tsconfig.json && pnpm --filter @captun/weather-reporter typecheck", "deploy": "wrangler deploy", "dev": "wrangler dev", - "test": "vp test", - "test:unit": "vp test test/worker.test.ts", + "test": "pnpm run build && vitest run", + "test:unit": "pnpm run build && vitest run test/worker.test.ts", "cli": "tsx src/cli.ts", "prepack": "pnpm run build", "prepublishOnly": "pnpm run check && pnpm run test", @@ -56,9 +74,11 @@ "devDependencies": { "@cloudflare/workers-types": "^4.20260515.1", "@types/node": "^25.8.0", + "esbuild": "^0.28.0", + "miniflare": "^4.20260515.0", "tsx": "^4.22.2", "typescript": "^6.0.3", - "vite-plus": "^0.1.21", + "vitest": "^4.1.6", "wrangler": "^4.92.0" }, "packageManager": "pnpm@10.11.1" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be1a9e4..0c21ca6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,15 +30,21 @@ importers: '@types/node': specifier: ^25.8.0 version: 25.8.0 + esbuild: + specifier: ^0.28.0 + version: 0.28.0 + miniflare: + specifier: ^4.20260515.0 + version: 4.20260515.0 tsx: specifier: ^4.22.2 version: 4.22.2 typescript: specifier: ^6.0.3 version: 6.0.3 - vite-plus: - specifier: ^0.1.21 - version: 0.1.21(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.2)(typescript@6.0.3)(vite@8.0.13(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.2)) + vitest: + specifier: ^4.1.6 + version: 4.1.6(@types/node@25.8.0)(vite@8.0.13(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.2)) wrangler: specifier: ^4.92.0 version: 4.92.0(@cloudflare/workers-types@4.20260518.1) @@ -52,9 +58,9 @@ importers: typescript: specifier: ^6.0.3 version: 6.0.3 - vite-plus: - specifier: ^0.1.21 - version: 0.1.21(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.2)(typescript@6.0.3)(vite@8.0.13(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.2)) + vitest: + specifier: ^4.1.6 + version: 4.1.6(@types/node@25.8.0)(vite@8.0.13(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.2)) packages: @@ -767,277 +773,9 @@ packages: '@orpc/standard-server@1.14.3': resolution: {integrity: sha512-qO6xJy+S15Wx0elQeVojo3p5EgBLJDTEtElPcUF9o4ac8hrikYZJBeSg7qGgu/elCIrVbaFk/16Lu8P4qatPWg==} - '@oxc-project/runtime@0.129.0': - resolution: {integrity: sha512-0+S67blQakgeNqoKGozOUp5rQBrz2ynXZ2QIINXZPiafsD0YL0UogB9hAWc1S7k6VSNwKYC/N7MqT0V6IzpHkQ==} - engines: {node: ^20.19.0 || >=22.12.0} - - '@oxc-project/types@0.129.0': - resolution: {integrity: sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==} - '@oxc-project/types@0.130.0': resolution: {integrity: sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==} - '@oxfmt/binding-android-arm-eabi@0.48.0': - resolution: {integrity: sha512-uwqk+/KhQvBIpULD8SMM/zAafMRC/+DV/xsEQjkkIsJ/kLmEI/2bxonVowcYTiXqqZ/a0FEW8DPkZY3VvwELDA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [android] - - '@oxfmt/binding-android-arm64@0.48.0': - resolution: {integrity: sha512-VUCiKuXK5+McVssgHEJdrcGK7hRJzrRb36zm9/jwzMholyYt4BgXhw5Nm1V1DX6Ce717Zi/1jk432b/tgmQgtQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - - '@oxfmt/binding-darwin-arm64@0.48.0': - resolution: {integrity: sha512-IkKp8rnIyQLW6Jt+6jragCbUVYSayk55lapiprLjIVvt4NczLyO/nwX2GgefLQ5iaBdfS8UEAFgCs/pLO6Cl0w==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - - '@oxfmt/binding-darwin-x64@0.48.0': - resolution: {integrity: sha512-+aFuhsGIuvnoOjXyKVHMhPKJZR1kQkAl8QyrKoMlA7yJsSTC3N0Asl53La8TChSHhW8epToQ/Q0nvLmEmfNmLg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - - '@oxfmt/binding-freebsd-x64@0.48.0': - resolution: {integrity: sha512-fbqzQL8FjI9gGnktI7RIo0dksDziTAYBy7xlI7jU7eID5fxLF/25fS4Xj6GydD8Y5oWHL83U4NK160QaOAxtyg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - - '@oxfmt/binding-linux-arm-gnueabihf@0.48.0': - resolution: {integrity: sha512-hn4i0zhAyTiB3ZHjQfYUZkDvrbVkohw1S7pySWxWUoZ87HnkDoTFThj7QTxk40hNPOTUP0vHbPRNamFIv1HBJQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@oxfmt/binding-linux-arm-musleabihf@0.48.0': - resolution: {integrity: sha512-R4WBD9qF3QM9hqgdAa+fBGXmquTvDUujrPQ36t2Sjk8RPOSKGHDeN7l/khr10hqbQaOq9KCgPHG9ubNET/X/RQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@oxfmt/binding-linux-arm64-gnu@0.48.0': - resolution: {integrity: sha512-5bVdwSwlm1M8wbYCorLOxWxUBw/8tBvHYyQNIfwWVPwOJaj5vg1APSGJQVpwJfV5VNE9PSrR91UKEpoNwHhqUA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - - '@oxfmt/binding-linux-arm64-musl@0.48.0': - resolution: {integrity: sha512-vCS3Fk7gFslTqE1lUE2IlroyVV7u/9SmMA/uBqDoshuck2psGWcjW0ePyPZI3rM3+qtf2pDaMVIKMHozraifuw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - - '@oxfmt/binding-linux-ppc64-gnu@0.48.0': - resolution: {integrity: sha512-gKtfFfueUClXDumyoHUbymqRf7prHejOOyzJK0eIJn93GF9JBdFHdo60TM1ZBHxkEwZvjuOgHmKtneKbEOc/Eg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] - os: [linux] - - '@oxfmt/binding-linux-riscv64-gnu@0.48.0': - resolution: {integrity: sha512-SYt0UhOvZD/UwZz9sXq6J2uAw8o24f5VZpLB2DH01f6MevshmlgakQlZe2lwek2sZJkd07eLu7mZa0g7yeiw7Q==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [riscv64] - os: [linux] - - '@oxfmt/binding-linux-riscv64-musl@0.48.0': - resolution: {integrity: sha512-JLbrwck2AopG4ud/XklZO5N+qxGC7cS7ROvXZVNfx0MCLDDL2kGOLvzuWORkVjnjAM0CMAfIMU2zNBtQbM+4dw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [riscv64] - os: [linux] - - '@oxfmt/binding-linux-s390x-gnu@0.48.0': - resolution: {integrity: sha512-mdxt5L8OQLxkQH+JVpdC/lknZNe0lX4hlO3d8+xvw2wToo+iDrid9tiGOd5bmHfUVd5wVhrUry0qlu5vq66NkQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - - '@oxfmt/binding-linux-x64-gnu@0.48.0': - resolution: {integrity: sha512-oEz1BQwMrV7OMEFx/3VPDU3n9TM0AnxpktDYXjEg5i6nTX87wo18wSfBvkl4tzAICdKtoAQAdBIl7Y7hsPlx5w==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - - '@oxfmt/binding-linux-x64-musl@0.48.0': - resolution: {integrity: sha512-g2SKTTurP5mWjd8Ecait0erYqmltL4IqW1EwttM25BxM6NiTt4ubobJYMR1uox1V2QgG4UfHH10CGRvWlUixjw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - - '@oxfmt/binding-openharmony-arm64@0.48.0': - resolution: {integrity: sha512-CIg24VgheEpvolHL2gQuax5qcQ602bRMHrJ9g8XsQr3iVj9aSPgopigBKuMqrXsupwkrU+RQCn5cG8PgFntR6w==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - - '@oxfmt/binding-win32-arm64-msvc@0.48.0': - resolution: {integrity: sha512-zeaWkcxcEULwkGF3I/HgEvcDPN8buYDrxibBUa/IFh5Vmwyge+KpLO+hEwSovW349H0O/C0Z2kaFmEzEDm00/Q==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - - '@oxfmt/binding-win32-ia32-msvc@0.48.0': - resolution: {integrity: sha512-yiEKnIAGvx5CyZQOlMaNlZkAbwT7/Quk0j3WLt+PR5hK+qYjPTRRJYDfD77wCBPLvEYAG41v4KG3iL0H+uxoxg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ia32] - os: [win32] - - '@oxfmt/binding-win32-x64-msvc@0.48.0': - resolution: {integrity: sha512-GSD2+7t2UoVMV2NgxXypa4bKewflPMAjYnF0Xw9/ht82ZfafAHhb8STwrEd7wlH2PFogt5zw3WVCxYJaHUdbeQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - - '@oxlint-tsgolint/darwin-arm64@0.22.1': - resolution: {integrity: sha512-4150Lpgc1YM09GcjA6GSrra1JoPjC7aOpfywLjWEY4vW0Sd1qKzqHF1WRaiw0/qUZ40OATYdv3aRd7ipPkWQbw==} - cpu: [arm64] - os: [darwin] - - '@oxlint-tsgolint/darwin-x64@0.22.1': - resolution: {integrity: sha512-vFWcPWYOgZs4HWcgS1EjUZg33NLcNfEYU49KGImmCfZWkflENrmBYV4HN/C0YeAPum6ZZ/goPSvQrB/cOD+NfA==} - cpu: [x64] - os: [darwin] - - '@oxlint-tsgolint/linux-arm64@0.22.1': - resolution: {integrity: sha512-6LiUpP0Zir3+29FvBm7Y28q/dBjSHqTZ5MhG1Ckw4fGhI4cAvbcwXaKvbjx1TP7rRmBNOoq/M5xdpHjTb+GAew==} - cpu: [arm64] - os: [linux] - - '@oxlint-tsgolint/linux-x64@0.22.1': - resolution: {integrity: sha512-fuX1hEQfpHauUbXADsfqVhRzrUrGabzGXbj5wsp2vKhV5uk/Rze8Mba9GdjFGECzvXudMGqHqxB4r6jGRdhxVA==} - cpu: [x64] - os: [linux] - - '@oxlint-tsgolint/win32-arm64@0.22.1': - resolution: {integrity: sha512-8SZidAj+jrbZf9ZjBEYW0tiNZ+KasqB2zgW26qdiPpQSF/DzURnPmXz651IeA9YsmbVdHGIooEHUmev6QJdquA==} - cpu: [arm64] - os: [win32] - - '@oxlint-tsgolint/win32-x64@0.22.1': - resolution: {integrity: sha512-QweSk9H5lFh5Y+WUf2Kq/OAN88V6+62ZwGhP38gqdRotI90luXSMkruFTj7Q2rYrzH4ZVNaSqx7NY8JpSfIzqg==} - cpu: [x64] - os: [win32] - - '@oxlint/binding-android-arm-eabi@1.63.0': - resolution: {integrity: sha512-A9xLtQt7i0OA1PoB/meog6kikXI9CdwEp7ZwQqmgnpKn3G3b1orvTDy8CQ6T7w1HvDrgWGB78PkFKcWgibcTCg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [android] - - '@oxlint/binding-android-arm64@1.63.0': - resolution: {integrity: sha512-SQo+ZMvdR9l3CxZp5W5gFNxSiDxclY6lOzzNpKYLF8asESpm3Pwumx0gER5T7aHLF1/2BAAtLD3DiDkdgy4V1A==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - - '@oxlint/binding-darwin-arm64@1.63.0': - resolution: {integrity: sha512-6W82XjJDTmMnjg30427l0dufpnyLoq7wEukKdM6/g2VIybRVuQiBVh43EA4b+UxZ3+tLcKm+Or/pXGNgLCEU8g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - - '@oxlint/binding-darwin-x64@1.63.0': - resolution: {integrity: sha512-CnWd/YCuVG5W1BYkjJEVbJG11o526O9qAwBEQM+nh8K19CRFUkFdROXCyYkGmroHEYQe4vgQ6+lh3550Lp35Xw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - - '@oxlint/binding-freebsd-x64@1.63.0': - resolution: {integrity: sha512-a4eZAqrmtajqcxfdAzC+l7g3PaE3V8hpAYqqeD3fTxLXOMFdK3eNTZrU80n4dDEVm0JXy1aL5PqvqWldBl6zYA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - - '@oxlint/binding-linux-arm-gnueabihf@1.63.0': - resolution: {integrity: sha512-tYUtU9TdbU3uXF5D62g5zXJ13iniFGhXQx5vp9cyEjGdbSAY3VdFBSaldYvyoDmgMZ0ZYuwQP1Y4t2Fhejwa0w==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@oxlint/binding-linux-arm-musleabihf@1.63.0': - resolution: {integrity: sha512-I5r3twFf776UZg9dmRo2xbrKt00tTkORXEVe0ctg4vdTkQvJAjiCHxnbAU2HL1AiJ9cqADA76MAliuilsAWnvg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@oxlint/binding-linux-arm64-gnu@1.63.0': - resolution: {integrity: sha512-t7ltUkg6FFh4b564QyGir8xIj/QZbXu8FlcRkcyW9+ztr/mfRHlvUOFd95pJCXi9s/L5DrUeWWgpXRS+V+6igQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - - '@oxlint/binding-linux-arm64-musl@1.63.0': - resolution: {integrity: sha512-Q5mmZy/XWjuYFUuQyYjOvZ5U/JkKEwnpir6hGxhh6HcdP0V/BKxLo8dqkfF/t7r7AguB17dfS/8+go5AQDRR6g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - - '@oxlint/binding-linux-ppc64-gnu@1.63.0': - resolution: {integrity: sha512-uBGtuZ0TzLB4x5wVa82HGNvYqY8buwDhyCnCP0R0gkk9szqVsP0MeTtD5HX7EsEuFIt+aYmYxuxeVxs3nTSwtQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] - os: [linux] - - '@oxlint/binding-linux-riscv64-gnu@1.63.0': - resolution: {integrity: sha512-h4s6FwxE+9MeA181o0dnDwHP32Y/bG8EiB/vrD6Ib+AMt6haigDc/0bUtI/sLmQDBMJnUfaCmtSSrEAqjtEVrA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [riscv64] - os: [linux] - - '@oxlint/binding-linux-riscv64-musl@1.63.0': - resolution: {integrity: sha512-2EaNcCBR8Mcjl5ARtuN3BdEpVkX7KpjSjMGZ/mJMIeaXgTtdz5ytg2VwygMSStA/k0ixfvZFoZOfjDEcouV5vQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [riscv64] - os: [linux] - - '@oxlint/binding-linux-s390x-gnu@1.63.0': - resolution: {integrity: sha512-p4hlf/fd7TrYYl3QrWWD0GocqJefwMu3cHQhmi2FvEB/YOvFb5DZN3SMBaPi7B1TM5DeypkEtrVib674q1KKPg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - - '@oxlint/binding-linux-x64-gnu@1.63.0': - resolution: {integrity: sha512-Vgq9rkRVcPcjbcH+ihYTfpeR7vCXfqpd+z5ItTGc0yYUV59L5ceHYN1iV4H9bKGV7Rn5hkVc7x3mSvHegduENA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - - '@oxlint/binding-linux-x64-musl@1.63.0': - resolution: {integrity: sha512-3/Lkq/ncooA61rorrC+ZQed1Bc4VpGj+WnGsp58zmxKgvZ2vhreu+dcVyr3mX8NUpq7mfZ4gDDTou/yrF1Pd7A==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - - '@oxlint/binding-openharmony-arm64@1.63.0': - resolution: {integrity: sha512-0/EdD/6hDkx5Mfd769PTjvEM8mZ/6Dfukp1dBCL/2PjlIVGEtYdNZyok6ChqYPsT9JcFnlQnUeQzO0/1L/oC9w==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - - '@oxlint/binding-win32-arm64-msvc@1.63.0': - resolution: {integrity: sha512-wb0CUkN8ngwPiRQBjD1Cj0LsHeNvm+Xt6YBHDMtj2DVQVD6Oj8Ri7g6BD+KICf6LaBqZlmzOvy6nF9E/8yyGOg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - - '@oxlint/binding-win32-ia32-msvc@1.63.0': - resolution: {integrity: sha512-BX5iq+ovdNlVYhSn5qPMUIT0uwAwt2lmEnCnzK+Gkhw4DovIvhGb96OFhV8yzQNUnQxn/xGkOR+X+BLrLDNm8w==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ia32] - os: [win32] - - '@oxlint/binding-win32-x64-msvc@1.63.0': - resolution: {integrity: sha512-QeN/WELOfsXMeYwxvfgQrl6CbVftYUCZsGXHjXQd5Trccm8+i4gmtxaOui4xbJQaiDlviF8F3yLSBloQUeFsfA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - - '@polka/url@1.0.0-next.29': - resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} - '@poppinss/colors@4.1.6': resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} @@ -1158,150 +896,40 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/node@25.8.0': resolution: {integrity: sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==} - '@voidzero-dev/vite-plus-core@0.1.21': - resolution: {integrity: sha512-BEnqw8h2vxgKkzBjmmW4e1kwPwzoWc+jXJQB+7e0Dm1/1AkdTaQ9FgUMFzDfYrwTDkGzCZHSSsHDOl3RERQFTA==} - engines: {node: ^20.19.0 || >=22.12.0} - peerDependencies: - '@arethetypeswrong/core': ^0.18.1 - '@tsdown/css': 0.22.0 - '@tsdown/exe': 0.22.0 - '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.1.18 - esbuild: ^0.27.0 || ^0.28.0 - jiti: '>=1.21.0' - less: ^4.0.0 - publint: ^0.3.8 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - typescript: ^5.0.0 || ^6.0.0 - unplugin-unused: ^0.5.0 - unrun: '*' - yaml: ^2.4.2 - peerDependenciesMeta: - '@arethetypeswrong/core': - optional: true - '@tsdown/css': - optional: true - '@tsdown/exe': - optional: true - '@types/node': - optional: true - '@vitejs/devtools': - optional: true - esbuild: - optional: true - jiti: - optional: true - less: - optional: true - publint: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - typescript: - optional: true - unplugin-unused: - optional: true - unrun: - optional: true - yaml: - optional: true - - '@voidzero-dev/vite-plus-darwin-arm64@0.1.21': - resolution: {integrity: sha512-T7mPiDbE7VtjpegtJJ/e/uQOjOA/ufMo7npAaP9WVHxUEWLaR/OjVXXL9ALiW+CfKEQ0Qk/iWDB0mI6YMndzNQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - - '@voidzero-dev/vite-plus-darwin-x64@0.1.21': - resolution: {integrity: sha512-DqS0ZJ0sRtXu55loEIz/mkX99fl1HQ0b9UUj+Qm13NoMfh6JC8d0g6J+Dbu/EJy0TEU7QLjCXoALLQ7ZrsW34g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - - '@voidzero-dev/vite-plus-linux-arm64-gnu@0.1.21': - resolution: {integrity: sha512-PoW375e3pSayRuN43agoe8LdItEa82bPhcaW7elPxoiO8puynB+4x5Xy5ozU5XgjEuCB3nYKE6R/b9peWsaFEQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - - '@voidzero-dev/vite-plus-linux-arm64-musl@0.1.21': - resolution: {integrity: sha512-Q5c6i1TyRuMELDcGozuI5Y0JhIZsSbAJ067Okty6XmoJ5tSmUnMzEO5HKRmgjfGryjJpn5dpUKXzj2eWsdx+6g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - - '@voidzero-dev/vite-plus-linux-x64-gnu@0.1.21': - resolution: {integrity: sha512-DquiIAvGIUIJBfqOnqAtBPoyhNIGLM1DzswzDPddO5kjg6NCE4hPbFr5ncjhSKqyp88Kd1YWYD93yg3FAdr/EA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] + '@vitest/expect@4.1.6': + resolution: {integrity: sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==} - '@voidzero-dev/vite-plus-linux-x64-musl@0.1.21': - resolution: {integrity: sha512-/nVT+eFySYwf3uEv+n9FrdJ0bg3SbfqUkAnIxH7XMQ/hhmU4lAE2dH4SiUqk6h70gESaFHfJmhWtp5Qzt/W7Vw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - - '@voidzero-dev/vite-plus-test@0.1.21': - resolution: {integrity: sha512-kRUh2rELGFg9Agv2OhoaNCSRfy7XUFCL9n+aNEvSStI/p8C5iPMMe2auZCfbLbUcvpsDFvNVOwtY4XQRDFJiJQ==} - engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + '@vitest/mocker@4.1.6': + resolution: {integrity: sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==} peerDependencies: - '@edge-runtime/vm': '*' - '@opentelemetry/api': ^1.9.0 - '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/coverage-istanbul': 4.1.5 - '@vitest/coverage-v8': 4.1.5 - '@vitest/ui': 4.1.5 - happy-dom: '*' - jsdom: '*' + msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - '@edge-runtime/vm': - optional: true - '@opentelemetry/api': - optional: true - '@types/node': - optional: true - '@vitest/coverage-istanbul': - optional: true - '@vitest/coverage-v8': - optional: true - '@vitest/ui': - optional: true - happy-dom: + peerDependenciesMeta: + msw: optional: true - jsdom: + vite: optional: true - '@voidzero-dev/vite-plus-win32-arm64-msvc@0.1.21': - resolution: {integrity: sha512-KhEqfRKlfuuVJb2eY8ZCmmXcxUqy8/ZBZPWPipxcB1QDWIykyN5F4zbewLxXw18JL/Q0ISP0p7V8RIldAh4Gyw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] + '@vitest/pretty-format@4.1.6': + resolution: {integrity: sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==} - '@voidzero-dev/vite-plus-win32-x64-msvc@0.1.21': - resolution: {integrity: sha512-lH8d2qElBfUUpejZhYvK3CrP6kHTz0z2jff/ZowlY6jkr1pQkYj3EVhRi5r81DXcbXZs9yAbXbBPw8mVZtG9FA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] + '@vitest/runner@4.1.6': + resolution: {integrity: sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==} + + '@vitest/snapshot@4.1.6': + resolution: {integrity: sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==} + + '@vitest/spy@4.1.6': + resolution: {integrity: sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==} + + '@vitest/utils@4.1.6': + resolution: {integrity: sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==} assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} @@ -1313,6 +941,10 @@ packages: capnweb@0.8.0: resolution: {integrity: sha512-BK/TuXUiyfLSKsmjojn70yN7oYG/JJzoURZ3tckjg5Zj2KcygPm0A5jyOlswK7SYB4f0Gh9tt+RZ132b80iLfA==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} @@ -1324,6 +956,9 @@ packages: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie@1.1.1: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} @@ -1335,8 +970,8 @@ packages: error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} - es-module-lexer@1.7.0: - resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} esbuild@0.27.3: resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} @@ -1348,6 +983,13 @@ packages: engines: {node: '>=18'} hasBin: true + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + fast-string-truncated-width@3.0.3: resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} @@ -1449,15 +1091,14 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + miniflare@4.20260515.0: resolution: {integrity: sha512-2j0oQWizk1Eu4Cm8tDX7Z+Nsjd0nebIj1TQcQ+Oy1QKeo0Ay9+bdn8wfLAtOj9znDCybDCUlnS1+nYvKXEdfNg==} engines: {node: '>=22.0.0'} hasBin: true - mrmime@2.0.1: - resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} - engines: {node: '>=10'} - mute-stream@3.0.0: resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} engines: {node: ^20.17.0 || >=22.9.0} @@ -1473,25 +1114,6 @@ packages: openapi-types@12.1.3: resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} - oxfmt@0.48.0: - resolution: {integrity: sha512-AVaLh+7XeGx+R1zfFV+f6VV61nT2MWVJXVUDhbTm5LBWGyNt64xAyh3NYYyjeY2WykNt9AvqSQLPHcbWquYF9g==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - - oxlint-tsgolint@0.22.1: - resolution: {integrity: sha512-YUSGSLUnoolsu8gxISEDio3q1rtsCozwfOzASUn3DT2mR2EeQ93uEEnen7s+6LpF+lyTQFln1pQfqwBh/fsVEg==} - hasBin: true - - oxlint@1.63.0: - resolution: {integrity: sha512-9TGXetdjgIHOJ9OiReomP7nnrMkV9HxC1xM2ramJSLQpzxjsAJtQwa4wqkJN2f/uCrqZuJseFuSlWDdvcruveg==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - oxlint-tsgolint: '>=0.22.1' - peerDependenciesMeta: - oxlint-tsgolint: - optional: true - path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} @@ -1505,14 +1127,6 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} - pixelmatch@7.2.0: - resolution: {integrity: sha512-xhcb4yHu9sM/G7foGzoLtXYcC0zHEaOXXjRKhGup0fw78Nf2Tkiapv4EQyMzrbcmQPsllAI7DbFY2UT7PlI9Pg==} - hasBin: true - - pngjs@7.0.0: - resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} - engines: {node: '>=14.19.0'} - postcss@8.5.14: resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} engines: {node: ^10 || ^12 || >=14} @@ -1538,18 +1152,20 @@ packages: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} - sirv@3.0.2: - resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} - engines: {node: '>=18'} - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} @@ -1572,13 +1188,9 @@ packages: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} - tinypool@2.1.0: - resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} - engines: {node: ^20.0.0 || >=22.0.0} - - totalist@3.0.1: - resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} - engines: {node: '>=6'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} trpc-cli@0.14.1: resolution: {integrity: sha512-yXycwKWAu322fJGQqdh9fbEPTLjmqnwrpHiLnLNM+kPEwDxXlkH3gbO9iOTmN5CNOBo+8Uk+Tuae+XS8uANz/w==} @@ -1632,11 +1244,6 @@ packages: unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} - vite-plus@0.1.21: - resolution: {integrity: sha512-7MLc9abMelE8g5/vj/xEY8joWT9PLnN/XjX3FhwOliB75WOX3YADcMEFrufmvnl+D4UhrMNZQa2k3A5CSuFJhw==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - vite@8.0.13: resolution: {integrity: sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1680,6 +1287,52 @@ packages: yaml: optional: true + vitest@4.1.6: + resolution: {integrity: sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.6 + '@vitest/browser-preview': 4.1.6 + '@vitest/browser-webdriverio': 4.1.6 + '@vitest/coverage-istanbul': 4.1.6 + '@vitest/coverage-v8': 4.1.6 + '@vitest/ui': 4.1.6 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + workerd@1.20260515.1: resolution: {integrity: sha512-MjKOJLcvU45xXedQowvuiHtJTxu4WTHYQeIlF7YmjuqhiI6dImTFxWCEoRQHiskztxuVSNEmdO7/0UfDu6OMnQ==} engines: {node: '>=16'} @@ -2251,146 +1904,8 @@ snapshots: transitivePeerDependencies: - '@opentelemetry/api' - '@oxc-project/runtime@0.129.0': {} - - '@oxc-project/types@0.129.0': {} - '@oxc-project/types@0.130.0': {} - '@oxfmt/binding-android-arm-eabi@0.48.0': - optional: true - - '@oxfmt/binding-android-arm64@0.48.0': - optional: true - - '@oxfmt/binding-darwin-arm64@0.48.0': - optional: true - - '@oxfmt/binding-darwin-x64@0.48.0': - optional: true - - '@oxfmt/binding-freebsd-x64@0.48.0': - optional: true - - '@oxfmt/binding-linux-arm-gnueabihf@0.48.0': - optional: true - - '@oxfmt/binding-linux-arm-musleabihf@0.48.0': - optional: true - - '@oxfmt/binding-linux-arm64-gnu@0.48.0': - optional: true - - '@oxfmt/binding-linux-arm64-musl@0.48.0': - optional: true - - '@oxfmt/binding-linux-ppc64-gnu@0.48.0': - optional: true - - '@oxfmt/binding-linux-riscv64-gnu@0.48.0': - optional: true - - '@oxfmt/binding-linux-riscv64-musl@0.48.0': - optional: true - - '@oxfmt/binding-linux-s390x-gnu@0.48.0': - optional: true - - '@oxfmt/binding-linux-x64-gnu@0.48.0': - optional: true - - '@oxfmt/binding-linux-x64-musl@0.48.0': - optional: true - - '@oxfmt/binding-openharmony-arm64@0.48.0': - optional: true - - '@oxfmt/binding-win32-arm64-msvc@0.48.0': - optional: true - - '@oxfmt/binding-win32-ia32-msvc@0.48.0': - optional: true - - '@oxfmt/binding-win32-x64-msvc@0.48.0': - optional: true - - '@oxlint-tsgolint/darwin-arm64@0.22.1': - optional: true - - '@oxlint-tsgolint/darwin-x64@0.22.1': - optional: true - - '@oxlint-tsgolint/linux-arm64@0.22.1': - optional: true - - '@oxlint-tsgolint/linux-x64@0.22.1': - optional: true - - '@oxlint-tsgolint/win32-arm64@0.22.1': - optional: true - - '@oxlint-tsgolint/win32-x64@0.22.1': - optional: true - - '@oxlint/binding-android-arm-eabi@1.63.0': - optional: true - - '@oxlint/binding-android-arm64@1.63.0': - optional: true - - '@oxlint/binding-darwin-arm64@1.63.0': - optional: true - - '@oxlint/binding-darwin-x64@1.63.0': - optional: true - - '@oxlint/binding-freebsd-x64@1.63.0': - optional: true - - '@oxlint/binding-linux-arm-gnueabihf@1.63.0': - optional: true - - '@oxlint/binding-linux-arm-musleabihf@1.63.0': - optional: true - - '@oxlint/binding-linux-arm64-gnu@1.63.0': - optional: true - - '@oxlint/binding-linux-arm64-musl@1.63.0': - optional: true - - '@oxlint/binding-linux-ppc64-gnu@1.63.0': - optional: true - - '@oxlint/binding-linux-riscv64-gnu@1.63.0': - optional: true - - '@oxlint/binding-linux-riscv64-musl@1.63.0': - optional: true - - '@oxlint/binding-linux-s390x-gnu@1.63.0': - optional: true - - '@oxlint/binding-linux-x64-gnu@1.63.0': - optional: true - - '@oxlint/binding-linux-x64-musl@1.63.0': - optional: true - - '@oxlint/binding-openharmony-arm64@1.63.0': - optional: true - - '@oxlint/binding-win32-arm64-msvc@1.63.0': - optional: true - - '@oxlint/binding-win32-ia32-msvc@1.63.0': - optional: true - - '@oxlint/binding-win32-x64-msvc@1.63.0': - optional: true - - '@polka/url@1.0.0-next.29': {} - '@poppinss/colors@4.1.6': dependencies: kleur: 4.1.5 @@ -2472,86 +1987,52 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.9': {} + '@types/node@25.8.0': dependencies: undici-types: 7.24.6 - '@voidzero-dev/vite-plus-core@0.1.21(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.2)(typescript@6.0.3)': + '@vitest/expect@4.1.6': dependencies: - '@oxc-project/runtime': 0.129.0 - '@oxc-project/types': 0.129.0 - lightningcss: 1.32.0 - postcss: 8.5.14 - optionalDependencies: - '@types/node': 25.8.0 - esbuild: 0.28.0 - fsevents: 2.3.3 - tsx: 4.22.2 - typescript: 6.0.3 - - '@voidzero-dev/vite-plus-darwin-arm64@0.1.21': - optional: true - - '@voidzero-dev/vite-plus-darwin-x64@0.1.21': - optional: true - - '@voidzero-dev/vite-plus-linux-arm64-gnu@0.1.21': - optional: true + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 + chai: 6.2.2 + tinyrainbow: 3.1.0 - '@voidzero-dev/vite-plus-linux-arm64-musl@0.1.21': - optional: true + '@vitest/mocker@4.1.6(vite@8.0.13(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.2))': + dependencies: + '@vitest/spy': 4.1.6 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.13(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.2) - '@voidzero-dev/vite-plus-linux-x64-gnu@0.1.21': - optional: true + '@vitest/pretty-format@4.1.6': + dependencies: + tinyrainbow: 3.1.0 - '@voidzero-dev/vite-plus-linux-x64-musl@0.1.21': - optional: true + '@vitest/runner@4.1.6': + dependencies: + '@vitest/utils': 4.1.6 + pathe: 2.0.3 - '@voidzero-dev/vite-plus-test@0.1.21(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.2)(typescript@6.0.3)(vite@8.0.13(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.2))': + '@vitest/snapshot@4.1.6': dependencies: - '@standard-schema/spec': 1.1.0 - '@types/chai': 5.2.3 - '@voidzero-dev/vite-plus-core': 0.1.21(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.2)(typescript@6.0.3) - es-module-lexer: 1.7.0 - obug: 2.1.1 - pixelmatch: 7.2.0 - pngjs: 7.0.0 - sirv: 3.0.2 - std-env: 4.1.0 - tinybench: 2.9.0 - tinyexec: 1.1.2 - tinyglobby: 0.2.16 - vite: 8.0.13(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.2) - ws: 8.20.1 - optionalDependencies: - '@types/node': 25.8.0 - transitivePeerDependencies: - - '@arethetypeswrong/core' - - '@tsdown/css' - - '@tsdown/exe' - - '@vitejs/devtools' - - bufferutil - - esbuild - - jiti - - less - - publint - - sass - - sass-embedded - - stylus - - sugarss - - terser - - tsx - - typescript - - unplugin-unused - - unrun - - utf-8-validate - - yaml + '@vitest/pretty-format': 4.1.6 + '@vitest/utils': 4.1.6 + magic-string: 0.30.21 + pathe: 2.0.3 - '@voidzero-dev/vite-plus-win32-arm64-msvc@0.1.21': - optional: true + '@vitest/spy@4.1.6': {} - '@voidzero-dev/vite-plus-win32-x64-msvc@0.1.21': - optional: true + '@vitest/utils@4.1.6': + dependencies: + '@vitest/pretty-format': 4.1.6 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 assertion-error@2.0.1: {} @@ -2559,19 +2040,23 @@ snapshots: capnweb@0.8.0: {} + chai@6.2.2: {} + chardet@2.1.1: {} cli-width@4.1.0: {} commander@14.0.3: {} + convert-source-map@2.0.0: {} + cookie@1.1.1: {} detect-libc@2.1.2: {} error-stack-parser-es@1.0.5: {} - es-module-lexer@1.7.0: {} + es-module-lexer@2.1.0: {} esbuild@0.27.3: optionalDependencies: @@ -2631,6 +2116,12 @@ snapshots: '@esbuild/win32-ia32': 0.28.0 '@esbuild/win32-x64': 0.28.0 + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.3.0: {} + fast-string-truncated-width@3.0.3: {} fast-string-width@3.0.2: @@ -2703,6 +2194,10 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + miniflare@4.20260515.0: dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -2715,8 +2210,6 @@ snapshots: - bufferutil - utf-8-validate - mrmime@2.0.1: {} - mute-stream@3.0.0: {} nanoid@3.3.12: {} @@ -2725,62 +2218,6 @@ snapshots: openapi-types@12.1.3: {} - oxfmt@0.48.0: - dependencies: - tinypool: 2.1.0 - optionalDependencies: - '@oxfmt/binding-android-arm-eabi': 0.48.0 - '@oxfmt/binding-android-arm64': 0.48.0 - '@oxfmt/binding-darwin-arm64': 0.48.0 - '@oxfmt/binding-darwin-x64': 0.48.0 - '@oxfmt/binding-freebsd-x64': 0.48.0 - '@oxfmt/binding-linux-arm-gnueabihf': 0.48.0 - '@oxfmt/binding-linux-arm-musleabihf': 0.48.0 - '@oxfmt/binding-linux-arm64-gnu': 0.48.0 - '@oxfmt/binding-linux-arm64-musl': 0.48.0 - '@oxfmt/binding-linux-ppc64-gnu': 0.48.0 - '@oxfmt/binding-linux-riscv64-gnu': 0.48.0 - '@oxfmt/binding-linux-riscv64-musl': 0.48.0 - '@oxfmt/binding-linux-s390x-gnu': 0.48.0 - '@oxfmt/binding-linux-x64-gnu': 0.48.0 - '@oxfmt/binding-linux-x64-musl': 0.48.0 - '@oxfmt/binding-openharmony-arm64': 0.48.0 - '@oxfmt/binding-win32-arm64-msvc': 0.48.0 - '@oxfmt/binding-win32-ia32-msvc': 0.48.0 - '@oxfmt/binding-win32-x64-msvc': 0.48.0 - - oxlint-tsgolint@0.22.1: - optionalDependencies: - '@oxlint-tsgolint/darwin-arm64': 0.22.1 - '@oxlint-tsgolint/darwin-x64': 0.22.1 - '@oxlint-tsgolint/linux-arm64': 0.22.1 - '@oxlint-tsgolint/linux-x64': 0.22.1 - '@oxlint-tsgolint/win32-arm64': 0.22.1 - '@oxlint-tsgolint/win32-x64': 0.22.1 - - oxlint@1.63.0(oxlint-tsgolint@0.22.1): - optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.63.0 - '@oxlint/binding-android-arm64': 1.63.0 - '@oxlint/binding-darwin-arm64': 1.63.0 - '@oxlint/binding-darwin-x64': 1.63.0 - '@oxlint/binding-freebsd-x64': 1.63.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.63.0 - '@oxlint/binding-linux-arm-musleabihf': 1.63.0 - '@oxlint/binding-linux-arm64-gnu': 1.63.0 - '@oxlint/binding-linux-arm64-musl': 1.63.0 - '@oxlint/binding-linux-ppc64-gnu': 1.63.0 - '@oxlint/binding-linux-riscv64-gnu': 1.63.0 - '@oxlint/binding-linux-riscv64-musl': 1.63.0 - '@oxlint/binding-linux-s390x-gnu': 1.63.0 - '@oxlint/binding-linux-x64-gnu': 1.63.0 - '@oxlint/binding-linux-x64-musl': 1.63.0 - '@oxlint/binding-openharmony-arm64': 1.63.0 - '@oxlint/binding-win32-arm64-msvc': 1.63.0 - '@oxlint/binding-win32-ia32-msvc': 1.63.0 - '@oxlint/binding-win32-x64-msvc': 1.63.0 - oxlint-tsgolint: 0.22.1 - path-to-regexp@6.3.0: {} pathe@2.0.3: {} @@ -2789,12 +2226,6 @@ snapshots: picomatch@4.0.4: {} - pixelmatch@7.2.0: - dependencies: - pngjs: 7.0.0 - - pngjs@7.0.0: {} - postcss@8.5.14: dependencies: nanoid: 3.3.12 @@ -2859,16 +2290,14 @@ snapshots: '@img/sharp-win32-ia32': 0.34.5 '@img/sharp-win32-x64': 0.34.5 - signal-exit@4.1.0: {} + siginfo@2.0.0: {} - sirv@3.0.2: - dependencies: - '@polka/url': 1.0.0-next.29 - mrmime: 2.0.1 - totalist: 3.0.1 + signal-exit@4.1.0: {} source-map-js@1.2.1: {} + stackback@0.0.2: {} + std-env@4.1.0: {} supports-color@10.2.2: {} @@ -2884,9 +2313,7 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - tinypool@2.1.0: {} - - totalist@3.0.1: {} + tinyrainbow@3.1.0: {} trpc-cli@0.14.1(@orpc/server@1.14.3(ws@8.20.1))(zod@4.4.3): dependencies: @@ -2918,54 +2345,6 @@ snapshots: dependencies: pathe: 2.0.3 - vite-plus@0.1.21(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.2)(typescript@6.0.3)(vite@8.0.13(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.2)): - dependencies: - '@oxc-project/types': 0.129.0 - '@voidzero-dev/vite-plus-core': 0.1.21(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.2)(typescript@6.0.3) - '@voidzero-dev/vite-plus-test': 0.1.21(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.2)(typescript@6.0.3)(vite@8.0.13(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.2)) - oxfmt: 0.48.0 - oxlint: 1.63.0(oxlint-tsgolint@0.22.1) - oxlint-tsgolint: 0.22.1 - optionalDependencies: - '@voidzero-dev/vite-plus-darwin-arm64': 0.1.21 - '@voidzero-dev/vite-plus-darwin-x64': 0.1.21 - '@voidzero-dev/vite-plus-linux-arm64-gnu': 0.1.21 - '@voidzero-dev/vite-plus-linux-arm64-musl': 0.1.21 - '@voidzero-dev/vite-plus-linux-x64-gnu': 0.1.21 - '@voidzero-dev/vite-plus-linux-x64-musl': 0.1.21 - '@voidzero-dev/vite-plus-win32-arm64-msvc': 0.1.21 - '@voidzero-dev/vite-plus-win32-x64-msvc': 0.1.21 - transitivePeerDependencies: - - '@arethetypeswrong/core' - - '@edge-runtime/vm' - - '@opentelemetry/api' - - '@tsdown/css' - - '@tsdown/exe' - - '@types/node' - - '@vitejs/devtools' - - '@vitest/coverage-istanbul' - - '@vitest/coverage-v8' - - '@vitest/ui' - - bufferutil - - esbuild - - happy-dom - - jiti - - jsdom - - less - - publint - - sass - - sass-embedded - - stylus - - sugarss - - terser - - tsx - - typescript - - unplugin-unused - - unrun - - utf-8-validate - - vite - - yaml - vite@8.0.13(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.2): dependencies: lightningcss: 1.32.0 @@ -2979,6 +2358,38 @@ snapshots: fsevents: 2.3.3 tsx: 4.22.2 + vitest@4.1.6(@types/node@25.8.0)(vite@8.0.13(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.2)): + dependencies: + '@vitest/expect': 4.1.6 + '@vitest/mocker': 4.1.6(vite@8.0.13(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.2)) + '@vitest/pretty-format': 4.1.6 + '@vitest/runner': 4.1.6 + '@vitest/snapshot': 4.1.6 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.2 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 8.0.13(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.8.0 + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + workerd@1.20260515.1: optionalDependencies: '@cloudflare/workerd-darwin-64': 1.20260515.1 @@ -3006,7 +2417,8 @@ snapshots: ws@8.18.0: {} - ws@8.20.1: {} + ws@8.20.1: + optional: true youch-core@0.3.3: dependencies: diff --git a/scripts/rewrite-dts-imports.mjs b/scripts/rewrite-dts-imports.mjs new file mode 100644 index 0000000..14e5a9d --- /dev/null +++ b/scripts/rewrite-dts-imports.mjs @@ -0,0 +1,23 @@ +import { readdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +await rewriteDeclarations("dist"); + +async function rewriteDeclarations(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + await rewriteDeclarations(path); + continue; + } + + if (!entry.isFile() || !entry.name.endsWith(".d.ts")) continue; + + const source = await readFile(path, "utf8"); + const rewritten = source.replace( + /((?:from|import)\s*["']\.{1,2}\/[^"']+)\.ts(["'])/g, + "$1.js$2", + ); + if (rewritten !== source) await writeFile(path, rewritten); + } +} diff --git a/src/cli.ts b/src/cli.ts old mode 100644 new mode 100755 diff --git a/src/client.ts b/src/client.ts index b73957d..26e636e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,5 +1,5 @@ import { newWebSocketRpcSession, RpcTarget } from "capnweb"; -import type { CaptunClientCreateTunnelOptions, CaptunClientRemoteFetcher } from "./types"; +import type { CaptunClientCreateTunnelOptions, CaptunClientRemoteFetcher } from "./types.ts"; /** Creates a tunnel from a public Worker URL to a local fetch implementation. * diff --git a/src/server.ts b/src/server.ts index 34b74ba..dd01c2b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,8 +3,8 @@ import type { CaptunClientRemoteFetcher, CaptunServerAcceptTunnelOptions, CaptunServerTunnel, -} from "./types"; -export type { CaptunServerAcceptTunnelOptions, CaptunServerTunnel } from "./types"; +} from "./types.ts"; +export type { CaptunServerAcceptTunnelOptions, CaptunServerTunnel } from "./types.ts"; /** Creates a Worker WebSocket upgrade response and matching tunnel handle. */ export function acceptCaptunTunnel(options: CaptunServerAcceptTunnelOptions = {}) { diff --git a/src/worker.ts b/src/worker.ts index 866c9c6..a5b55b4 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -1,6 +1,6 @@ import { DurableObject } from "cloudflare:workers"; -import { acceptCaptunTunnel } from "./server"; -import type { CaptunServerTunnel } from "./types"; +import { acceptCaptunTunnel } from "./server.ts"; +import type { CaptunServerTunnel } from "./types.ts"; type CaptunEnv = Env & { CAPTUN_SECRET?: string; @@ -27,12 +27,14 @@ export class CaptunServerShard extends DurableObject { const expectedAuthorization = this.env.CAPTUN_SECRET ? `Bearer ${this.env.CAPTUN_SECRET}` : undefined; + const actualAuthorization = new TextEncoder().encode( + routedRequest.headers.get("authorization") || "", + ); + const encodedExpectedAuthorization = new TextEncoder().encode(expectedAuthorization || ""); if ( expectedAuthorization && - !crypto.subtle.timingSafeEqual( - new TextEncoder().encode(routedRequest.headers.get("authorization") ?? ""), - new TextEncoder().encode(expectedAuthorization), - ) + (actualAuthorization.length !== encodedExpectedAuthorization.length || + !crypto.subtle.timingSafeEqual(actualAuthorization, encodedExpectedAuthorization)) ) { return new Response("Unauthorized\n", { status: 401 }); } @@ -59,11 +61,19 @@ export default { fetch(request: Request, env: CaptunEnv) { const route = captunRoute(request); if (!route) return new Response("Missing tunnel name\n", { status: 404 }); - const shard = captunShardName(route.tunnelName, Number(env.CAPTUN_SHARDS ?? 1)); + const shard = captunShardName(route.tunnelName, Number(env.CAPTUN_SHARDS || 1)); return env.CaptunServerShard.getByName(shard).fetch(route.request); }, } satisfies ExportedHandler; +/** Rebuilds the request only when the Durable Object route prefix must be stripped. */ +function rewritePath(request: Request, pathname: string) { + const url = new URL(request.url); + if (url.pathname === pathname) return request; + url.pathname = pathname; + return new Request(url, request); +} + /** Turns an incoming Worker request into a Durable Object name and forwarded request. */ function captunRoute(request: Request) { const url = new URL(request.url); @@ -75,7 +85,7 @@ function captunRoute(request: Request) { } /** Extracts the tunnel name and forwarded path from just the hostname and path. */ -export function captunRouteParts(hostname: string, pathname: string) { +function captunRouteParts(hostname: string, pathname: string) { if (!usesFolderRouting(hostname)) { const [name] = hostname.split("."); if (!name) return undefined; @@ -89,7 +99,7 @@ export function captunRouteParts(hostname: string, pathname: string) { } /** Maps a tunnel name to a stable Durable Object shard name. */ -export function captunShardName(tunnelName: string, shardCount: number) { +function captunShardName(tunnelName: string, shardCount: number) { if (!Number.isFinite(shardCount) || shardCount <= 1) return "tunnel-shard-0"; let hash = 2166136261; for (let index = 0; index < tunnelName.length; index++) { @@ -118,11 +128,3 @@ function safeDecodeURIComponent(value: string) { return undefined; } } - -/** Rebuilds the request only when the Durable Object route prefix must be stripped. */ -function rewritePath(request: Request, pathname: string) { - const url = new URL(request.url); - if (url.pathname === pathname) return request; - url.pathname = pathname; - return new Request(url, request); -} diff --git a/test/cloudflare-workers-shim.ts b/test/cloudflare-workers-shim.ts deleted file mode 100644 index ca8de21..0000000 --- a/test/cloudflare-workers-shim.ts +++ /dev/null @@ -1,7 +0,0 @@ -export class DurableObject { - protected env: Env; - - constructor(_ctx: unknown, env: Env) { - this.env = env; - } -} diff --git a/test/e2e.test.ts b/test/e2e.test.ts index 3db1a61..1303e5c 100644 --- a/test/e2e.test.ts +++ b/test/e2e.test.ts @@ -1,79 +1,70 @@ import { createHash } from "node:crypto"; -import { describe, test, vi } from "vite-plus/test"; -import { createCaptunTunnel } from "../src/client"; +import { describe, expect, test, vi } from "vitest"; + +import { createCaptunTunnel } from "../src/client.ts"; vi.setConfig({ testTimeout: 15_000 }); const serverUrl = process.env.CAPTUN_SERVER_URL; -const describeE2e = serverUrl ? describe : describe.skip; +const testE2e = serverUrl ? test.concurrent : test.skip; -describeE2e("Captun e2e", () => { - test.concurrent("forwards HTTP", async ({ task, expect }) => { +describe("Captun e2e", () => { + testE2e("forwards HTTP", async ({ task }) => { const { url, tunnel } = await connectTunnel(task.name); - try { - const response = await fetch(new URL("hello", url), { - method: "POST", - body: "hello through tunnel", - }); - expect(await response.json()).toMatchObject({ path: "/hello", body: "hello through tunnel" }); - } finally { - tunnel[Symbol.dispose](); - } + using _tunnel = tunnel; + + const response = await fetch(new URL("hello", url), { + method: "POST", + body: "hello through tunnel", + }); + expect(await response.json()).toMatchObject({ path: "/hello", body: "hello through tunnel" }); }); - test.concurrent("streams a binary response", async ({ task, expect }) => { + testE2e("streams a binary response", async ({ task }) => { const { url, tunnel } = await connectTunnel(task.name); - try { - const response = await fetch(new URL("stream", url)); - expect(response.status).toBe(200); - expect(await readBytes(response)).toBe(2_097_152); - } finally { - tunnel[Symbol.dispose](); - } + using _tunnel = tunnel; + + const response = await fetch(new URL("stream", url)); + expect(response.status).toBe(200); + expect(await readBytes(response)).toBe(2_097_152); }); - test.concurrent("streams SSE events", async ({ task, expect }) => { + testE2e("streams SSE events", async ({ task }) => { const { url, tunnel } = await connectTunnel(task.name); - try { - const response = await fetch(new URL("sse", url)); - expect(response.headers.get("content-type")).toContain("text/event-stream"); - expect((await response.text()).match(/^event: tunnel$/gm)).toHaveLength(5); - } finally { - tunnel[Symbol.dispose](); - } + using _tunnel = tunnel; + + const response = await fetch(new URL("sse", url)); + expect(response.headers.get("content-type")).toContain("text/event-stream"); + expect((await response.text()).match(/^event: tunnel$/gm)).toHaveLength(5); }); - test.concurrent("uploads a raw file body", async ({ task, expect }) => { + testE2e("uploads a raw file body", async ({ task }) => { const { url, tunnel } = await connectTunnel(task.name); - try { - const bytes = makeBytes(1024 * 1024); - const response = await fetch(new URL("upload", url), { - method: "POST", - headers: { "content-type": "application/octet-stream" }, - body: bytes.buffer, - }); - expect(await response.json()).toMatchObject({ - bytes: bytes.byteLength, - sha256: sha256(bytes), - }); - } finally { - tunnel[Symbol.dispose](); - } + using _tunnel = tunnel; + + const bytes = makeBytes(1024 * 1024); + const response = await fetch(new URL("upload", url), { + method: "POST", + headers: { "content-type": "application/octet-stream" }, + body: bytes.buffer, + }); + expect(await response.json()).toMatchObject({ + bytes: bytes.byteLength, + sha256: sha256(bytes), + }); }); - test.concurrent("uploads multipart form data", async ({ task, expect }) => { + testE2e("uploads multipart form data", async ({ task }) => { const { url, tunnel } = await connectTunnel(task.name); - try { - const file = makeBytes(256 * 1024); - const form = new FormData(); - form.set("name", "multipart-proof"); - form.set("file", new Blob([file.buffer]), "proof.bin"); - - const response = await fetch(new URL("multipart", url), { method: "POST", body: form }); - expect(hasMultipartFilePart(await response.json(), file.byteLength, sha256(file))).toBe(true); - } finally { - tunnel[Symbol.dispose](); - } + using _tunnel = tunnel; + + const file = makeBytes(256 * 1024); + const form = new FormData(); + form.set("name", "multipart-proof"); + form.set("file", new Blob([file.buffer]), "proof.bin"); + + const response = await fetch(new URL("multipart", url), { method: "POST", body: form }); + expect(hasMultipartFilePart(await response.json(), file.byteLength, sha256(file))).toBe(true); }); }); diff --git a/test/worker.test.ts b/test/worker.test.ts index 7a966c0..bd1a449 100644 --- a/test/worker.test.ts +++ b/test/worker.test.ts @@ -1,46 +1,118 @@ -import { describe, expect, test } from "vite-plus/test"; -import { captunRouteParts, captunShardName } from "../src/worker"; - -describe("captunRouteParts", () => { - const cases: Array< - [ - hostname: string, - path: string, - tunnelName: string | undefined, - forwardedPath: string | undefined, - ] - > = [ - ["captun.account.workers.dev", "/my-test/hello", "my-test", "/hello"], - ["captun.account.workers.dev", "/my-test/__connect", "my-test", "/__connect"], - ["captun.account.workers.dev", "/__connect", undefined, undefined], - ["captun.account.workers.dev", "/", undefined, undefined], - ["localhost", "/my-test/hello", "my-test", "/hello"], - ["my-tunnels.com", "/my-test/hello", "my-test", "/hello"], - ["tunnels.example.com", "/my-test/hello", "my-test", "/hello"], - ["tunnels.example.com", "/my-test/__connect", "my-test", "/__connect"], - ["my-test.tunnels.example.com", "/hello", "my-test", "/hello"], - ["my-test.my-tunnels.com", "/hello", "my-test", "/hello"], - ["my-test.my-tunnels.com", "/__connect", "my-test", "/__connect"], - ["my-test.mysubdomain.mydomain.com", "/hello", "my-test", "/hello"], - ["some-tunnel.example.com", "/some-path", "some-tunnel", "/some-path"], - ["captun.account.workers.dev", "/bad%/hello", undefined, undefined], - ]; - - test.each(cases)("%s%s -> %s %s", (hostname, path, tunnelName, forwardedPath) => { - expect(captunRouteParts(hostname, path)).toEqual( - tunnelName ? { name: tunnelName, path: forwardedPath } : undefined, - ); +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import * as esbuild from "esbuild"; +import { Miniflare } from "miniflare"; +import { expect, test } from "vitest"; +import { createCaptunTunnel } from "../src/client.ts"; + +test("Captun Worker forwards requests through a real Durable Object tunnel", async () => { + await using fixture = await createWorkerFixture(); + using _tunnel = await createCaptunTunnel({ + url: new URL("/demo/__connect", fixture.origin), + fetch: async (request) => { + const url = new URL(request.url); + return Response.json({ + path: url.pathname, + body: await request.text(), + }); + }, }); -}); -describe("captunShardName", () => { - test("uses one warm shard by default", () => { - expect(captunShardName("alpha", 1)).toBe("tunnel-shard-0"); - expect(captunShardName("beta", 0)).toBe("tunnel-shard-0"); + const response = await fetch(new URL("/demo/hello", fixture.origin), { + method: "POST", + body: "hello through miniflare", }); - test("keeps a tunnel name on a stable shard", () => { - expect(captunShardName("my-test", 16)).toBe(captunShardName("my-test", 16)); - expect(captunShardName("my-test", 16)).toMatch(/^tunnel-shard-(?:[0-9]|1[0-5])$/); + expect(await response.json()).toMatchObject({ + path: "/hello", + body: "hello through miniflare", }); }); + +test("Captun Worker returns 503 when a named tunnel has no connected client", async () => { + await using fixture = await createWorkerFixture(); + + const response = await fetch(new URL("/missing/hello", fixture.origin)); + + expect(response.status).toBe(503); + expect(await response.text()).toBe("No tunnel client connected\n"); +}); + +test("Captun Worker routes subdomain tunnel requests", async () => { + await using fixture = await createWorkerFixture(); + + const response = await fixture.worker.fetch("http://demo.tunnels.example.com/hello"); + + expect(response.status).toBe(503); + expect(await response.text()).toBe("No tunnel client connected\n"); +}); + +test("Captun Worker rejects missing tunnel names before Durable Object dispatch", async () => { + await using fixture = await createWorkerFixture(); + + const response = await fetch(new URL("/__connect", fixture.origin)); + + expect(response.status).toBe(404); + expect(await response.text()).toBe("Missing tunnel name\n"); +}); + +test("Captun Worker rejects malformed folder tunnel names", async () => { + await using fixture = await createWorkerFixture(); + + const response = await fetch(new URL("/bad%/hello", fixture.origin)); + + expect(response.status).toBe(404); + expect(await response.text()).toBe("Missing tunnel name\n"); +}); + +test("Captun Worker requires the configured secret before accepting a tunnel client", async () => { + await using fixture = await createWorkerFixture({ CAPTUN_SECRET: "secret" }); + + const response = await fetch(new URL("/demo/__connect", fixture.origin)); + + expect(response.status).toBe(401); + expect(await response.text()).toBe("Unauthorized\n"); +}); + +async function createWorkerFixture(bindings: Record = {}) { + const tempDir = await mkdtemp(join(tmpdir(), "captun-miniflare-")); + await esbuild.build({ + entryPoints: ["dist/worker.js"], + outfile: join(tempDir, "worker.js"), + bundle: true, + format: "esm", + platform: "neutral", + target: "es2022", + external: ["cloudflare:workers"], + }); + + const miniflare = new Miniflare({ + modules: true, + rootPath: tempDir, + modulesRoot: tempDir, + scriptPath: "worker.js", + modulesRules: [{ type: "ESModule", include: ["**/*.js"] }], + compatibilityDate: "2024-04-03", + durableObjects: { + CaptunServerShard: { className: "CaptunServerShard" }, + }, + bindings, + }); + const url = await miniflare.ready; + const worker = (await miniflare.getWorker()) as unknown as WorkerFetcherLike; + + return { + origin: url.origin, + worker, + async [Symbol.asyncDispose]() { + await miniflare.dispose(); + await rm(tempDir, { recursive: true, force: true }); + }, + }; +} + +interface WorkerFetcherLike { + fetch(input: string, init?: RequestInit): Promise; +} diff --git a/tsconfig.json b/tsconfig.json index 4a736dd..0458a59 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,5 @@ { "compilerOptions": { - "outDir": "dist", "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", @@ -8,21 +7,14 @@ "strict": true, "skipLibCheck": true, "noEmit": true, - "declaration": true, "allowImportingTsExtensions": true, - "types": ["node"], - "paths": { - "captun": ["./src/index.ts"], - "captun/client": ["./src/client.ts"], - "captun/server": ["./src/server.ts"] - } + "types": ["node"] }, "include": [ "src/**/*.ts", "worker-configuration.d.ts", "test/**/*.ts", "scripts/**/*.ts", - "examples/weather-reporter/*.ts", - "vite.config.ts" + "examples/weather-reporter/*.ts" ] } diff --git a/tsconfig.lib.json b/tsconfig.lib.json index 864a233..3aa0f6d 100644 --- a/tsconfig.lib.json +++ b/tsconfig.lib.json @@ -1,8 +1,14 @@ { "extends": "./tsconfig.json", "compilerOptions": { + "noEmit": false, "rootDir": "src", "outDir": "dist", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "sourceMap": true, + "declarationMap": true, "rewriteRelativeImportExtensions": true }, "include": ["src/**/*.ts", "worker-configuration.d.ts"] diff --git a/vite.config.ts b/vite.config.ts deleted file mode 100644 index b5420e8..0000000 --- a/vite.config.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { fileURLToPath } from "node:url"; -import { defineConfig } from "vite-plus"; - -export default defineConfig({ - lint: { - options: { - typeAware: true, - typeCheck: true, - }, - }, - pack: { - entry: { - index: "src/index.ts", - client: "src/client.ts", - server: "src/server.ts", - cli: "src/cli.ts", - }, - dts: { build: true }, - target: "es2022", - deps: { - neverBundle: ["cloudflare:workers"], - }, - format: ["esm"], - sourcemap: true, - }, - resolve: { - alias: { - "cloudflare:workers": fileURLToPath( - new URL("./test/cloudflare-workers-shim.ts", import.meta.url).href, - ), - "captun/client": fileURLToPath(new URL("./src/client.ts", import.meta.url).href), - "captun/server": fileURLToPath(new URL("./src/server.ts", import.meta.url).href), - }, - }, - test: { - include: ["*.test.ts", "test/**/*.test.ts", "examples/**/*.test.ts"], - }, -}); From c90c117d4aa236d7d3f8c9d57aa26ed52e254711 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Mon, 18 May 2026 20:48:02 +0100 Subject: [PATCH 07/43] Tighten build script and e2e env handling --- examples/weather-reporter/e2e.test.ts | 9 ++++++--- package.json | 14 +++++++++++++- scripts/rewrite-dts-imports.mjs | 23 ----------------------- src/cli.ts | 2 +- src/client.ts | 2 +- src/index.ts | 4 ++-- src/server.ts | 4 ++-- src/worker.ts | 4 ++-- test/e2e.test.ts | 16 ++++++++-------- 9 files changed, 35 insertions(+), 43 deletions(-) delete mode 100644 scripts/rewrite-dts-imports.mjs diff --git a/examples/weather-reporter/e2e.test.ts b/examples/weather-reporter/e2e.test.ts index 4740069..95d6792 100644 --- a/examples/weather-reporter/e2e.test.ts +++ b/examples/weather-reporter/e2e.test.ts @@ -4,11 +4,14 @@ import { createCaptunTunnel } from "../../src/client.ts"; vi.setConfig({ testTimeout: 15_000 }); -const myWeatherAppUrl = process.env.WEATHER_REPORTER_URL; -const testE2e = myWeatherAppUrl ? test : test.skip; +const weatherReporterUrl = process.env.WEATHER_REPORTER_URL; +if (!weatherReporterUrl) { + throw new Error("WEATHER_REPORTER_URL is required to load this e2e test module"); +} +const myWeatherAppUrl = weatherReporterUrl; describe("weather reporter e2e", () => { - testE2e("returns nicely formatted weather report", async () => { + test("returns nicely formatted weather report", async () => { using _tunnel = await createCaptunTunnel({ url: `${myWeatherAppUrl}/__intercept-egress-traffic`, fetch(request) { diff --git a/package.json b/package.json index 4784bb0..7584fe3 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,12 @@ "import": "./src/server.ts" } }, + "imports": { + "#types": { + "types": "./src/types.ts", + "default": "./src/types.ts" + } + }, "publishConfig": { "bin": { "captun": "./dist/cli.js" @@ -49,10 +55,16 @@ "types": "./dist/server.d.ts", "import": "./dist/server.js" } + }, + "imports": { + "#types": { + "types": "./dist/types.d.ts", + "default": "./dist/types.js" + } } }, "scripts": { - "build": "rm -rf dist && tsc -p tsconfig.lib.json && node scripts/rewrite-dts-imports.mjs && chmod +x dist/cli.js", + "build": "rm -rf dist && tsc -p tsconfig.lib.json", "check": "pnpm run typecheck", "typecheck": "tsc -p tsconfig.json && pnpm --filter @captun/weather-reporter typecheck", "deploy": "wrangler deploy", diff --git a/scripts/rewrite-dts-imports.mjs b/scripts/rewrite-dts-imports.mjs deleted file mode 100644 index 14e5a9d..0000000 --- a/scripts/rewrite-dts-imports.mjs +++ /dev/null @@ -1,23 +0,0 @@ -import { readdir, readFile, writeFile } from "node:fs/promises"; -import { join } from "node:path"; - -await rewriteDeclarations("dist"); - -async function rewriteDeclarations(directory) { - for (const entry of await readdir(directory, { withFileTypes: true })) { - const path = join(directory, entry.name); - if (entry.isDirectory()) { - await rewriteDeclarations(path); - continue; - } - - if (!entry.isFile() || !entry.name.endsWith(".d.ts")) continue; - - const source = await readFile(path, "utf8"); - const rewritten = source.replace( - /((?:from|import)\s*["']\.{1,2}\/[^"']+)\.ts(["'])/g, - "$1.js$2", - ); - if (rewritten !== source) await writeFile(path, rewritten); - } -} diff --git a/src/cli.ts b/src/cli.ts index b382d83..cb22e04 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,7 +11,7 @@ import * as prompts from "@inquirer/prompts"; import { os } from "@orpc/server"; import { createCli } from "trpc-cli"; import { z } from "zod/v4"; -import { createCaptunTunnel } from "./client.ts"; +import { createCaptunTunnel } from "captun/client"; type Config = { serverUrl: string; diff --git a/src/client.ts b/src/client.ts index 26e636e..316ff2e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,5 +1,5 @@ import { newWebSocketRpcSession, RpcTarget } from "capnweb"; -import type { CaptunClientCreateTunnelOptions, CaptunClientRemoteFetcher } from "./types.ts"; +import type { CaptunClientCreateTunnelOptions, CaptunClientRemoteFetcher } from "#types"; /** Creates a tunnel from a public Worker URL to a local fetch implementation. * diff --git a/src/index.ts b/src/index.ts index 7672194..da2919d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,2 @@ -export { createCaptunTunnel } from "./client.ts"; -export { acceptCaptunTunnel, acceptCaptunTunnelFromSocket } from "./server.ts"; +export { createCaptunTunnel } from "captun/client"; +export { acceptCaptunTunnel, acceptCaptunTunnelFromSocket } from "captun/server"; diff --git a/src/server.ts b/src/server.ts index dd01c2b..f72624e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,8 +3,8 @@ import type { CaptunClientRemoteFetcher, CaptunServerAcceptTunnelOptions, CaptunServerTunnel, -} from "./types.ts"; -export type { CaptunServerAcceptTunnelOptions, CaptunServerTunnel } from "./types.ts"; +} from "#types"; +export type { CaptunServerAcceptTunnelOptions, CaptunServerTunnel } from "#types"; /** Creates a Worker WebSocket upgrade response and matching tunnel handle. */ export function acceptCaptunTunnel(options: CaptunServerAcceptTunnelOptions = {}) { diff --git a/src/worker.ts b/src/worker.ts index a5b55b4..548174c 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -1,6 +1,6 @@ import { DurableObject } from "cloudflare:workers"; -import { acceptCaptunTunnel } from "./server.ts"; -import type { CaptunServerTunnel } from "./types.ts"; +import type { CaptunServerTunnel } from "#types"; +import { acceptCaptunTunnel } from "captun/server"; type CaptunEnv = Env & { CAPTUN_SECRET?: string; diff --git a/test/e2e.test.ts b/test/e2e.test.ts index 1303e5c..56824e0 100644 --- a/test/e2e.test.ts +++ b/test/e2e.test.ts @@ -5,11 +5,12 @@ import { createCaptunTunnel } from "../src/client.ts"; vi.setConfig({ testTimeout: 15_000 }); -const serverUrl = process.env.CAPTUN_SERVER_URL; -const testE2e = serverUrl ? test.concurrent : test.skip; +const captunServerUrl = process.env.CAPTUN_SERVER_URL; +if (!captunServerUrl) throw new Error("CAPTUN_SERVER_URL is required to load this e2e test module"); +const serverUrl = captunServerUrl; describe("Captun e2e", () => { - testE2e("forwards HTTP", async ({ task }) => { + test.concurrent("forwards HTTP", async ({ task }) => { const { url, tunnel } = await connectTunnel(task.name); using _tunnel = tunnel; @@ -20,7 +21,7 @@ describe("Captun e2e", () => { expect(await response.json()).toMatchObject({ path: "/hello", body: "hello through tunnel" }); }); - testE2e("streams a binary response", async ({ task }) => { + test.concurrent("streams a binary response", async ({ task }) => { const { url, tunnel } = await connectTunnel(task.name); using _tunnel = tunnel; @@ -29,7 +30,7 @@ describe("Captun e2e", () => { expect(await readBytes(response)).toBe(2_097_152); }); - testE2e("streams SSE events", async ({ task }) => { + test.concurrent("streams SSE events", async ({ task }) => { const { url, tunnel } = await connectTunnel(task.name); using _tunnel = tunnel; @@ -38,7 +39,7 @@ describe("Captun e2e", () => { expect((await response.text()).match(/^event: tunnel$/gm)).toHaveLength(5); }); - testE2e("uploads a raw file body", async ({ task }) => { + test.concurrent("uploads a raw file body", async ({ task }) => { const { url, tunnel } = await connectTunnel(task.name); using _tunnel = tunnel; @@ -54,7 +55,7 @@ describe("Captun e2e", () => { }); }); - testE2e("uploads multipart form data", async ({ task }) => { + test.concurrent("uploads multipart form data", async ({ task }) => { const { url, tunnel } = await connectTunnel(task.name); using _tunnel = tunnel; @@ -89,7 +90,6 @@ function tunnelName(testName: string) { } function tunnelUrl(name: string) { - if (!serverUrl) throw new Error("CAPTUN_SERVER_URL is required to run Captun e2e tests"); if (serverUrl.includes("{name}")) return new URL(serverUrl.replaceAll("{name}", name)); const url = new URL(serverUrl); From 9e2aea8f8f39e0ca24f7b5e10f4edf4485e00828 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Mon, 18 May 2026 20:58:26 +0100 Subject: [PATCH 08/43] Refactor Captun e2e tunnel fixtures --- test/e2e.test.ts | 227 ++++++++++++++++++++--------------------------- 1 file changed, 97 insertions(+), 130 deletions(-) diff --git a/test/e2e.test.ts b/test/e2e.test.ts index 56824e0..deae69d 100644 --- a/test/e2e.test.ts +++ b/test/e2e.test.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { describe, expect, test, vi } from "vitest"; +import { expect, test, vi } from "vitest"; import { createCaptunTunnel } from "../src/client.ts"; @@ -9,67 +9,107 @@ const captunServerUrl = process.env.CAPTUN_SERVER_URL; if (!captunServerUrl) throw new Error("CAPTUN_SERVER_URL is required to load this e2e test module"); const serverUrl = captunServerUrl; -describe("Captun e2e", () => { - test.concurrent("forwards HTTP", async ({ task }) => { - const { url, tunnel } = await connectTunnel(task.name); - using _tunnel = tunnel; +test.concurrent("forwards HTTP", async ({ task }) => { + using tunnel = await createTunnelFixture(task.name, async (request) => + Response.json({ body: await request.text() }), + ); - const response = await fetch(new URL("hello", url), { - method: "POST", - body: "hello through tunnel", - }); - expect(await response.json()).toMatchObject({ path: "/hello", body: "hello through tunnel" }); + const response = await fetch(tunnel.url, { + method: "POST", + body: "hello through tunnel", }); + expect(await response.json()).toMatchObject({ body: "hello through tunnel" }); +}); - test.concurrent("streams a binary response", async ({ task }) => { - const { url, tunnel } = await connectTunnel(task.name); - using _tunnel = tunnel; - - const response = await fetch(new URL("stream", url)); - expect(response.status).toBe(200); - expect(await readBytes(response)).toBe(2_097_152); +test.concurrent("streams a binary response", async ({ task }) => { + using tunnel = await createTunnelFixture(task.name, () => { + let sent = 0; + return new Response( + new ReadableStream({ + pull(controller) { + if (sent++ === 32) return controller.close(); + controller.enqueue(new Uint8Array(65_536)); + }, + }), + { headers: { "content-type": "application/octet-stream" } }, + ); }); - test.concurrent("streams SSE events", async ({ task }) => { - const { url, tunnel } = await connectTunnel(task.name); - using _tunnel = tunnel; + const response = await fetch(tunnel.url); + expect(response.status).toBe(200); + + if (!response.body) throw new Error("Response has no body"); + let bytes = 0; + for await (const chunk of response.body) bytes += chunk.byteLength; + expect(bytes).toBe(2_097_152); +}); - const response = await fetch(new URL("sse", url)); - expect(response.headers.get("content-type")).toContain("text/event-stream"); - expect((await response.text()).match(/^event: tunnel$/gm)).toHaveLength(5); +test.concurrent("streams SSE events", async ({ task }) => { + using tunnel = await createTunnelFixture(task.name, () => { + const array = Array.from({ length: 5 }, (_, i) => `event: tunnel\nid: ${i + 1}\ndata: ${i + 1}\n\n`) + return new Response( + array.join("",), + {headers: { "content-type": "text/event-stream; charset=utf-8" }}, + ); }); - test.concurrent("uploads a raw file body", async ({ task }) => { - const { url, tunnel } = await connectTunnel(task.name); - using _tunnel = tunnel; - - const bytes = makeBytes(1024 * 1024); - const response = await fetch(new URL("upload", url), { - method: "POST", - headers: { "content-type": "application/octet-stream" }, - body: bytes.buffer, - }); - expect(await response.json()).toMatchObject({ - bytes: bytes.byteLength, - sha256: sha256(bytes), - }); + const response = await fetch(tunnel.url); + expect(response.headers.get("content-type")).toContain("text/event-stream"); + expect((await response.text()).match(/^event: tunnel$/gm)).toHaveLength(5); +}); + +test.concurrent("uploads a raw file body", async ({ task }) => { + using tunnel = await createTunnelFixture(task.name, async (request) => { + const bytes = new Uint8Array(await request.arrayBuffer()); + return Response.json({ bytes: bytes.byteLength, sha256: sha256(bytes) }); }); - test.concurrent("uploads multipart form data", async ({ task }) => { - const { url, tunnel } = await connectTunnel(task.name); - using _tunnel = tunnel; + const bytes = makeBytes(1024 * 1024); + const response = await fetch(tunnel.url, { + method: "POST", + headers: { "content-type": "application/octet-stream" }, + body: bytes.buffer, + }); + expect(await response.json()).toMatchObject({ + bytes: bytes.byteLength, + sha256: sha256(bytes), + }); +}); - const file = makeBytes(256 * 1024); - const form = new FormData(); - form.set("name", "multipart-proof"); - form.set("file", new Blob([file.buffer]), "proof.bin"); +test.concurrent("uploads multipart form data", async ({ task }) => { + using tunnel = await createTunnelFixture(task.name, async (request) => { + const form = await request.formData(); + const parts = []; + for (const [name, value] of form.entries() as Iterable<[string, string | Blob]>) { + if (typeof value === "string") parts.push({ name, value }); + else + parts.push({ + name, + bytes: value.size, + sha256: sha256(new Uint8Array(await value.arrayBuffer())), + }); + } + return Response.json({ parts }); + }); - const response = await fetch(new URL("multipart", url), { method: "POST", body: form }); - expect(hasMultipartFilePart(await response.json(), file.byteLength, sha256(file))).toBe(true); + const file = makeBytes(256 * 1024); + const form = new FormData(); + form.set("name", "multipart-proof"); + form.set("file", new Blob([file.buffer]), "proof.bin"); + + const response = await fetch(tunnel.url, { method: "POST", body: form }); + expect(await response.json()).toMatchObject({ + parts: expect.arrayContaining([ + { name: "name", value: "multipart-proof" }, + { name: "file", bytes: file.byteLength, sha256: sha256(file) }, + ]), }); }); -async function connectTunnel(testName: string) { +async function createTunnelFixture( + testName: string, + fetch: (request: Request) => Response | Promise, +) { const name = tunnelName(testName); const url = tunnelUrl(name); const tunnel = await createCaptunTunnel({ @@ -77,14 +117,21 @@ async function connectTunnel(testName: string) { headers: process.env.CAPTUN_SECRET ? { authorization: `Bearer ${process.env.CAPTUN_SECRET}` } : undefined, - fetch: testFetch, + fetch, }); - return { url, tunnel }; + return { + url: url.toString(), + [Symbol.dispose]: () => tunnel[Symbol.dispose](), + }; } function tunnelName(testName: string) { const seed = `${testName}-${process.pid}-${Date.now()}-${Math.random()}`; - const prefix = slug(testName).slice(0, 32).replace(/-$/, "") || "test"; + const slug = testName + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + const prefix = slug.slice(0, 32).replace(/-$/, "") || "test"; const hash = createHash("sha256").update(seed).digest("hex").slice(0, 12); return `${prefix}-${hash}`; } @@ -101,86 +148,6 @@ function tunnelUrl(name: string) { return url; } -function slug(value: string) { - return value - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-|-$/g, ""); -} - -function hasMultipartFilePart(value: unknown, bytes: number, hash: string) { - if (typeof value !== "object" || value === null) return false; - const parts = Reflect.get(value, "parts"); - if (!Array.isArray(parts)) return false; - return parts.some((part) => { - if (typeof part !== "object" || part === null) return false; - return ( - Reflect.get(part, "name") === "file" && - Reflect.get(part, "bytes") === bytes && - Reflect.get(part, "sha256") === hash - ); - }); -} - -async function testFetch(request: Request) { - const url = new URL(request.url); - const path = url.pathname; - if (path === "/stream") return streamResponse(); - if (path === "/sse") return sseResponse(); - if (path === "/upload") return uploadResponse(request); - if (path === "/multipart") return multipartResponse(request); - return Response.json({ path, body: await request.text() }); -} - -function streamResponse() { - let sent = 0; - return new Response( - new ReadableStream({ - pull(controller) { - if (sent++ === 32) return controller.close(); - controller.enqueue(new Uint8Array(65_536)); - }, - }), - { headers: { "content-type": "application/octet-stream" } }, - ); -} - -function sseResponse() { - return new Response( - Array.from({ length: 5 }, (_, i) => `event: tunnel\nid: ${i + 1}\ndata: ${i + 1}\n\n`).join(""), - { - headers: { "content-type": "text/event-stream; charset=utf-8" }, - }, - ); -} - -async function uploadResponse(request: Request) { - const bytes = new Uint8Array(await request.arrayBuffer()); - return Response.json({ bytes: bytes.byteLength, sha256: sha256(bytes) }); -} - -async function multipartResponse(request: Request) { - const form = await request.formData(); - const parts = []; - for (const [name, value] of form.entries() as Iterable<[string, string | Blob]>) { - if (typeof value === "string") parts.push({ name, value }); - else - parts.push({ - name, - bytes: value.size, - sha256: sha256(new Uint8Array(await value.arrayBuffer())), - }); - } - return Response.json({ parts }); -} - -async function readBytes(response: Response) { - if (!response.body) throw new Error("Response has no body"); - let bytes = 0; - for await (const chunk of response.body) bytes += chunk.byteLength; - return bytes; -} - function makeBytes(size: number) { const bytes = new Uint8Array(new ArrayBuffer(size)); for (let i = 0; i < bytes.length; i++) bytes[i] = i % 251; From d59f910d495cbe293c98fe9d5a53b8f95ccc698f Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Mon, 18 May 2026 21:49:39 +0100 Subject: [PATCH 09/43] ignore .env --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index d833692..0e526d5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules/ .wrangler/ .dev.vars dist/ +.env From 660c7eb8d8cde42a1c32355bf20a92c0a6efb9e6 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Mon, 18 May 2026 22:02:49 +0100 Subject: [PATCH 10/43] Stabilize Captun binary stream e2e --- test/e2e.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/e2e.test.ts b/test/e2e.test.ts index deae69d..e503a5b 100644 --- a/test/e2e.test.ts +++ b/test/e2e.test.ts @@ -27,8 +27,9 @@ test.concurrent("streams a binary response", async ({ task }) => { return new Response( new ReadableStream({ pull(controller) { - if (sent++ === 32) return controller.close(); + sent += 1; controller.enqueue(new Uint8Array(65_536)); + if (sent === 8) controller.close(); }, }), { headers: { "content-type": "application/octet-stream" } }, @@ -41,7 +42,7 @@ test.concurrent("streams a binary response", async ({ task }) => { if (!response.body) throw new Error("Response has no body"); let bytes = 0; for await (const chunk of response.body) bytes += chunk.byteLength; - expect(bytes).toBe(2_097_152); + expect(bytes).toBe(524_288); }); test.concurrent("streams SSE events", async ({ task }) => { From bdade8ebe95d253bdf4613d2ef1265a22a812354 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Mon, 18 May 2026 22:21:26 +0100 Subject: [PATCH 11/43] Add incremental stream e2e coverage --- test/e2e.test.ts | 48 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/test/e2e.test.ts b/test/e2e.test.ts index e503a5b..8970ce6 100644 --- a/test/e2e.test.ts +++ b/test/e2e.test.ts @@ -47,11 +47,13 @@ test.concurrent("streams a binary response", async ({ task }) => { test.concurrent("streams SSE events", async ({ task }) => { using tunnel = await createTunnelFixture(task.name, () => { - const array = Array.from({ length: 5 }, (_, i) => `event: tunnel\nid: ${i + 1}\ndata: ${i + 1}\n\n`) - return new Response( - array.join("",), - {headers: { "content-type": "text/event-stream; charset=utf-8" }}, + const events = Array.from( + { length: 5 }, + (_, i) => `event: tunnel\nid: ${i + 1}\ndata: ${i + 1}\n\n`, ); + return new Response(events.join(""), { + headers: { "content-type": "text/event-stream; charset=utf-8" }, + }); }); const response = await fetch(tunnel.url); @@ -59,6 +61,44 @@ test.concurrent("streams SSE events", async ({ task }) => { expect((await response.text()).match(/^event: tunnel$/gm)).toHaveLength(5); }); +test.concurrent("streams response chunks before the local fetcher finishes", async ({ task }) => { + const encoder = new TextEncoder(); + const secondChunk = Promise.withResolvers(); + + using tunnel = await createTunnelFixture(task.name, () => { + async function* events() { + yield encoder.encode("first\n"); + await secondChunk.promise; + yield encoder.encode("second\n"); + } + + return new Response( + new ReadableStream({ + async start(controller) { + for await (const chunk of events()) controller.enqueue(chunk); + controller.close(); + }, + }), + { headers: { "content-type": "text/event-stream; charset=utf-8" } }, + ); + }); + + const response = await fetch(tunnel.url); + if (!response.body) throw new Error("Response has no body"); + + const reader = response.body.getReader(); + const first = await reader.read(); + expect(new TextDecoder().decode(first.value)).toBe("first\n"); + + secondChunk.resolve(); + + const second = await reader.read(); + expect(new TextDecoder().decode(second.value)).toBe("second\n"); + + const done = await reader.read(); + expect(done.done).toBe(true); +}); + test.concurrent("uploads a raw file body", async ({ task }) => { using tunnel = await createTunnelFixture(task.name, async (request) => { const bytes = new Uint8Array(await request.arrayBuffer()); From 22df79ddf8f3e72f89ad7672a6bb5204c356f84f Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Mon, 18 May 2026 22:30:29 +0100 Subject: [PATCH 12/43] Simplify source import paths --- package.json | 12 ------------ src/cli.ts | 2 +- src/client.ts | 6 ++---- src/server.ts | 4 ++-- src/worker.ts | 4 ++-- 5 files changed, 7 insertions(+), 21 deletions(-) diff --git a/package.json b/package.json index 7584fe3..f22eb83 100644 --- a/package.json +++ b/package.json @@ -32,12 +32,6 @@ "import": "./src/server.ts" } }, - "imports": { - "#types": { - "types": "./src/types.ts", - "default": "./src/types.ts" - } - }, "publishConfig": { "bin": { "captun": "./dist/cli.js" @@ -55,12 +49,6 @@ "types": "./dist/server.d.ts", "import": "./dist/server.js" } - }, - "imports": { - "#types": { - "types": "./dist/types.d.ts", - "default": "./dist/types.js" - } } }, "scripts": { diff --git a/src/cli.ts b/src/cli.ts index cb22e04..b382d83 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,7 +11,7 @@ import * as prompts from "@inquirer/prompts"; import { os } from "@orpc/server"; import { createCli } from "trpc-cli"; import { z } from "zod/v4"; -import { createCaptunTunnel } from "captun/client"; +import { createCaptunTunnel } from "./client.ts"; type Config = { serverUrl: string; diff --git a/src/client.ts b/src/client.ts index 316ff2e..229ff16 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,5 +1,5 @@ import { newWebSocketRpcSession, RpcTarget } from "capnweb"; -import type { CaptunClientCreateTunnelOptions, CaptunClientRemoteFetcher } from "#types"; +import type { CaptunClientCreateTunnelOptions, CaptunClientRemoteFetcher } from "./types.js"; /** Creates a tunnel from a public Worker URL to a local fetch implementation. * @@ -67,9 +67,7 @@ async function waitUntilOpen(socket: WebSocket) { socket.addEventListener( "error", () => settle(() => reject(new Error("WebSocket connection failed"))), - { - signal: listeners.signal, - }, + { signal: listeners.signal }, ); socket.addEventListener( "close", diff --git a/src/server.ts b/src/server.ts index f72624e..b930014 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,8 +3,8 @@ import type { CaptunClientRemoteFetcher, CaptunServerAcceptTunnelOptions, CaptunServerTunnel, -} from "#types"; -export type { CaptunServerAcceptTunnelOptions, CaptunServerTunnel } from "#types"; +} from "./types.js"; +export type { CaptunServerAcceptTunnelOptions, CaptunServerTunnel } from "./types.js"; /** Creates a Worker WebSocket upgrade response and matching tunnel handle. */ export function acceptCaptunTunnel(options: CaptunServerAcceptTunnelOptions = {}) { diff --git a/src/worker.ts b/src/worker.ts index 548174c..a5b55b4 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -1,6 +1,6 @@ import { DurableObject } from "cloudflare:workers"; -import type { CaptunServerTunnel } from "#types"; -import { acceptCaptunTunnel } from "captun/server"; +import { acceptCaptunTunnel } from "./server.ts"; +import type { CaptunServerTunnel } from "./types.ts"; type CaptunEnv = Env & { CAPTUN_SECRET?: string; From ffd1032887f6fc9e88efc0d44baf4f76e32bb376 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Mon, 18 May 2026 22:31:23 +0100 Subject: [PATCH 13/43] formatting --- src/cli.ts | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index b382d83..db0814a 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -18,18 +18,9 @@ type Config = { secret?: string; }; -const adjectives = - "apple amber bright cedar copper daisy ember forest ginger harbor indigo jolly kiwi lemon maple nova olive pearl quartz ruby".split( - " ", - ); -const speeds = - "fast swift quick rapid zippy brisk fleet nimble snappy speedy lively eager sharp ready active bold crisp fresh keen spry".split( - " ", - ); -const things = - "tree river stone cloud field bridge spark meadow tower trail garden island planet signal anchor valley window canyon summit harvest".split( - " ", - ); +const adjectives = "apple amber bright cedar copper daisy ember forest ginger harbor indigo jolly kiwi lemon maple nova olive pearl quartz ruby".split(" "); +const speeds = "fast swift quick rapid zippy brisk fleet nimble snappy speedy lively eager sharp ready active bold crisp fresh keen spry".split(" "); +const things = "tree river stone cloud field bridge spark meadow tower trail garden island planet signal anchor valley window canyon summit harvest".split(" "); const require = createRequire(import.meta.url); const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); From fbc070bdd34eb0531bc8da019cff91f5d2c68005 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Mon, 18 May 2026 23:00:06 +0100 Subject: [PATCH 14/43] Simplify example response formatting --- README.md | 4 +--- examples/weather-reporter/worker.ts | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 82ffff4..c3e2f34 100644 --- a/README.md +++ b/README.md @@ -98,9 +98,7 @@ export class MyDurableObject { return response; } if (url.pathname.startsWith("/egress/")) { - return ( - this.tunnel?.fetch(request) ?? new Response("No tunnel client connected", { status: 503 }) - ); + return this.tunnel?.fetch(request) ?? new Response("No tunnel client connected", { status: 503 }); } return new Response("Not found", { status: 404 }); } diff --git a/examples/weather-reporter/worker.ts b/examples/weather-reporter/worker.ts index 051b9ee..9d2baa9 100644 --- a/examples/weather-reporter/worker.ts +++ b/examples/weather-reporter/worker.ts @@ -15,9 +15,7 @@ export class WeatherReporterEgressTunnel extends DurableObject Date: Tue, 19 May 2026 07:56:03 +0100 Subject: [PATCH 15/43] Start weather e2e worker automatically --- examples/weather-reporter/README.md | 9 +- examples/weather-reporter/e2e.test.ts | 193 +++++++++++++++++++++---- examples/weather-reporter/package.json | 1 + pnpm-lock.yaml | 3 + 4 files changed, 176 insertions(+), 30 deletions(-) diff --git a/examples/weather-reporter/README.md b/examples/weather-reporter/README.md index 46d8ab0..b405bcd 100644 --- a/examples/weather-reporter/README.md +++ b/examples/weather-reporter/README.md @@ -14,13 +14,14 @@ pnpm install pnpm run build ``` -Then run the example from this directory: +Then run the example test from this directory: ```sh -pnpm exec wrangler dev +pnpm test ``` -In another terminal: +The test starts `wrangler dev` automatically when `WEATHER_REPORTER_URL` is not set. +To point the same test at an already-running local Worker: ```sh WEATHER_REPORTER_URL=http://127.0.0.1:8787 pnpm test @@ -40,4 +41,4 @@ doppler run -- pnpm exec wrangler deploy WEATHER_REPORTER_URL=https://weather-reporter.garple-pretend-customer-should-be-iterate-dev-stg-will-chan.workers.dev pnpm test ``` -The test awaits `createCaptunTunnel()` at `WEATHER_REPORTER_URL + "/__intercept-egress-traffic"`, mocks the `wttr.in` response, then calls `/weather/london` and `/weather/new+york` on the deployed Worker. +The test awaits `createCaptunTunnel()` at the Worker's `/__intercept-egress-traffic` route, mocks the `wttr.in` response, then calls `/weather/london` and `/weather/new+york` on the Worker. diff --git a/examples/weather-reporter/e2e.test.ts b/examples/weather-reporter/e2e.test.ts index 95d6792..9230d35 100644 --- a/examples/weather-reporter/e2e.test.ts +++ b/examples/weather-reporter/e2e.test.ts @@ -1,34 +1,175 @@ -import { describe, expect, test, vi } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { setTimeout } from "node:timers/promises"; +import { fileURLToPath } from "node:url"; +import { x } from "tinyexec"; +import { expect, test, vi } from "vitest"; import { createCaptunTunnel } from "../../src/client.ts"; -vi.setConfig({ testTimeout: 15_000 }); +vi.setConfig({ testTimeout: 30_000 }); -const weatherReporterUrl = process.env.WEATHER_REPORTER_URL; -if (!weatherReporterUrl) { - throw new Error("WEATHER_REPORTER_URL is required to load this e2e test module"); -} -const myWeatherAppUrl = weatherReporterUrl; - -describe("weather reporter e2e", () => { - test("returns nicely formatted weather report", async () => { - using _tunnel = await createCaptunTunnel({ - url: `${myWeatherAppUrl}/__intercept-egress-traffic`, - fetch(request) { - if (request.url === "https://wttr.in/london?format=j1") { - return Response.json({ current_condition: [{ temp_C: "18" }] }); - } - if (request.url === "https://wttr.in/new+york?format=j1") { - return Response.json({ current_condition: [{ temp_C: "22" }] }); - } - return new Response("Unexpected egress", { status: 500 }); +test("returns nicely formatted weather report", async () => { + await using app = await createWeatherReporterFixture(); + using _tunnel = await createCaptunTunnel({ + url: `${app.url}/__intercept-egress-traffic`, + fetch(request) { + if (request.url === "https://wttr.in/london?format=j1") { + return Response.json({ current_condition: [{ temp_C: "18" }] }); + } + if (request.url === "https://wttr.in/new+york?format=j1") { + return Response.json({ current_condition: [{ temp_C: "22" }] }); + } + return new Response("Unexpected egress", { status: 500 }); + }, + }); + + const london = await fetch(`${app.url}/weather/london`); + expect(await london.text()).toBe("The temperature in london is 18 celsius"); + + const newYork = await fetch(`${app.url}/weather/new+york`); + expect(await newYork.text()).toBe("The temperature in new+york is 22 celsius"); +}); + +async function createWeatherReporterFixture() { + if (process.env.WEATHER_REPORTER_URL) { + return { + url: process.env.WEATHER_REPORTER_URL, + async [Symbol.asyncDispose]() {}, + }; + } + + const port = await getAvailablePort(); + const inspectorPort = await getAvailablePort(); + const persistTo = await mkdtemp(join(tmpdir(), "captun-weather-reporter-")); + const aborter = new AbortController(); + const wrangler = x( + "wrangler", + [ + "dev", + "--local", + "--ip", + "127.0.0.1", + "--port", + String(port), + "--inspector-port", + String(inspectorPort), + "--persist-to", + persistTo, + "--show-interactive-dev-session=false", + "--log-level", + "warn", + ], + { + signal: aborter.signal, + nodeOptions: { + cwd: dirname(fileURLToPath(import.meta.url)), + env: { ...process.env, NO_COLOR: "1" }, }, - }); + }, + ); + const wranglerResult: WranglerDevResult = Promise.resolve(wrangler).catch((error: unknown) => + error instanceof Error ? error : new Error(String(error)), + ); + + const url = `http://127.0.0.1:${port}`; + try { + await waitForWranglerDev(url, wrangler, wranglerResult); + } catch (error) { + await stopWranglerDev(wrangler, aborter, wranglerResult); + await rm(persistTo, { recursive: true, force: true }); + throw error; + } - const london = await fetch(`${myWeatherAppUrl}/weather/london`); - expect(await london.text()).toBe("The temperature in london is 18 celsius"); + return { + url, + async [Symbol.asyncDispose]() { + await stopWranglerDev(wrangler, aborter, wranglerResult); + await rm(persistTo, { recursive: true, force: true }); + }, + }; +} + +type WranglerDevProcess = ReturnType; +type WranglerDevOutput = Awaited; +type WranglerDevResult = Promise; - const newYork = await fetch(`${myWeatherAppUrl}/weather/new+york`); - expect(await newYork.text()).toBe("The temperature in new+york is 22 celsius"); +async function waitForWranglerDev(url: string, wrangler: WranglerDevProcess, wranglerResult: WranglerDevResult) { + const deadline = Date.now() + 20_000; + while (Date.now() < deadline) { + const startupError = getWranglerExit(wrangler); + if (startupError) { + throw new Error(`wrangler dev failed to start\n\n${formatWranglerResult(await wranglerResult)}`, { + cause: startupError, + }); + } + try { + const response = await fetch(url, { signal: AbortSignal.timeout(1_000) }); + await response.body?.cancel(); + return; + } catch { + await setTimeout(100); + } + } + throw new Error(`Timed out waiting for wrangler dev at ${url}`); +} + +async function stopWranglerDev(wrangler: WranglerDevProcess, aborter: AbortController, wranglerResult: WranglerDevResult) { + if (getWranglerExit(wrangler)) { + await wranglerResult; + return; + } + + aborter.abort(); + wrangler.kill("SIGTERM"); + const exited = await Promise.race([ + wranglerResult.then(() => true), + setTimeout(5_000).then(() => false), + ]); + if (!exited) { + wrangler.kill("SIGKILL"); + await wranglerResult; + } +} + +function formatWranglerResult(result: WranglerDevOutput | Error) { + if (result instanceof Error) return result.stack || result.message; + const output = [result.stdout, result.stderr].filter(Boolean).join("\n"); + if (output) return output; + return `wrangler dev exited with code ${result.exitCode || "none"}`; +} + +function getWranglerExit(wrangler: WranglerDevProcess) { + if (!wrangler.process) { + return new Error("wrangler dev did not start"); + } + if (wrangler.process.exitCode !== null || wrangler.process.signalCode !== null) { + return new Error( + `wrangler dev exited with code ${wrangler.process.exitCode || "none"} and signal ${wrangler.process.signalCode || "none"}`, + ); + } +} + +function getAvailablePort() { + const { promise, resolve, reject } = Promise.withResolvers(); + const server = createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("Expected an IPv4 port from the test server")); + return; + } + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(address.port); + }); }); -}); + return promise; +} diff --git a/examples/weather-reporter/package.json b/examples/weather-reporter/package.json index c885238..6c0fd11 100644 --- a/examples/weather-reporter/package.json +++ b/examples/weather-reporter/package.json @@ -10,6 +10,7 @@ "captun": "workspace:*" }, "devDependencies": { + "tinyexec": "^1.1.2", "typescript": "^6.0.3", "vitest": "^4.1.6" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c21ca6..7b1a788 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,6 +55,9 @@ importers: specifier: workspace:* version: link:../.. devDependencies: + tinyexec: + specifier: ^1.1.2 + version: 1.1.2 typescript: specifier: ^6.0.3 version: 6.0.3 From d37c240dd07af8a542967e1592f488c552accb07 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Tue, 19 May 2026 08:05:14 +0100 Subject: [PATCH 16/43] Address PR review feedback --- README.md | 12 +++--- package.json | 12 +++--- src/{cli.ts => bin.ts} | 32 +++++++++++++--- src/index.ts | 17 ++++++++- src/worker.ts | 16 ++++---- test/e2e.test.ts | 67 +++++++++++++++++++++++----------- test/fixtures/captun-worker.ts | 47 ++++++++++++++++++++++++ test/worker.test.ts | 60 ++++-------------------------- 8 files changed, 160 insertions(+), 103 deletions(-) rename src/{cli.ts => bin.ts} (89%) create mode 100644 test/fixtures/captun-worker.ts diff --git a/README.md b/README.md index c3e2f34..d70f98e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Captun is a tiny reference implementation of a self-hosted ngrok or Cloudflare T npx captun deploy python3 -m http.server 3000 -npx captun --name demo 3000 +CAPTUN_SERVER_URL=https://captun..workers.dev npx captun --name demo 3000 curl https://captun..workers.dev/demo/ ``` @@ -36,18 +36,18 @@ Then expose a local port through a named folder tunnel: ```bash python3 -m http.server 3000 -captun --name demo 3000 +CAPTUN_SERVER_URL=https://captun..workers.dev captun --name demo 3000 curl https://captun..workers.dev/demo/ ``` -If you omit `--name`, the CLI generates a random hyphenated tunnel name. If you set `CAPTUN_SECRET` on the Worker manually, pass the same value to the CLI with `--secret`: +If you omit `--name`, the CLI generates a random hyphenated tunnel name. If you set `CAPTUN_SECRET` on the Worker manually, pass the same value to the CLI through `CAPTUN_SECRET` or `--secret`: ```bash pnpm exec wrangler secret put CAPTUN_SECRET -captun --server-url https://captun..workers.dev --secret secret --name demo 3000 +CAPTUN_SECRET=secret CAPTUN_SERVER_URL=https://captun..workers.dev captun --name demo 3000 ``` -`captun deploy` stores the deployed Worker URL and generated secret in `$XDG_CONFIG_HOME/captun/config.json`, or `~/.config/captun/config.json` when `XDG_CONFIG_HOME` is not set. `--server-url` and `--secret` override the saved config. The repo script runs the same source CLI with `pnpm run cli --`. +`captun deploy` stores the deployed Worker URL and generated secret in `$XDG_CONFIG_HOME/captun/config.json`, or `~/.config/captun/config.json` when `XDG_CONFIG_HOME` is not set. `CAPTUN_SERVER_URL` and `CAPTUN_SECRET` override the saved config, and `--server-url` and `--secret` override both. The repo script runs the same source CLI with `pnpm run cli --`. Folder tunnels are the golden path. The Worker routes `/:name/__connect` to the Cap'n Web session and `/:name/*` to normal proxied HTTP requests, stripping `/:name` before calling your local fetcher. @@ -140,7 +140,7 @@ pnpm run build pnpm run dev ``` -Run tests with `pnpm test`. The unit tests run without external services; the root e2e suite also runs when `CAPTUN_SERVER_URL` is set, with optional `CAPTUN_SECRET`. +Run tests with `pnpm test`. The root e2e suite uses Miniflare by default; set `CAPTUN_SERVER_URL`, with optional `CAPTUN_SECRET`, to run the same cases against a deployed Worker. ## 4. Performance diff --git a/package.json b/package.json index f22eb83..a2bbf5e 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "url": "https://github.com/iterate/captun.git" }, "bin": { - "captun": "./src/cli.ts" + "captun": "./src/bin.ts" }, "files": [ "dist", @@ -16,8 +16,6 @@ "wrangler.toml" ], "type": "module", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", "exports": { ".": { "types": "./src/index.ts", @@ -34,7 +32,7 @@ }, "publishConfig": { "bin": { - "captun": "./dist/cli.js" + "captun": "./dist/bin.js" }, "exports": { ".": { @@ -57,9 +55,9 @@ "typecheck": "tsc -p tsconfig.json && pnpm --filter @captun/weather-reporter typecheck", "deploy": "wrangler deploy", "dev": "wrangler dev", - "test": "pnpm run build && vitest run", - "test:unit": "pnpm run build && vitest run test/worker.test.ts", - "cli": "tsx src/cli.ts", + "test": "vitest run", + "test:unit": "vitest run test/worker.test.ts", + "cli": "tsx src/bin.ts", "prepack": "pnpm run build", "prepublishOnly": "pnpm run check && pnpm run test", "publish:dry-run": "pnpm publish --dry-run --no-git-checks" diff --git a/src/cli.ts b/src/bin.ts similarity index 89% rename from src/cli.ts rename to src/bin.ts index db0814a..2216ea4 100755 --- a/src/cli.ts +++ b/src/bin.ts @@ -37,6 +37,7 @@ const router = os.router({ .input( z.object({ port: z + .coerce .number() .int() .positive() @@ -50,14 +51,14 @@ const router = os.router({ ) .handler(async ({ input }) => { const config = await readConfig(); - const serverUrl = input.serverUrl || config?.serverUrl; + const serverUrl = input.serverUrl || process.env.CAPTUN_SERVER_URL || config?.serverUrl; if (!serverUrl) { throw new Error( `No tunnel server configured. Run "captun deploy" first or pass --server-url.`, ); } - const secret = input.secret || config?.secret; + const secret = input.secret || process.env.CAPTUN_SECRET || config?.secret; const name = input.name || randomName(); const tunnel = tunnelUrl(serverUrl, name); const origin = `http://127.0.0.1:${input.port}`; @@ -145,8 +146,8 @@ async function deployWorker(input: { route?: string; secret: string }) { } async function runWrangler(args: string[]) { - const wranglerBin = require.resolve("wrangler/bin/wrangler.js"); - const child = spawn(process.execPath, [wranglerBin, ...args], { + const wrangler = wranglerCommand(args); + const child = spawn(wrangler.command, wrangler.args, { stdio: ["ignore", "pipe", "pipe"], }); @@ -161,7 +162,17 @@ async function runWrangler(args: string[]) { }); return new Promise((resolvePromise, reject) => { - child.on("error", reject); + child.on("error", (error) => { + if ("code" in error && error.code === "ENOENT") { + reject( + new Error( + "Wrangler is required for `captun deploy`. Install it globally or run `pnpm add -D wrangler` in the project invoking captun.", + ), + ); + return; + } + reject(error); + }); child.on("close", (code) => { if (code === 0) resolvePromise(output); else reject(new Error(`wrangler deploy failed with exit code ${code ?? "unknown"}`)); @@ -169,6 +180,17 @@ async function runWrangler(args: string[]) { }); } +function wranglerCommand(args: string[]) { + try { + return { + command: process.execPath, + args: [require.resolve("wrangler/bin/wrangler.js"), ...args], + }; + } catch { + return { command: "wrangler", args }; + } +} + async function readConfig() { try { return JSON.parse(await readFile(configPath, "utf8")) as Config; diff --git a/src/index.ts b/src/index.ts index da2919d..49b7be6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,15 @@ -export { createCaptunTunnel } from "captun/client"; -export { acceptCaptunTunnel, acceptCaptunTunnelFromSocket } from "captun/server"; +import { createCaptunTunnel as createTunnel } from "./client.ts"; +import { + acceptCaptunTunnel as acceptTunnel, + acceptCaptunTunnelFromSocket as acceptTunnelFromSocket, +} from "./server.ts"; +import type { createCaptunTunnel as CreateCaptunTunnel } from "./client.js"; +import type { + acceptCaptunTunnel as AcceptCaptunTunnel, + acceptCaptunTunnelFromSocket as AcceptCaptunTunnelFromSocket, +} from "./server.js"; + +export const createCaptunTunnel: typeof CreateCaptunTunnel = createTunnel; +export const acceptCaptunTunnel: typeof AcceptCaptunTunnel = acceptTunnel; +export const acceptCaptunTunnelFromSocket: typeof AcceptCaptunTunnelFromSocket = + acceptTunnelFromSocket; diff --git a/src/worker.ts b/src/worker.ts index a5b55b4..97f9e1c 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -22,7 +22,13 @@ export class CaptunServerShard extends DurableObject { const route = captunRouteParts("localhost", new URL(request.url).pathname); if (!route) return new Response("Missing tunnel name\n", { status: 404 }); - const routedRequest = rewritePath(request, route.path); + let routedRequest = request; + const routedUrl = new URL(request.url); + if (routedUrl.pathname !== route.path) { + routedUrl.pathname = route.path; + routedRequest = new Request(routedUrl, request); + } + if (route.path === "/__connect") { const expectedAuthorization = this.env.CAPTUN_SECRET ? `Bearer ${this.env.CAPTUN_SECRET}` @@ -66,14 +72,6 @@ export default { }, } satisfies ExportedHandler; -/** Rebuilds the request only when the Durable Object route prefix must be stripped. */ -function rewritePath(request: Request, pathname: string) { - const url = new URL(request.url); - if (url.pathname === pathname) return request; - url.pathname = pathname; - return new Request(url, request); -} - /** Turns an incoming Worker request into a Durable Object name and forwarded request. */ function captunRoute(request: Request) { const url = new URL(request.url); diff --git a/test/e2e.test.ts b/test/e2e.test.ts index 8970ce6..85e1948 100644 --- a/test/e2e.test.ts +++ b/test/e2e.test.ts @@ -2,15 +2,12 @@ import { createHash } from "node:crypto"; import { expect, test, vi } from "vitest"; import { createCaptunTunnel } from "../src/client.ts"; +import { createCaptunWorkerFixture } from "./fixtures/captun-worker.ts"; vi.setConfig({ testTimeout: 15_000 }); -const captunServerUrl = process.env.CAPTUN_SERVER_URL; -if (!captunServerUrl) throw new Error("CAPTUN_SERVER_URL is required to load this e2e test module"); -const serverUrl = captunServerUrl; - test.concurrent("forwards HTTP", async ({ task }) => { - using tunnel = await createTunnelFixture(task.name, async (request) => + await using tunnel = await createTunnelFixture(task.name, async (request) => Response.json({ body: await request.text() }), ); @@ -22,7 +19,7 @@ test.concurrent("forwards HTTP", async ({ task }) => { }); test.concurrent("streams a binary response", async ({ task }) => { - using tunnel = await createTunnelFixture(task.name, () => { + await using tunnel = await createTunnelFixture(task.name, () => { let sent = 0; return new Response( new ReadableStream({ @@ -46,7 +43,7 @@ test.concurrent("streams a binary response", async ({ task }) => { }); test.concurrent("streams SSE events", async ({ task }) => { - using tunnel = await createTunnelFixture(task.name, () => { + await using tunnel = await createTunnelFixture(task.name, () => { const events = Array.from( { length: 5 }, (_, i) => `event: tunnel\nid: ${i + 1}\ndata: ${i + 1}\n\n`, @@ -65,7 +62,7 @@ test.concurrent("streams response chunks before the local fetcher finishes", asy const encoder = new TextEncoder(); const secondChunk = Promise.withResolvers(); - using tunnel = await createTunnelFixture(task.name, () => { + await using tunnel = await createTunnelFixture(task.name, () => { async function* events() { yield encoder.encode("first\n"); await secondChunk.promise; @@ -100,7 +97,7 @@ test.concurrent("streams response chunks before the local fetcher finishes", asy }); test.concurrent("uploads a raw file body", async ({ task }) => { - using tunnel = await createTunnelFixture(task.name, async (request) => { + await using tunnel = await createTunnelFixture(task.name, async (request) => { const bytes = new Uint8Array(await request.arrayBuffer()); return Response.json({ bytes: bytes.byteLength, sha256: sha256(bytes) }); }); @@ -118,7 +115,7 @@ test.concurrent("uploads a raw file body", async ({ task }) => { }); test.concurrent("uploads multipart form data", async ({ task }) => { - using tunnel = await createTunnelFixture(task.name, async (request) => { + await using tunnel = await createTunnelFixture(task.name, async (request) => { const form = await request.formData(); const parts = []; for (const [name, value] of form.entries() as Iterable<[string, string | Blob]>) { @@ -151,18 +148,46 @@ async function createTunnelFixture( testName: string, fetch: (request: Request) => Response | Promise, ) { + const server = await createServerFixture(); const name = tunnelName(testName); - const url = tunnelUrl(name); - const tunnel = await createCaptunTunnel({ - url: new URL("__connect", url), - headers: process.env.CAPTUN_SECRET - ? { authorization: `Bearer ${process.env.CAPTUN_SECRET}` } - : undefined, - fetch, - }); + try { + const url = tunnelUrl(server.url, name); + const tunnel = await createCaptunTunnel({ + url: new URL("__connect", url), + headers: server.headers, + fetch, + }); + return { + url: url.toString(), + async [Symbol.asyncDispose]() { + tunnel[Symbol.dispose](); + await server[Symbol.asyncDispose](); + }, + }; + } catch (error) { + await server[Symbol.asyncDispose](); + throw error; + } +} + +async function createServerFixture() { + if (process.env.CAPTUN_SERVER_URL) { + return { + url: process.env.CAPTUN_SERVER_URL, + headers: process.env.CAPTUN_SECRET + ? { authorization: `Bearer ${process.env.CAPTUN_SECRET}` } + : undefined, + async [Symbol.asyncDispose]() {}, + }; + } + + const worker = await createCaptunWorkerFixture(); return { - url: url.toString(), - [Symbol.dispose]: () => tunnel[Symbol.dispose](), + url: worker.origin, + headers: undefined, + async [Symbol.asyncDispose]() { + await worker[Symbol.asyncDispose](); + }, }; } @@ -177,7 +202,7 @@ function tunnelName(testName: string) { return `${prefix}-${hash}`; } -function tunnelUrl(name: string) { +function tunnelUrl(serverUrl: string, name: string) { if (serverUrl.includes("{name}")) return new URL(serverUrl.replaceAll("{name}", name)); const url = new URL(serverUrl); diff --git a/test/fixtures/captun-worker.ts b/test/fixtures/captun-worker.ts new file mode 100644 index 0000000..b45f126 --- /dev/null +++ b/test/fixtures/captun-worker.ts @@ -0,0 +1,47 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import * as esbuild from "esbuild"; +import { Miniflare } from "miniflare"; + +export async function createCaptunWorkerFixture(bindings: Record = {}) { + const tempDir = await mkdtemp(join(tmpdir(), "captun-miniflare-")); + await esbuild.build({ + entryPoints: ["src/worker.ts"], + outfile: join(tempDir, "worker.js"), + bundle: true, + format: "esm", + platform: "neutral", + target: "es2022", + external: ["cloudflare:workers"], + }); + + const miniflare = new Miniflare({ + modules: true, + rootPath: tempDir, + modulesRoot: tempDir, + scriptPath: "worker.js", + modulesRules: [{ type: "ESModule", include: ["**/*.js"] }], + compatibilityDate: "2026-05-15", + durableObjects: { + CaptunServerShard: { className: "CaptunServerShard" }, + }, + bindings, + }); + const url = await miniflare.ready; + const worker = (await miniflare.getWorker()) as unknown as WorkerFetcherLike; + + return { + origin: url.origin, + worker, + async [Symbol.asyncDispose]() { + await miniflare.dispose(); + await rm(tempDir, { recursive: true, force: true }); + }, + }; +} + +export interface WorkerFetcherLike { + fetch(input: string, init?: RequestInit): Promise; +} diff --git a/test/worker.test.ts b/test/worker.test.ts index bd1a449..ed4505a 100644 --- a/test/worker.test.ts +++ b/test/worker.test.ts @@ -1,14 +1,9 @@ -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import * as esbuild from "esbuild"; -import { Miniflare } from "miniflare"; import { expect, test } from "vitest"; import { createCaptunTunnel } from "../src/client.ts"; +import { createCaptunWorkerFixture } from "./fixtures/captun-worker.ts"; test("Captun Worker forwards requests through a real Durable Object tunnel", async () => { - await using fixture = await createWorkerFixture(); + await using fixture = await createCaptunWorkerFixture(); using _tunnel = await createCaptunTunnel({ url: new URL("/demo/__connect", fixture.origin), fetch: async (request) => { @@ -32,7 +27,7 @@ test("Captun Worker forwards requests through a real Durable Object tunnel", asy }); test("Captun Worker returns 503 when a named tunnel has no connected client", async () => { - await using fixture = await createWorkerFixture(); + await using fixture = await createCaptunWorkerFixture(); const response = await fetch(new URL("/missing/hello", fixture.origin)); @@ -41,7 +36,7 @@ test("Captun Worker returns 503 when a named tunnel has no connected client", as }); test("Captun Worker routes subdomain tunnel requests", async () => { - await using fixture = await createWorkerFixture(); + await using fixture = await createCaptunWorkerFixture(); const response = await fixture.worker.fetch("http://demo.tunnels.example.com/hello"); @@ -50,7 +45,7 @@ test("Captun Worker routes subdomain tunnel requests", async () => { }); test("Captun Worker rejects missing tunnel names before Durable Object dispatch", async () => { - await using fixture = await createWorkerFixture(); + await using fixture = await createCaptunWorkerFixture(); const response = await fetch(new URL("/__connect", fixture.origin)); @@ -59,7 +54,7 @@ test("Captun Worker rejects missing tunnel names before Durable Object dispatch" }); test("Captun Worker rejects malformed folder tunnel names", async () => { - await using fixture = await createWorkerFixture(); + await using fixture = await createCaptunWorkerFixture(); const response = await fetch(new URL("/bad%/hello", fixture.origin)); @@ -68,51 +63,10 @@ test("Captun Worker rejects malformed folder tunnel names", async () => { }); test("Captun Worker requires the configured secret before accepting a tunnel client", async () => { - await using fixture = await createWorkerFixture({ CAPTUN_SECRET: "secret" }); + await using fixture = await createCaptunWorkerFixture({ CAPTUN_SECRET: "secret" }); const response = await fetch(new URL("/demo/__connect", fixture.origin)); expect(response.status).toBe(401); expect(await response.text()).toBe("Unauthorized\n"); }); - -async function createWorkerFixture(bindings: Record = {}) { - const tempDir = await mkdtemp(join(tmpdir(), "captun-miniflare-")); - await esbuild.build({ - entryPoints: ["dist/worker.js"], - outfile: join(tempDir, "worker.js"), - bundle: true, - format: "esm", - platform: "neutral", - target: "es2022", - external: ["cloudflare:workers"], - }); - - const miniflare = new Miniflare({ - modules: true, - rootPath: tempDir, - modulesRoot: tempDir, - scriptPath: "worker.js", - modulesRules: [{ type: "ESModule", include: ["**/*.js"] }], - compatibilityDate: "2024-04-03", - durableObjects: { - CaptunServerShard: { className: "CaptunServerShard" }, - }, - bindings, - }); - const url = await miniflare.ready; - const worker = (await miniflare.getWorker()) as unknown as WorkerFetcherLike; - - return { - origin: url.origin, - worker, - async [Symbol.asyncDispose]() { - await miniflare.dispose(); - await rm(tempDir, { recursive: true, force: true }); - }, - }; -} - -interface WorkerFetcherLike { - fetch(input: string, init?: RequestInit): Promise; -} From 53c92a8491a6643ad5a974d8d056c31f6de7c027 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Tue, 19 May 2026 08:14:15 +0100 Subject: [PATCH 17/43] Add GitHub Actions CI --- .github/workflows/ci.yml | 22 ++++++++++++++++++++++ .github/workflows/pkg-pr-new.yml | 18 ++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/pkg-pr-new.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a1b1621 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: CI + +on: + push: {} + pull_request: {} + +jobs: + build-test-types: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + - run: npm install -g corepack@0.31.0 + - run: corepack enable + - run: pnpm install --frozen-lockfile + - run: pnpm run build + - run: pnpm run test + - run: mkdir -p .pkg + - run: pnpm pack --pack-destination .pkg --json + - run: npx --yes @arethetypeswrong/cli .pkg/*.tgz --profile esm-only diff --git a/.github/workflows/pkg-pr-new.yml b/.github/workflows/pkg-pr-new.yml new file mode 100644 index 0000000..bf0e0d1 --- /dev/null +++ b/.github/workflows/pkg-pr-new.yml @@ -0,0 +1,18 @@ +name: pkg.pr.new + +on: + push: {} + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + - run: npm install -g corepack@0.31.0 + - run: corepack enable + - run: pnpm install --frozen-lockfile + - run: pnpm run build + - run: pnpm dlx pkg-pr-new publish From e9c28b1b87203eeb431d700266ed7690af90b6cd Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Tue, 19 May 2026 08:40:12 +0100 Subject: [PATCH 18/43] Use CLI-provided port number --- src/bin.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/bin.ts b/src/bin.ts index 2216ea4..f7e780c 100755 --- a/src/bin.ts +++ b/src/bin.ts @@ -37,7 +37,6 @@ const router = os.router({ .input( z.object({ port: z - .coerce .number() .int() .positive() From ecac5e6e74b0e442afabe7d4f962ce98fb76e4a5 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Tue, 19 May 2026 09:22:34 +0100 Subject: [PATCH 19/43] Use Miniflare for weather e2e --- examples/weather-reporter/README.md | 5 +- examples/weather-reporter/e2e.test.ts | 143 ++---------------- examples/weather-reporter/package.json | 1 - pnpm-lock.yaml | 3 - src/bin.ts | 46 ++---- src/exec.ts | 79 ++++++++++ test/e2e.test.ts | 4 +- .../captun-worker.ts => miniflare.ts} | 30 +++- test/worker.test.ts | 12 +- 9 files changed, 135 insertions(+), 188 deletions(-) create mode 100644 src/exec.ts rename test/{fixtures/captun-worker.ts => miniflare.ts} (61%) diff --git a/examples/weather-reporter/README.md b/examples/weather-reporter/README.md index b405bcd..0beb1bb 100644 --- a/examples/weather-reporter/README.md +++ b/examples/weather-reporter/README.md @@ -7,11 +7,10 @@ tunnel is used from the same Worker request context. ## Run Locally -From the repository root, install and build once: +From the repository root, install once: ```sh pnpm install -pnpm run build ``` Then run the example test from this directory: @@ -20,7 +19,7 @@ Then run the example test from this directory: pnpm test ``` -The test starts `wrangler dev` automatically when `WEATHER_REPORTER_URL` is not set. +The test starts a Miniflare Worker automatically when `WEATHER_REPORTER_URL` is not set. To point the same test at an already-running local Worker: ```sh diff --git a/examples/weather-reporter/e2e.test.ts b/examples/weather-reporter/e2e.test.ts index 9230d35..6d7e11e 100644 --- a/examples/weather-reporter/e2e.test.ts +++ b/examples/weather-reporter/e2e.test.ts @@ -1,15 +1,9 @@ -import { mkdtemp, rm } from "node:fs/promises"; -import { createServer } from "node:net"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { setTimeout } from "node:timers/promises"; -import { fileURLToPath } from "node:url"; -import { x } from "tinyexec"; import { expect, test, vi } from "vitest"; import { createCaptunTunnel } from "../../src/client.ts"; +import { createMiniflareWorkerFixture } from "../../test/miniflare.ts"; -vi.setConfig({ testTimeout: 30_000 }); +vi.setConfig({ testTimeout: 15_000 }); test("returns nicely formatted weather report", async () => { await using app = await createWeatherReporterFixture(); @@ -41,135 +35,18 @@ async function createWeatherReporterFixture() { }; } - const port = await getAvailablePort(); - const inspectorPort = await getAvailablePort(); - const persistTo = await mkdtemp(join(tmpdir(), "captun-weather-reporter-")); - const aborter = new AbortController(); - const wrangler = x( - "wrangler", - [ - "dev", - "--local", - "--ip", - "127.0.0.1", - "--port", - String(port), - "--inspector-port", - String(inspectorPort), - "--persist-to", - persistTo, - "--show-interactive-dev-session=false", - "--log-level", - "warn", - ], - { - signal: aborter.signal, - nodeOptions: { - cwd: dirname(fileURLToPath(import.meta.url)), - env: { ...process.env, NO_COLOR: "1" }, - }, + const worker = await createMiniflareWorkerFixture({ + entryPoint: "examples/weather-reporter/worker.ts", + durableObjects: { + WEATHER_REPORTER_EGRESS: { className: "WeatherReporterEgressTunnel" }, }, - ); - const wranglerResult: WranglerDevResult = Promise.resolve(wrangler).catch((error: unknown) => - error instanceof Error ? error : new Error(String(error)), - ); - - const url = `http://127.0.0.1:${port}`; - try { - await waitForWranglerDev(url, wrangler, wranglerResult); - } catch (error) { - await stopWranglerDev(wrangler, aborter, wranglerResult); - await rm(persistTo, { recursive: true, force: true }); - throw error; - } + bindings: {}, + }); return { - url, + url: worker.origin, async [Symbol.asyncDispose]() { - await stopWranglerDev(wrangler, aborter, wranglerResult); - await rm(persistTo, { recursive: true, force: true }); + await worker[Symbol.asyncDispose](); }, }; } - -type WranglerDevProcess = ReturnType; -type WranglerDevOutput = Awaited; -type WranglerDevResult = Promise; - -async function waitForWranglerDev(url: string, wrangler: WranglerDevProcess, wranglerResult: WranglerDevResult) { - const deadline = Date.now() + 20_000; - while (Date.now() < deadline) { - const startupError = getWranglerExit(wrangler); - if (startupError) { - throw new Error(`wrangler dev failed to start\n\n${formatWranglerResult(await wranglerResult)}`, { - cause: startupError, - }); - } - try { - const response = await fetch(url, { signal: AbortSignal.timeout(1_000) }); - await response.body?.cancel(); - return; - } catch { - await setTimeout(100); - } - } - throw new Error(`Timed out waiting for wrangler dev at ${url}`); -} - -async function stopWranglerDev(wrangler: WranglerDevProcess, aborter: AbortController, wranglerResult: WranglerDevResult) { - if (getWranglerExit(wrangler)) { - await wranglerResult; - return; - } - - aborter.abort(); - wrangler.kill("SIGTERM"); - const exited = await Promise.race([ - wranglerResult.then(() => true), - setTimeout(5_000).then(() => false), - ]); - if (!exited) { - wrangler.kill("SIGKILL"); - await wranglerResult; - } -} - -function formatWranglerResult(result: WranglerDevOutput | Error) { - if (result instanceof Error) return result.stack || result.message; - const output = [result.stdout, result.stderr].filter(Boolean).join("\n"); - if (output) return output; - return `wrangler dev exited with code ${result.exitCode || "none"}`; -} - -function getWranglerExit(wrangler: WranglerDevProcess) { - if (!wrangler.process) { - return new Error("wrangler dev did not start"); - } - if (wrangler.process.exitCode !== null || wrangler.process.signalCode !== null) { - return new Error( - `wrangler dev exited with code ${wrangler.process.exitCode || "none"} and signal ${wrangler.process.signalCode || "none"}`, - ); - } -} - -function getAvailablePort() { - const { promise, resolve, reject } = Promise.withResolvers(); - const server = createServer(); - server.once("error", reject); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - if (!address || typeof address === "string") { - server.close(); - reject(new Error("Expected an IPv4 port from the test server")); - return; - } - server.close((error) => { - if (error) { - reject(error); - return; - } - resolve(address.port); - }); - }); - return promise; -} diff --git a/examples/weather-reporter/package.json b/examples/weather-reporter/package.json index 6c0fd11..c885238 100644 --- a/examples/weather-reporter/package.json +++ b/examples/weather-reporter/package.json @@ -10,7 +10,6 @@ "captun": "workspace:*" }, "devDependencies": { - "tinyexec": "^1.1.2", "typescript": "^6.0.3", "vitest": "^4.1.6" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b1a788..0c21ca6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,9 +55,6 @@ importers: specifier: workspace:* version: link:../.. devDependencies: - tinyexec: - specifier: ^1.1.2 - version: 1.1.2 typescript: specifier: ^6.0.3 version: 6.0.3 diff --git a/src/bin.ts b/src/bin.ts index f7e780c..2b6f611 100755 --- a/src/bin.ts +++ b/src/bin.ts @@ -1,5 +1,4 @@ #!/usr/bin/env node -import { spawn } from "node:child_process"; import { randomBytes } from "node:crypto"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { createRequire } from "node:module"; @@ -12,6 +11,7 @@ import { os } from "@orpc/server"; import { createCli } from "trpc-cli"; import { z } from "zod/v4"; import { createCaptunTunnel } from "./client.ts"; +import { CommandNotFoundError, ExecError, exec } from "./exec.ts"; type Config = { serverUrl: string; @@ -146,37 +146,19 @@ async function deployWorker(input: { route?: string; secret: string }) { async function runWrangler(args: string[]) { const wrangler = wranglerCommand(args); - const child = spawn(wrangler.command, wrangler.args, { - stdio: ["ignore", "pipe", "pipe"], - }); - - let output = ""; - child.stdout.on("data", (chunk: Buffer) => { - output += chunk; - process.stdout.write(chunk); - }); - child.stderr.on("data", (chunk: Buffer) => { - output += chunk; - process.stderr.write(chunk); - }); - - return new Promise((resolvePromise, reject) => { - child.on("error", (error) => { - if ("code" in error && error.code === "ENOENT") { - reject( - new Error( - "Wrangler is required for `captun deploy`. Install it globally or run `pnpm add -D wrangler` in the project invoking captun.", - ), - ); - return; - } - reject(error); - }); - child.on("close", (code) => { - if (code === 0) resolvePromise(output); - else reject(new Error(`wrangler deploy failed with exit code ${code ?? "unknown"}`)); - }); - }); + try { + return (await exec(wrangler.command, wrangler.args, { cwd: packageRoot })).output; + } catch (error) { + if (error instanceof CommandNotFoundError) { + throw new Error( + "Wrangler is required for `captun deploy`. Install it globally or run `pnpm add -D wrangler` in the project invoking captun.", + ); + } + if (error instanceof ExecError) { + throw new Error(`wrangler deploy failed with exit code ${error.result.exitCode}`); + } + throw error; + } } function wranglerCommand(args: string[]) { diff --git a/src/exec.ts b/src/exec.ts new file mode 100644 index 0000000..e9c154f --- /dev/null +++ b/src/exec.ts @@ -0,0 +1,79 @@ +import { spawn } from "node:child_process"; + +export type ExecOptions = { + cwd: string; +}; + +export type ExecResult = { + command: string; + args: string[]; + cwd: string; + stdout: string; + stderr: string; + output: string; + exitCode: number; +}; + +export class CommandNotFoundError extends Error { + command: string; + + constructor(command: string) { + super(`Command not found: ${command}`); + this.command = command; + } +} + +export class ExecError extends Error { + result: ExecResult; + + constructor(result: ExecResult) { + super(`${result.command} exited with code ${result.exitCode}`); + this.result = result; + } +} + +export function exec(command: string, args: string[], options: ExecOptions) { + const child = spawn(command, args, { + cwd: options.cwd, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + + child.stdout?.on("data", (chunk: Buffer) => { + stdout += chunk; + process.stdout.write(chunk); + }); + child.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk; + process.stderr.write(chunk); + }); + + return new Promise((resolvePromise, reject) => { + child.on("error", (error) => { + if ("code" in error && error.code === "ENOENT") { + reject(new CommandNotFoundError(command)); + return; + } + reject(error); + }); + child.on("close", (code) => { + const exitCode = code === null ? 1 : code; + const result = { + command, + args, + cwd: options.cwd, + stdout, + stderr, + output: stdout + stderr, + exitCode, + }; + if (exitCode === 0) { + resolvePromise(result); + return; + } + reject(new ExecError(result)); + }); + }); +} diff --git a/test/e2e.test.ts b/test/e2e.test.ts index 85e1948..78642d7 100644 --- a/test/e2e.test.ts +++ b/test/e2e.test.ts @@ -2,7 +2,7 @@ import { createHash } from "node:crypto"; import { expect, test, vi } from "vitest"; import { createCaptunTunnel } from "../src/client.ts"; -import { createCaptunWorkerFixture } from "./fixtures/captun-worker.ts"; +import { createCaptunWorkerFixture } from "./miniflare.ts"; vi.setConfig({ testTimeout: 15_000 }); @@ -181,7 +181,7 @@ async function createServerFixture() { }; } - const worker = await createCaptunWorkerFixture(); + const worker = await createCaptunWorkerFixture({}); return { url: worker.origin, headers: undefined, diff --git a/test/fixtures/captun-worker.ts b/test/miniflare.ts similarity index 61% rename from test/fixtures/captun-worker.ts rename to test/miniflare.ts index b45f126..88fdaa6 100644 --- a/test/fixtures/captun-worker.ts +++ b/test/miniflare.ts @@ -1,14 +1,20 @@ +// This could move to a wrangler dev based flow someday if we need to test Wrangler's local runtime. import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import * as esbuild from "esbuild"; import { Miniflare } from "miniflare"; - -export async function createCaptunWorkerFixture(bindings: Record = {}) { +export async function createMiniflareWorkerFixture(options: { + entryPoint: string; + durableObjects: Record; + bindings: Record; +}) { + const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const tempDir = await mkdtemp(join(tmpdir(), "captun-miniflare-")); await esbuild.build({ - entryPoints: ["src/worker.ts"], + entryPoints: [resolve(repoRoot, options.entryPoint)], outfile: join(tempDir, "worker.js"), bundle: true, format: "esm", @@ -24,10 +30,8 @@ export async function createCaptunWorkerFixture(bindings: Record scriptPath: "worker.js", modulesRules: [{ type: "ESModule", include: ["**/*.js"] }], compatibilityDate: "2026-05-15", - durableObjects: { - CaptunServerShard: { className: "CaptunServerShard" }, - }, - bindings, + durableObjects: options.durableObjects, + bindings: options.bindings, }); const url = await miniflare.ready; const worker = (await miniflare.getWorker()) as unknown as WorkerFetcherLike; @@ -42,6 +46,16 @@ export async function createCaptunWorkerFixture(bindings: Record }; } +export function createCaptunWorkerFixture(bindings: Record) { + return createMiniflareWorkerFixture({ + entryPoint: "src/worker.ts", + durableObjects: { + CaptunServerShard: { className: "CaptunServerShard" }, + }, + bindings, + }); +} + export interface WorkerFetcherLike { fetch(input: string, init?: RequestInit): Promise; } diff --git a/test/worker.test.ts b/test/worker.test.ts index ed4505a..4be269c 100644 --- a/test/worker.test.ts +++ b/test/worker.test.ts @@ -1,9 +1,9 @@ import { expect, test } from "vitest"; import { createCaptunTunnel } from "../src/client.ts"; -import { createCaptunWorkerFixture } from "./fixtures/captun-worker.ts"; +import { createCaptunWorkerFixture } from "./miniflare.ts"; test("Captun Worker forwards requests through a real Durable Object tunnel", async () => { - await using fixture = await createCaptunWorkerFixture(); + await using fixture = await createCaptunWorkerFixture({}); using _tunnel = await createCaptunTunnel({ url: new URL("/demo/__connect", fixture.origin), fetch: async (request) => { @@ -27,7 +27,7 @@ test("Captun Worker forwards requests through a real Durable Object tunnel", asy }); test("Captun Worker returns 503 when a named tunnel has no connected client", async () => { - await using fixture = await createCaptunWorkerFixture(); + await using fixture = await createCaptunWorkerFixture({}); const response = await fetch(new URL("/missing/hello", fixture.origin)); @@ -36,7 +36,7 @@ test("Captun Worker returns 503 when a named tunnel has no connected client", as }); test("Captun Worker routes subdomain tunnel requests", async () => { - await using fixture = await createCaptunWorkerFixture(); + await using fixture = await createCaptunWorkerFixture({}); const response = await fixture.worker.fetch("http://demo.tunnels.example.com/hello"); @@ -45,7 +45,7 @@ test("Captun Worker routes subdomain tunnel requests", async () => { }); test("Captun Worker rejects missing tunnel names before Durable Object dispatch", async () => { - await using fixture = await createCaptunWorkerFixture(); + await using fixture = await createCaptunWorkerFixture({}); const response = await fetch(new URL("/__connect", fixture.origin)); @@ -54,7 +54,7 @@ test("Captun Worker rejects missing tunnel names before Durable Object dispatch" }); test("Captun Worker rejects malformed folder tunnel names", async () => { - await using fixture = await createCaptunWorkerFixture(); + await using fixture = await createCaptunWorkerFixture({}); const response = await fetch(new URL("/bad%/hello", fixture.origin)); From 2e0d0f3cc59ec4b43d5d1ff899f4f73df040658f Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Tue, 19 May 2026 09:42:32 +0100 Subject: [PATCH 20/43] Fix ATTW CI check --- .github/workflows/ci.yml | 2 +- package.json | 6 + pnpm-lock.yaml | 419 +++++++++++++++++++++++++++++++++++++++ tsconfig.lib.json | 2 - 4 files changed, 426 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1b1621..0c7460a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,4 +19,4 @@ jobs: - run: pnpm run test - run: mkdir -p .pkg - run: pnpm pack --pack-destination .pkg --json - - run: npx --yes @arethetypeswrong/cli .pkg/*.tgz --profile esm-only + - run: pnpm exec attw .pkg/*.tgz --profile esm-only diff --git a/package.json b/package.json index a2bbf5e..5666d83 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "zod": "^4.4.3" }, "devDependencies": { + "@arethetypeswrong/cli": "0.18.2", "@cloudflare/workers-types": "^4.20260515.1", "@types/node": "^25.8.0", "esbuild": "^0.28.0", @@ -79,5 +80,10 @@ "vitest": "^4.1.6", "wrangler": "^4.92.0" }, + "pnpm": { + "overrides": { + "@arethetypeswrong/core>fflate": "0.8.2" + } + }, "packageManager": "pnpm@10.11.1" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c21ca6..f465e3b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + '@arethetypeswrong/core>fflate': 0.8.2 + importers: .: @@ -24,6 +27,9 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@arethetypeswrong/cli': + specifier: 0.18.2 + version: 0.18.2 '@cloudflare/workers-types': specifier: ^4.20260515.1 version: 4.20260518.1 @@ -64,6 +70,21 @@ importers: packages: + '@andrewbranch/untar.js@1.0.3': + resolution: {integrity: sha512-Jh15/qVmrLGhkKJBdXlK1+9tY4lZruYjsgkDFj08ZmDiWVBLJcqkok7Z0/R0In+i1rScBpJlSvrTS2Lm41Pbnw==} + + '@arethetypeswrong/cli@0.18.2': + resolution: {integrity: sha512-PcFM20JNlevEDKBg4Re29Rtv2xvjvQZzg7ENnrWFSS0PHgdP2njibVFw+dRUhNkPgNfac9iUqO0ohAXqQL4hbw==} + engines: {node: '>=20'} + hasBin: true + + '@arethetypeswrong/core@0.18.2': + resolution: {integrity: sha512-GiwTmBFOU1/+UVNqqCGzFJYfBXEytUkiI+iRZ6Qx7KmUVtLm00sYySkfe203C9QtPG11yOz1ZaMek8dT/xnlgg==} + engines: {node: '>=20'} + + '@braidai/lang@1.1.2': + resolution: {integrity: sha512-qBcknbBufNHlui137Hft8xauQMTZDKdophmLFv05r2eNmdIv/MlPuP4TdUknHG68UdWLgVZwgxVe735HzJNIwA==} + '@cloudflare/kv-asset-handler@0.5.0': resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} engines: {node: '>=22.0.0'} @@ -110,6 +131,10 @@ packages: '@cloudflare/workers-types@4.20260518.1': resolution: {integrity: sha512-xXzGrbRi8RHRBNQFgXYkzrB4DgF0RXvmp8E1vCxoBmINpeitM/ZjVDd1CNC+N3uXjgcNjacoz4OgTa0rxgig1A==} + '@colors/colors@1.5.0': + resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} + engines: {node: '>=0.1.90'} + '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -716,6 +741,9 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@loaderkit/resolve@1.0.6': + resolution: {integrity: sha512-G8FdIoF5CypfwmD9rl8BXod5HDn8JqB0CCNBXDTaRZ+yRYhARrrSToX1zg1zy9jX3zLqigsELwhT4gNtkdQAUg==} + '@napi-rs/wasm-runtime@1.1.4': resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} peerDependencies: @@ -877,6 +905,10 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@sindresorhus/is@4.6.0': + resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} + engines: {node: '>=10'} + '@sindresorhus/is@7.2.0': resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} engines: {node: '>=18'} @@ -931,6 +963,25 @@ packages: '@vitest/utils@4.1.6': resolution: {integrity: sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==} + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -945,13 +996,51 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + char-regex@1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} + chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + cjs-module-lexer@1.4.3: + resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + + cli-highlight@2.1.11: + resolution: {integrity: sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==} + engines: {node: '>=8.0.0', npm: '>=5.0.0'} + hasBin: true + + cli-table3@0.6.5: + resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} + engines: {node: 10.* || >= 12.*} + cli-width@4.1.0: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} + cliui@7.0.4: + resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + commander@14.0.3: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} @@ -967,6 +1056,16 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emojilib@2.4.0: + resolution: {integrity: sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==} + + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} @@ -983,6 +1082,10 @@ packages: engines: {node: '>=18'} hasBin: true + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -1008,15 +1111,33 @@ packages: picomatch: optional: true + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + highlight.js@10.7.3: + resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} + iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + kleur@4.1.5: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} @@ -1091,9 +1212,24 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + lru-cache@11.4.0: + resolution: {integrity: sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==} + engines: {node: 20 || >=22} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + marked-terminal@7.3.0: + resolution: {integrity: sha512-t4rBvPsHc57uE/2nJOLmMbZCQ4tgAccAED3ngXQqW6g+TxA488JzJ+FK3lQkzBQOI1mRV/r/Kq+1ZlJ4D0owQw==} + engines: {node: '>=16.0.0'} + peerDependencies: + marked: '>=1 <16' + + marked@9.1.6: + resolution: {integrity: sha512-jcByLnIFkd5gSXZmjNvS1TlmRhCXZjIzHYlaGkPlLIekG55JDR2Z4va9tZwCiP+/RDERiNhMOFu01xd6O5ct1Q==} + engines: {node: '>= 16'} + hasBin: true + miniflare@4.20260515.0: resolution: {integrity: sha512-2j0oQWizk1Eu4Cm8tDX7Z+Nsjd0nebIj1TQcQ+Oy1QKeo0Ay9+bdn8wfLAtOj9znDCybDCUlnS1+nYvKXEdfNg==} engines: {node: '>=22.0.0'} @@ -1103,17 +1239,37 @@ packages: resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} engines: {node: ^20.17.0 || >=22.9.0} + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + nanoid@3.3.12: resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + node-emoji@2.2.0: + resolution: {integrity: sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==} + engines: {node: '>=18'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} openapi-types@12.1.3: resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + parse5-htmlparser2-tree-adapter@6.0.1: + resolution: {integrity: sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==} + + parse5@5.1.1: + resolution: {integrity: sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==} + + parse5@6.0.1: + resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==} + path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} @@ -1135,6 +1291,10 @@ packages: resolution: {integrity: sha512-h36JMxKRqrAxVD8201FrCpyeNuUY9Y5zZwujr20fFO77tpUtGa6EZzfKw/3WaiBX95fq7+MpsuMLNdSnORAwSA==} engines: {node: '>=14.18.0'} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + rolldown@1.0.1: resolution: {integrity: sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1159,6 +1319,10 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + skin-tone@2.0.0: + resolution: {integrity: sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==} + engines: {node: '>=8'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1169,14 +1333,37 @@ packages: std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + supports-color@10.2.2: resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} engines: {node: '>=18'} + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-hyperlinks@3.2.0: + resolution: {integrity: sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==} + engines: {node: '>=14.18'} + tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1229,6 +1416,11 @@ packages: resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==} engines: {node: '>=20'} + typescript@5.6.1-rc: + resolution: {integrity: sha512-E3b2+1zEFu84jB0YQi9BORDjz9+jGbwwy1Zi3G0LUNw7a7cePUrHMRNy8aPh53nXpkFGVHSxIZo5vKTfYaFiBQ==} + engines: {node: '>=14.17'} + hasBin: true + typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} @@ -1244,6 +1436,14 @@ packages: unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + unicode-emoji-modifier-base@1.0.0: + resolution: {integrity: sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==} + engines: {node: '>=4'} + + validate-npm-package-name@5.0.1: + resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + vite@8.0.13: resolution: {integrity: sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1348,6 +1548,10 @@ packages: '@cloudflare/workers-types': optional: true + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + ws@8.18.0: resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} engines: {node: '>=10.0.0'} @@ -1372,6 +1576,18 @@ packages: utf-8-validate: optional: true + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yargs-parser@20.2.9: + resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} + engines: {node: '>=10'} + + yargs@16.2.0: + resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} + engines: {node: '>=10'} + youch-core@0.3.3: resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} @@ -1383,6 +1599,31 @@ packages: snapshots: + '@andrewbranch/untar.js@1.0.3': {} + + '@arethetypeswrong/cli@0.18.2': + dependencies: + '@arethetypeswrong/core': 0.18.2 + chalk: 4.1.2 + cli-table3: 0.6.5 + commander: 10.0.1 + marked: 9.1.6 + marked-terminal: 7.3.0(marked@9.1.6) + semver: 7.8.0 + + '@arethetypeswrong/core@0.18.2': + dependencies: + '@andrewbranch/untar.js': 1.0.3 + '@loaderkit/resolve': 1.0.6 + cjs-module-lexer: 1.4.3 + fflate: 0.8.2 + lru-cache: 11.4.0 + semver: 7.8.0 + typescript: 5.6.1-rc + validate-npm-package-name: 5.0.1 + + '@braidai/lang@1.1.2': {} + '@cloudflare/kv-asset-handler@0.5.0': {} '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260515.1)': @@ -1408,6 +1649,9 @@ snapshots: '@cloudflare/workers-types@4.20260518.1': {} + '@colors/colors@1.5.0': + optional: true + '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 @@ -1808,6 +2052,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@loaderkit/resolve@1.0.6': + dependencies: + '@braidai/lang': 1.1.2 + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -1969,6 +2217,8 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@sindresorhus/is@4.6.0': {} + '@sindresorhus/is@7.2.0': {} '@speed-highlight/core@1.2.15': {} @@ -2034,6 +2284,20 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + any-promise@1.3.0: {} + assertion-error@2.0.1: {} blake3-wasm@2.1.5: {} @@ -2042,10 +2306,50 @@ snapshots: chai@6.2.2: {} + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.6.2: {} + + char-regex@1.0.2: {} + chardet@2.1.1: {} + cjs-module-lexer@1.4.3: {} + + cli-highlight@2.1.11: + dependencies: + chalk: 4.1.2 + highlight.js: 10.7.3 + mz: 2.7.0 + parse5: 5.1.1 + parse5-htmlparser2-tree-adapter: 6.0.1 + yargs: 16.2.0 + + cli-table3@0.6.5: + dependencies: + string-width: 4.2.3 + optionalDependencies: + '@colors/colors': 1.5.0 + cli-width@4.1.0: {} + cliui@7.0.4: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + commander@10.0.1: {} + commander@14.0.3: {} convert-source-map@2.0.0: {} @@ -2054,6 +2358,12 @@ snapshots: detect-libc@2.1.2: {} + emoji-regex@8.0.0: {} + + emojilib@2.4.0: {} + + environment@1.1.0: {} + error-stack-parser-es@1.0.5: {} es-module-lexer@2.1.0: {} @@ -2116,6 +2426,8 @@ snapshots: '@esbuild/win32-ia32': 0.28.0 '@esbuild/win32-x64': 0.28.0 + escalade@3.2.0: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -2136,13 +2448,23 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fflate@0.8.2: {} + fsevents@2.3.3: optional: true + get-caller-file@2.0.5: {} + + has-flag@4.0.0: {} + + highlight.js@10.7.3: {} + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 + is-fullwidth-code-point@3.0.0: {} + kleur@4.1.5: {} lightningcss-android-arm64@1.32.0: @@ -2194,10 +2516,25 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + lru-cache@11.4.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + marked-terminal@7.3.0(marked@9.1.6): + dependencies: + ansi-escapes: 7.3.0 + ansi-regex: 6.2.2 + chalk: 5.6.2 + cli-highlight: 2.1.11 + cli-table3: 0.6.5 + marked: 9.1.6 + node-emoji: 2.2.0 + supports-hyperlinks: 3.2.0 + + marked@9.1.6: {} + miniflare@4.20260515.0: dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -2212,12 +2549,35 @@ snapshots: mute-stream@3.0.0: {} + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + nanoid@3.3.12: {} + node-emoji@2.2.0: + dependencies: + '@sindresorhus/is': 4.6.0 + char-regex: 1.0.2 + emojilib: 2.4.0 + skin-tone: 2.0.0 + + object-assign@4.1.1: {} + obug@2.1.1: {} openapi-types@12.1.3: {} + parse5-htmlparser2-tree-adapter@6.0.1: + dependencies: + parse5: 6.0.1 + + parse5@5.1.1: {} + + parse5@6.0.1: {} + path-to-regexp@6.3.0: {} pathe@2.0.3: {} @@ -2234,6 +2594,8 @@ snapshots: radash@12.1.1: {} + require-directory@2.1.1: {} + rolldown@1.0.1: dependencies: '@oxc-project/types': 0.130.0 @@ -2294,16 +2656,47 @@ snapshots: signal-exit@4.1.0: {} + skin-tone@2.0.0: + dependencies: + unicode-emoji-modifier-base: 1.0.0 + source-map-js@1.2.1: {} stackback@0.0.2: {} std-env@4.1.0: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + supports-color@10.2.2: {} + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-hyperlinks@3.2.0: + dependencies: + has-flag: 4.0.0 + supports-color: 7.2.0 + tagged-tag@1.0.0: {} + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + tinybench@2.9.0: {} tinyexec@1.1.2: {} @@ -2335,6 +2728,8 @@ snapshots: dependencies: tagged-tag: 1.0.0 + typescript@5.6.1-rc: {} + typescript@6.0.3: {} undici-types@7.24.6: {} @@ -2345,6 +2740,10 @@ snapshots: dependencies: pathe: 2.0.3 + unicode-emoji-modifier-base@1.0.0: {} + + validate-npm-package-name@5.0.1: {} + vite@8.0.13(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.2): dependencies: lightningcss: 1.32.0 @@ -2415,11 +2814,31 @@ snapshots: - bufferutil - utf-8-validate + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + ws@8.18.0: {} ws@8.20.1: optional: true + y18n@5.0.8: {} + + yargs-parser@20.2.9: {} + + yargs@16.2.0: + dependencies: + cliui: 7.0.4 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 20.2.9 + youch-core@0.3.3: dependencies: '@poppinss/exception': 1.2.3 diff --git a/tsconfig.lib.json b/tsconfig.lib.json index 3aa0f6d..b61e043 100644 --- a/tsconfig.lib.json +++ b/tsconfig.lib.json @@ -7,8 +7,6 @@ "module": "NodeNext", "moduleResolution": "NodeNext", "declaration": true, - "sourceMap": true, - "declarationMap": true, "rewriteRelativeImportExtensions": true }, "include": ["src/**/*.ts", "worker-configuration.d.ts"] From a80b6bedf41fa6aa094bd926c85471e39d140b1d Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Tue, 19 May 2026 09:56:21 +0100 Subject: [PATCH 21/43] Use pnpm pack for ATTW CI --- .github/workflows/ci.yml | 8 +- package.json | 6 - pnpm-lock.yaml | 419 --------------------------------------- 3 files changed, 5 insertions(+), 428 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c7460a..43f34cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,8 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm run build - run: pnpm run test - - run: mkdir -p .pkg - - run: pnpm pack --pack-destination .pkg --json - - run: pnpm exec attw .pkg/*.tgz --profile esm-only + - name: arethetypeswrong + run: | + pnpm pack + mv $(ls | grep .tgz) pkg-ignoreme.tgz + npx --yes --package @arethetypeswrong/cli@0.18.2 --package fflate@0.8.2 attw pkg-ignoreme.tgz --profile esm-only diff --git a/package.json b/package.json index 5666d83..a2bbf5e 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,6 @@ "zod": "^4.4.3" }, "devDependencies": { - "@arethetypeswrong/cli": "0.18.2", "@cloudflare/workers-types": "^4.20260515.1", "@types/node": "^25.8.0", "esbuild": "^0.28.0", @@ -80,10 +79,5 @@ "vitest": "^4.1.6", "wrangler": "^4.92.0" }, - "pnpm": { - "overrides": { - "@arethetypeswrong/core>fflate": "0.8.2" - } - }, "packageManager": "pnpm@10.11.1" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f465e3b..0c21ca6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,9 +4,6 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false -overrides: - '@arethetypeswrong/core>fflate': 0.8.2 - importers: .: @@ -27,9 +24,6 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: - '@arethetypeswrong/cli': - specifier: 0.18.2 - version: 0.18.2 '@cloudflare/workers-types': specifier: ^4.20260515.1 version: 4.20260518.1 @@ -70,21 +64,6 @@ importers: packages: - '@andrewbranch/untar.js@1.0.3': - resolution: {integrity: sha512-Jh15/qVmrLGhkKJBdXlK1+9tY4lZruYjsgkDFj08ZmDiWVBLJcqkok7Z0/R0In+i1rScBpJlSvrTS2Lm41Pbnw==} - - '@arethetypeswrong/cli@0.18.2': - resolution: {integrity: sha512-PcFM20JNlevEDKBg4Re29Rtv2xvjvQZzg7ENnrWFSS0PHgdP2njibVFw+dRUhNkPgNfac9iUqO0ohAXqQL4hbw==} - engines: {node: '>=20'} - hasBin: true - - '@arethetypeswrong/core@0.18.2': - resolution: {integrity: sha512-GiwTmBFOU1/+UVNqqCGzFJYfBXEytUkiI+iRZ6Qx7KmUVtLm00sYySkfe203C9QtPG11yOz1ZaMek8dT/xnlgg==} - engines: {node: '>=20'} - - '@braidai/lang@1.1.2': - resolution: {integrity: sha512-qBcknbBufNHlui137Hft8xauQMTZDKdophmLFv05r2eNmdIv/MlPuP4TdUknHG68UdWLgVZwgxVe735HzJNIwA==} - '@cloudflare/kv-asset-handler@0.5.0': resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} engines: {node: '>=22.0.0'} @@ -131,10 +110,6 @@ packages: '@cloudflare/workers-types@4.20260518.1': resolution: {integrity: sha512-xXzGrbRi8RHRBNQFgXYkzrB4DgF0RXvmp8E1vCxoBmINpeitM/ZjVDd1CNC+N3uXjgcNjacoz4OgTa0rxgig1A==} - '@colors/colors@1.5.0': - resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} - engines: {node: '>=0.1.90'} - '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -741,9 +716,6 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - '@loaderkit/resolve@1.0.6': - resolution: {integrity: sha512-G8FdIoF5CypfwmD9rl8BXod5HDn8JqB0CCNBXDTaRZ+yRYhARrrSToX1zg1zy9jX3zLqigsELwhT4gNtkdQAUg==} - '@napi-rs/wasm-runtime@1.1.4': resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} peerDependencies: @@ -905,10 +877,6 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} - '@sindresorhus/is@4.6.0': - resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} - engines: {node: '>=10'} - '@sindresorhus/is@7.2.0': resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} engines: {node: '>=18'} @@ -963,25 +931,6 @@ packages: '@vitest/utils@4.1.6': resolution: {integrity: sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==} - ansi-escapes@7.3.0: - resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} - engines: {node: '>=18'} - - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - ansi-regex@6.2.2: - resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} - engines: {node: '>=12'} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - any-promise@1.3.0: - resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} - assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -996,51 +945,13 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - - chalk@5.6.2: - resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} - engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - - char-regex@1.0.2: - resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} - engines: {node: '>=10'} - chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} - cjs-module-lexer@1.4.3: - resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} - - cli-highlight@2.1.11: - resolution: {integrity: sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==} - engines: {node: '>=8.0.0', npm: '>=5.0.0'} - hasBin: true - - cli-table3@0.6.5: - resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} - engines: {node: 10.* || >= 12.*} - cli-width@4.1.0: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} - cliui@7.0.4: - resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - - commander@10.0.1: - resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} - engines: {node: '>=14'} - commander@14.0.3: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} @@ -1056,16 +967,6 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - - emojilib@2.4.0: - resolution: {integrity: sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==} - - environment@1.1.0: - resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} - engines: {node: '>=18'} - error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} @@ -1082,10 +983,6 @@ packages: engines: {node: '>=18'} hasBin: true - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -1111,33 +1008,15 @@ packages: picomatch: optional: true - fflate@0.8.2: - resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} - - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - - highlight.js@10.7.3: - resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} - iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - kleur@4.1.5: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} @@ -1212,24 +1091,9 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} - lru-cache@11.4.0: - resolution: {integrity: sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==} - engines: {node: 20 || >=22} - magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - marked-terminal@7.3.0: - resolution: {integrity: sha512-t4rBvPsHc57uE/2nJOLmMbZCQ4tgAccAED3ngXQqW6g+TxA488JzJ+FK3lQkzBQOI1mRV/r/Kq+1ZlJ4D0owQw==} - engines: {node: '>=16.0.0'} - peerDependencies: - marked: '>=1 <16' - - marked@9.1.6: - resolution: {integrity: sha512-jcByLnIFkd5gSXZmjNvS1TlmRhCXZjIzHYlaGkPlLIekG55JDR2Z4va9tZwCiP+/RDERiNhMOFu01xd6O5ct1Q==} - engines: {node: '>= 16'} - hasBin: true - miniflare@4.20260515.0: resolution: {integrity: sha512-2j0oQWizk1Eu4Cm8tDX7Z+Nsjd0nebIj1TQcQ+Oy1QKeo0Ay9+bdn8wfLAtOj9znDCybDCUlnS1+nYvKXEdfNg==} engines: {node: '>=22.0.0'} @@ -1239,37 +1103,17 @@ packages: resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} engines: {node: ^20.17.0 || >=22.9.0} - mz@2.7.0: - resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - nanoid@3.3.12: resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - node-emoji@2.2.0: - resolution: {integrity: sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==} - engines: {node: '>=18'} - - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} - obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} openapi-types@12.1.3: resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} - parse5-htmlparser2-tree-adapter@6.0.1: - resolution: {integrity: sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==} - - parse5@5.1.1: - resolution: {integrity: sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==} - - parse5@6.0.1: - resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==} - path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} @@ -1291,10 +1135,6 @@ packages: resolution: {integrity: sha512-h36JMxKRqrAxVD8201FrCpyeNuUY9Y5zZwujr20fFO77tpUtGa6EZzfKw/3WaiBX95fq7+MpsuMLNdSnORAwSA==} engines: {node: '>=14.18.0'} - require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.0'} - rolldown@1.0.1: resolution: {integrity: sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1319,10 +1159,6 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} - skin-tone@2.0.0: - resolution: {integrity: sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==} - engines: {node: '>=8'} - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1333,37 +1169,14 @@ packages: std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - supports-color@10.2.2: resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} engines: {node: '>=18'} - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - - supports-hyperlinks@3.2.0: - resolution: {integrity: sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==} - engines: {node: '>=14.18'} - tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} - thenify-all@1.6.0: - resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} - engines: {node: '>=0.8'} - - thenify@3.3.1: - resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} - tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1416,11 +1229,6 @@ packages: resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==} engines: {node: '>=20'} - typescript@5.6.1-rc: - resolution: {integrity: sha512-E3b2+1zEFu84jB0YQi9BORDjz9+jGbwwy1Zi3G0LUNw7a7cePUrHMRNy8aPh53nXpkFGVHSxIZo5vKTfYaFiBQ==} - engines: {node: '>=14.17'} - hasBin: true - typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} @@ -1436,14 +1244,6 @@ packages: unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} - unicode-emoji-modifier-base@1.0.0: - resolution: {integrity: sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==} - engines: {node: '>=4'} - - validate-npm-package-name@5.0.1: - resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - vite@8.0.13: resolution: {integrity: sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1548,10 +1348,6 @@ packages: '@cloudflare/workers-types': optional: true - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - ws@8.18.0: resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} engines: {node: '>=10.0.0'} @@ -1576,18 +1372,6 @@ packages: utf-8-validate: optional: true - y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} - - yargs-parser@20.2.9: - resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} - engines: {node: '>=10'} - - yargs@16.2.0: - resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} - engines: {node: '>=10'} - youch-core@0.3.3: resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} @@ -1599,31 +1383,6 @@ packages: snapshots: - '@andrewbranch/untar.js@1.0.3': {} - - '@arethetypeswrong/cli@0.18.2': - dependencies: - '@arethetypeswrong/core': 0.18.2 - chalk: 4.1.2 - cli-table3: 0.6.5 - commander: 10.0.1 - marked: 9.1.6 - marked-terminal: 7.3.0(marked@9.1.6) - semver: 7.8.0 - - '@arethetypeswrong/core@0.18.2': - dependencies: - '@andrewbranch/untar.js': 1.0.3 - '@loaderkit/resolve': 1.0.6 - cjs-module-lexer: 1.4.3 - fflate: 0.8.2 - lru-cache: 11.4.0 - semver: 7.8.0 - typescript: 5.6.1-rc - validate-npm-package-name: 5.0.1 - - '@braidai/lang@1.1.2': {} - '@cloudflare/kv-asset-handler@0.5.0': {} '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260515.1)': @@ -1649,9 +1408,6 @@ snapshots: '@cloudflare/workers-types@4.20260518.1': {} - '@colors/colors@1.5.0': - optional: true - '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 @@ -2052,10 +1808,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@loaderkit/resolve@1.0.6': - dependencies: - '@braidai/lang': 1.1.2 - '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -2217,8 +1969,6 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} - '@sindresorhus/is@4.6.0': {} - '@sindresorhus/is@7.2.0': {} '@speed-highlight/core@1.2.15': {} @@ -2284,20 +2034,6 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 - ansi-escapes@7.3.0: - dependencies: - environment: 1.1.0 - - ansi-regex@5.0.1: {} - - ansi-regex@6.2.2: {} - - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - any-promise@1.3.0: {} - assertion-error@2.0.1: {} blake3-wasm@2.1.5: {} @@ -2306,50 +2042,10 @@ snapshots: chai@6.2.2: {} - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - - chalk@5.6.2: {} - - char-regex@1.0.2: {} - chardet@2.1.1: {} - cjs-module-lexer@1.4.3: {} - - cli-highlight@2.1.11: - dependencies: - chalk: 4.1.2 - highlight.js: 10.7.3 - mz: 2.7.0 - parse5: 5.1.1 - parse5-htmlparser2-tree-adapter: 6.0.1 - yargs: 16.2.0 - - cli-table3@0.6.5: - dependencies: - string-width: 4.2.3 - optionalDependencies: - '@colors/colors': 1.5.0 - cli-width@4.1.0: {} - cliui@7.0.4: - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 7.0.0 - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - - commander@10.0.1: {} - commander@14.0.3: {} convert-source-map@2.0.0: {} @@ -2358,12 +2054,6 @@ snapshots: detect-libc@2.1.2: {} - emoji-regex@8.0.0: {} - - emojilib@2.4.0: {} - - environment@1.1.0: {} - error-stack-parser-es@1.0.5: {} es-module-lexer@2.1.0: {} @@ -2426,8 +2116,6 @@ snapshots: '@esbuild/win32-ia32': 0.28.0 '@esbuild/win32-x64': 0.28.0 - escalade@3.2.0: {} - estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -2448,23 +2136,13 @@ snapshots: optionalDependencies: picomatch: 4.0.4 - fflate@0.8.2: {} - fsevents@2.3.3: optional: true - get-caller-file@2.0.5: {} - - has-flag@4.0.0: {} - - highlight.js@10.7.3: {} - iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 - is-fullwidth-code-point@3.0.0: {} - kleur@4.1.5: {} lightningcss-android-arm64@1.32.0: @@ -2516,25 +2194,10 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 - lru-cache@11.4.0: {} - magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - marked-terminal@7.3.0(marked@9.1.6): - dependencies: - ansi-escapes: 7.3.0 - ansi-regex: 6.2.2 - chalk: 5.6.2 - cli-highlight: 2.1.11 - cli-table3: 0.6.5 - marked: 9.1.6 - node-emoji: 2.2.0 - supports-hyperlinks: 3.2.0 - - marked@9.1.6: {} - miniflare@4.20260515.0: dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -2549,35 +2212,12 @@ snapshots: mute-stream@3.0.0: {} - mz@2.7.0: - dependencies: - any-promise: 1.3.0 - object-assign: 4.1.1 - thenify-all: 1.6.0 - nanoid@3.3.12: {} - node-emoji@2.2.0: - dependencies: - '@sindresorhus/is': 4.6.0 - char-regex: 1.0.2 - emojilib: 2.4.0 - skin-tone: 2.0.0 - - object-assign@4.1.1: {} - obug@2.1.1: {} openapi-types@12.1.3: {} - parse5-htmlparser2-tree-adapter@6.0.1: - dependencies: - parse5: 6.0.1 - - parse5@5.1.1: {} - - parse5@6.0.1: {} - path-to-regexp@6.3.0: {} pathe@2.0.3: {} @@ -2594,8 +2234,6 @@ snapshots: radash@12.1.1: {} - require-directory@2.1.1: {} - rolldown@1.0.1: dependencies: '@oxc-project/types': 0.130.0 @@ -2656,47 +2294,16 @@ snapshots: signal-exit@4.1.0: {} - skin-tone@2.0.0: - dependencies: - unicode-emoji-modifier-base: 1.0.0 - source-map-js@1.2.1: {} stackback@0.0.2: {} std-env@4.1.0: {} - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - - strip-ansi@6.0.1: - dependencies: - ansi-regex: 5.0.1 - supports-color@10.2.2: {} - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - - supports-hyperlinks@3.2.0: - dependencies: - has-flag: 4.0.0 - supports-color: 7.2.0 - tagged-tag@1.0.0: {} - thenify-all@1.6.0: - dependencies: - thenify: 3.3.1 - - thenify@3.3.1: - dependencies: - any-promise: 1.3.0 - tinybench@2.9.0: {} tinyexec@1.1.2: {} @@ -2728,8 +2335,6 @@ snapshots: dependencies: tagged-tag: 1.0.0 - typescript@5.6.1-rc: {} - typescript@6.0.3: {} undici-types@7.24.6: {} @@ -2740,10 +2345,6 @@ snapshots: dependencies: pathe: 2.0.3 - unicode-emoji-modifier-base@1.0.0: {} - - validate-npm-package-name@5.0.1: {} - vite@8.0.13(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.2): dependencies: lightningcss: 1.32.0 @@ -2814,31 +2415,11 @@ snapshots: - bufferutil - utf-8-validate - wrap-ansi@7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - ws@8.18.0: {} ws@8.20.1: optional: true - y18n@5.0.8: {} - - yargs-parser@20.2.9: {} - - yargs@16.2.0: - dependencies: - cliui: 7.0.4 - escalade: 3.2.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - string-width: 4.2.3 - y18n: 5.0.8 - yargs-parser: 20.2.9 - youch-core@0.3.3: dependencies: '@poppinss/exception': 1.2.3 From 1acd08d2adb273655577a993d37a06c7c01fb5c5 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Tue, 19 May 2026 10:20:24 +0100 Subject: [PATCH 22/43] Simplify tunnel URL callsites --- scripts/benchmark-startup.ts | 14 +++++++------- scripts/benchmark-streams.ts | 10 +++++----- src/bin.ts | 14 +++++++------- test/e2e.test.ts | 10 +++++----- test/worker.test.ts | 12 ++++++------ 5 files changed, 30 insertions(+), 30 deletions(-) diff --git a/scripts/benchmark-startup.ts b/scripts/benchmark-startup.ts index db3a2d5..cb3ac74 100644 --- a/scripts/benchmark-startup.ts +++ b/scripts/benchmark-startup.ts @@ -112,17 +112,17 @@ async function measureCaptun(index: number, originUrl: string): Promise { const incoming = new URL(request.url); - return fetch(new URL(incoming.pathname + incoming.search, originUrl), request); + return fetch(`${originUrl}${incoming.pathname}${incoming.search}`, request); }, }); await waitForFetch(url, started); - return { index, ok: true, ms: performance.now() - started, url: url.toString() }; + return { index, ok: true, ms: performance.now() - started, url }; } finally { tunnel?.[Symbol.dispose](); } @@ -202,7 +202,7 @@ async function waitForDns(url: URL, started: number) { throw new Error(`DNS timed out for ${url.hostname}: ${lastError}`); } -async function waitForFetch(url: URL, started: number) { +async function waitForFetch(url: string | URL, started: number) { let lastError = ""; while (performance.now() - started < timeoutMs) { try { @@ -218,10 +218,10 @@ async function waitForFetch(url: URL, started: number) { } function tunnelUrl(base: string, name: string) { - if (base.includes("{name}")) return new URL(base.replaceAll("{name}", name)); + if (base.includes("{name}")) return base.replaceAll("{name}", name).replace(/\/$/, ""); const url = new URL(base); - url.pathname = `/${name}/`; - return url; + url.pathname = `/${name}`; + return url.toString().replace(/\/$/, ""); } function parseCloudflaredUrl(output: string) { diff --git a/scripts/benchmark-streams.ts b/scripts/benchmark-streams.ts index badaf2f..095b95f 100644 --- a/scripts/benchmark-streams.ts +++ b/scripts/benchmark-streams.ts @@ -63,7 +63,7 @@ if (warmupCount > 0) { Math.min(warmupCount, Math.max(connectConcurrency, 25)), async (index) => { const session = await createCaptunTunnel({ - url: new URL("__connect", tunnelUrl(`${namePrefix}-warm-${index}`)), + url: `${tunnelUrl(`${namePrefix}-warm-${index}`)}/__connect`, headers: captunHeaders(), fetch: () => testResponse("stream"), }); @@ -134,7 +134,7 @@ async function measure(index: number, mode: string): Promise { let tunnel: Disposable | undefined; try { tunnel = await createCaptunTunnel({ - url: new URL("__connect", url), + url: `${url}/__connect`, headers: captunHeaders(), fetch: () => testResponse(mode), }); @@ -217,11 +217,11 @@ async function readBytes(response: Response) { } function tunnelUrl(name: string) { - if (serverUrl.includes("{name}")) return new URL(serverUrl.replaceAll("{name}", name)); + if (serverUrl.includes("{name}")) return serverUrl.replaceAll("{name}", name).replace(/\/$/, ""); const url = new URL(serverUrl); if (url.hostname.match(/^[^.]+\.tunnels\./)) url.pathname = "/"; - else url.pathname = `/${name}/`; - return url; + else url.pathname = `/${name}`; + return url.toString().replace(/\/$/, ""); } async function withTimeout(promise: Promise, ms: number, message: string) { diff --git a/src/bin.ts b/src/bin.ts index 2b6f611..ce95fd4 100755 --- a/src/bin.ts +++ b/src/bin.ts @@ -63,11 +63,11 @@ const router = os.router({ const origin = `http://127.0.0.1:${input.port}`; using _tunnelSession = await createCaptunTunnel({ - url: new URL("__connect", tunnel), + url: `${tunnel}/__connect`, headers: secret ? { authorization: `Bearer ${secret}` } : undefined, fetch: (request) => { const url = new URL(request.url); - return fetch(new Request(new URL(url.pathname + url.search, origin), request)); + return fetch(new Request(`${origin}${url.pathname}${url.search}`, request)); }, }); @@ -187,15 +187,15 @@ async function writeConfig(config: Config) { } function tunnelUrl(baseUrl: string, name: string) { - if (baseUrl.includes("{name}")) return ensureTrailingSlash(baseUrl.replaceAll("{name}", name)); + if (baseUrl.includes("{name}")) return removeTrailingSlash(baseUrl.replaceAll("{name}", name)); const url = new URL(baseUrl); if (url.hostname.match(/^[^.]+\.tunnels\./)) { url.pathname = "/"; } else { - url.pathname = `${url.pathname.replace(/\/$/, "")}/${encodeURIComponent(name)}/`; + url.pathname = `${url.pathname.replace(/\/$/, "")}/${encodeURIComponent(name)}`; } - return url.toString(); + return removeTrailingSlash(url.toString()); } function serverUrlFromRoute(route: string) { @@ -212,8 +212,8 @@ function serverUrlFromWranglerOutput(output: string) { return output.match(/https:\/\/[^\s]+\.workers\.dev[^\s]*/)?.[0]; } -function ensureTrailingSlash(url: string) { - return url.endsWith("/") ? url : `${url}/`; +function removeTrailingSlash(url: string) { + return url.replace(/\/$/, ""); } function waitForShutdown() { diff --git a/test/e2e.test.ts b/test/e2e.test.ts index 78642d7..9047173 100644 --- a/test/e2e.test.ts +++ b/test/e2e.test.ts @@ -153,12 +153,12 @@ async function createTunnelFixture( try { const url = tunnelUrl(server.url, name); const tunnel = await createCaptunTunnel({ - url: new URL("__connect", url), + url: `${url}/__connect`, headers: server.headers, fetch, }); return { - url: url.toString(), + url, async [Symbol.asyncDispose]() { tunnel[Symbol.dispose](); await server[Symbol.asyncDispose](); @@ -203,15 +203,15 @@ function tunnelName(testName: string) { } function tunnelUrl(serverUrl: string, name: string) { - if (serverUrl.includes("{name}")) return new URL(serverUrl.replaceAll("{name}", name)); + if (serverUrl.includes("{name}")) return serverUrl.replaceAll("{name}", name).replace(/\/$/, ""); const url = new URL(serverUrl); if (url.hostname.match(/^[^.]+\.tunnels\./)) { url.pathname = "/"; } else { - url.pathname = `/${name}/`; + url.pathname = `/${name}`; } - return url; + return url.toString().replace(/\/$/, ""); } function makeBytes(size: number) { diff --git a/test/worker.test.ts b/test/worker.test.ts index 4be269c..924bd2d 100644 --- a/test/worker.test.ts +++ b/test/worker.test.ts @@ -5,7 +5,7 @@ import { createCaptunWorkerFixture } from "./miniflare.ts"; test("Captun Worker forwards requests through a real Durable Object tunnel", async () => { await using fixture = await createCaptunWorkerFixture({}); using _tunnel = await createCaptunTunnel({ - url: new URL("/demo/__connect", fixture.origin), + url: `${fixture.origin}/demo/__connect`, fetch: async (request) => { const url = new URL(request.url); return Response.json({ @@ -15,7 +15,7 @@ test("Captun Worker forwards requests through a real Durable Object tunnel", asy }, }); - const response = await fetch(new URL("/demo/hello", fixture.origin), { + const response = await fetch(`${fixture.origin}/demo/hello`, { method: "POST", body: "hello through miniflare", }); @@ -29,7 +29,7 @@ test("Captun Worker forwards requests through a real Durable Object tunnel", asy test("Captun Worker returns 503 when a named tunnel has no connected client", async () => { await using fixture = await createCaptunWorkerFixture({}); - const response = await fetch(new URL("/missing/hello", fixture.origin)); + const response = await fetch(`${fixture.origin}/missing/hello`); expect(response.status).toBe(503); expect(await response.text()).toBe("No tunnel client connected\n"); @@ -47,7 +47,7 @@ test("Captun Worker routes subdomain tunnel requests", async () => { test("Captun Worker rejects missing tunnel names before Durable Object dispatch", async () => { await using fixture = await createCaptunWorkerFixture({}); - const response = await fetch(new URL("/__connect", fixture.origin)); + const response = await fetch(`${fixture.origin}/__connect`); expect(response.status).toBe(404); expect(await response.text()).toBe("Missing tunnel name\n"); @@ -56,7 +56,7 @@ test("Captun Worker rejects missing tunnel names before Durable Object dispatch" test("Captun Worker rejects malformed folder tunnel names", async () => { await using fixture = await createCaptunWorkerFixture({}); - const response = await fetch(new URL("/bad%/hello", fixture.origin)); + const response = await fetch(`${fixture.origin}/bad%/hello`); expect(response.status).toBe(404); expect(await response.text()).toBe("Missing tunnel name\n"); @@ -65,7 +65,7 @@ test("Captun Worker rejects malformed folder tunnel names", async () => { test("Captun Worker requires the configured secret before accepting a tunnel client", async () => { await using fixture = await createCaptunWorkerFixture({ CAPTUN_SECRET: "secret" }); - const response = await fetch(new URL("/demo/__connect", fixture.origin)); + const response = await fetch(`${fixture.origin}/demo/__connect`); expect(response.status).toBe(401); expect(await response.text()).toBe("Unauthorized\n"); From 5fcb9ec7a6a23b8ff61eb09d2684092cc22c3101 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Tue, 19 May 2026 12:55:59 +0100 Subject: [PATCH 23/43] minor --- README.md | 106 ++++++++++++++++++++++++++-- examples/weather-reporter/worker.ts | 2 +- src/bin.ts | 4 +- test/worker.test.ts | 4 +- 4 files changed, 104 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index d70f98e..5260277 100644 --- a/README.md +++ b/README.md @@ -2,24 +2,116 @@ Captun is a tiny reference implementation of a self-hosted ngrok or Cloudflare Tunnel alternative. It runs the public side on Cloudflare Workers and sends matching HTTP requests back to a Node process over [Cap'n Web](https://github.com/cloudflare/capnweb). +## Quick start + +First deploy a captun worker to your cloudflare account. You can think of this like your own personal ngrok server: + ```bash npx captun deploy +``` -python3 -m http.server 3000 -CAPTUN_SERVER_URL=https://captun..workers.dev npx captun --name demo 3000 +Then tunnel to it: -curl https://captun..workers.dev/demo/ +```bash +npx captun 3000 ``` -Or use it directly from code: + + +This will use `wrangler` under the hood to deploy an opinionated captun-tunneler-worker to your cloudflare account, and will store the server url in an XDG config file, and uses it when you tunnel to it. + + + +### Programmatic usage + +You can use the worker you just deployed to create a tunnel from code for receiving HTTP requests. First `npm install captun` to add it as a dependency. Then create it: ```ts import { createCaptunTunnel } from "captun/client"; -using tunnel = await createCaptunTunnel({ - url: "https://captun..workers.dev/demo/__connect", - fetch: (request) => fetch(request), +const url = "https://captun.account.workers.dev/my-cool-tunnel" + +const tunnel = await createCaptunTunnel({ + url: `${url}/__captun-connect`, // creates a tunnel named "my-tunnel". choose any slug-safe string here + fetch: async (request) => { + const url = new URL(request.url) + if (url.pathname.endsWith('/webhook')) { + console.log('Received a webhook:', await request.json()) + return Response.json({ ok: true }) + } + + return new Response('not found', { status: 404 }) + }, }); + +console.log(`Listening to webhooks on ${url}/webhook`) + +await new Promise(() => {}) // stay alive until killed +``` + +## Advanced usage + +The captun [worker.ts](./src/worker.ts) implementation has useful opinions about "named tunnels", but you can also take full control of the server implementation (which is what we do in [iterate/iterate](https://github.com/iterate/iterate)). For example, here's a weather application which allows mocking its egress to the weather API: + +```ts +import { DurableObject } from "cloudflare:workers"; +import { acceptCaptunTunnel, type CaptunServerTunnel } from "captun/server"; + +type WeatherReporterEnv = Env & { + WEATHER_REPORTER_EGRESS: DurableObjectNamespace; +}; + +export class WeatherReporterEgressTunnel extends DurableObject { + private egressTunnel: CaptunServerTunnel | undefined; + + async fetch(request: Request) { + const url = new URL(request.url); + + const city = url.pathname.match(/^\/weather\/([^/]+)$/)?.[1]; + if (city) { + const response = await this.egressFetch(new Request(`https://wttr.in/${city}?format=j1`)); + const weather = await response.json<{ current_condition: [{ temp_C: string }] }>(); + return new Response(`The temperature in ${city} is ${weather.current_condition[0].temp_C} celsius`); + } + + if (url.pathname === "/__intercept-egress-traffic") { + this.egressTunnel?.[Symbol.dispose](); + const { response, tunnel } = acceptCaptunTunnel({ + onDisconnect: () => { + if (this.egressTunnel === tunnel) this.egressTunnel = undefined; + }, + }); + this.egressTunnel = tunnel; + return response; + } + + return new Response("Not found\n", { status: 404 }); + } + + private egressFetch(request: Request) { + if (this.egressTunnel) return this.egressTunnel.fetch(request); + return fetch(request); + } +} + +export default { + fetch(request: Request, env: WeatherReporterEnv) { + return env.WEATHER_REPORTER_EGRESS.getByName("default").fetch(request); + }, +} ``` The core client/server pieces are small TypeScript modules around [Cap'n Web](https://github.com/cloudflare/capnweb): [src/client.ts](./src/client.ts), [src/server.ts](./src/server.ts), and [src/types.ts](./src/types.ts). For a deployable Cloudflare Worker, also copy or adapt [src/worker.ts](./src/worker.ts) and the Durable Object binding in [wrangler.toml](./wrangler.toml). diff --git a/examples/weather-reporter/worker.ts b/examples/weather-reporter/worker.ts index 9d2baa9..326ba3c 100644 --- a/examples/weather-reporter/worker.ts +++ b/examples/weather-reporter/worker.ts @@ -14,7 +14,7 @@ export class WeatherReporterEgressTunnel extends DurableObject(); return new Response(`The temperature in ${city} is ${weather.current_condition[0].temp_C} celsius`); } diff --git a/src/bin.ts b/src/bin.ts index ce95fd4..63f3960 100755 --- a/src/bin.ts +++ b/src/bin.ts @@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url"; import * as prompts from "@inquirer/prompts"; import { os } from "@orpc/server"; -import { createCli } from "trpc-cli"; +import { createCli, yamlTableConsoleLogger } from "trpc-cli"; import { z } from "zod/v4"; import { createCaptunTunnel } from "./client.ts"; import { CommandNotFoundError, ExecError, exec } from "./exec.ts"; @@ -108,7 +108,7 @@ const cli = createCli({ description: "Expose local HTTP servers through a tiny Cloudflare Worker tunnel.", }); -await cli.run({ prompts }); +await cli.run({ prompts, logger: yamlTableConsoleLogger }); async function deployWorker(input: { route?: string; secret: string }) { const tempDir = await mkdtemp(resolve(tmpdir(), "captun-")); diff --git a/test/worker.test.ts b/test/worker.test.ts index 924bd2d..5c157ec 100644 --- a/test/worker.test.ts +++ b/test/worker.test.ts @@ -10,7 +10,7 @@ test("Captun Worker forwards requests through a real Durable Object tunnel", asy const url = new URL(request.url); return Response.json({ path: url.pathname, - body: await request.text(), + body: `You said: ${await request.text()}`, }); }, }); @@ -22,7 +22,7 @@ test("Captun Worker forwards requests through a real Durable Object tunnel", asy expect(await response.json()).toMatchObject({ path: "/hello", - body: "hello through miniflare", + body: "You said: hello through miniflare", }); }); From 4d41bd6559810b44836d36a7fa1cd65dd25cf178 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Tue, 19 May 2026 12:58:48 +0100 Subject: [PATCH 24/43] Use js specifiers for local imports --- examples/weather-reporter/e2e.test.ts | 4 ++-- scripts/benchmark-startup.ts | 2 +- scripts/benchmark-streams.ts | 2 +- src/bin.ts | 4 ++-- src/index.ts | 17 ++--------------- src/worker.ts | 4 ++-- test/e2e.test.ts | 4 ++-- test/worker.test.ts | 4 ++-- tsconfig.json | 1 - tsconfig.lib.json | 3 +-- 10 files changed, 15 insertions(+), 30 deletions(-) diff --git a/examples/weather-reporter/e2e.test.ts b/examples/weather-reporter/e2e.test.ts index 6d7e11e..82fed83 100644 --- a/examples/weather-reporter/e2e.test.ts +++ b/examples/weather-reporter/e2e.test.ts @@ -1,7 +1,7 @@ import { expect, test, vi } from "vitest"; -import { createCaptunTunnel } from "../../src/client.ts"; -import { createMiniflareWorkerFixture } from "../../test/miniflare.ts"; +import { createCaptunTunnel } from "../../src/client.js"; +import { createMiniflareWorkerFixture } from "../../test/miniflare.js"; vi.setConfig({ testTimeout: 15_000 }); diff --git a/scripts/benchmark-startup.ts b/scripts/benchmark-startup.ts index cb3ac74..00cdc32 100644 --- a/scripts/benchmark-startup.ts +++ b/scripts/benchmark-startup.ts @@ -4,7 +4,7 @@ import { resolve4 } from "node:dns/promises"; import { mkdir, writeFile } from "node:fs/promises"; import { createServer } from "node:http"; import { performance } from "node:perf_hooks"; -import { createCaptunTunnel } from "../src/client.ts"; +import { createCaptunTunnel } from "../src/client.js"; // Measures time from "start creating a tunnel" to the first successful public // HTTP fetch through that tunnel. It can compare this project with cloudflared diff --git a/scripts/benchmark-streams.ts b/scripts/benchmark-streams.ts index 095b95f..359f144 100644 --- a/scripts/benchmark-streams.ts +++ b/scripts/benchmark-streams.ts @@ -1,7 +1,7 @@ import { randomBytes } from "node:crypto"; import { mkdir, writeFile } from "node:fs/promises"; import { performance } from "node:perf_hooks"; -import { createCaptunTunnel } from "../src/client.ts"; +import { createCaptunTunnel } from "../src/client.js"; // Stress the expensive path: many named tunnels, each returning a large streamed // binary response through Captun. This measures aggregate tunnel throughput, diff --git a/src/bin.ts b/src/bin.ts index 63f3960..3b9f015 100755 --- a/src/bin.ts +++ b/src/bin.ts @@ -10,8 +10,8 @@ import * as prompts from "@inquirer/prompts"; import { os } from "@orpc/server"; import { createCli, yamlTableConsoleLogger } from "trpc-cli"; import { z } from "zod/v4"; -import { createCaptunTunnel } from "./client.ts"; -import { CommandNotFoundError, ExecError, exec } from "./exec.ts"; +import { createCaptunTunnel } from "./client.js"; +import { CommandNotFoundError, ExecError, exec } from "./exec.js"; type Config = { serverUrl: string; diff --git a/src/index.ts b/src/index.ts index 49b7be6..a5ba0f7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,15 +1,2 @@ -import { createCaptunTunnel as createTunnel } from "./client.ts"; -import { - acceptCaptunTunnel as acceptTunnel, - acceptCaptunTunnelFromSocket as acceptTunnelFromSocket, -} from "./server.ts"; -import type { createCaptunTunnel as CreateCaptunTunnel } from "./client.js"; -import type { - acceptCaptunTunnel as AcceptCaptunTunnel, - acceptCaptunTunnelFromSocket as AcceptCaptunTunnelFromSocket, -} from "./server.js"; - -export const createCaptunTunnel: typeof CreateCaptunTunnel = createTunnel; -export const acceptCaptunTunnel: typeof AcceptCaptunTunnel = acceptTunnel; -export const acceptCaptunTunnelFromSocket: typeof AcceptCaptunTunnelFromSocket = - acceptTunnelFromSocket; +export { createCaptunTunnel } from "./client.js"; +export { acceptCaptunTunnel, acceptCaptunTunnelFromSocket } from "./server.js"; diff --git a/src/worker.ts b/src/worker.ts index 97f9e1c..9c625c3 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -1,6 +1,6 @@ import { DurableObject } from "cloudflare:workers"; -import { acceptCaptunTunnel } from "./server.ts"; -import type { CaptunServerTunnel } from "./types.ts"; +import { acceptCaptunTunnel } from "./server.js"; +import type { CaptunServerTunnel } from "./types.js"; type CaptunEnv = Env & { CAPTUN_SECRET?: string; diff --git a/test/e2e.test.ts b/test/e2e.test.ts index 9047173..5072301 100644 --- a/test/e2e.test.ts +++ b/test/e2e.test.ts @@ -1,8 +1,8 @@ import { createHash } from "node:crypto"; import { expect, test, vi } from "vitest"; -import { createCaptunTunnel } from "../src/client.ts"; -import { createCaptunWorkerFixture } from "./miniflare.ts"; +import { createCaptunTunnel } from "../src/client.js"; +import { createCaptunWorkerFixture } from "./miniflare.js"; vi.setConfig({ testTimeout: 15_000 }); diff --git a/test/worker.test.ts b/test/worker.test.ts index 5c157ec..f178072 100644 --- a/test/worker.test.ts +++ b/test/worker.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "vitest"; -import { createCaptunTunnel } from "../src/client.ts"; -import { createCaptunWorkerFixture } from "./miniflare.ts"; +import { createCaptunTunnel } from "../src/client.js"; +import { createCaptunWorkerFixture } from "./miniflare.js"; test("Captun Worker forwards requests through a real Durable Object tunnel", async () => { await using fixture = await createCaptunWorkerFixture({}); diff --git a/tsconfig.json b/tsconfig.json index 0458a59..832b2e7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,6 @@ "strict": true, "skipLibCheck": true, "noEmit": true, - "allowImportingTsExtensions": true, "types": ["node"] }, "include": [ diff --git a/tsconfig.lib.json b/tsconfig.lib.json index b61e043..9f04ee4 100644 --- a/tsconfig.lib.json +++ b/tsconfig.lib.json @@ -6,8 +6,7 @@ "outDir": "dist", "module": "NodeNext", "moduleResolution": "NodeNext", - "declaration": true, - "rewriteRelativeImportExtensions": true + "declaration": true }, "include": ["src/**/*.ts", "worker-configuration.d.ts"] } From 227a9a189717230bd4c4652af32614b22f00184e Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Tue, 19 May 2026 13:31:44 +0100 Subject: [PATCH 25/43] Rename Captun connect endpoint --- README.md | 41 ++++++++++++--------------- examples/weather-reporter/e2e.test.ts | 8 +++--- examples/weather-reporter/worker.ts | 16 +++++++---- scripts/benchmark-startup.ts | 2 +- scripts/benchmark-streams.ts | 4 +-- src/bin.ts | 2 +- src/worker.ts | 4 +-- test/e2e.test.ts | 2 +- test/worker.test.ts | 6 ++-- 9 files changed, 42 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 5260277..1622337 100644 --- a/README.md +++ b/README.md @@ -80,14 +80,16 @@ export class WeatherReporterEgressTunnel extends DurableObject(); return new Response(`The temperature in ${city} is ${weather.current_condition[0].temp_C} celsius`); } if (url.pathname === "/__intercept-egress-traffic") { + // Here we set up our worker to allow clients/tests to intercept egress traffic this.egressTunnel?.[Symbol.dispose](); const { response, tunnel } = acceptCaptunTunnel({ onDisconnect: () => { @@ -101,9 +103,11 @@ export class WeatherReporterEgressTunnel extends DurableObject this.egressTunnel!.fetch(new Request(input, init)); + } + return fetch; } } @@ -111,35 +115,26 @@ export default { fetch(request: Request, env: WeatherReporterEnv) { return env.WEATHER_REPORTER_EGRESS.getByName("default").fetch(request); }, -} +} satisfies ExportedHandler; ``` The core client/server pieces are small TypeScript modules around [Cap'n Web](https://github.com/cloudflare/capnweb): [src/client.ts](./src/client.ts), [src/server.ts](./src/server.ts), and [src/types.ts](./src/types.ts). For a deployable Cloudflare Worker, also copy or adapt [src/worker.ts](./src/worker.ts) and the Durable Object binding in [wrangler.toml](./wrangler.toml). -## 1. CLI Usage - -Deploy the Worker first: - -```bash -npx captun deploy -``` +## Advanced CLI Usage -Then expose a local port through a named folder tunnel: +The CLI is mostly focused on ngrok-style use-cases with our opinionated worker deployment. Once you have run `npx captun deploy`, further commands will pick up the server URL and connection secret from your machine's captun config. You can also pass them explicitly (for example, to create a tunnel using a deployment created from someone else's machine): -```bash -python3 -m http.server 3000 -CAPTUN_SERVER_URL=https://captun..workers.dev captun --name demo 3000 -curl https://captun..workers.dev/demo/ +```3000 +npx captun 3000 --server-url 'https://abc123.captun.youraccount.workers.dev' --secret abc123 ``` -If you omit `--name`, the CLI generates a random hyphenated tunnel name. If you set `CAPTUN_SECRET` on the Worker manually, pass the same value to the CLI through `CAPTUN_SECRET` or `--secret`: +By default, the `npx captun 3000` command will generate a name for the tunnel it creates. You can customise this with `--name`: ```bash -pnpm exec wrangler secret put CAPTUN_SECRET -CAPTUN_SECRET=secret CAPTUN_SERVER_URL=https://captun..workers.dev captun --name demo 3000 +npx captun 3000 --name my-very-serious-tunnel-name ``` -`captun deploy` stores the deployed Worker URL and generated secret in `$XDG_CONFIG_HOME/captun/config.json`, or `~/.config/captun/config.json` when `XDG_CONFIG_HOME` is not set. `CAPTUN_SERVER_URL` and `CAPTUN_SECRET` override the saved config, and `--server-url` and `--secret` override both. The repo script runs the same source CLI with `pnpm run cli --`. + Folder tunnels are the golden path. The Worker routes `/:name/__connect` to the Cap'n Web session and `/:name/*` to normal proxied HTTP requests, stripping `/:name` before calling your local fetcher. diff --git a/examples/weather-reporter/e2e.test.ts b/examples/weather-reporter/e2e.test.ts index 82fed83..82a6fda 100644 --- a/examples/weather-reporter/e2e.test.ts +++ b/examples/weather-reporter/e2e.test.ts @@ -13,18 +13,18 @@ test("returns nicely formatted weather report", async () => { if (request.url === "https://wttr.in/london?format=j1") { return Response.json({ current_condition: [{ temp_C: "18" }] }); } - if (request.url === "https://wttr.in/new+york?format=j1") { + if (request.url === "https://wttr.in/paris?format=j1") { return Response.json({ current_condition: [{ temp_C: "22" }] }); } return new Response("Unexpected egress", { status: 500 }); }, }); - const london = await fetch(`${app.url}/weather/london`); + const london = await fetch(`${app.url}/weather?city=london`); expect(await london.text()).toBe("The temperature in london is 18 celsius"); - const newYork = await fetch(`${app.url}/weather/new+york`); - expect(await newYork.text()).toBe("The temperature in new+york is 22 celsius"); + const paris = await fetch(`${app.url}/weather?city=paris`); + expect(await paris.text()).toBe("The temperature in paris is 22 celsius"); }); async function createWeatherReporterFixture() { diff --git a/examples/weather-reporter/worker.ts b/examples/weather-reporter/worker.ts index 326ba3c..7433e82 100644 --- a/examples/weather-reporter/worker.ts +++ b/examples/weather-reporter/worker.ts @@ -11,14 +11,16 @@ export class WeatherReporterEgressTunnel extends DurableObject(); return new Response(`The temperature in ${city} is ${weather.current_condition[0].temp_C} celsius`); } if (url.pathname === "/__intercept-egress-traffic") { + // Here we set up our worker to allow clients/tests to intercept egress traffic this.egressTunnel?.[Symbol.dispose](); const { response, tunnel } = acceptCaptunTunnel({ onDisconnect: () => { @@ -32,9 +34,11 @@ export class WeatherReporterEgressTunnel extends DurableObject this.egressTunnel!.fetch(new Request(input, init)); + } + return fetch; } } diff --git a/scripts/benchmark-startup.ts b/scripts/benchmark-startup.ts index 00cdc32..7d8b925 100644 --- a/scripts/benchmark-startup.ts +++ b/scripts/benchmark-startup.ts @@ -112,7 +112,7 @@ async function measureCaptun(index: number, originUrl: string): Promise 0) { Math.min(warmupCount, Math.max(connectConcurrency, 25)), async (index) => { const session = await createCaptunTunnel({ - url: `${tunnelUrl(`${namePrefix}-warm-${index}`)}/__connect`, + url: `${tunnelUrl(`${namePrefix}-warm-${index}`)}/__captun-connect`, headers: captunHeaders(), fetch: () => testResponse("stream"), }); @@ -134,7 +134,7 @@ async function measure(index: number, mode: string): Promise { let tunnel: Disposable | undefined; try { tunnel = await createCaptunTunnel({ - url: `${url}/__connect`, + url: `${url}/__captun-connect`, headers: captunHeaders(), fetch: () => testResponse(mode), }); diff --git a/src/bin.ts b/src/bin.ts index 3b9f015..3824a76 100755 --- a/src/bin.ts +++ b/src/bin.ts @@ -63,7 +63,7 @@ const router = os.router({ const origin = `http://127.0.0.1:${input.port}`; using _tunnelSession = await createCaptunTunnel({ - url: `${tunnel}/__connect`, + url: `${tunnel}/__captun-connect`, headers: secret ? { authorization: `Bearer ${secret}` } : undefined, fetch: (request) => { const url = new URL(request.url); diff --git a/src/worker.ts b/src/worker.ts index 9c625c3..553e6a3 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -29,7 +29,7 @@ export class CaptunServerShard extends DurableObject { routedRequest = new Request(routedUrl, request); } - if (route.path === "/__connect") { + if (route.path === "/__captun-connect") { const expectedAuthorization = this.env.CAPTUN_SECRET ? `Bearer ${this.env.CAPTUN_SECRET}` : undefined; @@ -91,7 +91,7 @@ function captunRouteParts(hostname: string, pathname: string) { return decodedName ? { name: decodedName, path: pathname } : undefined; } const [name, ...rest] = pathname.split("/").filter(Boolean); - if (!name || name === "__connect") return undefined; + if (!name || name === "__captun-connect") return undefined; const decodedName = safeDecodeURIComponent(name); return decodedName ? { name: decodedName, path: `/${rest.join("/")}` } : undefined; } diff --git a/test/e2e.test.ts b/test/e2e.test.ts index 5072301..9d2c4ff 100644 --- a/test/e2e.test.ts +++ b/test/e2e.test.ts @@ -153,7 +153,7 @@ async function createTunnelFixture( try { const url = tunnelUrl(server.url, name); const tunnel = await createCaptunTunnel({ - url: `${url}/__connect`, + url: `${url}/__captun-connect`, headers: server.headers, fetch, }); diff --git a/test/worker.test.ts b/test/worker.test.ts index f178072..f30024c 100644 --- a/test/worker.test.ts +++ b/test/worker.test.ts @@ -5,7 +5,7 @@ import { createCaptunWorkerFixture } from "./miniflare.js"; test("Captun Worker forwards requests through a real Durable Object tunnel", async () => { await using fixture = await createCaptunWorkerFixture({}); using _tunnel = await createCaptunTunnel({ - url: `${fixture.origin}/demo/__connect`, + url: `${fixture.origin}/demo/__captun-connect`, fetch: async (request) => { const url = new URL(request.url); return Response.json({ @@ -47,7 +47,7 @@ test("Captun Worker routes subdomain tunnel requests", async () => { test("Captun Worker rejects missing tunnel names before Durable Object dispatch", async () => { await using fixture = await createCaptunWorkerFixture({}); - const response = await fetch(`${fixture.origin}/__connect`); + const response = await fetch(`${fixture.origin}/__captun-connect`); expect(response.status).toBe(404); expect(await response.text()).toBe("Missing tunnel name\n"); @@ -65,7 +65,7 @@ test("Captun Worker rejects malformed folder tunnel names", async () => { test("Captun Worker requires the configured secret before accepting a tunnel client", async () => { await using fixture = await createCaptunWorkerFixture({ CAPTUN_SECRET: "secret" }); - const response = await fetch(`${fixture.origin}/demo/__connect`); + const response = await fetch(`${fixture.origin}/demo/__captun-connect`); expect(response.status).toBe(401); expect(await response.text()).toBe("Unauthorized\n"); From 1a4300b477510e3981c46d50a851ed2bba42e81d Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Tue, 19 May 2026 13:41:38 +0100 Subject: [PATCH 26/43] Add deploy shards option --- README.md | 62 +++++++++--------------------------------------------- src/bin.ts | 19 +++++++++++++---- 2 files changed, 25 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index 1622337..297a8b0 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,8 @@ console.log(`Listening to webhooks on ${url}/webhook`) await new Promise(() => {}) // stay alive until killed ``` +That's all you need! No local ports, just a fetch function. + ## Advanced usage The captun [worker.ts](./src/worker.ts) implementation has useful opinions about "named tunnels", but you can also take full control of the server implementation (which is what we do in [iterate/iterate](https://github.com/iterate/iterate)). For example, here's a weather application which allows mocking its egress to the weather API: @@ -134,67 +136,23 @@ By default, the `npx captun 3000` command will generate a name for the tunnel it npx captun 3000 --name my-very-serious-tunnel-name ``` - - -Folder tunnels are the golden path. The Worker routes `/:name/__connect` to the Cap'n Web session and `/:name/*` to normal proxied HTTP requests, stripping `/:name` before calling your local fetcher. - -Some proxy targets behave better with a naked hostname than with a path prefix. In that case, route `*.my-tunnels.com/*` to the Worker and call `https://demo.my-tunnels.com/`; buying a throwaway domain like `my-tunnels.com` for around $10/year is often the simplest option. The built-in router uses folder routing on `workers.dev`, `tunnels.*`, and apex-style hosts, and subdomain routing for wildcard hosts like `demo.my-tunnels.com`. If you prefer `*.tunnels.example.com/*`, Cloudflare's [Universal SSL](https://developers.cloudflare.com/ssl/edge-certificates/universal-ssl/) covers the apex and first-level subdomains, so deeper wildcard hostnames normally need [Advanced Certificate Manager](https://developers.cloudflare.com/ssl/edge-certificates/advanced-certificate-manager/) or another certificate option. - -By default, all tunnel names live in one warm `CaptunServerShard` Durable Object. That minimizes cold-start latency. Set `CAPTUN_SHARDS` only when you need more aggregate throughput for many concurrent large responses: - -```bash -pnpm exec wrangler deploy --var CAPTUN_SHARDS:256 -``` - -## 2. Programmatic Usage +By default the worker routes `/my-tunnel/foo/bar` to the capnweb session for "my-tunnel", and becomes a corresponding HTTP request with pathname `/foo/bar` when it reaches your client. -The client side is just a disposable connection: - -```ts -import { createCaptunTunnel } from "captun/client"; - -using tunnel = await createCaptunTunnel({ - url: "https://captun.example.workers.dev/my-test/__connect", - headers: process.env.CAPTUN_SECRET - ? { authorization: `Bearer ${process.env.CAPTUN_SECRET}` } - : undefined, - fetch: async (request) => { - const url = new URL(request.url); - return fetch(`http://localhost:3000${url.pathname}${url.search}`, request); - }, -}); -``` +### Custom hostnames -On the server side, authorize your connect route, accept it as a tunnel, then hand normal requests to `tunnel.fetch(request)`: +Some proxy targets behave better with a naked hostname than with a path prefix. In that case, route `*.my-tunnels.com/*` to the Worker and call `https://demo.my-tunnels.com/`; buying a throwaway domain like `my-tunnels.com`. The built-in router uses folder routing on `workers.dev`, `tunnels.*`, and apex-style hosts, and subdomain routing for wildcard hosts like `demo.my-tunnels.com`. If you prefer `*.tunnels.example.com/*`, Cloudflare's [Universal SSL](https://developers.cloudflare.com/ssl/edge-certificates/universal-ssl/) covers the apex and first-level subdomains, so deeper wildcard hostnames normally need [Advanced Certificate Manager](https://developers.cloudflare.com/ssl/edge-certificates/advanced-certificate-manager/) or another certificate option. -```ts -import { acceptCaptunTunnel, type CaptunServerTunnel } from "captun/server"; +### Sharding -export class MyDurableObject { - private tunnel?: CaptunServerTunnel; +By default, all tunnel names live in one warm `CaptunServerShard` Durable Object. That minimizes cold-start latency. Use `--shards` only when you need more aggregate throughput for many concurrent large responses: - fetch(request: Request) { - const url = new URL(request.url); - if (url.pathname === "/egress/__connect") { - const { response, tunnel } = acceptCaptunTunnel({ - onDisconnect: () => { - if (this.tunnel === tunnel) this.tunnel = undefined; - }, - }); - this.tunnel = tunnel; - return response; - } - if (url.pathname.startsWith("/egress/")) { - return this.tunnel?.fetch(request) ?? new Response("No tunnel client connected", { status: 503 }); - } - return new Response("Not found", { status: 404 }); - } -} +```bash +npx captun deploy --shards 256 ``` You can import the public API from `captun`, or use subpath imports from `captun/client` and `captun/server`. The server package also exports `acceptCaptunTunnelFromSocket(socket)` for Workers that already performed the WebSocket upgrade. -### How Does It Work? +## How Does It Work? We just pass `fetch()` through `fetch()`. No, really. diff --git a/src/bin.ts b/src/bin.ts index 3824a76..25da8fe 100755 --- a/src/bin.ts +++ b/src/bin.ts @@ -78,8 +78,12 @@ const router = os.router({ deploy: os .meta({ description: "Deploy the Captun tunnel Worker with Wrangler and save local CLI config.", - prompt: true, - examples: ["captun deploy", "captun deploy --route '*.tunnels.example.com/*'"], + prompt: false, + examples: [ + "captun deploy", + "captun deploy --route '*.tunnels.example.com/*'", + "captun deploy --shards 16", + ], }) .input( z.object({ @@ -91,11 +95,17 @@ const router = os.router({ .string() .optional() .describe("Secret required by tunnel clients; generated when omitted"), + shards: z + .number() + .int() + .min(1) + .optional() + .describe("Number of Durable Object shards to spread tunnel names across"), }), ) .handler(async ({ input }) => { const secret = input.secret || randomSecret(); - const serverUrl = await deployWorker({ route: input.route, secret }); + const serverUrl = await deployWorker({ route: input.route, secret, shards: input.shards }); await writeConfig({ serverUrl, secret }); return { serverUrl, configPath }; }), @@ -110,7 +120,7 @@ const cli = createCli({ await cli.run({ prompts, logger: yamlTableConsoleLogger }); -async function deployWorker(input: { route?: string; secret: string }) { +async function deployWorker(input: { route?: string; secret: string; shards?: number }) { const tempDir = await mkdtemp(resolve(tmpdir(), "captun-")); const secretsFile = resolve(tempDir, "secrets.json"); try { @@ -130,6 +140,7 @@ async function deployWorker(input: { route?: string; secret: string }) { "--keep-vars", ]; if (input.route) args.push("--route", input.route); + if (input.shards) args.push("--var", `CAPTUN_SHARDS:${input.shards}`); const output = await runWrangler(args); const serverUrl = input.route From 79b2bdd41663d538197e6c699eb4e77ac1aff0d0 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Tue, 19 May 2026 13:46:40 +0100 Subject: [PATCH 27/43] Address PR review comments --- package.json | 2 +- src/bin.ts | 4 ++-- src/worker-routing.ts | 44 +++++++++++++++++++++++++++++++++++++++++ src/worker.ts | 46 +------------------------------------------ test/miniflare.ts | 3 ++- test/worker.test.ts | 41 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 91 insertions(+), 49 deletions(-) create mode 100644 src/worker-routing.ts diff --git a/package.json b/package.json index a2bbf5e..d8ca760 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "scripts": { "build": "rm -rf dist && tsc -p tsconfig.lib.json", "check": "pnpm run typecheck", - "typecheck": "tsc -p tsconfig.json && pnpm --filter @captun/weather-reporter typecheck", + "typecheck": "tsc -p tsconfig.json && pnpm --recursive --filter '!captun' --if-present run typecheck", "deploy": "wrangler deploy", "dev": "wrangler dev", "test": "vitest run", diff --git a/src/bin.ts b/src/bin.ts index 25da8fe..65978b7 100755 --- a/src/bin.ts +++ b/src/bin.ts @@ -50,14 +50,14 @@ const router = os.router({ ) .handler(async ({ input }) => { const config = await readConfig(); - const serverUrl = input.serverUrl || process.env.CAPTUN_SERVER_URL || config?.serverUrl; + const serverUrl = input.serverUrl || config?.serverUrl; if (!serverUrl) { throw new Error( `No tunnel server configured. Run "captun deploy" first or pass --server-url.`, ); } - const secret = input.secret || process.env.CAPTUN_SECRET || config?.secret; + const secret = input.secret || config?.secret; const name = input.name || randomName(); const tunnel = tunnelUrl(serverUrl, name); const origin = `http://127.0.0.1:${input.port}`; diff --git a/src/worker-routing.ts b/src/worker-routing.ts new file mode 100644 index 0000000..17ed9df --- /dev/null +++ b/src/worker-routing.ts @@ -0,0 +1,44 @@ +/** Extracts the tunnel name and forwarded path from just the hostname and path. */ +export function captunRouteParts(hostname: string, pathname: string) { + if (!usesFolderRouting(hostname)) { + const [name] = hostname.split("."); + if (!name) return undefined; + const decodedName = safeDecodeURIComponent(name); + return decodedName ? { name: decodedName, path: pathname } : undefined; + } + const [name, ...rest] = pathname.split("/").filter(Boolean); + if (!name || name === "__captun-connect") return undefined; + const decodedName = safeDecodeURIComponent(name); + return decodedName ? { name: decodedName, path: `/${rest.join("/")}` } : undefined; +} + +/** Maps a tunnel name to a stable Durable Object shard name. */ +export function captunShardName(tunnelName: string, shardCount: number) { + if (!Number.isFinite(shardCount) || shardCount <= 1) return "tunnel-shard-0"; + let hash = 2166136261; + for (let index = 0; index < tunnelName.length; index++) { + hash ^= tunnelName.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return `tunnel-shard-${(hash >>> 0) % Math.floor(shardCount)}`; +} + +/** Chooses folder routing for Worker preview hosts, apex domains, and local dev. */ +function usesFolderRouting(hostname: string) { + return ( + hostname === "localhost" || + /^\d+\.\d+\.\d+\.\d+$/.test(hostname) || + hostname.endsWith(".workers.dev") || + hostname.startsWith("tunnels.") || + hostname.split(".").length < 3 + ); +} + +/** Decodes a route segment, returning undefined for malformed percent escapes. */ +function safeDecodeURIComponent(value: string) { + try { + return decodeURIComponent(value); + } catch { + return undefined; + } +} diff --git a/src/worker.ts b/src/worker.ts index 553e6a3..b894ac8 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -1,6 +1,7 @@ import { DurableObject } from "cloudflare:workers"; import { acceptCaptunTunnel } from "./server.js"; import type { CaptunServerTunnel } from "./types.js"; +import { captunRouteParts, captunShardName } from "./worker-routing.js"; type CaptunEnv = Env & { CAPTUN_SECRET?: string; @@ -81,48 +82,3 @@ function captunRoute(request: Request) { url.pathname = `/${encodeURIComponent(route.name)}${route.path}`; return { tunnelName: route.name, request: new Request(url, request) }; } - -/** Extracts the tunnel name and forwarded path from just the hostname and path. */ -function captunRouteParts(hostname: string, pathname: string) { - if (!usesFolderRouting(hostname)) { - const [name] = hostname.split("."); - if (!name) return undefined; - const decodedName = safeDecodeURIComponent(name); - return decodedName ? { name: decodedName, path: pathname } : undefined; - } - const [name, ...rest] = pathname.split("/").filter(Boolean); - if (!name || name === "__captun-connect") return undefined; - const decodedName = safeDecodeURIComponent(name); - return decodedName ? { name: decodedName, path: `/${rest.join("/")}` } : undefined; -} - -/** Maps a tunnel name to a stable Durable Object shard name. */ -function captunShardName(tunnelName: string, shardCount: number) { - if (!Number.isFinite(shardCount) || shardCount <= 1) return "tunnel-shard-0"; - let hash = 2166136261; - for (let index = 0; index < tunnelName.length; index++) { - hash ^= tunnelName.charCodeAt(index); - hash = Math.imul(hash, 16777619); - } - return `tunnel-shard-${(hash >>> 0) % Math.floor(shardCount)}`; -} - -/** Chooses folder routing for Worker preview hosts, apex domains, and local dev. */ -function usesFolderRouting(hostname: string) { - return ( - hostname === "localhost" || - /^\d+\.\d+\.\d+\.\d+$/.test(hostname) || - hostname.endsWith(".workers.dev") || - hostname.startsWith("tunnels.") || - hostname.split(".").length < 3 - ); -} - -/** Decodes a route segment, returning undefined for malformed percent escapes. */ -function safeDecodeURIComponent(value: string) { - try { - return decodeURIComponent(value); - } catch { - return undefined; - } -} diff --git a/test/miniflare.ts b/test/miniflare.ts index 88fdaa6..cfaabe9 100644 --- a/test/miniflare.ts +++ b/test/miniflare.ts @@ -6,8 +6,9 @@ import { fileURLToPath } from "node:url"; import * as esbuild from "esbuild"; import { Miniflare } from "miniflare"; + export async function createMiniflareWorkerFixture(options: { - entryPoint: string; + entryPoint: `${string}.ts`; durableObjects: Record; bindings: Record; }) { diff --git a/test/worker.test.ts b/test/worker.test.ts index f30024c..7f54c5f 100644 --- a/test/worker.test.ts +++ b/test/worker.test.ts @@ -1,7 +1,48 @@ import { expect, test } from "vitest"; import { createCaptunTunnel } from "../src/client.js"; +import { captunRouteParts, captunShardName } from "../src/worker-routing.js"; import { createCaptunWorkerFixture } from "./miniflare.js"; +const routeCases: Array< + [ + hostname: string, + path: string, + tunnelName: string | undefined, + forwardedPath: string | undefined, + ] +> = [ + ["captun.account.workers.dev", "/my-test/hello", "my-test", "/hello"], + ["captun.account.workers.dev", "/my-test/__captun-connect", "my-test", "/__captun-connect"], + ["captun.account.workers.dev", "/__captun-connect", undefined, undefined], + ["captun.account.workers.dev", "/", undefined, undefined], + ["localhost", "/my-test/hello", "my-test", "/hello"], + ["my-tunnels.com", "/my-test/hello", "my-test", "/hello"], + ["tunnels.example.com", "/my-test/hello", "my-test", "/hello"], + ["tunnels.example.com", "/my-test/__captun-connect", "my-test", "/__captun-connect"], + ["my-test.tunnels.example.com", "/hello", "my-test", "/hello"], + ["my-test.my-tunnels.com", "/hello", "my-test", "/hello"], + ["my-test.my-tunnels.com", "/__captun-connect", "my-test", "/__captun-connect"], + ["my-test.mysubdomain.mydomain.com", "/hello", "my-test", "/hello"], + ["some-tunnel.example.com", "/some-path", "some-tunnel", "/some-path"], + ["captun.account.workers.dev", "/bad%/hello", undefined, undefined], +]; + +test.each(routeCases)("%s%s -> %s %s", (hostname, path, tunnelName, forwardedPath) => { + expect(captunRouteParts(hostname, path)).toEqual( + tunnelName ? { name: tunnelName, path: forwardedPath } : undefined, + ); +}); + +test("Captun Worker uses one warm shard by default", () => { + expect(captunShardName("alpha", 1)).toBe("tunnel-shard-0"); + expect(captunShardName("beta", 0)).toBe("tunnel-shard-0"); +}); + +test("Captun Worker keeps a tunnel name on a stable shard", () => { + expect(captunShardName("my-test", 16)).toBe(captunShardName("my-test", 16)); + expect(captunShardName("my-test", 16)).toMatch(/^tunnel-shard-(?:[0-9]|1[0-5])$/); +}); + test("Captun Worker forwards requests through a real Durable Object tunnel", async () => { await using fixture = await createCaptunWorkerFixture({}); using _tunnel = await createCaptunTunnel({ From 80f585a5b841e8dded7dfb7cc45d3adb94671fc7 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Tue, 19 May 2026 14:27:01 +0100 Subject: [PATCH 28/43] restructure a bit more --- README.md | 56 +++++++++++++++++++++++++++---------------------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index ed2291d..4740498 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Captun is a tiny reference implementation of a self-hosted ngrok or Cloudflare T ## Quick start -First deploy a captun worker to your cloudflare account. You can think of this like your own personal ngrok server: +First deploy a captun worker to your cloudflare account. You can think of this like your own personal ngrok server, but [faster](#performance): ```bash npx captun deploy @@ -24,7 +24,7 @@ npx captun 3000 # mydomain.com and www.mydomain.com and something.mydomain.com # then you _could_ say *.mydomain.com goes to tunnels - and that could include __tunnels.mydomain.com --> -This will use `wrangler` under the hood to deploy an opinionated captun-tunneler-worker to your cloudflare account, and will store the server url in an XDG config file, and uses it when you tunnel to it. +The deploy command will use `wrangler` under the hood to deploy an opinionated captun-tunneler-worker to your cloudflare account, and will store the server url in an XDG config file, and uses it when you tunnel to it. >HTTP: Response ``` -See [examples/weather-reporter](./examples/weather-reporter) for a small workspace package that imports `captun/server` and has its own e2e tests. +See [examples/weather-reporter](./examples/weather-reporter) for a small workspace package that runs the same egress-intercepting weather app on Cloudflare Workers, Bun, Deno, and Node from Vitest. ## Development diff --git a/examples/weather-reporter/README.md b/examples/weather-reporter/README.md index 0beb1bb..a7084c1 100644 --- a/examples/weather-reporter/README.md +++ b/examples/weather-reporter/README.md @@ -2,8 +2,13 @@ Tiny example app that uses Captun to mock outbound network egress in an e2e test. The app fetches live weather from the free, no-key `wttr.in` API. -All requests proxy through one small Durable Object so the intercepted egress -tunnel is used from the same Worker request context. + +The same `WeatherReporter` app runs behind four server shapes: + +- `worker.ts`: Cloudflare Worker plus a Durable Object, using `captun/server`. +- `bun.ts`: `Bun.serve`, using `captun/bun`. +- `deno.ts`: `Deno.serve`, using `captun/deno`. +- `node.ts`: Node `http` plus `ws`, using `captun/node`. ## Run Locally @@ -19,7 +24,18 @@ Then run the example test from this directory: pnpm test ``` -The test starts a Miniflare Worker automatically when `WEATHER_REPORTER_URL` is not set. +The test starts a Miniflare Worker for the Cloudflare case, and starts Bun, +Deno, and Node servers in subprocesses for the runtime adapter cases. + +To run one runtime directly from the repository root: + +```sh +pnpm exec vitest run examples/weather-reporter/bun.e2e.test.ts +pnpm exec vitest run examples/weather-reporter/deno.e2e.test.ts +pnpm exec vitest run examples/weather-reporter/node.e2e.test.ts +``` + +The Cloudflare test starts a Miniflare Worker automatically when `WEATHER_REPORTER_URL` is not set. To point the same test at an already-running local Worker: ```sh @@ -40,4 +56,4 @@ doppler run -- pnpm exec wrangler deploy WEATHER_REPORTER_URL=https://weather-reporter.garple-pretend-customer-should-be-iterate-dev-stg-will-chan.workers.dev pnpm test ``` -The test awaits `createCaptunTunnel()` at the Worker's `/__intercept-egress-traffic` route, mocks the `wttr.in` response, then calls `/weather/london` and `/weather/new+york` on the Worker. +The tests await `createCaptunTunnel()` at each server's `/__intercept-egress-traffic` route, mock the `wttr.in` response, then call `/weather?city=london` and `/weather?city=paris`. diff --git a/examples/weather-reporter/app.ts b/examples/weather-reporter/app.ts new file mode 100644 index 0000000..58ceee4 --- /dev/null +++ b/examples/weather-reporter/app.ts @@ -0,0 +1,38 @@ +import type { CaptunServerTunnel } from "captun/server"; + +export class WeatherReporter { + private egressTunnel: CaptunServerTunnel | undefined; + + replaceEgressTunnel(tunnel: CaptunServerTunnel) { + this.egressTunnel?.[Symbol.dispose](); + this.egressTunnel = tunnel; + } + + clearEgressTunnel(tunnel: CaptunServerTunnel) { + if (this.egressTunnel === tunnel) this.egressTunnel = undefined; + } + + async fetch(request: Request) { + const url = new URL(request.url); + + if (url.pathname === "/weather") { + const city = url.searchParams.get("city") || ""; + const response = await this.egressFetch(`https://wttr.in/${city}?format=j1`); + const weather = (await response.json()) as { + current_condition: [{ temp_C: string }]; + }; + return new Response( + `The temperature in ${city} is ${weather.current_condition[0].temp_C} celsius`, + ); + } + + return new Response("Not found\n", { status: 404 }); + } + + private get egressFetch(): typeof fetch { + if (this.egressTunnel) { + return async (input, init) => this.egressTunnel!.fetch(new Request(input, init)); + } + return fetch; + } +} diff --git a/examples/weather-reporter/bun.e2e.test.ts b/examples/weather-reporter/bun.e2e.test.ts new file mode 100644 index 0000000..99aca18 --- /dev/null +++ b/examples/weather-reporter/bun.e2e.test.ts @@ -0,0 +1,28 @@ +import { expect, test, vi } from "vitest"; + +import { createCaptunTunnel } from "../../src/client.js"; +import { createRuntimeWeatherReporterFixture } from "./runtime-fixtures.js"; + +vi.setConfig({ testTimeout: 20_000 }); + +test("returns nicely formatted weather report from a Bun server", async () => { + await using app = await createRuntimeWeatherReporterFixture("bun"); + using _tunnel = await createCaptunTunnel({ + url: `${app.url}/__intercept-egress-traffic`, + fetch(request) { + if (request.url === "https://wttr.in/london?format=j1") { + return Response.json({ current_condition: [{ temp_C: "18" }] }); + } + if (request.url === "https://wttr.in/paris?format=j1") { + return Response.json({ current_condition: [{ temp_C: "22" }] }); + } + return new Response("Unexpected egress", { status: 500 }); + }, + }); + + const london = await fetch(`${app.url}/weather?city=london`); + expect(await london.text()).toBe("The temperature in london is 18 celsius"); + + const paris = await fetch(`${app.url}/weather?city=paris`); + expect(await paris.text()).toBe("The temperature in paris is 22 celsius"); +}); diff --git a/examples/weather-reporter/bun.ts b/examples/weather-reporter/bun.ts new file mode 100644 index 0000000..10aeead --- /dev/null +++ b/examples/weather-reporter/bun.ts @@ -0,0 +1,53 @@ +import { + createCaptunBunWebSocketHandler, + type CaptunBunWebSocketHandler, +} from "captun/bun"; +import { WeatherReporter } from "./app.js"; + +declare const Bun: { + serve(options: { + hostname: string; + port: number; + fetch( + request: Request, + server: { upgrade(request: Request): boolean }, + ): Response | Promise | undefined; + websocket: CaptunBunWebSocketHandler; + }): { stop(force?: boolean): void }; +}; + +const app = new WeatherReporter(); +const websocket = createCaptunBunWebSocketHandler({ + onTunnel(tunnel) { + app.replaceEgressTunnel(tunnel); + }, + onDisconnect(tunnel) { + app.clearEgressTunnel(tunnel); + }, +}); + +const server = Bun.serve({ + hostname: "127.0.0.1", + port: Number(process.env.PORT), + async fetch(request, server) { + const url = new URL(request.url); + + if (url.pathname === "/__health__") return new Response("ok"); + + if (url.pathname === "/__intercept-egress-traffic") { + if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") { + return new Response("Expected WebSocket upgrade\n", { status: 400 }); + } + if (server.upgrade(request)) return; + return new Response("WebSocket upgrade failed\n", { status: 500 }); + } + + return app.fetch(request); + }, + websocket, +}); + +process.on("SIGINT", () => { + server.stop(true); + process.exit(0); +}); diff --git a/examples/weather-reporter/deno.e2e.test.ts b/examples/weather-reporter/deno.e2e.test.ts new file mode 100644 index 0000000..ba50a6f --- /dev/null +++ b/examples/weather-reporter/deno.e2e.test.ts @@ -0,0 +1,28 @@ +import { expect, test, vi } from "vitest"; + +import { createCaptunTunnel } from "../../src/client.js"; +import { createRuntimeWeatherReporterFixture } from "./runtime-fixtures.js"; + +vi.setConfig({ testTimeout: 20_000 }); + +test("returns nicely formatted weather report from a Deno server", async () => { + await using app = await createRuntimeWeatherReporterFixture("deno"); + using _tunnel = await createCaptunTunnel({ + url: `${app.url}/__intercept-egress-traffic`, + fetch(request) { + if (request.url === "https://wttr.in/london?format=j1") { + return Response.json({ current_condition: [{ temp_C: "18" }] }); + } + if (request.url === "https://wttr.in/paris?format=j1") { + return Response.json({ current_condition: [{ temp_C: "22" }] }); + } + return new Response("Unexpected egress", { status: 500 }); + }, + }); + + const london = await fetch(`${app.url}/weather?city=london`); + expect(await london.text()).toBe("The temperature in london is 18 celsius"); + + const paris = await fetch(`${app.url}/weather?city=paris`); + expect(await paris.text()).toBe("The temperature in paris is 22 celsius"); +}); diff --git a/examples/weather-reporter/deno.json b/examples/weather-reporter/deno.json new file mode 100644 index 0000000..294a35e --- /dev/null +++ b/examples/weather-reporter/deno.json @@ -0,0 +1,7 @@ +{ + "imports": { + "captun/deno": "../../src/deno.ts", + "captun/server": "../../src/server.ts", + "capnweb": "npm:capnweb@0.8.0" + } +} diff --git a/examples/weather-reporter/deno.ts b/examples/weather-reporter/deno.ts new file mode 100644 index 0000000..3de0a2f --- /dev/null +++ b/examples/weather-reporter/deno.ts @@ -0,0 +1,49 @@ +import { acceptCaptunDenoTunnel, type CaptunServerTunnel } from "captun/deno"; +import { WeatherReporter } from "./app.js"; + +declare const Deno: { + env: { get(name: string): string | undefined }; + serve( + options: { hostname: string; port: number }, + handler: (request: Request) => Response | Promise, + ): { shutdown(): Promise }; + upgradeWebSocket(request: Request): { socket: WebSocket; response: Response }; + addSignalListener(signal: "SIGINT", handler: () => void): void; + exit(code?: number): never; +}; + +const app = new WeatherReporter(); + +const server = Deno.serve( + { hostname: "127.0.0.1", port: Number(Deno.env.get("PORT")) }, + (request) => { + const url = new URL(request.url); + + if (url.pathname === "/__health__") return new Response("ok"); + + if (url.pathname === "/__intercept-egress-traffic") { + if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") { + return new Response("Expected WebSocket upgrade\n", { status: 400 }); + } + + const { socket, response } = Deno.upgradeWebSocket(request); + let acceptedTunnel: CaptunServerTunnel | undefined; + socket.addEventListener("open", () => { + const tunnel = acceptCaptunDenoTunnel(socket, { + onDisconnect: () => { + if (acceptedTunnel) app.clearEgressTunnel(acceptedTunnel); + }, + }); + acceptedTunnel = tunnel; + app.replaceEgressTunnel(tunnel); + }); + return response; + } + + return app.fetch(request); + }, +); + +Deno.addSignalListener("SIGINT", () => { + server.shutdown().finally(() => Deno.exit(0)); +}); diff --git a/examples/weather-reporter/node.e2e.test.ts b/examples/weather-reporter/node.e2e.test.ts new file mode 100644 index 0000000..8820fb9 --- /dev/null +++ b/examples/weather-reporter/node.e2e.test.ts @@ -0,0 +1,28 @@ +import { expect, test, vi } from "vitest"; + +import { createCaptunTunnel } from "../../src/client.js"; +import { createRuntimeWeatherReporterFixture } from "./runtime-fixtures.js"; + +vi.setConfig({ testTimeout: 20_000 }); + +test("returns nicely formatted weather report from a Node server", async () => { + await using app = await createRuntimeWeatherReporterFixture("node"); + using _tunnel = await createCaptunTunnel({ + url: `${app.url}/__intercept-egress-traffic`, + fetch(request) { + if (request.url === "https://wttr.in/london?format=j1") { + return Response.json({ current_condition: [{ temp_C: "18" }] }); + } + if (request.url === "https://wttr.in/paris?format=j1") { + return Response.json({ current_condition: [{ temp_C: "22" }] }); + } + return new Response("Unexpected egress", { status: 500 }); + }, + }); + + const london = await fetch(`${app.url}/weather?city=london`); + expect(await london.text()).toBe("The temperature in london is 18 celsius"); + + const paris = await fetch(`${app.url}/weather?city=paris`); + expect(await paris.text()).toBe("The temperature in paris is 22 celsius"); +}); diff --git a/examples/weather-reporter/node.ts b/examples/weather-reporter/node.ts new file mode 100644 index 0000000..accd6b9 --- /dev/null +++ b/examples/weather-reporter/node.ts @@ -0,0 +1,87 @@ +import http, { type IncomingMessage, type ServerResponse } from "node:http"; +import { Readable } from "node:stream"; + +import { + acceptCaptunNodeTunnel, + type CaptunNodeWebSocket, + type CaptunServerTunnel, +} from "captun/node"; +import { WebSocketServer } from "ws"; +import { WeatherReporter } from "./app.js"; + +const app = new WeatherReporter(); +const port = Number(process.env.PORT); +const webSockets = new WebSocketServer({ noServer: true }); + +const server = http.createServer(async (request, response) => { + if (request.headers.upgrade?.toLowerCase() === "websocket") return; + + try { + const fetchRequest = nodeRequestToFetchRequest(request); + const url = new URL(fetchRequest.url); + const fetchResponse = + url.pathname === "/__health__" ? new Response("ok") : await app.fetch(fetchRequest); + await writeFetchResponse(response, fetchResponse); + } catch (error) { + response.writeHead(500, { "content-type": "text/plain" }); + response.end(String(error instanceof Error ? error.stack || error.message : error)); + } +}); + +server.on("upgrade", (request, socket, head) => { + const url = new URL(request.url || "/", `http://${request.headers.host || "127.0.0.1"}`); + if (url.pathname !== "/__intercept-egress-traffic") { + socket.destroy(); + return; + } + + webSockets.handleUpgrade(request, socket, head, (webSocket) => { + let acceptedTunnel: CaptunServerTunnel | undefined; + const tunnel = acceptCaptunNodeTunnel(webSocket as unknown as CaptunNodeWebSocket, { + onDisconnect: () => { + if (acceptedTunnel) app.clearEgressTunnel(acceptedTunnel); + }, + }); + acceptedTunnel = tunnel; + app.replaceEgressTunnel(tunnel); + }); +}); + +server.listen(port, "127.0.0.1"); + +process.on("SIGINT", () => { + webSockets.close(); + server.close(() => process.exit(0)); + setTimeout(() => process.exit(1), 5_000).unref(); +}); + +function nodeRequestToFetchRequest(request: IncomingMessage) { + const url = new URL(request.url || "/", `http://${request.headers.host || "127.0.0.1"}`); + const headers = new Headers(); + for (const [name, value] of Object.entries(request.headers)) { + if (Array.isArray(value)) { + for (const item of value) headers.append(name, item); + } else if (value) { + headers.set(name, value); + } + } + + const init: RequestInit & { duplex?: "half" } = { + method: request.method, + headers, + }; + if (request.method !== "GET" && request.method !== "HEAD") { + init.body = Readable.toWeb(request) as unknown as ReadableStream; + init.duplex = "half"; + } + + return new Request(url, init); +} + +async function writeFetchResponse(response: ServerResponse, fetchResponse: Response) { + response.writeHead(fetchResponse.status, Object.fromEntries(fetchResponse.headers.entries())); + if (fetchResponse.body) { + for await (const chunk of fetchResponse.body) response.write(chunk); + } + response.end(); +} diff --git a/examples/weather-reporter/package.json b/examples/weather-reporter/package.json index c885238..3147462 100644 --- a/examples/weather-reporter/package.json +++ b/examples/weather-reporter/package.json @@ -7,9 +7,12 @@ "test": "vitest run" }, "dependencies": { - "captun": "workspace:*" + "captun": "workspace:*", + "ws": "^8.19.0" }, "devDependencies": { + "@types/ws": "^8.18.1", + "tsx": "^4.22.2", "typescript": "^6.0.3", "vitest": "^4.1.6" } diff --git a/examples/weather-reporter/runtime-fixtures.ts b/examples/weather-reporter/runtime-fixtures.ts new file mode 100644 index 0000000..e15baa4 --- /dev/null +++ b/examples/weather-reporter/runtime-fixtures.ts @@ -0,0 +1,143 @@ +import { spawn, type ChildProcessByStdio } from "node:child_process"; +import net from "node:net"; +import { dirname, resolve } from "node:path"; +import type { Readable } from "node:stream"; +import { fileURLToPath } from "node:url"; + +export type WeatherReporterRuntime = "bun" | "deno" | "node"; + +const exampleRoot = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(exampleRoot, "../.."); +type WeatherReporterProcess = ChildProcessByStdio; + +export async function createRuntimeWeatherReporterFixture(runtime: WeatherReporterRuntime) { + const port = await getAvailablePort(); + const url = `http://127.0.0.1:${port}`; + const server = startWeatherReporterProcess(runtime, port); + const logs = captureOutput(server); + + try { + await waitForHttp(`${url}/__health__`, 15_000, server, logs); + return { + url, + async [Symbol.asyncDispose]() { + await stopProcess(server); + }, + }; + } catch (error) { + await stopProcess(server); + throw new Error(formatFixtureFailure(error instanceof Error ? error.message : String(error), logs())); + } +} + +function startWeatherReporterProcess(runtime: WeatherReporterRuntime, port: number) { + const commands: Record = { + bun: { command: "bun", args: ["run", "examples/weather-reporter/bun.ts"] }, + deno: { + command: "deno", + args: [ + "run", + "--config", + "examples/weather-reporter/deno.json", + "--node-modules-dir=auto", + "--no-lock", + "--sloppy-imports", + "--allow-env=PORT", + "--allow-net=127.0.0.1", + "examples/weather-reporter/deno.ts", + ], + }, + node: { command: "pnpm", args: ["exec", "tsx", "examples/weather-reporter/node.ts"] }, + }; + const command = commands[runtime]; + return spawn(command.command, command.args, { + cwd: repoRoot, + env: { ...process.env, PORT: String(port) }, + stdio: ["ignore", "pipe", "pipe"], + }); +} + +async function getAvailablePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + reject(new Error(`Failed to allocate a local port: ${String(address)}`)); + return; + } + + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(address.port); + }); + }); + server.on("error", reject); + }); +} + +async function waitForHttp( + url: string, + timeoutMs: number, + server: WeatherReporterProcess, + logs: () => string, +): Promise { + const startedAt = Date.now(); + + while (Date.now() - startedAt < timeoutMs) { + if (server.exitCode !== null || server.signalCode) { + throw new Error( + `Weather reporter process exited before ${url} responded\n\n${logs().trim() || "(none)"}`, + ); + } + + try { + const response = await fetch(url); + if (response.ok) return; + } catch {} + + await delay(100); + } + + throw new Error(`Timed out waiting for weather reporter to respond at ${url}`); +} + +function captureOutput(child: WeatherReporterProcess) { + const chunks: string[] = []; + const capture = (chunk: string | Buffer) => { + chunks.push(String(chunk)); + if (chunks.length > 200) chunks.shift(); + }; + child.stdout.on("data", capture); + child.stderr.on("data", capture); + + return () => chunks.join(""); +} + +function formatFixtureFailure(message: string, serverLogs: string): string { + return [message, "", "Server logs:", serverLogs.trim() || "(none)"].join("\n"); +} + +async function stopProcess(child: WeatherReporterProcess): Promise { + if (child.exitCode !== null || child.killed) return; + + child.kill("SIGINT"); + const exited = await Promise.race([ + new Promise((resolve) => child.once("exit", () => resolve(true))), + delay(5_000).then(() => false), + ]); + + if (!exited && child.exitCode === null && !child.killed) { + child.kill("SIGKILL"); + await new Promise((resolve) => child.once("exit", () => resolve())); + } +} + +function delay(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} diff --git a/examples/weather-reporter/worker.ts b/examples/weather-reporter/worker.ts index 7433e82..93a9bfe 100644 --- a/examples/weather-reporter/worker.ts +++ b/examples/weather-reporter/worker.ts @@ -1,44 +1,31 @@ import { DurableObject } from "cloudflare:workers"; import { acceptCaptunTunnel, type CaptunServerTunnel } from "captun/server"; +import { WeatherReporter } from "./app.js"; type WeatherReporterEnv = Env & { WEATHER_REPORTER_EGRESS: DurableObjectNamespace; }; export class WeatherReporterEgressTunnel extends DurableObject { - private egressTunnel: CaptunServerTunnel | undefined; + private readonly app = new WeatherReporter(); - async fetch(request: Request) { + fetch(request: Request) { const url = new URL(request.url); - if (url.pathname === "/weather") { - // Here's the value our app provides: fetching and gorgeously formatting weather data - const city = url.searchParams.get("city"); - const response = await this.egressFetch(`https://wttr.in/${city}?format=j1`); - const weather = await response.json<{ current_condition: [{ temp_C: string }] }>(); - return new Response(`The temperature in ${city} is ${weather.current_condition[0].temp_C} celsius`); - } - if (url.pathname === "/__intercept-egress-traffic") { // Here we set up our worker to allow clients/tests to intercept egress traffic - this.egressTunnel?.[Symbol.dispose](); + let acceptedTunnel: CaptunServerTunnel | undefined; const { response, tunnel } = acceptCaptunTunnel({ onDisconnect: () => { - if (this.egressTunnel === tunnel) this.egressTunnel = undefined; + if (acceptedTunnel) this.app.clearEgressTunnel(acceptedTunnel); }, }); - this.egressTunnel = tunnel; + acceptedTunnel = tunnel; + this.app.replaceEgressTunnel(tunnel); return response; } - return new Response("Not found\n", { status: 404 }); - } - - get egressFetch(): typeof fetch { - if (this.egressTunnel) { - return async (input, init) => this.egressTunnel!.fetch(new Request(input, init)); - } - return fetch; + return this.app.fetch(request); } } diff --git a/package.json b/package.json index 9584c20..eb23bf6 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,18 @@ "./server": { "types": "./src/server.ts", "import": "./src/server.ts" + }, + "./bun": { + "types": "./src/bun.ts", + "import": "./src/bun.ts" + }, + "./deno": { + "types": "./src/deno.ts", + "import": "./src/deno.ts" + }, + "./node": { + "types": "./src/node.ts", + "import": "./src/node.ts" } }, "publishConfig": { @@ -46,6 +58,18 @@ "./server": { "types": "./dist/server.d.ts", "import": "./dist/server.js" + }, + "./bun": { + "types": "./dist/bun.d.ts", + "import": "./dist/bun.js" + }, + "./deno": { + "types": "./dist/deno.d.ts", + "import": "./dist/deno.js" + }, + "./node": { + "types": "./dist/node.d.ts", + "import": "./dist/node.js" } } }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c21ca6..044f44c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,7 +54,16 @@ importers: captun: specifier: workspace:* version: link:../.. + ws: + specifier: ^8.19.0 + version: 8.20.1 devDependencies: + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 + tsx: + specifier: ^4.22.2 + version: 4.22.2 typescript: specifier: ^6.0.3 version: 6.0.3 @@ -902,6 +911,9 @@ packages: '@types/node@25.8.0': resolution: {integrity: sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@vitest/expect@4.1.6': resolution: {integrity: sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==} @@ -1993,6 +2005,10 @@ snapshots: dependencies: undici-types: 7.24.6 + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.8.0 + '@vitest/expect@4.1.6': dependencies: '@standard-schema/spec': 1.1.0 @@ -2417,8 +2433,7 @@ snapshots: ws@8.18.0: {} - ws@8.20.1: - optional: true + ws@8.20.1: {} youch-core@0.3.3: dependencies: diff --git a/src/bun.ts b/src/bun.ts new file mode 100644 index 0000000..bb0751f --- /dev/null +++ b/src/bun.ts @@ -0,0 +1,77 @@ +import { captunTunnelFromRemoteClient, type CaptunRemoteClient } from "./server-core.js"; +import type { CaptunServerTunnel } from "./types.js"; + +export type { CaptunServerTunnel } from "./types.js"; + +export interface CaptunBunWebSocketHandlerOptions { + onTunnel(tunnel: CaptunServerTunnel): void; + onDisconnect?: (tunnel: CaptunServerTunnel) => void; +} + +export interface CaptunBunWebSocketHandler { + open(socket: CaptunBunServerWebSocket): void; + message(socket: CaptunBunServerWebSocket, message: CaptunBunWebSocketMessage): void; + close(socket: CaptunBunServerWebSocket, code: number, reason: string): void; + error(socket: CaptunBunServerWebSocket, error: Error): void; +} + +export interface CaptunBunServerWebSocket { + send(message: string): unknown; + close(code?: number, reason?: string): void; +} + +export type CaptunBunWebSocketMessage = string | Uint8Array | ArrayBuffer; + +interface CaptunBunWebSocketTransport { + dispatchMessage(message: CaptunBunWebSocketMessage): void; + dispatchClose(code: number, reason: string): void; + dispatchError(error: Error): void; +} + +interface CaptunBunWebSocketSession { + transport: CaptunBunWebSocketTransport; +} + +const { newBunWebSocketRpcSession } = (await import("capnweb")) as unknown as { + newBunWebSocketRpcSession( + socket: CaptunBunServerWebSocket, + localMain?: unknown, + ): { stub: T; transport: CaptunBunWebSocketTransport }; +}; + +export function createCaptunBunWebSocketHandler( + options: CaptunBunWebSocketHandlerOptions, +): CaptunBunWebSocketHandler { + const sessions = new WeakMap(); + + return { + open(socket) { + let acceptedTunnel: CaptunServerTunnel | undefined; + const session = newBunWebSocketRpcSession(socket); + const tunnel = captunTunnelFromRemoteClient(session.stub, { + onDisconnect: () => { + if (acceptedTunnel) options.onDisconnect?.(acceptedTunnel); + }, + }); + + acceptedTunnel = tunnel; + sessions.set(socket, session); + options.onTunnel(tunnel); + }, + message(socket, message) { + sessions.get(socket)?.transport.dispatchMessage(message); + }, + close(socket, code, reason) { + const session = sessions.get(socket); + if (!session) return; + session.transport.dispatchClose(code, reason); + sessions.delete(socket); + }, + error(socket, error) { + const session = sessions.get(socket); + if (!session) return; + session.transport.dispatchError(error); + sessions.delete(socket); + }, + }; +} diff --git a/src/deno.ts b/src/deno.ts new file mode 100644 index 0000000..7ec8023 --- /dev/null +++ b/src/deno.ts @@ -0,0 +1,11 @@ +import { acceptCaptunTunnelFromSocket } from "./server.js"; +import type { CaptunServerAcceptTunnelOptions, CaptunServerTunnel } from "./types.js"; + +export type { CaptunServerAcceptTunnelOptions, CaptunServerTunnel } from "./types.js"; + +export function acceptCaptunDenoTunnel( + socket: WebSocket, + options: CaptunServerAcceptTunnelOptions = {}, +): CaptunServerTunnel { + return acceptCaptunTunnelFromSocket(socket, options); +} diff --git a/src/node.ts b/src/node.ts new file mode 100644 index 0000000..2acbb76 --- /dev/null +++ b/src/node.ts @@ -0,0 +1,18 @@ +import { acceptCaptunTunnelFromSocket } from "./server.js"; +import type { CaptunServerAcceptTunnelOptions, CaptunServerTunnel } from "./types.js"; + +export type { CaptunServerAcceptTunnelOptions, CaptunServerTunnel } from "./types.js"; + +export interface CaptunNodeWebSocket { + readyState: number; + addEventListener(type: string, listener: (event: any) => void): void; + send(message: string): unknown; + close(code?: number, reason?: string): void; +} + +export function acceptCaptunNodeTunnel( + socket: CaptunNodeWebSocket, + options: CaptunServerAcceptTunnelOptions = {}, +): CaptunServerTunnel { + return acceptCaptunTunnelFromSocket(socket as unknown as WebSocket, options); +} diff --git a/src/server-core.ts b/src/server-core.ts new file mode 100644 index 0000000..897ac1f --- /dev/null +++ b/src/server-core.ts @@ -0,0 +1,21 @@ +import type { + CaptunClientRemoteFetcher, + CaptunServerAcceptTunnelOptions, + CaptunServerTunnel, +} from "./types.js"; + +export interface CaptunRemoteClient extends CaptunClientRemoteFetcher, Disposable { + onRpcBroken(callback: () => void): void; +} + +export function captunTunnelFromRemoteClient( + remoteClient: CaptunRemoteClient, + options: CaptunServerAcceptTunnelOptions, +): CaptunServerTunnel { + remoteClient.onRpcBroken(() => options.onDisconnect?.()); + + return { + fetch: (request) => remoteClient.fetch(request), + [Symbol.dispose]: () => remoteClient[Symbol.dispose](), + }; +} diff --git a/src/server.ts b/src/server.ts index b930014..bfb20a1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,4 +1,5 @@ import { newWebSocketRpcSession } from "capnweb"; +import { captunTunnelFromRemoteClient, type CaptunRemoteClient } from "./server-core.js"; import type { CaptunClientRemoteFetcher, CaptunServerAcceptTunnelOptions, @@ -25,11 +26,8 @@ export function acceptCaptunTunnelFromSocket( socket: WebSocket, options: CaptunServerAcceptTunnelOptions = {}, ): CaptunServerTunnel { - const remoteClient = newWebSocketRpcSession(socket); - remoteClient.onRpcBroken(() => options.onDisconnect?.()); - - return { - fetch: (request) => remoteClient.fetch(request), - [Symbol.dispose]: () => remoteClient[Symbol.dispose](), - }; + const remoteClient = newWebSocketRpcSession( + socket, + ) as CaptunRemoteClient; + return captunTunnelFromRemoteClient(remoteClient, options); } diff --git a/tasks/complete/2026-05-19-runtime-server-adapters.md b/tasks/complete/2026-05-19-runtime-server-adapters.md new file mode 100644 index 0000000..5f540c4 --- /dev/null +++ b/tasks/complete/2026-05-19-runtime-server-adapters.md @@ -0,0 +1,36 @@ +--- +status: complete +size: medium +--- + +# Runtime server adapters + +**Status summary:** Complete on PR #3. Captun now has runtime-specific Bun, Deno, and Node server adapter entry points, the weather reporter app runs on all four server shapes, and Vitest covers Cloudflare/Miniflare plus Bun/Deno/Node subprocess servers. No scoped implementation pieces are missing. + +## Goal + +Captun should support the weather-reporter pattern outside Cloudflare Workers. The Cloudflare path can keep using `WebSocketPair`/Durable Objects, but Bun, Deno, and Node need server-side adapters for their different HTTP upgrade shapes so examples can accept a Captun tunnel and forward `/weather` egress through it. + +## Acceptance checklist + +- [x] Add a small adapter concept around accepting server WebSocket upgrades while keeping the Cap'n Web tunnel session logic in shared code. _`src/server-core.ts` owns the shared remote-client-to-tunnel wrapper; `captun/bun`, `captun/deno`, and `captun/node` expose runtime-specific accept helpers._ +- [x] Preserve the existing Cloudflare Worker/Durable Object weather example and test behavior. _`examples/weather-reporter/worker.ts` now delegates weather logic to `app.ts`, and the existing Miniflare weather test still passes._ +- [x] Add a Bun weather reporter example and Vitest coverage that starts `Bun.serve` in a subprocess, connects `createCaptunTunnel()`, intercepts `wttr.in`, and verifies `/weather?city=...`. _`examples/weather-reporter/bun.ts` plus `bun.e2e.test.ts` cover the Bun subprocess._ +- [x] Add a Deno weather reporter example and Vitest coverage that starts a Deno server in a subprocess, connects `createCaptunTunnel()`, intercepts `wttr.in`, and verifies `/weather?city=...`. _`examples/weather-reporter/deno.ts` plus `deno.e2e.test.ts` cover the Deno subprocess._ +- [x] Add a Node weather reporter example and Vitest coverage that starts a Node server in a subprocess, connects `createCaptunTunnel()`, intercepts `wttr.in`, and verifies `/weather?city=...`. _`examples/weather-reporter/node.ts` plus `node.e2e.test.ts` cover the Node subprocess._ +- [x] Keep the Bun/Node/Deno tests shaped similarly to `examples/weather-reporter/e2e.test.ts`; runtime-specific differences should live in fixtures or adapter calls, not in the assertions. _The three runtime tests share the same assertion flow and only vary the runtime fixture argument._ +- [x] Run everything from Vitest. It is acceptable for tests to pay subprocess startup cost. _`runtime-fixtures.ts` starts Bun, Deno, and Node servers from Vitest-managed subprocesses._ +- [x] Document the new adapter/example entry points enough that a reader can choose the Cloudflare, Bun, Deno, or Node shape. _Updated the root README API notes and the weather example README runtime list._ +- [x] Verify with focused example tests plus the package typecheck/test command. _Ran `pnpm exec vitest run examples/weather-reporter/bun.e2e.test.ts examples/weather-reporter/deno.e2e.test.ts examples/weather-reporter/node.e2e.test.ts`, `pnpm run build`, `pnpm test`, `pnpm run check`, and `pnpm --filter @captun/weather-reporter test`._ + +## Implementation notes + +- 2026-05-19: Created this task in a dedicated worktree from `mmkal/26/05/18/tweaks` on branch `mmkal/26/05/19/runtime-adapters`. +- 2026-05-19: Opened draft PR #3 after the spec commit, then implemented the runtime adapters and subprocess-backed weather tests. +- 2026-05-19: Verification passed: + - `pnpm exec tsc -p tsconfig.json --noEmit` + - `pnpm exec vitest run examples/weather-reporter/bun.e2e.test.ts examples/weather-reporter/deno.e2e.test.ts examples/weather-reporter/node.e2e.test.ts` + - `pnpm run build` + - `pnpm test` + - `pnpm run check` + - `pnpm --filter @captun/weather-reporter test` diff --git a/tasks/runtime-server-adapters.md b/tasks/runtime-server-adapters.md deleted file mode 100644 index a98d38a..0000000 --- a/tasks/runtime-server-adapters.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -status: ready -size: medium ---- - -# Runtime server adapters - -**Status summary:** Just specified. The goal is to keep Captun's tunnel core runtime-neutral while adding enough server adapter surface and weather-style examples to prove Bun, Deno, and Node can all host the same egress-intercepting app from Vitest. Implementation and verification are still missing. - -## Goal - -Captun should support the weather-reporter pattern outside Cloudflare Workers. The Cloudflare path can keep using `WebSocketPair`/Durable Objects, but Bun, Deno, and Node need server-side adapters for their different HTTP upgrade shapes so examples can accept a Captun tunnel and forward `/weather` egress through it. - -## Acceptance checklist - -- [ ] Add a small adapter concept around accepting server WebSocket upgrades while keeping the Cap'n Web tunnel session logic in shared code. -- [ ] Preserve the existing Cloudflare Worker/Durable Object weather example and test behavior. -- [ ] Add a Bun weather reporter example and Vitest coverage that starts `Bun.serve` in a subprocess, connects `createCaptunTunnel()`, intercepts `wttr.in`, and verifies `/weather?city=...`. -- [ ] Add a Deno weather reporter example and Vitest coverage that starts a Deno server in a subprocess, connects `createCaptunTunnel()`, intercepts `wttr.in`, and verifies `/weather?city=...`. -- [ ] Add a Node weather reporter example and Vitest coverage that starts a Node server in a subprocess, connects `createCaptunTunnel()`, intercepts `wttr.in`, and verifies `/weather?city=...`. -- [ ] Keep the Bun/Node/Deno tests shaped similarly to `examples/weather-reporter/e2e.test.ts`; runtime-specific differences should live in fixtures or adapter calls, not in the assertions. -- [ ] Run everything from Vitest. It is acceptable for tests to pay subprocess startup cost. -- [ ] Document the new adapter/example entry points enough that a reader can choose the Cloudflare, Bun, Deno, or Node shape. -- [ ] Verify with focused example tests plus the package typecheck/test command. - -## Implementation notes - -- 2026-05-19: Created this task in a dedicated worktree from `mmkal/26/05/18/tweaks` on branch `mmkal/26/05/19/runtime-adapters`. From 2a2d1bcfacf5861024d866530b1a0739a5711bdd Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Tue, 19 May 2026 14:52:20 +0100 Subject: [PATCH 31/43] Install Bun and Deno in CI --- .github/workflows/ci.yml | 6 ++++ examples/weather-reporter/runtime-fixtures.ts | 28 +++++++++++++++---- .../2026-05-19-runtime-server-adapters.md | 1 + 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43f34cc..a56a6ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,12 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 24 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.11 + - uses: denoland/setup-deno@v2 + with: + deno-version: 2.7.11 - run: npm install -g corepack@0.31.0 - run: corepack enable - run: pnpm install --frozen-lockfile diff --git a/examples/weather-reporter/runtime-fixtures.ts b/examples/weather-reporter/runtime-fixtures.ts index e15baa4..c4d790a 100644 --- a/examples/weather-reporter/runtime-fixtures.ts +++ b/examples/weather-reporter/runtime-fixtures.ts @@ -14,10 +14,10 @@ export async function createRuntimeWeatherReporterFixture(runtime: WeatherReport const port = await getAvailablePort(); const url = `http://127.0.0.1:${port}`; const server = startWeatherReporterProcess(runtime, port); - const logs = captureOutput(server); + const output = captureOutput(server); try { - await waitForHttp(`${url}/__health__`, 15_000, server, logs); + await waitForHttp(`${url}/__health__`, 15_000, server, output); return { url, async [Symbol.asyncDispose]() { @@ -26,7 +26,7 @@ export async function createRuntimeWeatherReporterFixture(runtime: WeatherReport }; } catch (error) { await stopProcess(server); - throw new Error(formatFixtureFailure(error instanceof Error ? error.message : String(error), logs())); + throw new Error(formatFixtureFailure(error instanceof Error ? error.message : String(error), output.logs())); } } @@ -83,14 +83,17 @@ async function waitForHttp( url: string, timeoutMs: number, server: WeatherReporterProcess, - logs: () => string, + output: CapturedProcessOutput, ): Promise { const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { + const error = output.error(); + if (error) throw error; + if (server.exitCode !== null || server.signalCode) { throw new Error( - `Weather reporter process exited before ${url} responded\n\n${logs().trim() || "(none)"}`, + `Weather reporter process exited before ${url} responded\n\n${output.logs().trim() || "(none)"}`, ); } @@ -107,14 +110,27 @@ async function waitForHttp( function captureOutput(child: WeatherReporterProcess) { const chunks: string[] = []; + let processError: Error | undefined; const capture = (chunk: string | Buffer) => { chunks.push(String(chunk)); if (chunks.length > 200) chunks.shift(); }; child.stdout.on("data", capture); child.stderr.on("data", capture); + child.on("error", (error) => { + processError = error; + chunks.push(error.stack || error.message); + }); + + return { + logs: () => chunks.join(""), + error: () => processError, + }; +} - return () => chunks.join(""); +interface CapturedProcessOutput { + logs(): string; + error(): Error | undefined; } function formatFixtureFailure(message: string, serverLogs: string): string { diff --git a/tasks/complete/2026-05-19-runtime-server-adapters.md b/tasks/complete/2026-05-19-runtime-server-adapters.md index 5f540c4..29f1d88 100644 --- a/tasks/complete/2026-05-19-runtime-server-adapters.md +++ b/tasks/complete/2026-05-19-runtime-server-adapters.md @@ -27,6 +27,7 @@ Captun should support the weather-reporter pattern outside Cloudflare Workers. T - 2026-05-19: Created this task in a dedicated worktree from `mmkal/26/05/18/tweaks` on branch `mmkal/26/05/19/runtime-adapters`. - 2026-05-19: Opened draft PR #3 after the spec commit, then implemented the runtime adapters and subprocess-backed weather tests. +- 2026-05-19: CI initially failed because the GitHub Node runner did not have `bun` or `deno` installed; added explicit setup steps for both runtimes and made fixture spawn errors report cleanly. - 2026-05-19: Verification passed: - `pnpm exec tsc -p tsconfig.json --noEmit` - `pnpm exec vitest run examples/weather-reporter/bun.e2e.test.ts examples/weather-reporter/deno.e2e.test.ts examples/weather-reporter/node.e2e.test.ts` From cea5713cc4ce398b9e05e04c0d9f9cbc7441f447 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Tue, 19 May 2026 15:02:53 +0100 Subject: [PATCH 32/43] Simplify Bun tunnel accept flow --- README.md | 2 +- examples/weather-reporter/app.ts | 20 +-- examples/weather-reporter/bun.ts | 33 +++-- examples/weather-reporter/deno.ts | 13 +- examples/weather-reporter/node.ts | 13 +- examples/weather-reporter/worker.ts | 13 +- src/bun.ts | 129 +++++++++++++----- .../2026-05-19-runtime-server-adapters.md | 1 + 8 files changed, 147 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index 2a1cd0f..ca56305 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ export default { The core client/server pieces are small TypeScript modules around [Cap'n Web](https://github.com/cloudflare/capnweb): [src/client.ts](./src/client.ts), [src/server.ts](./src/server.ts), and [src/types.ts](./src/types.ts). `captun/server` contains the Cloudflare `WebSocketPair` helper and the standard `acceptCaptunTunnelFromSocket(socket)` core. Runtime-specific subpaths adapt that core to the server upgrade shape: -- `captun/bun`: `createCaptunBunWebSocketHandler()` for `Bun.serve({ websocket })`. +- `captun/bun`: `createCaptunBunTunnelHandler()` for `Bun.serve({ fetch, websocket })`. - `captun/deno`: `acceptCaptunDenoTunnel(socket)` for `Deno.upgradeWebSocket(request)`. - `captun/node`: `acceptCaptunNodeTunnel(socket)` for `ws`/Node HTTP upgrade handlers. diff --git a/examples/weather-reporter/app.ts b/examples/weather-reporter/app.ts index 58ceee4..f29b0d4 100644 --- a/examples/weather-reporter/app.ts +++ b/examples/weather-reporter/app.ts @@ -1,15 +1,10 @@ -import type { CaptunServerTunnel } from "captun/server"; +type EgressFetch = typeof fetch; export class WeatherReporter { - private egressTunnel: CaptunServerTunnel | undefined; + private readonly egressFetch: EgressFetch; - replaceEgressTunnel(tunnel: CaptunServerTunnel) { - this.egressTunnel?.[Symbol.dispose](); - this.egressTunnel = tunnel; - } - - clearEgressTunnel(tunnel: CaptunServerTunnel) { - if (this.egressTunnel === tunnel) this.egressTunnel = undefined; + constructor(egressFetch: EgressFetch) { + this.egressFetch = egressFetch; } async fetch(request: Request) { @@ -28,11 +23,4 @@ export class WeatherReporter { return new Response("Not found\n", { status: 404 }); } - - private get egressFetch(): typeof fetch { - if (this.egressTunnel) { - return async (input, init) => this.egressTunnel!.fetch(new Request(input, init)); - } - return fetch; - } } diff --git a/examples/weather-reporter/bun.ts b/examples/weather-reporter/bun.ts index 10aeead..148817b 100644 --- a/examples/weather-reporter/bun.ts +++ b/examples/weather-reporter/bun.ts @@ -1,6 +1,8 @@ import { - createCaptunBunWebSocketHandler, + createCaptunBunTunnelHandler, + type CaptunBunServer, type CaptunBunWebSocketHandler, + type CaptunServerTunnel, } from "captun/bun"; import { WeatherReporter } from "./app.js"; @@ -10,21 +12,18 @@ declare const Bun: { port: number; fetch( request: Request, - server: { upgrade(request: Request): boolean }, + server: CaptunBunServer, ): Response | Promise | undefined; websocket: CaptunBunWebSocketHandler; }): { stop(force?: boolean): void }; }; -const app = new WeatherReporter(); -const websocket = createCaptunBunWebSocketHandler({ - onTunnel(tunnel) { - app.replaceEgressTunnel(tunnel); - }, - onDisconnect(tunnel) { - app.clearEgressTunnel(tunnel); - }, +let egressTunnel: CaptunServerTunnel | undefined; +const app = new WeatherReporter(async (input, init) => { + if (egressTunnel) return egressTunnel.fetch(new Request(input, init)); + return fetch(input, init); }); +const captun = createCaptunBunTunnelHandler(); const server = Bun.serve({ hostname: "127.0.0.1", @@ -38,13 +37,21 @@ const server = Bun.serve({ if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") { return new Response("Expected WebSocket upgrade\n", { status: 400 }); } - if (server.upgrade(request)) return; - return new Response("WebSocket upgrade failed\n", { status: 500 }); + + const tunnel = captun.accept(request, server, { + onDisconnect: () => { + if (egressTunnel === tunnel) egressTunnel = undefined; + }, + }); + if (!tunnel) return new Response("WebSocket upgrade failed\n", { status: 500 }); + egressTunnel?.[Symbol.dispose](); + egressTunnel = tunnel; + return; } return app.fetch(request); }, - websocket, + websocket: captun.websocket, }); process.on("SIGINT", () => { diff --git a/examples/weather-reporter/deno.ts b/examples/weather-reporter/deno.ts index 3de0a2f..4b4f0de 100644 --- a/examples/weather-reporter/deno.ts +++ b/examples/weather-reporter/deno.ts @@ -12,7 +12,11 @@ declare const Deno: { exit(code?: number): never; }; -const app = new WeatherReporter(); +let egressTunnel: CaptunServerTunnel | undefined; +const app = new WeatherReporter(async (input, init) => { + if (egressTunnel) return egressTunnel.fetch(new Request(input, init)); + return fetch(input, init); +}); const server = Deno.serve( { hostname: "127.0.0.1", port: Number(Deno.env.get("PORT")) }, @@ -27,15 +31,14 @@ const server = Deno.serve( } const { socket, response } = Deno.upgradeWebSocket(request); - let acceptedTunnel: CaptunServerTunnel | undefined; socket.addEventListener("open", () => { const tunnel = acceptCaptunDenoTunnel(socket, { onDisconnect: () => { - if (acceptedTunnel) app.clearEgressTunnel(acceptedTunnel); + if (egressTunnel === tunnel) egressTunnel = undefined; }, }); - acceptedTunnel = tunnel; - app.replaceEgressTunnel(tunnel); + egressTunnel?.[Symbol.dispose](); + egressTunnel = tunnel; }); return response; } diff --git a/examples/weather-reporter/node.ts b/examples/weather-reporter/node.ts index accd6b9..1adda81 100644 --- a/examples/weather-reporter/node.ts +++ b/examples/weather-reporter/node.ts @@ -9,7 +9,11 @@ import { import { WebSocketServer } from "ws"; import { WeatherReporter } from "./app.js"; -const app = new WeatherReporter(); +let egressTunnel: CaptunServerTunnel | undefined; +const app = new WeatherReporter(async (input, init) => { + if (egressTunnel) return egressTunnel.fetch(new Request(input, init)); + return fetch(input, init); +}); const port = Number(process.env.PORT); const webSockets = new WebSocketServer({ noServer: true }); @@ -36,14 +40,13 @@ server.on("upgrade", (request, socket, head) => { } webSockets.handleUpgrade(request, socket, head, (webSocket) => { - let acceptedTunnel: CaptunServerTunnel | undefined; const tunnel = acceptCaptunNodeTunnel(webSocket as unknown as CaptunNodeWebSocket, { onDisconnect: () => { - if (acceptedTunnel) app.clearEgressTunnel(acceptedTunnel); + if (egressTunnel === tunnel) egressTunnel = undefined; }, }); - acceptedTunnel = tunnel; - app.replaceEgressTunnel(tunnel); + egressTunnel?.[Symbol.dispose](); + egressTunnel = tunnel; }); }); diff --git a/examples/weather-reporter/worker.ts b/examples/weather-reporter/worker.ts index 93a9bfe..ae06a9c 100644 --- a/examples/weather-reporter/worker.ts +++ b/examples/weather-reporter/worker.ts @@ -7,21 +7,24 @@ type WeatherReporterEnv = Env & { }; export class WeatherReporterEgressTunnel extends DurableObject { - private readonly app = new WeatherReporter(); + private egressTunnel: CaptunServerTunnel | undefined; + private readonly app = new WeatherReporter(async (input, init) => { + if (this.egressTunnel) return this.egressTunnel.fetch(new Request(input, init)); + return fetch(input, init); + }); fetch(request: Request) { const url = new URL(request.url); if (url.pathname === "/__intercept-egress-traffic") { // Here we set up our worker to allow clients/tests to intercept egress traffic - let acceptedTunnel: CaptunServerTunnel | undefined; const { response, tunnel } = acceptCaptunTunnel({ onDisconnect: () => { - if (acceptedTunnel) this.app.clearEgressTunnel(acceptedTunnel); + if (this.egressTunnel === tunnel) this.egressTunnel = undefined; }, }); - acceptedTunnel = tunnel; - this.app.replaceEgressTunnel(tunnel); + this.egressTunnel?.[Symbol.dispose](); + this.egressTunnel = tunnel; return response; } diff --git a/src/bun.ts b/src/bun.ts index bb0751f..e3cce60 100644 --- a/src/bun.ts +++ b/src/bun.ts @@ -1,11 +1,15 @@ import { captunTunnelFromRemoteClient, type CaptunRemoteClient } from "./server-core.js"; -import type { CaptunServerTunnel } from "./types.js"; +import type { CaptunServerAcceptTunnelOptions, CaptunServerTunnel } from "./types.js"; -export type { CaptunServerTunnel } from "./types.js"; +export type { CaptunServerAcceptTunnelOptions, CaptunServerTunnel } from "./types.js"; -export interface CaptunBunWebSocketHandlerOptions { - onTunnel(tunnel: CaptunServerTunnel): void; - onDisconnect?: (tunnel: CaptunServerTunnel) => void; +export interface CaptunBunTunnelHandler { + accept( + request: Request, + server: CaptunBunServer, + options?: CaptunServerAcceptTunnelOptions, + ): CaptunServerTunnel | undefined; + websocket: CaptunBunWebSocketHandler; } export interface CaptunBunWebSocketHandler { @@ -16,10 +20,15 @@ export interface CaptunBunWebSocketHandler { } export interface CaptunBunServerWebSocket { + data: unknown; send(message: string): unknown; close(code?: number, reason?: string): void; } +export interface CaptunBunServer { + upgrade(request: Request, options: { data: unknown }): boolean; +} + export type CaptunBunWebSocketMessage = string | Uint8Array | ArrayBuffer; interface CaptunBunWebSocketTransport { @@ -32,6 +41,15 @@ interface CaptunBunWebSocketSession { transport: CaptunBunWebSocketTransport; } +interface CaptunBunAcceptedTunnel { + tunnel: CaptunServerTunnel; + connect(remoteClient: CaptunRemoteClient): void; +} + +interface CaptunBunServerWebSocketData { + captunTunnel?: CaptunBunAcceptedTunnel; +} + const { newBunWebSocketRpcSession } = (await import("capnweb")) as unknown as { newBunWebSocketRpcSession( socket: CaptunBunServerWebSocket, @@ -39,39 +57,86 @@ const { newBunWebSocketRpcSession } = (await import("capnweb")) as unknown as { ): { stub: T; transport: CaptunBunWebSocketTransport }; }; -export function createCaptunBunWebSocketHandler( - options: CaptunBunWebSocketHandlerOptions, -): CaptunBunWebSocketHandler { +export function createCaptunBunTunnelHandler(): CaptunBunTunnelHandler { const sessions = new WeakMap(); return { - open(socket) { - let acceptedTunnel: CaptunServerTunnel | undefined; - const session = newBunWebSocketRpcSession(socket); - const tunnel = captunTunnelFromRemoteClient(session.stub, { - onDisconnect: () => { - if (acceptedTunnel) options.onDisconnect?.(acceptedTunnel); - }, - }); - - acceptedTunnel = tunnel; - sessions.set(socket, session); - options.onTunnel(tunnel); + accept(request, server, options = {}) { + const acceptedTunnel = createCaptunBunAcceptedTunnel(options); + if (!server.upgrade(request, { data: { captunTunnel: acceptedTunnel } })) { + acceptedTunnel.tunnel[Symbol.dispose](); + return undefined; + } + return acceptedTunnel.tunnel; }, - message(socket, message) { - sessions.get(socket)?.transport.dispatchMessage(message); + websocket: { + open(socket) { + const acceptedTunnel = (socket.data as CaptunBunServerWebSocketData).captunTunnel; + if (!acceptedTunnel) { + socket.close(1008, "Missing Captun tunnel data"); + return; + } + + const session = newBunWebSocketRpcSession(socket); + acceptedTunnel.connect(session.stub); + sessions.set(socket, session); + }, + message(socket, message) { + sessions.get(socket)?.transport.dispatchMessage(message); + }, + close(socket, code, reason) { + const session = sessions.get(socket); + if (!session) return; + session.transport.dispatchClose(code, reason); + sessions.delete(socket); + }, + error(socket, error) { + const session = sessions.get(socket); + if (!session) return; + session.transport.dispatchError(error); + sessions.delete(socket); + }, }, - close(socket, code, reason) { - const session = sessions.get(socket); - if (!session) return; - session.transport.dispatchClose(code, reason); - sessions.delete(socket); + }; +} + +function createCaptunBunAcceptedTunnel( + options: CaptunServerAcceptTunnelOptions, +): CaptunBunAcceptedTunnel { + let connectedTunnel: CaptunServerTunnel | undefined; + let connectRemoteClient: (remoteClient: CaptunRemoteClient) => void = () => {}; + let rejectRemoteClient: (error: Error) => void = () => {}; + let closed = false; + const remoteClient = new Promise((resolve, reject) => { + connectRemoteClient = resolve; + rejectRemoteClient = reject; + }); + remoteClient.catch(() => undefined); + + const tunnel: CaptunServerTunnel = { + async fetch(request) { + if (closed) throw new Error("Captun Bun tunnel is closed"); + const remote = await remoteClient; + if (closed) throw new Error("Captun Bun tunnel is closed"); + return remote.fetch(request); + }, + [Symbol.dispose]() { + if (closed) return; + closed = true; + connectedTunnel?.[Symbol.dispose](); + rejectRemoteClient(new Error("Captun Bun tunnel closed before the WebSocket opened")); }, - error(socket, error) { - const session = sessions.get(socket); - if (!session) return; - session.transport.dispatchError(error); - sessions.delete(socket); + }; + + return { + tunnel, + connect(remote) { + if (closed) { + remote[Symbol.dispose](); + return; + } + connectedTunnel = captunTunnelFromRemoteClient(remote, options); + connectRemoteClient(remote); }, }; } diff --git a/tasks/complete/2026-05-19-runtime-server-adapters.md b/tasks/complete/2026-05-19-runtime-server-adapters.md index 29f1d88..9c856e1 100644 --- a/tasks/complete/2026-05-19-runtime-server-adapters.md +++ b/tasks/complete/2026-05-19-runtime-server-adapters.md @@ -28,6 +28,7 @@ Captun should support the weather-reporter pattern outside Cloudflare Workers. T - 2026-05-19: Created this task in a dedicated worktree from `mmkal/26/05/18/tweaks` on branch `mmkal/26/05/19/runtime-adapters`. - 2026-05-19: Opened draft PR #3 after the spec commit, then implemented the runtime adapters and subprocess-backed weather tests. - 2026-05-19: CI initially failed because the GitHub Node runner did not have `bun` or `deno` installed; added explicit setup steps for both runtimes and made fixture spawn errors report cleanly. +- 2026-05-19: Review feedback called out that the PR body example referenced non-existent `app.*` methods and made Bun look unlike the Cloudflare accept flow. Reworked the Bun adapter to `createCaptunBunTunnelHandler().accept(...)` plus a `websocket` handler, and changed the runtime examples to use local `let egressTunnel` variables. - 2026-05-19: Verification passed: - `pnpm exec tsc -p tsconfig.json --noEmit` - `pnpm exec vitest run examples/weather-reporter/bun.e2e.test.ts examples/weather-reporter/deno.e2e.test.ts examples/weather-reporter/node.e2e.test.ts` From 93efb4e7763a62d5331bac51800db1d9d6a76f83 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Tue, 19 May 2026 15:22:04 +0100 Subject: [PATCH 33/43] Align weather example route order --- examples/weather-reporter/bun.ts | 11 ++++++++--- examples/weather-reporter/deno.ts | 11 ++++++++--- examples/weather-reporter/node.ts | 15 +++++++++++---- examples/weather-reporter/worker.ts | 9 +++++++-- .../2026-05-19-runtime-server-adapters.md | 1 + 5 files changed, 35 insertions(+), 12 deletions(-) diff --git a/examples/weather-reporter/bun.ts b/examples/weather-reporter/bun.ts index 148817b..cfecf4f 100644 --- a/examples/weather-reporter/bun.ts +++ b/examples/weather-reporter/bun.ts @@ -19,10 +19,11 @@ declare const Bun: { }; let egressTunnel: CaptunServerTunnel | undefined; -const app = new WeatherReporter(async (input, init) => { +const egressFetch: typeof fetch = async (input, init) => { if (egressTunnel) return egressTunnel.fetch(new Request(input, init)); return fetch(input, init); -}); +}; +const app = new WeatherReporter(egressFetch); const captun = createCaptunBunTunnelHandler(); const server = Bun.serve({ @@ -31,7 +32,9 @@ const server = Bun.serve({ async fetch(request, server) { const url = new URL(request.url); - if (url.pathname === "/__health__") return new Response("ok"); + if (url.pathname === "/weather") { + return app.fetch(request); + } if (url.pathname === "/__intercept-egress-traffic") { if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") { @@ -49,6 +52,8 @@ const server = Bun.serve({ return; } + if (url.pathname === "/__health__") return new Response("ok"); + return app.fetch(request); }, websocket: captun.websocket, diff --git a/examples/weather-reporter/deno.ts b/examples/weather-reporter/deno.ts index 4b4f0de..6504af0 100644 --- a/examples/weather-reporter/deno.ts +++ b/examples/weather-reporter/deno.ts @@ -13,17 +13,20 @@ declare const Deno: { }; let egressTunnel: CaptunServerTunnel | undefined; -const app = new WeatherReporter(async (input, init) => { +const egressFetch: typeof fetch = async (input, init) => { if (egressTunnel) return egressTunnel.fetch(new Request(input, init)); return fetch(input, init); -}); +}; +const app = new WeatherReporter(egressFetch); const server = Deno.serve( { hostname: "127.0.0.1", port: Number(Deno.env.get("PORT")) }, (request) => { const url = new URL(request.url); - if (url.pathname === "/__health__") return new Response("ok"); + if (url.pathname === "/weather") { + return app.fetch(request); + } if (url.pathname === "/__intercept-egress-traffic") { if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") { @@ -43,6 +46,8 @@ const server = Deno.serve( return response; } + if (url.pathname === "/__health__") return new Response("ok"); + return app.fetch(request); }, ); diff --git a/examples/weather-reporter/node.ts b/examples/weather-reporter/node.ts index 1adda81..e64ad90 100644 --- a/examples/weather-reporter/node.ts +++ b/examples/weather-reporter/node.ts @@ -10,10 +10,11 @@ import { WebSocketServer } from "ws"; import { WeatherReporter } from "./app.js"; let egressTunnel: CaptunServerTunnel | undefined; -const app = new WeatherReporter(async (input, init) => { +const egressFetch: typeof fetch = async (input, init) => { if (egressTunnel) return egressTunnel.fetch(new Request(input, init)); return fetch(input, init); -}); +}; +const app = new WeatherReporter(egressFetch); const port = Number(process.env.PORT); const webSockets = new WebSocketServer({ noServer: true }); @@ -23,8 +24,14 @@ const server = http.createServer(async (request, response) => { try { const fetchRequest = nodeRequestToFetchRequest(request); const url = new URL(fetchRequest.url); - const fetchResponse = - url.pathname === "/__health__" ? new Response("ok") : await app.fetch(fetchRequest); + let fetchResponse: Response; + if (url.pathname === "/weather") { + fetchResponse = await app.fetch(fetchRequest); + } else if (url.pathname === "/__health__") { + fetchResponse = new Response("ok"); + } else { + fetchResponse = await app.fetch(fetchRequest); + } await writeFetchResponse(response, fetchResponse); } catch (error) { response.writeHead(500, { "content-type": "text/plain" }); diff --git a/examples/weather-reporter/worker.ts b/examples/weather-reporter/worker.ts index ae06a9c..4d9684d 100644 --- a/examples/weather-reporter/worker.ts +++ b/examples/weather-reporter/worker.ts @@ -8,14 +8,19 @@ type WeatherReporterEnv = Env & { export class WeatherReporterEgressTunnel extends DurableObject { private egressTunnel: CaptunServerTunnel | undefined; - private readonly app = new WeatherReporter(async (input, init) => { + private readonly egressFetch: typeof fetch = async (input, init) => { if (this.egressTunnel) return this.egressTunnel.fetch(new Request(input, init)); return fetch(input, init); - }); + }; + private readonly app = new WeatherReporter(this.egressFetch); fetch(request: Request) { const url = new URL(request.url); + if (url.pathname === "/weather") { + return this.app.fetch(request); + } + if (url.pathname === "/__intercept-egress-traffic") { // Here we set up our worker to allow clients/tests to intercept egress traffic const { response, tunnel } = acceptCaptunTunnel({ diff --git a/tasks/complete/2026-05-19-runtime-server-adapters.md b/tasks/complete/2026-05-19-runtime-server-adapters.md index 9c856e1..d0abf6b 100644 --- a/tasks/complete/2026-05-19-runtime-server-adapters.md +++ b/tasks/complete/2026-05-19-runtime-server-adapters.md @@ -29,6 +29,7 @@ Captun should support the weather-reporter pattern outside Cloudflare Workers. T - 2026-05-19: Opened draft PR #3 after the spec commit, then implemented the runtime adapters and subprocess-backed weather tests. - 2026-05-19: CI initially failed because the GitHub Node runner did not have `bun` or `deno` installed; added explicit setup steps for both runtimes and made fixture spawn errors report cleanly. - 2026-05-19: Review feedback called out that the PR body example referenced non-existent `app.*` methods and made Bun look unlike the Cloudflare accept flow. Reworked the Bun adapter to `createCaptunBunTunnelHandler().accept(...)` plus a `websocket` handler, and changed the runtime examples to use local `let egressTunnel` variables. +- 2026-05-19: Follow-up review asked for the examples to keep the original weather reporter ordering. Updated Cloudflare/Bun/Deno/Node examples so `/weather` appears before `/__intercept-egress-traffic`, switched the runtime egress helpers to `const egressFetch: typeof fetch = ...`, and expanded the PR body with Cloudflare, Bun, Deno, and Node snippets that match the example files. - 2026-05-19: Verification passed: - `pnpm exec tsc -p tsconfig.json --noEmit` - `pnpm exec vitest run examples/weather-reporter/bun.e2e.test.ts examples/weather-reporter/deno.e2e.test.ts examples/weather-reporter/node.e2e.test.ts` From 52b3fdff70be46eb0756ee113f4a4dc47bf35566 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Tue, 19 May 2026 15:37:51 +0100 Subject: [PATCH 34/43] Make runtime examples self-contained --- README.md | 21 ++- .../runtime-fixtures.ts => bun/bun.test.ts} | 97 +++++------ .../bun.ts => bun/server.ts} | 13 +- examples/cloudflare/README.md | 44 +++++ .../cloudflare.test.ts} | 2 +- .../package.json | 7 +- .../tsconfig.json | 0 .../worker.ts | 16 +- .../wrangler.toml | 0 examples/{weather-reporter => deno}/deno.json | 1 - examples/deno/deno.test.ts | 162 ++++++++++++++++++ .../deno.ts => deno/server.ts} | 15 +- examples/node/node.test.ts | 148 ++++++++++++++++ examples/node/package.json | 12 ++ .../node.ts => node/server.ts} | 37 ++-- examples/weather-reporter/README.md | 59 ------- examples/weather-reporter/app.ts | 26 --- examples/weather-reporter/bun.e2e.test.ts | 28 --- examples/weather-reporter/deno.e2e.test.ts | 28 --- examples/weather-reporter/node.e2e.test.ts | 28 --- pnpm-lock.yaml | 24 +-- .../2026-05-19-runtime-server-adapters.md | 23 +-- tsconfig.json | 2 +- 23 files changed, 505 insertions(+), 288 deletions(-) rename examples/{weather-reporter/runtime-fixtures.ts => bun/bun.test.ts} (58%) rename examples/{weather-reporter/bun.ts => bun/server.ts} (79%) create mode 100644 examples/cloudflare/README.md rename examples/{weather-reporter/e2e.test.ts => cloudflare/cloudflare.test.ts} (96%) rename examples/{weather-reporter => cloudflare}/package.json (62%) rename examples/{weather-reporter => cloudflare}/tsconfig.json (100%) rename examples/{weather-reporter => cloudflare}/worker.ts (70%) rename examples/{weather-reporter => cloudflare}/wrangler.toml (100%) rename examples/{weather-reporter => deno}/deno.json (69%) create mode 100644 examples/deno/deno.test.ts rename examples/{weather-reporter/deno.ts => deno/server.ts} (78%) create mode 100644 examples/node/node.test.ts create mode 100644 examples/node/package.json rename examples/{weather-reporter/node.ts => node/server.ts} (75%) delete mode 100644 examples/weather-reporter/README.md delete mode 100644 examples/weather-reporter/app.ts delete mode 100644 examples/weather-reporter/bun.e2e.test.ts delete mode 100644 examples/weather-reporter/deno.e2e.test.ts delete mode 100644 examples/weather-reporter/node.e2e.test.ts diff --git a/README.md b/README.md index ca56305..20c285a 100644 --- a/README.md +++ b/README.md @@ -78,39 +78,38 @@ type WeatherReporterEnv = Env & { export class WeatherReporterEgressTunnel extends DurableObject { private egressTunnel: CaptunServerTunnel | undefined; + private readonly egressFetch: typeof fetch = async (input, init) => { + if (this.egressTunnel) return this.egressTunnel.fetch(new Request(input, init)); + return fetch(input, init); + }; async fetch(request: Request) { const url = new URL(request.url); if (url.pathname === "/weather") { // Here's the value our app provides: fetching and gorgeously formatting weather data - const city = url.searchParams.get("city"); + const city = url.searchParams.get("city") || ""; const response = await this.egressFetch(`https://wttr.in/${city}?format=j1`); - const weather = await response.json<{ current_condition: [{ temp_C: string }] }>(); + const weather = (await response.json()) as { + current_condition: [{ temp_C: string }]; + }; return new Response(`The temperature in ${city} is ${weather.current_condition[0].temp_C} celsius`); } if (url.pathname === "/__intercept-egress-traffic") { // Here we set up our worker to allow clients/tests to intercept egress traffic - this.egressTunnel?.[Symbol.dispose](); const { response, tunnel } = acceptCaptunTunnel({ onDisconnect: () => { if (this.egressTunnel === tunnel) this.egressTunnel = undefined; }, }); + this.egressTunnel?.[Symbol.dispose](); this.egressTunnel = tunnel; return response; } return new Response("Not found\n", { status: 404 }); } - - get egressFetch(): typeof fetch { - if (this.egressTunnel) { - return async (input, init) => this.egressTunnel!.fetch(new Request(input, init)); - } - return fetch; - } } export default { @@ -209,7 +208,7 @@ sequenceDiagram Server-->>HTTP: Response ``` -See [examples/weather-reporter](./examples/weather-reporter) for a small workspace package that runs the same egress-intercepting weather app on Cloudflare Workers, Bun, Deno, and Node from Vitest. +See [examples/bun](./examples/bun), [examples/node](./examples/node), [examples/deno](./examples/deno), and [examples/cloudflare](./examples/cloudflare) for small self-contained weather apps that run the same egress-intercepting flow from Vitest. ## Development diff --git a/examples/weather-reporter/runtime-fixtures.ts b/examples/bun/bun.test.ts similarity index 58% rename from examples/weather-reporter/runtime-fixtures.ts rename to examples/bun/bun.test.ts index c4d790a..bc35aa5 100644 --- a/examples/weather-reporter/runtime-fixtures.ts +++ b/examples/bun/bun.test.ts @@ -4,20 +4,46 @@ import { dirname, resolve } from "node:path"; import type { Readable } from "node:stream"; import { fileURLToPath } from "node:url"; -export type WeatherReporterRuntime = "bun" | "deno" | "node"; +import { expect, test, vi } from "vitest"; -const exampleRoot = dirname(fileURLToPath(import.meta.url)); -const repoRoot = resolve(exampleRoot, "../.."); -type WeatherReporterProcess = ChildProcessByStdio; +import { createCaptunTunnel } from "../../src/client.js"; -export async function createRuntimeWeatherReporterFixture(runtime: WeatherReporterRuntime) { +vi.setConfig({ testTimeout: 20_000 }); + +test("returns nicely formatted weather report from a Bun server", async () => { + await using app = await createBunWeatherReporterFixture(); + using _tunnel = await createCaptunTunnel({ + url: `${app.url}/__intercept-egress-traffic`, + fetch(request) { + if (request.url === "https://wttr.in/london?format=j1") { + return Response.json({ current_condition: [{ temp_C: "18" }] }); + } + if (request.url === "https://wttr.in/paris?format=j1") { + return Response.json({ current_condition: [{ temp_C: "22" }] }); + } + return new Response("Unexpected egress", { status: 500 }); + }, + }); + + const london = await fetch(`${app.url}/weather?city=london`); + expect(await london.text()).toBe("The temperature in london is 18 celsius"); + + const paris = await fetch(`${app.url}/weather?city=paris`); + expect(await paris.text()).toBe("The temperature in paris is 22 celsius"); +}); + +async function createBunWeatherReporterFixture() { const port = await getAvailablePort(); const url = `http://127.0.0.1:${port}`; - const server = startWeatherReporterProcess(runtime, port); + const server = spawn("bun", ["run", "examples/bun/server.ts"], { + cwd: resolve(dirname(fileURLToPath(import.meta.url)), "../.."), + env: { ...process.env, PORT: String(port) }, + stdio: ["ignore", "pipe", "pipe"], + }); const output = captureOutput(server); try { - await waitForHttp(`${url}/__health__`, 15_000, server, output); + await waitForHttp(`${url}/__health__`, server, output); return { url, async [Symbol.asyncDispose]() { @@ -30,32 +56,7 @@ export async function createRuntimeWeatherReporterFixture(runtime: WeatherReport } } -function startWeatherReporterProcess(runtime: WeatherReporterRuntime, port: number) { - const commands: Record = { - bun: { command: "bun", args: ["run", "examples/weather-reporter/bun.ts"] }, - deno: { - command: "deno", - args: [ - "run", - "--config", - "examples/weather-reporter/deno.json", - "--node-modules-dir=auto", - "--no-lock", - "--sloppy-imports", - "--allow-env=PORT", - "--allow-net=127.0.0.1", - "examples/weather-reporter/deno.ts", - ], - }, - node: { command: "pnpm", args: ["exec", "tsx", "examples/weather-reporter/node.ts"] }, - }; - const command = commands[runtime]; - return spawn(command.command, command.args, { - cwd: repoRoot, - env: { ...process.env, PORT: String(port) }, - stdio: ["ignore", "pipe", "pipe"], - }); -} +type ServerProcess = ChildProcessByStdio; async function getAvailablePort(): Promise { return new Promise((resolve, reject) => { @@ -68,33 +69,21 @@ async function getAvailablePort(): Promise { } server.close((error) => { - if (error) { - reject(error); - return; - } - resolve(address.port); + if (error) reject(error); + else resolve(address.port); }); }); server.on("error", reject); }); } -async function waitForHttp( - url: string, - timeoutMs: number, - server: WeatherReporterProcess, - output: CapturedProcessOutput, -): Promise { +async function waitForHttp(url: string, server: ServerProcess, output: CapturedProcessOutput) { const startedAt = Date.now(); - - while (Date.now() - startedAt < timeoutMs) { + while (Date.now() - startedAt < 15_000) { const error = output.error(); if (error) throw error; - if (server.exitCode !== null || server.signalCode) { - throw new Error( - `Weather reporter process exited before ${url} responded\n\n${output.logs().trim() || "(none)"}`, - ); + throw new Error(`Bun server exited before ${url} responded\n\n${output.logs().trim() || "(none)"}`); } try { @@ -105,10 +94,10 @@ async function waitForHttp( await delay(100); } - throw new Error(`Timed out waiting for weather reporter to respond at ${url}`); + throw new Error(`Timed out waiting for Bun server to respond at ${url}`); } -function captureOutput(child: WeatherReporterProcess) { +function captureOutput(child: ServerProcess) { const chunks: string[] = []; let processError: Error | undefined; const capture = (chunk: string | Buffer) => { @@ -133,11 +122,11 @@ interface CapturedProcessOutput { error(): Error | undefined; } -function formatFixtureFailure(message: string, serverLogs: string): string { +function formatFixtureFailure(message: string, serverLogs: string) { return [message, "", "Server logs:", serverLogs.trim() || "(none)"].join("\n"); } -async function stopProcess(child: WeatherReporterProcess): Promise { +async function stopProcess(child: ServerProcess): Promise { if (child.exitCode !== null || child.killed) return; child.kill("SIGINT"); diff --git a/examples/weather-reporter/bun.ts b/examples/bun/server.ts similarity index 79% rename from examples/weather-reporter/bun.ts rename to examples/bun/server.ts index cfecf4f..d54862d 100644 --- a/examples/weather-reporter/bun.ts +++ b/examples/bun/server.ts @@ -4,7 +4,6 @@ import { type CaptunBunWebSocketHandler, type CaptunServerTunnel, } from "captun/bun"; -import { WeatherReporter } from "./app.js"; declare const Bun: { serve(options: { @@ -23,7 +22,6 @@ const egressFetch: typeof fetch = async (input, init) => { if (egressTunnel) return egressTunnel.fetch(new Request(input, init)); return fetch(input, init); }; -const app = new WeatherReporter(egressFetch); const captun = createCaptunBunTunnelHandler(); const server = Bun.serve({ @@ -33,7 +31,14 @@ const server = Bun.serve({ const url = new URL(request.url); if (url.pathname === "/weather") { - return app.fetch(request); + const city = url.searchParams.get("city") || ""; + const response = await egressFetch(`https://wttr.in/${city}?format=j1`); + const weather = (await response.json()) as { + current_condition: [{ temp_C: string }]; + }; + return new Response( + `The temperature in ${city} is ${weather.current_condition[0].temp_C} celsius`, + ); } if (url.pathname === "/__intercept-egress-traffic") { @@ -54,7 +59,7 @@ const server = Bun.serve({ if (url.pathname === "/__health__") return new Response("ok"); - return app.fetch(request); + return new Response("Not found\n", { status: 404 }); }, websocket: captun.websocket, }); diff --git a/examples/cloudflare/README.md b/examples/cloudflare/README.md new file mode 100644 index 0000000..c786322 --- /dev/null +++ b/examples/cloudflare/README.md @@ -0,0 +1,44 @@ +# Cloudflare Weather Reporter + +Tiny example app that uses Captun to mock outbound network egress in an e2e test. +The app fetches live weather from the free, no-key `wttr.in` API. + +This variant runs on a Cloudflare Worker plus a Durable Object, using +`captun/server`. + +## Run Locally + +From the repository root, install once: + +```sh +pnpm install +``` + +Then run the example test from this directory: + +```sh +pnpm test +``` + +The test starts a Miniflare Worker automatically when `WEATHER_REPORTER_URL` is not set. +To point the same test at an already-running local Worker: + +```sh +WEATHER_REPORTER_URL=http://127.0.0.1:8787 pnpm test +``` + +## Deploy And Test + +```sh +pnpm exec wrangler deploy +WEATHER_REPORTER_URL=https://weather-reporter..workers.dev pnpm test +``` + +For this workspace, deploy with Doppler-provided Cloudflare credentials: + +```sh +doppler run -- pnpm exec wrangler deploy +WEATHER_REPORTER_URL=https://weather-reporter.garple-pretend-customer-should-be-iterate-dev-stg-will-chan.workers.dev pnpm test +``` + +The test awaits `createCaptunTunnel()` at the Worker's `/__intercept-egress-traffic` route, mocks the `wttr.in` response, then calls `/weather?city=london` and `/weather?city=paris`. diff --git a/examples/weather-reporter/e2e.test.ts b/examples/cloudflare/cloudflare.test.ts similarity index 96% rename from examples/weather-reporter/e2e.test.ts rename to examples/cloudflare/cloudflare.test.ts index 82a6fda..96b6f05 100644 --- a/examples/weather-reporter/e2e.test.ts +++ b/examples/cloudflare/cloudflare.test.ts @@ -36,7 +36,7 @@ async function createWeatherReporterFixture() { } const worker = await createMiniflareWorkerFixture({ - entryPoint: "examples/weather-reporter/worker.ts", + entryPoint: "examples/cloudflare/worker.ts", durableObjects: { WEATHER_REPORTER_EGRESS: { className: "WeatherReporterEgressTunnel" }, }, diff --git a/examples/weather-reporter/package.json b/examples/cloudflare/package.json similarity index 62% rename from examples/weather-reporter/package.json rename to examples/cloudflare/package.json index 3147462..fe2d015 100644 --- a/examples/weather-reporter/package.json +++ b/examples/cloudflare/package.json @@ -1,5 +1,5 @@ { - "name": "@captun/weather-reporter", + "name": "@captun/cloudflare-example", "private": true, "type": "module", "scripts": { @@ -7,12 +7,9 @@ "test": "vitest run" }, "dependencies": { - "captun": "workspace:*", - "ws": "^8.19.0" + "captun": "workspace:*" }, "devDependencies": { - "@types/ws": "^8.18.1", - "tsx": "^4.22.2", "typescript": "^6.0.3", "vitest": "^4.1.6" } diff --git a/examples/weather-reporter/tsconfig.json b/examples/cloudflare/tsconfig.json similarity index 100% rename from examples/weather-reporter/tsconfig.json rename to examples/cloudflare/tsconfig.json diff --git a/examples/weather-reporter/worker.ts b/examples/cloudflare/worker.ts similarity index 70% rename from examples/weather-reporter/worker.ts rename to examples/cloudflare/worker.ts index 4d9684d..c74809d 100644 --- a/examples/weather-reporter/worker.ts +++ b/examples/cloudflare/worker.ts @@ -1,6 +1,5 @@ import { DurableObject } from "cloudflare:workers"; import { acceptCaptunTunnel, type CaptunServerTunnel } from "captun/server"; -import { WeatherReporter } from "./app.js"; type WeatherReporterEnv = Env & { WEATHER_REPORTER_EGRESS: DurableObjectNamespace; @@ -12,13 +11,20 @@ export class WeatherReporterEgressTunnel extends DurableObject { + await using app = await createDenoWeatherReporterFixture(); + using _tunnel = await createCaptunTunnel({ + url: `${app.url}/__intercept-egress-traffic`, + fetch(request) { + if (request.url === "https://wttr.in/london?format=j1") { + return Response.json({ current_condition: [{ temp_C: "18" }] }); + } + if (request.url === "https://wttr.in/paris?format=j1") { + return Response.json({ current_condition: [{ temp_C: "22" }] }); + } + return new Response("Unexpected egress", { status: 500 }); + }, + }); + + const london = await fetch(`${app.url}/weather?city=london`); + expect(await london.text()).toBe("The temperature in london is 18 celsius"); + + const paris = await fetch(`${app.url}/weather?city=paris`); + expect(await paris.text()).toBe("The temperature in paris is 22 celsius"); +}); + +async function createDenoWeatherReporterFixture() { + const port = await getAvailablePort(); + const url = `http://127.0.0.1:${port}`; + const server = spawn( + "deno", + [ + "run", + "--config", + "examples/deno/deno.json", + "--node-modules-dir=auto", + "--no-lock", + "--sloppy-imports", + "--allow-env=PORT", + "--allow-net=127.0.0.1", + "examples/deno/server.ts", + ], + { + cwd: resolve(dirname(fileURLToPath(import.meta.url)), "../.."), + env: { ...process.env, PORT: String(port) }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + const output = captureOutput(server); + + try { + await waitForHttp(`${url}/__health__`, server, output); + return { + url, + async [Symbol.asyncDispose]() { + await stopProcess(server); + }, + }; + } catch (error) { + await stopProcess(server); + throw new Error(formatFixtureFailure(error instanceof Error ? error.message : String(error), output.logs())); + } +} + +type ServerProcess = ChildProcessByStdio; + +async function getAvailablePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + reject(new Error(`Failed to allocate a local port: ${String(address)}`)); + return; + } + + server.close((error) => { + if (error) reject(error); + else resolve(address.port); + }); + }); + server.on("error", reject); + }); +} + +async function waitForHttp(url: string, server: ServerProcess, output: CapturedProcessOutput) { + const startedAt = Date.now(); + while (Date.now() - startedAt < 15_000) { + const error = output.error(); + if (error) throw error; + if (server.exitCode !== null || server.signalCode) { + throw new Error(`Deno server exited before ${url} responded\n\n${output.logs().trim() || "(none)"}`); + } + + try { + const response = await fetch(url); + if (response.ok) return; + } catch {} + + await delay(100); + } + + throw new Error(`Timed out waiting for Deno server to respond at ${url}`); +} + +function captureOutput(child: ServerProcess) { + const chunks: string[] = []; + let processError: Error | undefined; + const capture = (chunk: string | Buffer) => { + chunks.push(String(chunk)); + if (chunks.length > 200) chunks.shift(); + }; + child.stdout.on("data", capture); + child.stderr.on("data", capture); + child.on("error", (error) => { + processError = error; + chunks.push(error.stack || error.message); + }); + + return { + logs: () => chunks.join(""), + error: () => processError, + }; +} + +interface CapturedProcessOutput { + logs(): string; + error(): Error | undefined; +} + +function formatFixtureFailure(message: string, serverLogs: string) { + return [message, "", "Server logs:", serverLogs.trim() || "(none)"].join("\n"); +} + +async function stopProcess(child: ServerProcess): Promise { + if (child.exitCode !== null || child.killed) return; + + child.kill("SIGINT"); + const exited = await Promise.race([ + new Promise((resolve) => child.once("exit", () => resolve(true))), + delay(5_000).then(() => false), + ]); + + if (!exited && child.exitCode === null && !child.killed) { + child.kill("SIGKILL"); + await new Promise((resolve) => child.once("exit", () => resolve())); + } +} + +function delay(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} diff --git a/examples/weather-reporter/deno.ts b/examples/deno/server.ts similarity index 78% rename from examples/weather-reporter/deno.ts rename to examples/deno/server.ts index 6504af0..be25e15 100644 --- a/examples/weather-reporter/deno.ts +++ b/examples/deno/server.ts @@ -1,5 +1,4 @@ import { acceptCaptunDenoTunnel, type CaptunServerTunnel } from "captun/deno"; -import { WeatherReporter } from "./app.js"; declare const Deno: { env: { get(name: string): string | undefined }; @@ -17,15 +16,21 @@ const egressFetch: typeof fetch = async (input, init) => { if (egressTunnel) return egressTunnel.fetch(new Request(input, init)); return fetch(input, init); }; -const app = new WeatherReporter(egressFetch); const server = Deno.serve( { hostname: "127.0.0.1", port: Number(Deno.env.get("PORT")) }, - (request) => { + async (request) => { const url = new URL(request.url); if (url.pathname === "/weather") { - return app.fetch(request); + const city = url.searchParams.get("city") || ""; + const response = await egressFetch(`https://wttr.in/${city}?format=j1`); + const weather = (await response.json()) as { + current_condition: [{ temp_C: string }]; + }; + return new Response( + `The temperature in ${city} is ${weather.current_condition[0].temp_C} celsius`, + ); } if (url.pathname === "/__intercept-egress-traffic") { @@ -48,7 +53,7 @@ const server = Deno.serve( if (url.pathname === "/__health__") return new Response("ok"); - return app.fetch(request); + return new Response("Not found\n", { status: 404 }); }, ); diff --git a/examples/node/node.test.ts b/examples/node/node.test.ts new file mode 100644 index 0000000..bff4540 --- /dev/null +++ b/examples/node/node.test.ts @@ -0,0 +1,148 @@ +import { spawn, type ChildProcessByStdio } from "node:child_process"; +import net from "node:net"; +import { dirname, resolve } from "node:path"; +import type { Readable } from "node:stream"; +import { fileURLToPath } from "node:url"; + +import { expect, test, vi } from "vitest"; + +import { createCaptunTunnel } from "../../src/client.js"; + +vi.setConfig({ testTimeout: 20_000 }); + +test("returns nicely formatted weather report from a Node server", async () => { + await using app = await createNodeWeatherReporterFixture(); + using _tunnel = await createCaptunTunnel({ + url: `${app.url}/__intercept-egress-traffic`, + fetch(request) { + if (request.url === "https://wttr.in/london?format=j1") { + return Response.json({ current_condition: [{ temp_C: "18" }] }); + } + if (request.url === "https://wttr.in/paris?format=j1") { + return Response.json({ current_condition: [{ temp_C: "22" }] }); + } + return new Response("Unexpected egress", { status: 500 }); + }, + }); + + const london = await fetch(`${app.url}/weather?city=london`); + expect(await london.text()).toBe("The temperature in london is 18 celsius"); + + const paris = await fetch(`${app.url}/weather?city=paris`); + expect(await paris.text()).toBe("The temperature in paris is 22 celsius"); +}); + +async function createNodeWeatherReporterFixture() { + const port = await getAvailablePort(); + const url = `http://127.0.0.1:${port}`; + const server = spawn("pnpm", ["exec", "tsx", "examples/node/server.ts"], { + cwd: resolve(dirname(fileURLToPath(import.meta.url)), "../.."), + env: { ...process.env, PORT: String(port) }, + stdio: ["ignore", "pipe", "pipe"], + }); + const output = captureOutput(server); + + try { + await waitForHttp(`${url}/__health__`, server, output); + return { + url, + async [Symbol.asyncDispose]() { + await stopProcess(server); + }, + }; + } catch (error) { + await stopProcess(server); + throw new Error(formatFixtureFailure(error instanceof Error ? error.message : String(error), output.logs())); + } +} + +type ServerProcess = ChildProcessByStdio; + +async function getAvailablePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + reject(new Error(`Failed to allocate a local port: ${String(address)}`)); + return; + } + + server.close((error) => { + if (error) reject(error); + else resolve(address.port); + }); + }); + server.on("error", reject); + }); +} + +async function waitForHttp(url: string, server: ServerProcess, output: CapturedProcessOutput) { + const startedAt = Date.now(); + while (Date.now() - startedAt < 15_000) { + const error = output.error(); + if (error) throw error; + if (server.exitCode !== null || server.signalCode) { + throw new Error(`Node server exited before ${url} responded\n\n${output.logs().trim() || "(none)"}`); + } + + try { + const response = await fetch(url); + if (response.ok) return; + } catch {} + + await delay(100); + } + + throw new Error(`Timed out waiting for Node server to respond at ${url}`); +} + +function captureOutput(child: ServerProcess) { + const chunks: string[] = []; + let processError: Error | undefined; + const capture = (chunk: string | Buffer) => { + chunks.push(String(chunk)); + if (chunks.length > 200) chunks.shift(); + }; + child.stdout.on("data", capture); + child.stderr.on("data", capture); + child.on("error", (error) => { + processError = error; + chunks.push(error.stack || error.message); + }); + + return { + logs: () => chunks.join(""), + error: () => processError, + }; +} + +interface CapturedProcessOutput { + logs(): string; + error(): Error | undefined; +} + +function formatFixtureFailure(message: string, serverLogs: string) { + return [message, "", "Server logs:", serverLogs.trim() || "(none)"].join("\n"); +} + +async function stopProcess(child: ServerProcess): Promise { + if (child.exitCode !== null || child.killed) return; + + child.kill("SIGINT"); + const exited = await Promise.race([ + new Promise((resolve) => child.once("exit", () => resolve(true))), + delay(5_000).then(() => false), + ]); + + if (!exited && child.exitCode === null && !child.killed) { + child.kill("SIGKILL"); + await new Promise((resolve) => child.once("exit", () => resolve())); + } +} + +function delay(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} diff --git a/examples/node/package.json b/examples/node/package.json new file mode 100644 index 0000000..af155e9 --- /dev/null +++ b/examples/node/package.json @@ -0,0 +1,12 @@ +{ + "name": "@captun/node-example", + "private": true, + "type": "module", + "dependencies": { + "captun": "workspace:*", + "ws": "^8.19.0" + }, + "devDependencies": { + "@types/ws": "^8.18.1" + } +} diff --git a/examples/weather-reporter/node.ts b/examples/node/server.ts similarity index 75% rename from examples/weather-reporter/node.ts rename to examples/node/server.ts index e64ad90..5d7a498 100644 --- a/examples/weather-reporter/node.ts +++ b/examples/node/server.ts @@ -7,15 +7,12 @@ import { type CaptunServerTunnel, } from "captun/node"; import { WebSocketServer } from "ws"; -import { WeatherReporter } from "./app.js"; let egressTunnel: CaptunServerTunnel | undefined; const egressFetch: typeof fetch = async (input, init) => { if (egressTunnel) return egressTunnel.fetch(new Request(input, init)); return fetch(input, init); }; -const app = new WeatherReporter(egressFetch); -const port = Number(process.env.PORT); const webSockets = new WebSocketServer({ noServer: true }); const server = http.createServer(async (request, response) => { @@ -24,15 +21,33 @@ const server = http.createServer(async (request, response) => { try { const fetchRequest = nodeRequestToFetchRequest(request); const url = new URL(fetchRequest.url); - let fetchResponse: Response; + if (url.pathname === "/weather") { - fetchResponse = await app.fetch(fetchRequest); - } else if (url.pathname === "/__health__") { - fetchResponse = new Response("ok"); - } else { - fetchResponse = await app.fetch(fetchRequest); + const city = url.searchParams.get("city") || ""; + const weatherResponse = await egressFetch(`https://wttr.in/${city}?format=j1`); + const weather = (await weatherResponse.json()) as { + current_condition: [{ temp_C: string }]; + }; + await writeFetchResponse( + response, + new Response( + `The temperature in ${city} is ${weather.current_condition[0].temp_C} celsius`, + ), + ); + return; + } + + if (url.pathname === "/__intercept-egress-traffic") { + await writeFetchResponse(response, new Response("Expected WebSocket upgrade\n", { status: 400 })); + return; } - await writeFetchResponse(response, fetchResponse); + + if (url.pathname === "/__health__") { + await writeFetchResponse(response, new Response("ok")); + return; + } + + await writeFetchResponse(response, new Response("Not found\n", { status: 404 })); } catch (error) { response.writeHead(500, { "content-type": "text/plain" }); response.end(String(error instanceof Error ? error.stack || error.message : error)); @@ -57,7 +72,7 @@ server.on("upgrade", (request, socket, head) => { }); }); -server.listen(port, "127.0.0.1"); +server.listen(Number(process.env.PORT), "127.0.0.1"); process.on("SIGINT", () => { webSockets.close(); diff --git a/examples/weather-reporter/README.md b/examples/weather-reporter/README.md deleted file mode 100644 index a7084c1..0000000 --- a/examples/weather-reporter/README.md +++ /dev/null @@ -1,59 +0,0 @@ -# Weather Reporter - -Tiny example app that uses Captun to mock outbound network egress in an e2e test. -The app fetches live weather from the free, no-key `wttr.in` API. - -The same `WeatherReporter` app runs behind four server shapes: - -- `worker.ts`: Cloudflare Worker plus a Durable Object, using `captun/server`. -- `bun.ts`: `Bun.serve`, using `captun/bun`. -- `deno.ts`: `Deno.serve`, using `captun/deno`. -- `node.ts`: Node `http` plus `ws`, using `captun/node`. - -## Run Locally - -From the repository root, install once: - -```sh -pnpm install -``` - -Then run the example test from this directory: - -```sh -pnpm test -``` - -The test starts a Miniflare Worker for the Cloudflare case, and starts Bun, -Deno, and Node servers in subprocesses for the runtime adapter cases. - -To run one runtime directly from the repository root: - -```sh -pnpm exec vitest run examples/weather-reporter/bun.e2e.test.ts -pnpm exec vitest run examples/weather-reporter/deno.e2e.test.ts -pnpm exec vitest run examples/weather-reporter/node.e2e.test.ts -``` - -The Cloudflare test starts a Miniflare Worker automatically when `WEATHER_REPORTER_URL` is not set. -To point the same test at an already-running local Worker: - -```sh -WEATHER_REPORTER_URL=http://127.0.0.1:8787 pnpm test -``` - -## Deploy And Test - -```sh -pnpm exec wrangler deploy -WEATHER_REPORTER_URL=https://weather-reporter..workers.dev pnpm test -``` - -For this workspace, deploy with Doppler-provided Cloudflare credentials: - -```sh -doppler run -- pnpm exec wrangler deploy -WEATHER_REPORTER_URL=https://weather-reporter.garple-pretend-customer-should-be-iterate-dev-stg-will-chan.workers.dev pnpm test -``` - -The tests await `createCaptunTunnel()` at each server's `/__intercept-egress-traffic` route, mock the `wttr.in` response, then call `/weather?city=london` and `/weather?city=paris`. diff --git a/examples/weather-reporter/app.ts b/examples/weather-reporter/app.ts deleted file mode 100644 index f29b0d4..0000000 --- a/examples/weather-reporter/app.ts +++ /dev/null @@ -1,26 +0,0 @@ -type EgressFetch = typeof fetch; - -export class WeatherReporter { - private readonly egressFetch: EgressFetch; - - constructor(egressFetch: EgressFetch) { - this.egressFetch = egressFetch; - } - - async fetch(request: Request) { - const url = new URL(request.url); - - if (url.pathname === "/weather") { - const city = url.searchParams.get("city") || ""; - const response = await this.egressFetch(`https://wttr.in/${city}?format=j1`); - const weather = (await response.json()) as { - current_condition: [{ temp_C: string }]; - }; - return new Response( - `The temperature in ${city} is ${weather.current_condition[0].temp_C} celsius`, - ); - } - - return new Response("Not found\n", { status: 404 }); - } -} diff --git a/examples/weather-reporter/bun.e2e.test.ts b/examples/weather-reporter/bun.e2e.test.ts deleted file mode 100644 index 99aca18..0000000 --- a/examples/weather-reporter/bun.e2e.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { expect, test, vi } from "vitest"; - -import { createCaptunTunnel } from "../../src/client.js"; -import { createRuntimeWeatherReporterFixture } from "./runtime-fixtures.js"; - -vi.setConfig({ testTimeout: 20_000 }); - -test("returns nicely formatted weather report from a Bun server", async () => { - await using app = await createRuntimeWeatherReporterFixture("bun"); - using _tunnel = await createCaptunTunnel({ - url: `${app.url}/__intercept-egress-traffic`, - fetch(request) { - if (request.url === "https://wttr.in/london?format=j1") { - return Response.json({ current_condition: [{ temp_C: "18" }] }); - } - if (request.url === "https://wttr.in/paris?format=j1") { - return Response.json({ current_condition: [{ temp_C: "22" }] }); - } - return new Response("Unexpected egress", { status: 500 }); - }, - }); - - const london = await fetch(`${app.url}/weather?city=london`); - expect(await london.text()).toBe("The temperature in london is 18 celsius"); - - const paris = await fetch(`${app.url}/weather?city=paris`); - expect(await paris.text()).toBe("The temperature in paris is 22 celsius"); -}); diff --git a/examples/weather-reporter/deno.e2e.test.ts b/examples/weather-reporter/deno.e2e.test.ts deleted file mode 100644 index ba50a6f..0000000 --- a/examples/weather-reporter/deno.e2e.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { expect, test, vi } from "vitest"; - -import { createCaptunTunnel } from "../../src/client.js"; -import { createRuntimeWeatherReporterFixture } from "./runtime-fixtures.js"; - -vi.setConfig({ testTimeout: 20_000 }); - -test("returns nicely formatted weather report from a Deno server", async () => { - await using app = await createRuntimeWeatherReporterFixture("deno"); - using _tunnel = await createCaptunTunnel({ - url: `${app.url}/__intercept-egress-traffic`, - fetch(request) { - if (request.url === "https://wttr.in/london?format=j1") { - return Response.json({ current_condition: [{ temp_C: "18" }] }); - } - if (request.url === "https://wttr.in/paris?format=j1") { - return Response.json({ current_condition: [{ temp_C: "22" }] }); - } - return new Response("Unexpected egress", { status: 500 }); - }, - }); - - const london = await fetch(`${app.url}/weather?city=london`); - expect(await london.text()).toBe("The temperature in london is 18 celsius"); - - const paris = await fetch(`${app.url}/weather?city=paris`); - expect(await paris.text()).toBe("The temperature in paris is 22 celsius"); -}); diff --git a/examples/weather-reporter/node.e2e.test.ts b/examples/weather-reporter/node.e2e.test.ts deleted file mode 100644 index 8820fb9..0000000 --- a/examples/weather-reporter/node.e2e.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { expect, test, vi } from "vitest"; - -import { createCaptunTunnel } from "../../src/client.js"; -import { createRuntimeWeatherReporterFixture } from "./runtime-fixtures.js"; - -vi.setConfig({ testTimeout: 20_000 }); - -test("returns nicely formatted weather report from a Node server", async () => { - await using app = await createRuntimeWeatherReporterFixture("node"); - using _tunnel = await createCaptunTunnel({ - url: `${app.url}/__intercept-egress-traffic`, - fetch(request) { - if (request.url === "https://wttr.in/london?format=j1") { - return Response.json({ current_condition: [{ temp_C: "18" }] }); - } - if (request.url === "https://wttr.in/paris?format=j1") { - return Response.json({ current_condition: [{ temp_C: "22" }] }); - } - return new Response("Unexpected egress", { status: 500 }); - }, - }); - - const london = await fetch(`${app.url}/weather?city=london`); - expect(await london.text()).toBe("The temperature in london is 18 celsius"); - - const paris = await fetch(`${app.url}/weather?city=paris`); - expect(await paris.text()).toBe("The temperature in paris is 22 celsius"); -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 044f44c..58a9d87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,21 +49,12 @@ importers: specifier: ^4.92.0 version: 4.92.0(@cloudflare/workers-types@4.20260518.1) - examples/weather-reporter: + examples/cloudflare: dependencies: captun: specifier: workspace:* version: link:../.. - ws: - specifier: ^8.19.0 - version: 8.20.1 devDependencies: - '@types/ws': - specifier: ^8.18.1 - version: 8.18.1 - tsx: - specifier: ^4.22.2 - version: 4.22.2 typescript: specifier: ^6.0.3 version: 6.0.3 @@ -71,6 +62,19 @@ importers: specifier: ^4.1.6 version: 4.1.6(@types/node@25.8.0)(vite@8.0.13(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.2)) + examples/node: + dependencies: + captun: + specifier: workspace:* + version: link:../.. + ws: + specifier: ^8.19.0 + version: 8.20.1 + devDependencies: + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 + packages: '@cloudflare/kv-asset-handler@0.5.0': diff --git a/tasks/complete/2026-05-19-runtime-server-adapters.md b/tasks/complete/2026-05-19-runtime-server-adapters.md index d0abf6b..1803920 100644 --- a/tasks/complete/2026-05-19-runtime-server-adapters.md +++ b/tasks/complete/2026-05-19-runtime-server-adapters.md @@ -5,7 +5,7 @@ size: medium # Runtime server adapters -**Status summary:** Complete on PR #3. Captun now has runtime-specific Bun, Deno, and Node server adapter entry points, the weather reporter app runs on all four server shapes, and Vitest covers Cloudflare/Miniflare plus Bun/Deno/Node subprocess servers. No scoped implementation pieces are missing. +**Status summary:** Complete on PR #3. Captun now has runtime-specific Bun, Deno, and Node server adapter entry points, each runtime has a self-contained weather example, and Vitest covers Cloudflare/Miniflare plus Bun/Deno/Node subprocess servers. No scoped implementation pieces are missing. ## Goal @@ -14,14 +14,14 @@ Captun should support the weather-reporter pattern outside Cloudflare Workers. T ## Acceptance checklist - [x] Add a small adapter concept around accepting server WebSocket upgrades while keeping the Cap'n Web tunnel session logic in shared code. _`src/server-core.ts` owns the shared remote-client-to-tunnel wrapper; `captun/bun`, `captun/deno`, and `captun/node` expose runtime-specific accept helpers._ -- [x] Preserve the existing Cloudflare Worker/Durable Object weather example and test behavior. _`examples/weather-reporter/worker.ts` now delegates weather logic to `app.ts`, and the existing Miniflare weather test still passes._ -- [x] Add a Bun weather reporter example and Vitest coverage that starts `Bun.serve` in a subprocess, connects `createCaptunTunnel()`, intercepts `wttr.in`, and verifies `/weather?city=...`. _`examples/weather-reporter/bun.ts` plus `bun.e2e.test.ts` cover the Bun subprocess._ -- [x] Add a Deno weather reporter example and Vitest coverage that starts a Deno server in a subprocess, connects `createCaptunTunnel()`, intercepts `wttr.in`, and verifies `/weather?city=...`. _`examples/weather-reporter/deno.ts` plus `deno.e2e.test.ts` cover the Deno subprocess._ -- [x] Add a Node weather reporter example and Vitest coverage that starts a Node server in a subprocess, connects `createCaptunTunnel()`, intercepts `wttr.in`, and verifies `/weather?city=...`. _`examples/weather-reporter/node.ts` plus `node.e2e.test.ts` cover the Node subprocess._ -- [x] Keep the Bun/Node/Deno tests shaped similarly to `examples/weather-reporter/e2e.test.ts`; runtime-specific differences should live in fixtures or adapter calls, not in the assertions. _The three runtime tests share the same assertion flow and only vary the runtime fixture argument._ -- [x] Run everything from Vitest. It is acceptable for tests to pay subprocess startup cost. _`runtime-fixtures.ts` starts Bun, Deno, and Node servers from Vitest-managed subprocesses._ -- [x] Document the new adapter/example entry points enough that a reader can choose the Cloudflare, Bun, Deno, or Node shape. _Updated the root README API notes and the weather example README runtime list._ -- [x] Verify with focused example tests plus the package typecheck/test command. _Ran `pnpm exec vitest run examples/weather-reporter/bun.e2e.test.ts examples/weather-reporter/deno.e2e.test.ts examples/weather-reporter/node.e2e.test.ts`, `pnpm run build`, `pnpm test`, `pnpm run check`, and `pnpm --filter @captun/weather-reporter test`._ +- [x] Preserve the existing Cloudflare Worker/Durable Object weather example and test behavior. _`examples/cloudflare/worker.ts` is self-contained again, and `examples/cloudflare/cloudflare.test.ts` covers the Miniflare path._ +- [x] Add a Bun weather reporter example and Vitest coverage that starts `Bun.serve` in a subprocess, connects `createCaptunTunnel()`, intercepts `wttr.in`, and verifies `/weather?city=...`. _`examples/bun/server.ts` plus `examples/bun/bun.test.ts` cover the Bun subprocess._ +- [x] Add a Deno weather reporter example and Vitest coverage that starts a Deno server in a subprocess, connects `createCaptunTunnel()`, intercepts `wttr.in`, and verifies `/weather?city=...`. _`examples/deno/server.ts` plus `examples/deno/deno.test.ts` cover the Deno subprocess._ +- [x] Add a Node weather reporter example and Vitest coverage that starts a Node server in a subprocess, connects `createCaptunTunnel()`, intercepts `wttr.in`, and verifies `/weather?city=...`. _`examples/node/server.ts` plus `examples/node/node.test.ts` cover the Node subprocess._ +- [x] Keep the Bun/Node/Deno tests shaped similarly to the Cloudflare test; runtime-specific differences should live in local helper functions or adapter calls, not in the assertions. _Each runtime test keeps its startup helper at the bottom of that file and uses the same assertion flow._ +- [x] Run everything from Vitest. It is acceptable for tests to pay subprocess startup cost. _The Bun, Deno, and Node test files start their own subprocesses from Vitest without a shared runtime fixture module._ +- [x] Document the new adapter/example entry points enough that a reader can choose the Cloudflare, Bun, Deno, or Node shape. _Updated the root README API notes plus the PR body examples for the split `examples/bun`, `examples/node`, `examples/deno`, and `examples/cloudflare` folders._ +- [x] Verify with focused example tests plus the package typecheck/test command. _Ran focused Bun/Node/Deno/Cloudflare example tests, `pnpm run build`, `pnpm test`, `pnpm run check`, and `pnpm --filter @captun/cloudflare-example test` after the folder split._ ## Implementation notes @@ -30,10 +30,11 @@ Captun should support the weather-reporter pattern outside Cloudflare Workers. T - 2026-05-19: CI initially failed because the GitHub Node runner did not have `bun` or `deno` installed; added explicit setup steps for both runtimes and made fixture spawn errors report cleanly. - 2026-05-19: Review feedback called out that the PR body example referenced non-existent `app.*` methods and made Bun look unlike the Cloudflare accept flow. Reworked the Bun adapter to `createCaptunBunTunnelHandler().accept(...)` plus a `websocket` handler, and changed the runtime examples to use local `let egressTunnel` variables. - 2026-05-19: Follow-up review asked for the examples to keep the original weather reporter ordering. Updated Cloudflare/Bun/Deno/Node examples so `/weather` appears before `/__intercept-egress-traffic`, switched the runtime egress helpers to `const egressFetch: typeof fetch = ...`, and expanded the PR body with Cloudflare, Bun, Deno, and Node snippets that match the example files. +- 2026-05-19: Follow-up review asked to remove the shared app module and shared runtime fixture. Split the example into `examples/bun`, `examples/node`, `examples/deno`, and `examples/cloudflare`; each server file now repeats the weather handler locally, and each runtime test keeps its process helper at the bottom of the test file. - 2026-05-19: Verification passed: - `pnpm exec tsc -p tsconfig.json --noEmit` - - `pnpm exec vitest run examples/weather-reporter/bun.e2e.test.ts examples/weather-reporter/deno.e2e.test.ts examples/weather-reporter/node.e2e.test.ts` + - `pnpm exec vitest run examples/bun/bun.test.ts examples/node/node.test.ts examples/deno/deno.test.ts examples/cloudflare/cloudflare.test.ts` - `pnpm run build` - `pnpm test` - `pnpm run check` - - `pnpm --filter @captun/weather-reporter test` + - `pnpm --filter @captun/cloudflare-example test` diff --git a/tsconfig.json b/tsconfig.json index 832b2e7..1adaeb9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,6 @@ "worker-configuration.d.ts", "test/**/*.ts", "scripts/**/*.ts", - "examples/weather-reporter/*.ts" + "examples/*/*.ts" ] } From 5a8740992b70e0cd2138be911a65ac1e432c77c0 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Tue, 19 May 2026 15:48:06 +0100 Subject: [PATCH 35/43] Simplify runtime examples --- examples/bun/bun.test.ts | 27 +++++-- examples/bun/server.ts | 6 -- examples/deno/deno.test.ts | 27 +++++-- examples/deno/server.ts | 6 -- examples/node/node.test.ts | 27 +++++-- examples/node/package.json | 1 + examples/node/server.ts | 72 +++--------------- pnpm-lock.yaml | 74 ++++++++++++++++++- src/worker.ts | 12 ++- .../2026-05-19-runtime-server-adapters.md | 4 +- 10 files changed, 154 insertions(+), 102 deletions(-) diff --git a/examples/bun/bun.test.ts b/examples/bun/bun.test.ts index bc35aa5..7c3fde0 100644 --- a/examples/bun/bun.test.ts +++ b/examples/bun/bun.test.ts @@ -43,7 +43,7 @@ async function createBunWeatherReporterFixture() { const output = captureOutput(server); try { - await waitForHttp(`${url}/__health__`, server, output); + await waitForTcp(port, server, output); return { url, async [Symbol.asyncDispose]() { @@ -77,24 +77,35 @@ async function getAvailablePort(): Promise { }); } -async function waitForHttp(url: string, server: ServerProcess, output: CapturedProcessOutput) { +async function waitForTcp(port: number, server: ServerProcess, output: CapturedProcessOutput) { const startedAt = Date.now(); while (Date.now() - startedAt < 15_000) { const error = output.error(); if (error) throw error; if (server.exitCode !== null || server.signalCode) { - throw new Error(`Bun server exited before ${url} responded\n\n${output.logs().trim() || "(none)"}`); + throw new Error(`Bun server exited before port ${port} accepted connections\n\n${output.logs().trim() || "(none)"}`); } - try { - const response = await fetch(url); - if (response.ok) return; - } catch {} + if (await canConnect(port)) return; await delay(100); } - throw new Error(`Timed out waiting for Bun server to respond at ${url}`); + throw new Error(`Timed out waiting for Bun server to accept connections on port ${port}`); +} + +async function canConnect(port: number) { + return new Promise((resolve) => { + const socket = net.connect(port, "127.0.0.1"); + socket.once("connect", () => { + socket.destroy(); + resolve(true); + }); + socket.once("error", () => { + socket.destroy(); + resolve(false); + }); + }); } function captureOutput(child: ServerProcess) { diff --git a/examples/bun/server.ts b/examples/bun/server.ts index d54862d..e7e3215 100644 --- a/examples/bun/server.ts +++ b/examples/bun/server.ts @@ -42,10 +42,6 @@ const server = Bun.serve({ } if (url.pathname === "/__intercept-egress-traffic") { - if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") { - return new Response("Expected WebSocket upgrade\n", { status: 400 }); - } - const tunnel = captun.accept(request, server, { onDisconnect: () => { if (egressTunnel === tunnel) egressTunnel = undefined; @@ -57,8 +53,6 @@ const server = Bun.serve({ return; } - if (url.pathname === "/__health__") return new Response("ok"); - return new Response("Not found\n", { status: 404 }); }, websocket: captun.websocket, diff --git a/examples/deno/deno.test.ts b/examples/deno/deno.test.ts index ea34802..0e73b5e 100644 --- a/examples/deno/deno.test.ts +++ b/examples/deno/deno.test.ts @@ -57,7 +57,7 @@ async function createDenoWeatherReporterFixture() { const output = captureOutput(server); try { - await waitForHttp(`${url}/__health__`, server, output); + await waitForTcp(port, server, output); return { url, async [Symbol.asyncDispose]() { @@ -91,24 +91,35 @@ async function getAvailablePort(): Promise { }); } -async function waitForHttp(url: string, server: ServerProcess, output: CapturedProcessOutput) { +async function waitForTcp(port: number, server: ServerProcess, output: CapturedProcessOutput) { const startedAt = Date.now(); while (Date.now() - startedAt < 15_000) { const error = output.error(); if (error) throw error; if (server.exitCode !== null || server.signalCode) { - throw new Error(`Deno server exited before ${url} responded\n\n${output.logs().trim() || "(none)"}`); + throw new Error(`Deno server exited before port ${port} accepted connections\n\n${output.logs().trim() || "(none)"}`); } - try { - const response = await fetch(url); - if (response.ok) return; - } catch {} + if (await canConnect(port)) return; await delay(100); } - throw new Error(`Timed out waiting for Deno server to respond at ${url}`); + throw new Error(`Timed out waiting for Deno server to accept connections on port ${port}`); +} + +async function canConnect(port: number) { + return new Promise((resolve) => { + const socket = net.connect(port, "127.0.0.1"); + socket.once("connect", () => { + socket.destroy(); + resolve(true); + }); + socket.once("error", () => { + socket.destroy(); + resolve(false); + }); + }); } function captureOutput(child: ServerProcess) { diff --git a/examples/deno/server.ts b/examples/deno/server.ts index be25e15..690d8fe 100644 --- a/examples/deno/server.ts +++ b/examples/deno/server.ts @@ -34,10 +34,6 @@ const server = Deno.serve( } if (url.pathname === "/__intercept-egress-traffic") { - if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") { - return new Response("Expected WebSocket upgrade\n", { status: 400 }); - } - const { socket, response } = Deno.upgradeWebSocket(request); socket.addEventListener("open", () => { const tunnel = acceptCaptunDenoTunnel(socket, { @@ -51,8 +47,6 @@ const server = Deno.serve( return response; } - if (url.pathname === "/__health__") return new Response("ok"); - return new Response("Not found\n", { status: 404 }); }, ); diff --git a/examples/node/node.test.ts b/examples/node/node.test.ts index bff4540..7e6a041 100644 --- a/examples/node/node.test.ts +++ b/examples/node/node.test.ts @@ -43,7 +43,7 @@ async function createNodeWeatherReporterFixture() { const output = captureOutput(server); try { - await waitForHttp(`${url}/__health__`, server, output); + await waitForTcp(port, server, output); return { url, async [Symbol.asyncDispose]() { @@ -77,24 +77,35 @@ async function getAvailablePort(): Promise { }); } -async function waitForHttp(url: string, server: ServerProcess, output: CapturedProcessOutput) { +async function waitForTcp(port: number, server: ServerProcess, output: CapturedProcessOutput) { const startedAt = Date.now(); while (Date.now() - startedAt < 15_000) { const error = output.error(); if (error) throw error; if (server.exitCode !== null || server.signalCode) { - throw new Error(`Node server exited before ${url} responded\n\n${output.logs().trim() || "(none)"}`); + throw new Error(`Node server exited before port ${port} accepted connections\n\n${output.logs().trim() || "(none)"}`); } - try { - const response = await fetch(url); - if (response.ok) return; - } catch {} + if (await canConnect(port)) return; await delay(100); } - throw new Error(`Timed out waiting for Node server to respond at ${url}`); + throw new Error(`Timed out waiting for Node server to accept connections on port ${port}`); +} + +async function canConnect(port: number) { + return new Promise((resolve) => { + const socket = net.connect(port, "127.0.0.1"); + socket.once("connect", () => { + socket.destroy(); + resolve(true); + }); + socket.once("error", () => { + socket.destroy(); + resolve(false); + }); + }); } function captureOutput(child: ServerProcess) { diff --git a/examples/node/package.json b/examples/node/package.json index af155e9..edf9a87 100644 --- a/examples/node/package.json +++ b/examples/node/package.json @@ -3,6 +3,7 @@ "private": true, "type": "module", "dependencies": { + "@whatwg-node/server": "^0.10.18", "captun": "workspace:*", "ws": "^8.19.0" }, diff --git a/examples/node/server.ts b/examples/node/server.ts index 5d7a498..481bb4f 100644 --- a/examples/node/server.ts +++ b/examples/node/server.ts @@ -1,6 +1,6 @@ -import http, { type IncomingMessage, type ServerResponse } from "node:http"; -import { Readable } from "node:stream"; +import http from "node:http"; +import { createServerAdapter } from "@whatwg-node/server"; import { acceptCaptunNodeTunnel, type CaptunNodeWebSocket, @@ -15,44 +15,23 @@ const egressFetch: typeof fetch = async (input, init) => { }; const webSockets = new WebSocketServer({ noServer: true }); -const server = http.createServer(async (request, response) => { - if (request.headers.upgrade?.toLowerCase() === "websocket") return; - - try { - const fetchRequest = nodeRequestToFetchRequest(request); - const url = new URL(fetchRequest.url); - +const server = http.createServer( + createServerAdapter(async (request) => { + const url = new URL(request.url); if (url.pathname === "/weather") { const city = url.searchParams.get("city") || ""; const weatherResponse = await egressFetch(`https://wttr.in/${city}?format=j1`); const weather = (await weatherResponse.json()) as { current_condition: [{ temp_C: string }]; }; - await writeFetchResponse( - response, - new Response( - `The temperature in ${city} is ${weather.current_condition[0].temp_C} celsius`, - ), + return new Response( + `The temperature in ${city} is ${weather.current_condition[0].temp_C} celsius`, ); - return; } - if (url.pathname === "/__intercept-egress-traffic") { - await writeFetchResponse(response, new Response("Expected WebSocket upgrade\n", { status: 400 })); - return; - } - - if (url.pathname === "/__health__") { - await writeFetchResponse(response, new Response("ok")); - return; - } - - await writeFetchResponse(response, new Response("Not found\n", { status: 404 })); - } catch (error) { - response.writeHead(500, { "content-type": "text/plain" }); - response.end(String(error instanceof Error ? error.stack || error.message : error)); - } -}); + return new Response("Not found\n", { status: 404 }); + }), +); server.on("upgrade", (request, socket, head) => { const url = new URL(request.url || "/", `http://${request.headers.host || "127.0.0.1"}`); @@ -79,34 +58,3 @@ process.on("SIGINT", () => { server.close(() => process.exit(0)); setTimeout(() => process.exit(1), 5_000).unref(); }); - -function nodeRequestToFetchRequest(request: IncomingMessage) { - const url = new URL(request.url || "/", `http://${request.headers.host || "127.0.0.1"}`); - const headers = new Headers(); - for (const [name, value] of Object.entries(request.headers)) { - if (Array.isArray(value)) { - for (const item of value) headers.append(name, item); - } else if (value) { - headers.set(name, value); - } - } - - const init: RequestInit & { duplex?: "half" } = { - method: request.method, - headers, - }; - if (request.method !== "GET" && request.method !== "HEAD") { - init.body = Readable.toWeb(request) as unknown as ReadableStream; - init.duplex = "half"; - } - - return new Request(url, init); -} - -async function writeFetchResponse(response: ServerResponse, fetchResponse: Response) { - response.writeHead(fetchResponse.status, Object.fromEntries(fetchResponse.headers.entries())); - if (fetchResponse.body) { - for await (const chunk of fetchResponse.body) response.write(chunk); - } - response.end(); -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 58a9d87..8648d7e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -64,6 +64,9 @@ importers: examples/node: dependencies: + '@whatwg-node/server': + specifier: ^0.10.18 + version: 0.10.18 captun: specifier: workspace:* version: link:../.. @@ -136,6 +139,10 @@ packages: '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@envelop/instrumentation@1.0.0': + resolution: {integrity: sha512-cxgkB66RQB95H3X27jlnxCRNTmPuSTgmBAq6/4n2Dtv4hsk4yz8FadA1ggmd0uZzvKqWD6CR+WFgTjhDqg7eyw==} + engines: {node: '>=18.0.0'} + '@esbuild/aix-ppc64@0.27.3': resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} engines: {node: '>=18'} @@ -448,6 +455,9 @@ packages: cpu: [x64] os: [win32] + '@fastify/busboy@3.2.0': + resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==} + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -947,6 +957,26 @@ packages: '@vitest/utils@4.1.6': resolution: {integrity: sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==} + '@whatwg-node/disposablestack@0.0.6': + resolution: {integrity: sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw==} + engines: {node: '>=18.0.0'} + + '@whatwg-node/fetch@0.10.13': + resolution: {integrity: sha512-b4PhJ+zYj4357zwk4TTuF2nEe0vVtOrwdsrNo5hL+u1ojXNhh1FgJ6pg1jzDlwlT4oBdzfSwaBwMCtFCsIWg8Q==} + engines: {node: '>=18.0.0'} + + '@whatwg-node/node-fetch@0.8.5': + resolution: {integrity: sha512-4xzCl/zphPqlp9tASLVeUhB5+WJHbuWGYpfoC2q1qh5dw0AqZBW7L27V5roxYWijPxj4sspRAAoOH3d2ztaHUQ==} + engines: {node: '>=18.0.0'} + + '@whatwg-node/promise-helpers@1.3.2': + resolution: {integrity: sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==} + engines: {node: '>=16.0.0'} + + '@whatwg-node/server@0.10.18': + resolution: {integrity: sha512-kMwLlxUbduttIgaPdSkmEarFpP+mSY8FEm+QWMBRJwxOHWkri+cxd8KZHO9EMrB9vgUuz+5WEaCawaL5wGVoXg==} + engines: {node: '>=18.0.0'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -1260,6 +1290,9 @@ packages: unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + urlpattern-polyfill@10.1.0: + resolution: {integrity: sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==} + vite@8.0.13: resolution: {integrity: sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1444,6 +1477,11 @@ snapshots: tslib: 2.8.1 optional: true + '@envelop/instrumentation@1.0.0': + dependencies: + '@whatwg-node/promise-helpers': 1.3.2 + tslib: 2.8.1 + '@esbuild/aix-ppc64@0.27.3': optional: true @@ -1600,6 +1638,8 @@ snapshots: '@esbuild/win32-x64@0.28.0': optional: true + '@fastify/busboy@3.2.0': {} + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.34.5': @@ -2054,6 +2094,35 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + '@whatwg-node/disposablestack@0.0.6': + dependencies: + '@whatwg-node/promise-helpers': 1.3.2 + tslib: 2.8.1 + + '@whatwg-node/fetch@0.10.13': + dependencies: + '@whatwg-node/node-fetch': 0.8.5 + urlpattern-polyfill: 10.1.0 + + '@whatwg-node/node-fetch@0.8.5': + dependencies: + '@fastify/busboy': 3.2.0 + '@whatwg-node/disposablestack': 0.0.6 + '@whatwg-node/promise-helpers': 1.3.2 + tslib: 2.8.1 + + '@whatwg-node/promise-helpers@1.3.2': + dependencies: + tslib: 2.8.1 + + '@whatwg-node/server@0.10.18': + dependencies: + '@envelop/instrumentation': 1.0.0 + '@whatwg-node/disposablestack': 0.0.6 + '@whatwg-node/fetch': 0.10.13 + '@whatwg-node/promise-helpers': 1.3.2 + tslib: 2.8.1 + assertion-error@2.0.1: {} blake3-wasm@2.1.5: {} @@ -2342,8 +2411,7 @@ snapshots: '@orpc/server': 1.14.3(ws@8.20.1) zod: 4.4.3 - tslib@2.8.1: - optional: true + tslib@2.8.1: {} tsx@4.22.2: dependencies: @@ -2365,6 +2433,8 @@ snapshots: dependencies: pathe: 2.0.3 + urlpattern-polyfill@10.1.0: {} + vite@8.0.13(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.2): dependencies: lightningcss: 1.32.0 diff --git a/src/worker.ts b/src/worker.ts index b894ac8..be43a1a 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -41,7 +41,7 @@ export class CaptunServerShard extends DurableObject { if ( expectedAuthorization && (actualAuthorization.length !== encodedExpectedAuthorization.length || - !crypto.subtle.timingSafeEqual(actualAuthorization, encodedExpectedAuthorization)) + !timingSafeEqual(actualAuthorization, encodedExpectedAuthorization)) ) { return new Response("Unauthorized\n", { status: 401 }); } @@ -82,3 +82,13 @@ function captunRoute(request: Request) { url.pathname = `/${encodeURIComponent(route.name)}${route.path}`; return { tunnelName: route.name, request: new Request(url, request) }; } + +function timingSafeEqual(left: Uint8Array, right: Uint8Array) { + if (left.length !== right.length) return false; + + let diff = 0; + for (let i = 0; i < left.length; i += 1) { + diff |= left[i] ^ right[i]; + } + return diff === 0; +} diff --git a/tasks/complete/2026-05-19-runtime-server-adapters.md b/tasks/complete/2026-05-19-runtime-server-adapters.md index 1803920..6e56d09 100644 --- a/tasks/complete/2026-05-19-runtime-server-adapters.md +++ b/tasks/complete/2026-05-19-runtime-server-adapters.md @@ -17,7 +17,7 @@ Captun should support the weather-reporter pattern outside Cloudflare Workers. T - [x] Preserve the existing Cloudflare Worker/Durable Object weather example and test behavior. _`examples/cloudflare/worker.ts` is self-contained again, and `examples/cloudflare/cloudflare.test.ts` covers the Miniflare path._ - [x] Add a Bun weather reporter example and Vitest coverage that starts `Bun.serve` in a subprocess, connects `createCaptunTunnel()`, intercepts `wttr.in`, and verifies `/weather?city=...`. _`examples/bun/server.ts` plus `examples/bun/bun.test.ts` cover the Bun subprocess._ - [x] Add a Deno weather reporter example and Vitest coverage that starts a Deno server in a subprocess, connects `createCaptunTunnel()`, intercepts `wttr.in`, and verifies `/weather?city=...`. _`examples/deno/server.ts` plus `examples/deno/deno.test.ts` cover the Deno subprocess._ -- [x] Add a Node weather reporter example and Vitest coverage that starts a Node server in a subprocess, connects `createCaptunTunnel()`, intercepts `wttr.in`, and verifies `/weather?city=...`. _`examples/node/server.ts` plus `examples/node/node.test.ts` cover the Node subprocess._ +- [x] Add a Node weather reporter example and Vitest coverage that starts a Node server in a subprocess, connects `createCaptunTunnel()`, intercepts `wttr.in`, and verifies `/weather?city=...`. _`examples/node/server.ts` uses `@whatwg-node/server` for the Fetch request adapter, while `examples/node/node.test.ts` covers the Node subprocess._ - [x] Keep the Bun/Node/Deno tests shaped similarly to the Cloudflare test; runtime-specific differences should live in local helper functions or adapter calls, not in the assertions. _Each runtime test keeps its startup helper at the bottom of that file and uses the same assertion flow._ - [x] Run everything from Vitest. It is acceptable for tests to pay subprocess startup cost. _The Bun, Deno, and Node test files start their own subprocesses from Vitest without a shared runtime fixture module._ - [x] Document the new adapter/example entry points enough that a reader can choose the Cloudflare, Bun, Deno, or Node shape. _Updated the root README API notes plus the PR body examples for the split `examples/bun`, `examples/node`, `examples/deno`, and `examples/cloudflare` folders._ @@ -31,6 +31,8 @@ Captun should support the weather-reporter pattern outside Cloudflare Workers. T - 2026-05-19: Review feedback called out that the PR body example referenced non-existent `app.*` methods and made Bun look unlike the Cloudflare accept flow. Reworked the Bun adapter to `createCaptunBunTunnelHandler().accept(...)` plus a `websocket` handler, and changed the runtime examples to use local `let egressTunnel` variables. - 2026-05-19: Follow-up review asked for the examples to keep the original weather reporter ordering. Updated Cloudflare/Bun/Deno/Node examples so `/weather` appears before `/__intercept-egress-traffic`, switched the runtime egress helpers to `const egressFetch: typeof fetch = ...`, and expanded the PR body with Cloudflare, Bun, Deno, and Node snippets that match the example files. - 2026-05-19: Follow-up review asked to remove the shared app module and shared runtime fixture. Split the example into `examples/bun`, `examples/node`, `examples/deno`, and `examples/cloudflare`; each server file now repeats the weather handler locally, and each runtime test keeps its process helper at the bottom of the test file. +- 2026-05-19: Follow-up review asked for the Node example to use a real Fetch adapter and for the runtime servers to avoid extra health endpoints. Switched Node to `@whatwg-node/server`, removed `/__health__` from Bun/Deno/Node, and changed the runtime test helpers to wait for the TCP listener instead. +- 2026-05-19: Adding `@whatwg-node/server` exposed the Worker's untyped `crypto.subtle.timingSafeEqual` usage during root typecheck. Replaced it with a small local byte comparison in `src/worker.ts`. - 2026-05-19: Verification passed: - `pnpm exec tsc -p tsconfig.json --noEmit` - `pnpm exec vitest run examples/bun/bun.test.ts examples/node/node.test.ts examples/deno/deno.test.ts examples/cloudflare/cloudflare.test.ts` From c751adf4baffa0bb93069dd10ed7335d68b2ccfe Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Tue, 19 May 2026 15:57:22 +0100 Subject: [PATCH 36/43] Remove runtime ambient declarations from examples --- examples/bun/package.json | 15 ++++++++++ examples/bun/server.ts | 21 ++++---------- examples/bun/tsconfig.json | 8 +++++ examples/deno/package.json | 8 +++++ examples/deno/server.ts | 11 ------- pnpm-lock.yaml | 29 +++++++++++++++++++ src/deno.ts | 2 +- src/node.ts | 2 +- src/server-core.ts | 11 +++++++ src/server.ts | 20 ++----------- .../2026-05-19-runtime-server-adapters.md | 1 + tsconfig.json | 4 +++ 12 files changed, 86 insertions(+), 46 deletions(-) create mode 100644 examples/bun/package.json create mode 100644 examples/bun/tsconfig.json create mode 100644 examples/deno/package.json diff --git a/examples/bun/package.json b/examples/bun/package.json new file mode 100644 index 0000000..21a7d2b --- /dev/null +++ b/examples/bun/package.json @@ -0,0 +1,15 @@ +{ + "name": "@captun/bun-example", + "private": true, + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "captun": "workspace:*" + }, + "devDependencies": { + "@types/bun": "^1.3.14", + "typescript": "^6.0.3" + } +} diff --git a/examples/bun/server.ts b/examples/bun/server.ts index e7e3215..7623459 100644 --- a/examples/bun/server.ts +++ b/examples/bun/server.ts @@ -1,25 +1,14 @@ import { createCaptunBunTunnelHandler, - type CaptunBunServer, - type CaptunBunWebSocketHandler, type CaptunServerTunnel, } from "captun/bun"; -declare const Bun: { - serve(options: { - hostname: string; - port: number; - fetch( - request: Request, - server: CaptunBunServer, - ): Response | Promise | undefined; - websocket: CaptunBunWebSocketHandler; - }): { stop(force?: boolean): void }; -}; - let egressTunnel: CaptunServerTunnel | undefined; -const egressFetch: typeof fetch = async (input, init) => { - if (egressTunnel) return egressTunnel.fetch(new Request(input, init)); +const egressFetch = async (input: Parameters[0], init?: Parameters[1]) => { + if (egressTunnel) { + const request = input instanceof Request ? new Request(input, init) : new Request(input.toString(), init); + return egressTunnel.fetch(request); + } return fetch(input, init); }; const captun = createCaptunBunTunnelHandler(); diff --git a/examples/bun/tsconfig.json b/examples/bun/tsconfig.json new file mode 100644 index 0000000..f83d1ca --- /dev/null +++ b/examples/bun/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["bun"] + }, + "include": ["server.ts"], + "exclude": [] +} diff --git a/examples/deno/package.json b/examples/deno/package.json new file mode 100644 index 0000000..d7d90ef --- /dev/null +++ b/examples/deno/package.json @@ -0,0 +1,8 @@ +{ + "name": "@captun/deno-example", + "private": true, + "type": "module", + "scripts": { + "typecheck": "deno check --config deno.json --node-modules-dir=auto --no-lock --sloppy-imports server.ts" + } +} diff --git a/examples/deno/server.ts b/examples/deno/server.ts index 690d8fe..8e3c71c 100644 --- a/examples/deno/server.ts +++ b/examples/deno/server.ts @@ -1,16 +1,5 @@ import { acceptCaptunDenoTunnel, type CaptunServerTunnel } from "captun/deno"; -declare const Deno: { - env: { get(name: string): string | undefined }; - serve( - options: { hostname: string; port: number }, - handler: (request: Request) => Response | Promise, - ): { shutdown(): Promise }; - upgradeWebSocket(request: Request): { socket: WebSocket; response: Response }; - addSignalListener(signal: "SIGINT", handler: () => void): void; - exit(code?: number): never; -}; - let egressTunnel: CaptunServerTunnel | undefined; const egressFetch: typeof fetch = async (input, init) => { if (egressTunnel) return egressTunnel.fetch(new Request(input, init)); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8648d7e..1c48496 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,6 +49,19 @@ importers: specifier: ^4.92.0 version: 4.92.0(@cloudflare/workers-types@4.20260518.1) + examples/bun: + dependencies: + captun: + specifier: workspace:* + version: link:../.. + devDependencies: + '@types/bun': + specifier: ^1.3.14 + version: 1.3.14 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + examples/cloudflare: dependencies: captun: @@ -62,6 +75,8 @@ importers: specifier: ^4.1.6 version: 4.1.6(@types/node@25.8.0)(vite@8.0.13(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.2)) + examples/deno: {} + examples/node: dependencies: '@whatwg-node/server': @@ -913,6 +928,9 @@ packages: '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@types/bun@1.3.14': + resolution: {integrity: sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -984,6 +1002,9 @@ packages: blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + bun-types@1.3.14: + resolution: {integrity: sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ==} + capnweb@0.8.0: resolution: {integrity: sha512-BK/TuXUiyfLSKsmjojn70yN7oYG/JJzoURZ3tckjg5Zj2KcygPm0A5jyOlswK7SYB4f0Gh9tt+RZ132b80iLfA==} @@ -2036,6 +2057,10 @@ snapshots: tslib: 2.8.1 optional: true + '@types/bun@1.3.14': + dependencies: + bun-types: 1.3.14 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -2127,6 +2152,10 @@ snapshots: blake3-wasm@2.1.5: {} + bun-types@1.3.14: + dependencies: + '@types/node': 25.8.0 + capnweb@0.8.0: {} chai@6.2.2: {} diff --git a/src/deno.ts b/src/deno.ts index 7ec8023..ec54014 100644 --- a/src/deno.ts +++ b/src/deno.ts @@ -1,4 +1,4 @@ -import { acceptCaptunTunnelFromSocket } from "./server.js"; +import { acceptCaptunTunnelFromSocket } from "./server-core.js"; import type { CaptunServerAcceptTunnelOptions, CaptunServerTunnel } from "./types.js"; export type { CaptunServerAcceptTunnelOptions, CaptunServerTunnel } from "./types.js"; diff --git a/src/node.ts b/src/node.ts index 2acbb76..cd8f23d 100644 --- a/src/node.ts +++ b/src/node.ts @@ -1,4 +1,4 @@ -import { acceptCaptunTunnelFromSocket } from "./server.js"; +import { acceptCaptunTunnelFromSocket } from "./server-core.js"; import type { CaptunServerAcceptTunnelOptions, CaptunServerTunnel } from "./types.js"; export type { CaptunServerAcceptTunnelOptions, CaptunServerTunnel } from "./types.js"; diff --git a/src/server-core.ts b/src/server-core.ts index 897ac1f..8d28008 100644 --- a/src/server-core.ts +++ b/src/server-core.ts @@ -1,3 +1,4 @@ +import { newWebSocketRpcSession } from "capnweb"; import type { CaptunClientRemoteFetcher, CaptunServerAcceptTunnelOptions, @@ -19,3 +20,13 @@ export function captunTunnelFromRemoteClient( [Symbol.dispose]: () => remoteClient[Symbol.dispose](), }; } + +export function acceptCaptunTunnelFromSocket( + socket: WebSocket, + options: CaptunServerAcceptTunnelOptions = {}, +): CaptunServerTunnel { + const remoteClient = newWebSocketRpcSession( + socket, + ) as CaptunRemoteClient; + return captunTunnelFromRemoteClient(remoteClient, options); +} diff --git a/src/server.ts b/src/server.ts index bfb20a1..120b920 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,11 +1,7 @@ -import { newWebSocketRpcSession } from "capnweb"; -import { captunTunnelFromRemoteClient, type CaptunRemoteClient } from "./server-core.js"; -import type { - CaptunClientRemoteFetcher, - CaptunServerAcceptTunnelOptions, - CaptunServerTunnel, -} from "./types.js"; +import { acceptCaptunTunnelFromSocket } from "./server-core.js"; +import type { CaptunServerAcceptTunnelOptions } from "./types.js"; export type { CaptunServerAcceptTunnelOptions, CaptunServerTunnel } from "./types.js"; +export { acceptCaptunTunnelFromSocket } from "./server-core.js"; /** Creates a Worker WebSocket upgrade response and matching tunnel handle. */ export function acceptCaptunTunnel(options: CaptunServerAcceptTunnelOptions = {}) { @@ -21,13 +17,3 @@ export function acceptCaptunTunnel(options: CaptunServerAcceptTunnelOptions = {} response: new Response(null, { status: 101, webSocket: clientSocket }), }; } - -export function acceptCaptunTunnelFromSocket( - socket: WebSocket, - options: CaptunServerAcceptTunnelOptions = {}, -): CaptunServerTunnel { - const remoteClient = newWebSocketRpcSession( - socket, - ) as CaptunRemoteClient; - return captunTunnelFromRemoteClient(remoteClient, options); -} diff --git a/tasks/complete/2026-05-19-runtime-server-adapters.md b/tasks/complete/2026-05-19-runtime-server-adapters.md index 6e56d09..b1a7d37 100644 --- a/tasks/complete/2026-05-19-runtime-server-adapters.md +++ b/tasks/complete/2026-05-19-runtime-server-adapters.md @@ -33,6 +33,7 @@ Captun should support the weather-reporter pattern outside Cloudflare Workers. T - 2026-05-19: Follow-up review asked to remove the shared app module and shared runtime fixture. Split the example into `examples/bun`, `examples/node`, `examples/deno`, and `examples/cloudflare`; each server file now repeats the weather handler locally, and each runtime test keeps its process helper at the bottom of the test file. - 2026-05-19: Follow-up review asked for the Node example to use a real Fetch adapter and for the runtime servers to avoid extra health endpoints. Switched Node to `@whatwg-node/server`, removed `/__health__` from Bun/Deno/Node, and changed the runtime test helpers to wait for the TCP listener instead. - 2026-05-19: Adding `@whatwg-node/server` exposed the Worker's untyped `crypto.subtle.timingSafeEqual` usage during root typecheck. Replaced it with a small local byte comparison in `src/worker.ts`. +- 2026-05-19: Follow-up review asked to remove local Bun and Deno ambient declarations from the server examples. Added runtime-specific example typecheck setup instead: Bun uses `@types/bun`, Deno uses `deno check`, and the shared socket accept helper moved to `src/server-core.ts` so Deno does not need to typecheck Cloudflare's `WebSocketPair` route. - 2026-05-19: Verification passed: - `pnpm exec tsc -p tsconfig.json --noEmit` - `pnpm exec vitest run examples/bun/bun.test.ts examples/node/node.test.ts examples/deno/deno.test.ts examples/cloudflare/cloudflare.test.ts` diff --git a/tsconfig.json b/tsconfig.json index 1adaeb9..de8cf9c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,5 +15,9 @@ "test/**/*.ts", "scripts/**/*.ts", "examples/*/*.ts" + ], + "exclude": [ + "examples/bun/server.ts", + "examples/deno/server.ts" ] } From 4c15f52387d2a2af7e54ac1b279516340815147a Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Tue, 19 May 2026 16:12:39 +0100 Subject: [PATCH 37/43] Tidy Node WebSocket adapter types --- examples/node/server.ts | 42 +++++++++++++++++++---------------------- src/node.ts | 5 +++-- 2 files changed, 22 insertions(+), 25 deletions(-) diff --git a/examples/node/server.ts b/examples/node/server.ts index 481bb4f..9cbd180 100644 --- a/examples/node/server.ts +++ b/examples/node/server.ts @@ -1,11 +1,7 @@ import http from "node:http"; import { createServerAdapter } from "@whatwg-node/server"; -import { - acceptCaptunNodeTunnel, - type CaptunNodeWebSocket, - type CaptunServerTunnel, -} from "captun/node"; +import { acceptCaptunNodeTunnel, type CaptunServerTunnel } from "captun/node"; import { WebSocketServer } from "ws"; let egressTunnel: CaptunServerTunnel | undefined; @@ -15,23 +11,23 @@ const egressFetch: typeof fetch = async (input, init) => { }; const webSockets = new WebSocketServer({ noServer: true }); -const server = http.createServer( - createServerAdapter(async (request) => { - const url = new URL(request.url); - if (url.pathname === "/weather") { - const city = url.searchParams.get("city") || ""; - const weatherResponse = await egressFetch(`https://wttr.in/${city}?format=j1`); - const weather = (await weatherResponse.json()) as { - current_condition: [{ temp_C: string }]; - }; - return new Response( - `The temperature in ${city} is ${weather.current_condition[0].temp_C} celsius`, - ); - } - - return new Response("Not found\n", { status: 404 }); - }), -); +const server = http.createServer(createServerAdapter(serverFetch)); + +async function serverFetch(request: Request): Promise { + const url = new URL(request.url); + if (url.pathname === "/weather") { + const city = url.searchParams.get("city") || ""; + const weatherResponse = await egressFetch(`https://wttr.in/${city}?format=j1`); + const weather = (await weatherResponse.json()) as { + current_condition: [{ temp_C: string }]; + }; + return new Response( + `The temperature in ${city} is ${weather.current_condition[0].temp_C} celsius`, + ); + } + + return new Response("Not found\n", { status: 404 }); +} server.on("upgrade", (request, socket, head) => { const url = new URL(request.url || "/", `http://${request.headers.host || "127.0.0.1"}`); @@ -41,7 +37,7 @@ server.on("upgrade", (request, socket, head) => { } webSockets.handleUpgrade(request, socket, head, (webSocket) => { - const tunnel = acceptCaptunNodeTunnel(webSocket as unknown as CaptunNodeWebSocket, { + const tunnel = acceptCaptunNodeTunnel(webSocket, { onDisconnect: () => { if (egressTunnel === tunnel) egressTunnel = undefined; }, diff --git a/src/node.ts b/src/node.ts index cd8f23d..1ba709d 100644 --- a/src/node.ts +++ b/src/node.ts @@ -3,7 +3,8 @@ import type { CaptunServerAcceptTunnelOptions, CaptunServerTunnel } from "./type export type { CaptunServerAcceptTunnelOptions, CaptunServerTunnel } from "./types.js"; -export interface CaptunNodeWebSocket { +/** A type `import('ws').WebSocket` conforms to. This will be cast internally before passing to `capnweb` */ +export interface WSWebSocketLike { readyState: number; addEventListener(type: string, listener: (event: any) => void): void; send(message: string): unknown; @@ -11,7 +12,7 @@ export interface CaptunNodeWebSocket { } export function acceptCaptunNodeTunnel( - socket: CaptunNodeWebSocket, + socket: WSWebSocketLike, options: CaptunServerAcceptTunnelOptions = {}, ): CaptunServerTunnel { return acceptCaptunTunnelFromSocket(socket as unknown as WebSocket, options); From 02617b21578a3262da9e777cc19618437a839534 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Tue, 19 May 2026 16:16:04 +0100 Subject: [PATCH 38/43] Centralize public tunnel types --- README.md | 3 ++- examples/bun/server.ts | 6 ++---- examples/bun/tsconfig.json | 3 +++ examples/cloudflare/worker.ts | 3 ++- examples/deno/deno.json | 1 + examples/deno/server.ts | 3 ++- examples/node/server.ts | 3 ++- src/bun.ts | 2 -- src/deno.ts | 2 -- src/index.ts | 6 ++++++ src/node.ts | 2 -- src/server.ts | 1 - tasks/complete/2026-05-19-runtime-server-adapters.md | 1 + 13 files changed, 21 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 20c285a..8e31744 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,8 @@ The captun [worker.ts](./src/worker.ts) implementation has useful opinions about ```ts import { DurableObject } from "cloudflare:workers"; -import { acceptCaptunTunnel, type CaptunServerTunnel } from "captun/server"; +import type { CaptunServerTunnel } from "captun"; +import { acceptCaptunTunnel } from "captun/server"; type WeatherReporterEnv = Env & { WEATHER_REPORTER_EGRESS: DurableObjectNamespace; diff --git a/examples/bun/server.ts b/examples/bun/server.ts index 7623459..3f9b699 100644 --- a/examples/bun/server.ts +++ b/examples/bun/server.ts @@ -1,7 +1,5 @@ -import { - createCaptunBunTunnelHandler, - type CaptunServerTunnel, -} from "captun/bun"; +import type { CaptunServerTunnel } from "captun"; +import { createCaptunBunTunnelHandler } from "captun/bun"; let egressTunnel: CaptunServerTunnel | undefined; const egressFetch = async (input: Parameters[0], init?: Parameters[1]) => { diff --git a/examples/bun/tsconfig.json b/examples/bun/tsconfig.json index f83d1ca..60fe319 100644 --- a/examples/bun/tsconfig.json +++ b/examples/bun/tsconfig.json @@ -1,6 +1,9 @@ { "extends": "../../tsconfig.json", "compilerOptions": { + "paths": { + "captun": ["../../src/types.ts"] + }, "types": ["bun"] }, "include": ["server.ts"], diff --git a/examples/cloudflare/worker.ts b/examples/cloudflare/worker.ts index c74809d..8c71c17 100644 --- a/examples/cloudflare/worker.ts +++ b/examples/cloudflare/worker.ts @@ -1,5 +1,6 @@ import { DurableObject } from "cloudflare:workers"; -import { acceptCaptunTunnel, type CaptunServerTunnel } from "captun/server"; +import type { CaptunServerTunnel } from "captun"; +import { acceptCaptunTunnel } from "captun/server"; type WeatherReporterEnv = Env & { WEATHER_REPORTER_EGRESS: DurableObjectNamespace; diff --git a/examples/deno/deno.json b/examples/deno/deno.json index 8018afe..8167a8f 100644 --- a/examples/deno/deno.json +++ b/examples/deno/deno.json @@ -1,5 +1,6 @@ { "imports": { + "captun": "../../src/types.ts", "captun/deno": "../../src/deno.ts", "capnweb": "npm:capnweb@0.8.0" } diff --git a/examples/deno/server.ts b/examples/deno/server.ts index 8e3c71c..0280c5f 100644 --- a/examples/deno/server.ts +++ b/examples/deno/server.ts @@ -1,4 +1,5 @@ -import { acceptCaptunDenoTunnel, type CaptunServerTunnel } from "captun/deno"; +import type { CaptunServerTunnel } from "captun"; +import { acceptCaptunDenoTunnel } from "captun/deno"; let egressTunnel: CaptunServerTunnel | undefined; const egressFetch: typeof fetch = async (input, init) => { diff --git a/examples/node/server.ts b/examples/node/server.ts index 9cbd180..9e7ef3a 100644 --- a/examples/node/server.ts +++ b/examples/node/server.ts @@ -1,7 +1,8 @@ import http from "node:http"; import { createServerAdapter } from "@whatwg-node/server"; -import { acceptCaptunNodeTunnel, type CaptunServerTunnel } from "captun/node"; +import type { CaptunServerTunnel } from "captun"; +import { acceptCaptunNodeTunnel } from "captun/node"; import { WebSocketServer } from "ws"; let egressTunnel: CaptunServerTunnel | undefined; diff --git a/src/bun.ts b/src/bun.ts index e3cce60..038b63b 100644 --- a/src/bun.ts +++ b/src/bun.ts @@ -1,8 +1,6 @@ import { captunTunnelFromRemoteClient, type CaptunRemoteClient } from "./server-core.js"; import type { CaptunServerAcceptTunnelOptions, CaptunServerTunnel } from "./types.js"; -export type { CaptunServerAcceptTunnelOptions, CaptunServerTunnel } from "./types.js"; - export interface CaptunBunTunnelHandler { accept( request: Request, diff --git a/src/deno.ts b/src/deno.ts index ec54014..ddda277 100644 --- a/src/deno.ts +++ b/src/deno.ts @@ -1,8 +1,6 @@ import { acceptCaptunTunnelFromSocket } from "./server-core.js"; import type { CaptunServerAcceptTunnelOptions, CaptunServerTunnel } from "./types.js"; -export type { CaptunServerAcceptTunnelOptions, CaptunServerTunnel } from "./types.js"; - export function acceptCaptunDenoTunnel( socket: WebSocket, options: CaptunServerAcceptTunnelOptions = {}, diff --git a/src/index.ts b/src/index.ts index a5ba0f7..9ee9613 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,8 @@ export { createCaptunTunnel } from "./client.js"; export { acceptCaptunTunnel, acceptCaptunTunnelFromSocket } from "./server.js"; +export type { + CaptunClientCreateTunnelOptions, + CaptunServerAcceptTunnelOptions, + CaptunServerTunnel, + Fetcher, +} from "./types.js"; diff --git a/src/node.ts b/src/node.ts index 1ba709d..8c60b54 100644 --- a/src/node.ts +++ b/src/node.ts @@ -1,8 +1,6 @@ import { acceptCaptunTunnelFromSocket } from "./server-core.js"; import type { CaptunServerAcceptTunnelOptions, CaptunServerTunnel } from "./types.js"; -export type { CaptunServerAcceptTunnelOptions, CaptunServerTunnel } from "./types.js"; - /** A type `import('ws').WebSocket` conforms to. This will be cast internally before passing to `capnweb` */ export interface WSWebSocketLike { readyState: number; diff --git a/src/server.ts b/src/server.ts index 120b920..761e69e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,6 +1,5 @@ import { acceptCaptunTunnelFromSocket } from "./server-core.js"; import type { CaptunServerAcceptTunnelOptions } from "./types.js"; -export type { CaptunServerAcceptTunnelOptions, CaptunServerTunnel } from "./types.js"; export { acceptCaptunTunnelFromSocket } from "./server-core.js"; /** Creates a Worker WebSocket upgrade response and matching tunnel handle. */ diff --git a/tasks/complete/2026-05-19-runtime-server-adapters.md b/tasks/complete/2026-05-19-runtime-server-adapters.md index b1a7d37..67e2098 100644 --- a/tasks/complete/2026-05-19-runtime-server-adapters.md +++ b/tasks/complete/2026-05-19-runtime-server-adapters.md @@ -34,6 +34,7 @@ Captun should support the weather-reporter pattern outside Cloudflare Workers. T - 2026-05-19: Follow-up review asked for the Node example to use a real Fetch adapter and for the runtime servers to avoid extra health endpoints. Switched Node to `@whatwg-node/server`, removed `/__health__` from Bun/Deno/Node, and changed the runtime test helpers to wait for the TCP listener instead. - 2026-05-19: Adding `@whatwg-node/server` exposed the Worker's untyped `crypto.subtle.timingSafeEqual` usage during root typecheck. Replaced it with a small local byte comparison in `src/worker.ts`. - 2026-05-19: Follow-up review asked to remove local Bun and Deno ambient declarations from the server examples. Added runtime-specific example typecheck setup instead: Bun uses `@types/bun`, Deno uses `deno check`, and the shared socket accept helper moved to `src/server-core.ts` so Deno does not need to typecheck Cloudflare's `WebSocketPair` route. +- 2026-05-19: Follow-up review questioned repeated runtime subpath type re-exports. Moved shared public types to the root `captun` export and removed `CaptunServerTunnel`/`CaptunServerAcceptTunnelOptions` re-exports from `captun/server`, `captun/bun`, `captun/deno`, and `captun/node`. - 2026-05-19: Verification passed: - `pnpm exec tsc -p tsconfig.json --noEmit` - `pnpm exec vitest run examples/bun/bun.test.ts examples/node/node.test.ts examples/deno/deno.test.ts examples/cloudflare/cloudflare.test.ts` From 39e8414285715879a921ce313cbff7198886dc08 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Tue, 19 May 2026 16:25:11 +0100 Subject: [PATCH 39/43] Simplify Bun tunnel handler --- src/bun.ts | 88 +++++++------------ .../2026-05-19-runtime-server-adapters.md | 1 + 2 files changed, 34 insertions(+), 55 deletions(-) diff --git a/src/bun.ts b/src/bun.ts index 038b63b..4655c25 100644 --- a/src/bun.ts +++ b/src/bun.ts @@ -29,100 +29,78 @@ export interface CaptunBunServer { export type CaptunBunWebSocketMessage = string | Uint8Array | ArrayBuffer; -interface CaptunBunWebSocketTransport { - dispatchMessage(message: CaptunBunWebSocketMessage): void; - dispatchClose(code: number, reason: string): void; - dispatchError(error: Error): void; -} - -interface CaptunBunWebSocketSession { - transport: CaptunBunWebSocketTransport; -} - -interface CaptunBunAcceptedTunnel { +interface PendingCaptunBunTunnel { tunnel: CaptunServerTunnel; connect(remoteClient: CaptunRemoteClient): void; } -interface CaptunBunServerWebSocketData { - captunTunnel?: CaptunBunAcceptedTunnel; +interface CaptunBunUpgradeData { + captunTunnel?: PendingCaptunBunTunnel; } -const { newBunWebSocketRpcSession } = (await import("capnweb")) as unknown as { - newBunWebSocketRpcSession( - socket: CaptunBunServerWebSocket, - localMain?: unknown, - ): { stub: T; transport: CaptunBunWebSocketTransport }; +interface CapnwebBunWebSocketData { + __capnwebStub: CaptunRemoteClient; +} + +const { newBunWebSocketRpcHandler } = (await import("capnweb")) as unknown as { + newBunWebSocketRpcHandler(createMain: () => unknown): CaptunBunWebSocketHandler; }; export function createCaptunBunTunnelHandler(): CaptunBunTunnelHandler { - const sessions = new WeakMap(); + const capnweb = newBunWebSocketRpcHandler(() => undefined); return { accept(request, server, options = {}) { - const acceptedTunnel = createCaptunBunAcceptedTunnel(options); - if (!server.upgrade(request, { data: { captunTunnel: acceptedTunnel } })) { - acceptedTunnel.tunnel[Symbol.dispose](); + const pendingTunnel = createPendingCaptunBunTunnel(options); + if (!server.upgrade(request, { data: { captunTunnel: pendingTunnel } })) { + pendingTunnel.tunnel[Symbol.dispose](); return undefined; } - return acceptedTunnel.tunnel; + return pendingTunnel.tunnel; }, websocket: { open(socket) { - const acceptedTunnel = (socket.data as CaptunBunServerWebSocketData).captunTunnel; - if (!acceptedTunnel) { + const pendingTunnel = (socket.data as CaptunBunUpgradeData).captunTunnel; + if (!pendingTunnel) { socket.close(1008, "Missing Captun tunnel data"); return; } - const session = newBunWebSocketRpcSession(socket); - acceptedTunnel.connect(session.stub); - sessions.set(socket, session); - }, - message(socket, message) { - sessions.get(socket)?.transport.dispatchMessage(message); - }, - close(socket, code, reason) { - const session = sessions.get(socket); - if (!session) return; - session.transport.dispatchClose(code, reason); - sessions.delete(socket); - }, - error(socket, error) { - const session = sessions.get(socket); - if (!session) return; - session.transport.dispatchError(error); - sessions.delete(socket); + capnweb.open(socket); + pendingTunnel.connect((socket.data as CapnwebBunWebSocketData).__capnwebStub); }, + message: capnweb.message, + close: capnweb.close, + error: capnweb.error, }, }; } -function createCaptunBunAcceptedTunnel( +function createPendingCaptunBunTunnel( options: CaptunServerAcceptTunnelOptions, -): CaptunBunAcceptedTunnel { +): PendingCaptunBunTunnel { let connectedTunnel: CaptunServerTunnel | undefined; - let connectRemoteClient: (remoteClient: CaptunRemoteClient) => void = () => {}; - let rejectRemoteClient: (error: Error) => void = () => {}; + let connectTunnel: (tunnel: CaptunServerTunnel) => void = () => {}; + let rejectTunnel: (error: Error) => void = () => {}; let closed = false; - const remoteClient = new Promise((resolve, reject) => { - connectRemoteClient = resolve; - rejectRemoteClient = reject; + const tunnelReady = new Promise((resolve, reject) => { + connectTunnel = resolve; + rejectTunnel = reject; }); - remoteClient.catch(() => undefined); + tunnelReady.catch(() => undefined); const tunnel: CaptunServerTunnel = { async fetch(request) { if (closed) throw new Error("Captun Bun tunnel is closed"); - const remote = await remoteClient; + const connected = await tunnelReady; if (closed) throw new Error("Captun Bun tunnel is closed"); - return remote.fetch(request); + return connected.fetch(request); }, [Symbol.dispose]() { if (closed) return; closed = true; connectedTunnel?.[Symbol.dispose](); - rejectRemoteClient(new Error("Captun Bun tunnel closed before the WebSocket opened")); + rejectTunnel(new Error("Captun Bun tunnel closed before the WebSocket opened")); }, }; @@ -134,7 +112,7 @@ function createCaptunBunAcceptedTunnel( return; } connectedTunnel = captunTunnelFromRemoteClient(remote, options); - connectRemoteClient(remote); + connectTunnel(connectedTunnel); }, }; } diff --git a/tasks/complete/2026-05-19-runtime-server-adapters.md b/tasks/complete/2026-05-19-runtime-server-adapters.md index 67e2098..1652715 100644 --- a/tasks/complete/2026-05-19-runtime-server-adapters.md +++ b/tasks/complete/2026-05-19-runtime-server-adapters.md @@ -35,6 +35,7 @@ Captun should support the weather-reporter pattern outside Cloudflare Workers. T - 2026-05-19: Adding `@whatwg-node/server` exposed the Worker's untyped `crypto.subtle.timingSafeEqual` usage during root typecheck. Replaced it with a small local byte comparison in `src/worker.ts`. - 2026-05-19: Follow-up review asked to remove local Bun and Deno ambient declarations from the server examples. Added runtime-specific example typecheck setup instead: Bun uses `@types/bun`, Deno uses `deno check`, and the shared socket accept helper moved to `src/server-core.ts` so Deno does not need to typecheck Cloudflare's `WebSocketPair` route. - 2026-05-19: Follow-up review questioned repeated runtime subpath type re-exports. Moved shared public types to the root `captun` export and removed `CaptunServerTunnel`/`CaptunServerAcceptTunnelOptions` re-exports from `captun/server`, `captun/bun`, `captun/deno`, and `captun/node`. +- 2026-05-19: Follow-up review asked why the Bun adapter was much larger than the Cap'n Web README example. Simplified `src/bun.ts` to wrap `newBunWebSocketRpcHandler()` instead of manually storing sessions and dispatching message/close/error events. - 2026-05-19: Verification passed: - `pnpm exec tsc -p tsconfig.json --noEmit` - `pnpm exec vitest run examples/bun/bun.test.ts examples/node/node.test.ts examples/deno/deno.test.ts examples/cloudflare/cloudflare.test.ts` From 8efdb6261d06144ce81decd87c4ab7c0ed811ea1 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Tue, 19 May 2026 16:29:39 +0100 Subject: [PATCH 40/43] Trim Bun adapter type surface --- src/bun.ts | 59 +++---------------- .../2026-05-19-runtime-server-adapters.md | 1 + 2 files changed, 9 insertions(+), 51 deletions(-) diff --git a/src/bun.ts b/src/bun.ts index 4655c25..5b1d29f 100644 --- a/src/bun.ts +++ b/src/bun.ts @@ -1,56 +1,13 @@ import { captunTunnelFromRemoteClient, type CaptunRemoteClient } from "./server-core.js"; import type { CaptunServerAcceptTunnelOptions, CaptunServerTunnel } from "./types.js"; -export interface CaptunBunTunnelHandler { - accept( - request: Request, - server: CaptunBunServer, - options?: CaptunServerAcceptTunnelOptions, - ): CaptunServerTunnel | undefined; - websocket: CaptunBunWebSocketHandler; -} - -export interface CaptunBunWebSocketHandler { - open(socket: CaptunBunServerWebSocket): void; - message(socket: CaptunBunServerWebSocket, message: CaptunBunWebSocketMessage): void; - close(socket: CaptunBunServerWebSocket, code: number, reason: string): void; - error(socket: CaptunBunServerWebSocket, error: Error): void; -} - -export interface CaptunBunServerWebSocket { - data: unknown; - send(message: string): unknown; - close(code?: number, reason?: string): void; -} - -export interface CaptunBunServer { - upgrade(request: Request, options: { data: unknown }): boolean; -} - -export type CaptunBunWebSocketMessage = string | Uint8Array | ArrayBuffer; - -interface PendingCaptunBunTunnel { - tunnel: CaptunServerTunnel; - connect(remoteClient: CaptunRemoteClient): void; -} - -interface CaptunBunUpgradeData { - captunTunnel?: PendingCaptunBunTunnel; -} - -interface CapnwebBunWebSocketData { - __capnwebStub: CaptunRemoteClient; -} - -const { newBunWebSocketRpcHandler } = (await import("capnweb")) as unknown as { - newBunWebSocketRpcHandler(createMain: () => unknown): CaptunBunWebSocketHandler; -}; +const { newBunWebSocketRpcHandler } = (await import("capnweb")) as any; -export function createCaptunBunTunnelHandler(): CaptunBunTunnelHandler { +export function createCaptunBunTunnelHandler() { const capnweb = newBunWebSocketRpcHandler(() => undefined); return { - accept(request, server, options = {}) { + accept(request: Request, server: any, options: CaptunServerAcceptTunnelOptions = {}) { const pendingTunnel = createPendingCaptunBunTunnel(options); if (!server.upgrade(request, { data: { captunTunnel: pendingTunnel } })) { pendingTunnel.tunnel[Symbol.dispose](); @@ -59,15 +16,15 @@ export function createCaptunBunTunnelHandler(): CaptunBunTunnelHandler { return pendingTunnel.tunnel; }, websocket: { - open(socket) { - const pendingTunnel = (socket.data as CaptunBunUpgradeData).captunTunnel; + open(socket: any) { + const pendingTunnel = socket.data.captunTunnel; if (!pendingTunnel) { socket.close(1008, "Missing Captun tunnel data"); return; } capnweb.open(socket); - pendingTunnel.connect((socket.data as CapnwebBunWebSocketData).__capnwebStub); + pendingTunnel.connect(socket.data.__capnwebStub); }, message: capnweb.message, close: capnweb.close, @@ -78,7 +35,7 @@ export function createCaptunBunTunnelHandler(): CaptunBunTunnelHandler { function createPendingCaptunBunTunnel( options: CaptunServerAcceptTunnelOptions, -): PendingCaptunBunTunnel { +) { let connectedTunnel: CaptunServerTunnel | undefined; let connectTunnel: (tunnel: CaptunServerTunnel) => void = () => {}; let rejectTunnel: (error: Error) => void = () => {}; @@ -106,7 +63,7 @@ function createPendingCaptunBunTunnel( return { tunnel, - connect(remote) { + connect(remote: CaptunRemoteClient) { if (closed) { remote[Symbol.dispose](); return; diff --git a/tasks/complete/2026-05-19-runtime-server-adapters.md b/tasks/complete/2026-05-19-runtime-server-adapters.md index 1652715..4bb6528 100644 --- a/tasks/complete/2026-05-19-runtime-server-adapters.md +++ b/tasks/complete/2026-05-19-runtime-server-adapters.md @@ -36,6 +36,7 @@ Captun should support the weather-reporter pattern outside Cloudflare Workers. T - 2026-05-19: Follow-up review asked to remove local Bun and Deno ambient declarations from the server examples. Added runtime-specific example typecheck setup instead: Bun uses `@types/bun`, Deno uses `deno check`, and the shared socket accept helper moved to `src/server-core.ts` so Deno does not need to typecheck Cloudflare's `WebSocketPair` route. - 2026-05-19: Follow-up review questioned repeated runtime subpath type re-exports. Moved shared public types to the root `captun` export and removed `CaptunServerTunnel`/`CaptunServerAcceptTunnelOptions` re-exports from `captun/server`, `captun/bun`, `captun/deno`, and `captun/node`. - 2026-05-19: Follow-up review asked why the Bun adapter was much larger than the Cap'n Web README example. Simplified `src/bun.ts` to wrap `newBunWebSocketRpcHandler()` instead of manually storing sessions and dispatching message/close/error events. +- 2026-05-19: Follow-up review asked to remove the remaining Bun type-interface wall. Collapsed the Bun/Cap'n Web callback glue to inferred/`any` types and kept only the meaningful Captun tunnel/option types. - 2026-05-19: Verification passed: - `pnpm exec tsc -p tsconfig.json --noEmit` - `pnpm exec vitest run examples/bun/bun.test.ts examples/node/node.test.ts examples/deno/deno.test.ts examples/cloudflare/cloudflare.test.ts` From 759346f0017e4186787d5b5c1cf35b60dba0b1bd Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Tue, 19 May 2026 16:42:11 +0100 Subject: [PATCH 41/43] Clarify Bun pending tunnel flow --- src/bun.ts | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/src/bun.ts b/src/bun.ts index 5b1d29f..7538b99 100644 --- a/src/bun.ts +++ b/src/bun.ts @@ -1,30 +1,36 @@ import { captunTunnelFromRemoteClient, type CaptunRemoteClient } from "./server-core.js"; import type { CaptunServerAcceptTunnelOptions, CaptunServerTunnel } from "./types.js"; -const { newBunWebSocketRpcHandler } = (await import("capnweb")) as any; +// @ts-ignore -- capnweb exports separate types for bun but this lib is built from node. it'll work at runtime though. +import { newBunWebSocketRpcHandler } from "capnweb"; export function createCaptunBunTunnelHandler() { const capnweb = newBunWebSocketRpcHandler(() => undefined); return { - accept(request: Request, server: any, options: CaptunServerAcceptTunnelOptions = {}) { + accept(request: Request, server: { upgrade: Function }, options: CaptunServerAcceptTunnelOptions = {}) { const pendingTunnel = createPendingCaptunBunTunnel(options); - if (!server.upgrade(request, { data: { captunTunnel: pendingTunnel } })) { + const upgraded = server.upgrade(request, { data: { captunTunnel: pendingTunnel } }); + if (!upgraded) { pendingTunnel.tunnel[Symbol.dispose](); return undefined; } return pendingTunnel.tunnel; }, websocket: { - open(socket: any) { - const pendingTunnel = socket.data.captunTunnel; + open(socket: unknown) { + const _socket = socket as { + data: { __capnwebStub: any; captunTunnel: any }; + close: Function; + }; + const pendingTunnel = _socket.data.captunTunnel; if (!pendingTunnel) { - socket.close(1008, "Missing Captun tunnel data"); + _socket.close(1008, "Missing Captun tunnel data"); return; } - capnweb.open(socket); - pendingTunnel.connect(socket.data.__capnwebStub); + capnweb.open(_socket); + pendingTunnel.connect(_socket.data.__capnwebStub); }, message: capnweb.message, close: capnweb.close, @@ -33,9 +39,11 @@ export function createCaptunBunTunnelHandler() { }; } -function createPendingCaptunBunTunnel( - options: CaptunServerAcceptTunnelOptions, -) { +/** + * Creates a tunnel handle *before* Bun gives us the actual socket, because `server.upgrade(...)` just returns a boolean. The WebSocket arrives later in `open(...)`. + * So we need to pass this "pending tunnel" reference to `server.upgrade(...)` via `data`, to be fished out later. + */ +function createPendingCaptunBunTunnel(options: CaptunServerAcceptTunnelOptions) { let connectedTunnel: CaptunServerTunnel | undefined; let connectTunnel: (tunnel: CaptunServerTunnel) => void = () => {}; let rejectTunnel: (error: Error) => void = () => {}; From e1950a5a1629f7cb62978b33bc9beb4e41a2be48 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Tue, 19 May 2026 17:46:13 +0100 Subject: [PATCH 42/43] Annotate performance comparisons --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 4740498..17cef7d 100644 --- a/README.md +++ b/README.md @@ -164,15 +164,15 @@ On May 18, 2026 from London, one warm-shard Captun tunnel reached first fetch in | Ad-hoc tunnel | First fetch | | ------------------------ | ----------: | -| Captun | 188ms | -| ngrok | 451ms | -| cloudflared quick tunnel | 8.51s | +| captun | 188ms | +| ngrok | 451ms (+140%) | +| cloudflared quick tunnel | 8.51s (+4,427%) | | 10 concurrent ad-hoc tunnels | Successful | p50 | p90 | p99 | | ---------------------------- | ---------: | ----: | ----: | ----: | -| Captun | 10/10 | 172ms | 186ms | 189ms | -| ngrok | 10/10 | 658ms | 695ms | 985ms | -| cloudflared quick tunnel | 2/10 | 8.89s | 9.00s | 9.00s | +| captun | 10/10 | 172ms | 186ms | 189ms | +| ngrok | 10/10 | 658ms (+283%) | 695ms (+274%) | 985ms (+421%) | +| cloudflared quick tunnel | 2/10 | 8.89s (+5,069%) | 9.00s (+4,739%) | 9.00s (+4,662%) | One shard is the default because it spins up fastest. More shards trade extra cold starts for more total throughput: 100 concurrent 2MiB streams through one shard took 26.34s p50, while 150 concurrent 2MiB streams spread over 256 warmed shards took 9.76s p50. From 2830d50debca791837e149478b29a696ebeb6f1c Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Thu, 21 May 2026 10:06:34 +0100 Subject: [PATCH 43/43] Align runtime adapters with consolidated API types --- examples/bun/server.ts | 11 ++--- examples/bun/tsconfig.json | 3 -- examples/cloudflare/worker.ts | 4 +- examples/deno/deno.json | 1 - examples/deno/server.ts | 3 +- examples/node/server.ts | 3 +- src/bun.ts | 23 +++++----- src/deno.ts | 5 +-- src/index.ts | 43 ++++++++++--------- src/node.ts | 5 +-- src/server-core.ts | 30 ++++++------- src/types.ts | 26 ----------- src/worker.ts | 4 +- .../2026-05-19-runtime-server-adapters.md | 2 +- 14 files changed, 64 insertions(+), 99 deletions(-) delete mode 100644 src/types.ts diff --git a/examples/bun/server.ts b/examples/bun/server.ts index b93c957..75e3518 100644 --- a/examples/bun/server.ts +++ b/examples/bun/server.ts @@ -1,11 +1,9 @@ -import type { CaptunServerTunnel } from "captun"; import { createCaptunBunTunnelHandler } from "captun/bun"; -let egressTunnel: CaptunServerTunnel | undefined; -const egressFetch = async ( - input: Parameters[0], - init?: Parameters[1], -) => { +const captun = createCaptunBunTunnelHandler(); + +let egressTunnel: ReturnType; +const egressFetch = async (input: string | URL | Request, init?: RequestInit) => { if (egressTunnel) { const request = input instanceof Request ? new Request(input, init) : new Request(input.toString(), init); @@ -13,7 +11,6 @@ const egressFetch = async ( } return fetch(input, init); }; -const captun = createCaptunBunTunnelHandler(); const server = Bun.serve({ hostname: "127.0.0.1", diff --git a/examples/bun/tsconfig.json b/examples/bun/tsconfig.json index 60fe319..f83d1ca 100644 --- a/examples/bun/tsconfig.json +++ b/examples/bun/tsconfig.json @@ -1,9 +1,6 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "paths": { - "captun": ["../../src/types.ts"] - }, "types": ["bun"] }, "include": ["server.ts"], diff --git a/examples/cloudflare/worker.ts b/examples/cloudflare/worker.ts index 1061c0e..df53c5c 100644 --- a/examples/cloudflare/worker.ts +++ b/examples/cloudflare/worker.ts @@ -1,12 +1,12 @@ import { DurableObject } from "cloudflare:workers"; -import { acceptCaptunTunnel, type CaptunServerTunnel } from "captun"; +import { acceptCaptunTunnel } from "captun"; type WeatherReporterEnv = Env & { WEATHER_REPORTER_EGRESS: DurableObjectNamespace; }; export class WeatherReporterEgressTunnel extends DurableObject { - private egressTunnel: CaptunServerTunnel | undefined; + private egressTunnel: ReturnType["tunnel"] | undefined; private readonly egressFetch: typeof fetch = async (input, init) => { if (this.egressTunnel) return this.egressTunnel.fetch(new Request(input, init)); return fetch(input, init); diff --git a/examples/deno/deno.json b/examples/deno/deno.json index 8167a8f..8018afe 100644 --- a/examples/deno/deno.json +++ b/examples/deno/deno.json @@ -1,6 +1,5 @@ { "imports": { - "captun": "../../src/types.ts", "captun/deno": "../../src/deno.ts", "capnweb": "npm:capnweb@0.8.0" } diff --git a/examples/deno/server.ts b/examples/deno/server.ts index 0280c5f..bf19471 100644 --- a/examples/deno/server.ts +++ b/examples/deno/server.ts @@ -1,7 +1,6 @@ -import type { CaptunServerTunnel } from "captun"; import { acceptCaptunDenoTunnel } from "captun/deno"; -let egressTunnel: CaptunServerTunnel | undefined; +let egressTunnel: ReturnType | undefined; const egressFetch: typeof fetch = async (input, init) => { if (egressTunnel) return egressTunnel.fetch(new Request(input, init)); return fetch(input, init); diff --git a/examples/node/server.ts b/examples/node/server.ts index 9e7ef3a..1c52e56 100644 --- a/examples/node/server.ts +++ b/examples/node/server.ts @@ -1,11 +1,10 @@ import http from "node:http"; import { createServerAdapter } from "@whatwg-node/server"; -import type { CaptunServerTunnel } from "captun"; import { acceptCaptunNodeTunnel } from "captun/node"; import { WebSocketServer } from "ws"; -let egressTunnel: CaptunServerTunnel | undefined; +let egressTunnel: ReturnType | undefined; const egressFetch: typeof fetch = async (input, init) => { if (egressTunnel) return egressTunnel.fetch(new Request(input, init)); return fetch(input, init); diff --git a/src/bun.ts b/src/bun.ts index 9bb0997..a0f0c2b 100644 --- a/src/bun.ts +++ b/src/bun.ts @@ -1,5 +1,4 @@ import { captunTunnelFromRemoteClient, type CaptunRemoteClient } from "./server-core.js"; -import type { CaptunServerAcceptTunnelOptions, CaptunServerTunnel } from "./types.js"; // @ts-ignore -- capnweb exports separate types for bun but this lib is built from node. it'll work at runtime though. import { newBunWebSocketRpcHandler } from "capnweb"; @@ -11,7 +10,7 @@ export function createCaptunBunTunnelHandler() { accept( request: Request, server: { upgrade: Function }, - options: CaptunServerAcceptTunnelOptions = {}, + options: { onDisconnect?: () => void } = {}, ) { const pendingTunnel = createPendingCaptunBunTunnel(options); const upgraded = server.upgrade(request, { data: { captunTunnel: pendingTunnel } }); @@ -47,19 +46,21 @@ export function createCaptunBunTunnelHandler() { * Creates a tunnel handle *before* Bun gives us the actual socket, because `server.upgrade(...)` just returns a boolean. The WebSocket arrives later in `open(...)`. * So we need to pass this "pending tunnel" reference to `server.upgrade(...)` via `data`, to be fished out later. */ -function createPendingCaptunBunTunnel(options: CaptunServerAcceptTunnelOptions) { - let connectedTunnel: CaptunServerTunnel | undefined; - let connectTunnel: (tunnel: CaptunServerTunnel) => void = () => {}; +function createPendingCaptunBunTunnel(options: { onDisconnect?: () => void }) { + let connectedTunnel: ReturnType | undefined; + let connectTunnel: (tunnel: ReturnType) => void = () => {}; let rejectTunnel: (error: Error) => void = () => {}; let closed = false; - const tunnelReady = new Promise((resolve, reject) => { - connectTunnel = resolve; - rejectTunnel = reject; - }); + const tunnelReady = new Promise>( + (resolve, reject) => { + connectTunnel = resolve; + rejectTunnel = reject; + }, + ); tunnelReady.catch(() => undefined); - const tunnel: CaptunServerTunnel = { - async fetch(request) { + const tunnel = { + async fetch(request: Request) { if (closed) throw new Error("Captun Bun tunnel is closed"); const connected = await tunnelReady; if (closed) throw new Error("Captun Bun tunnel is closed"); diff --git a/src/deno.ts b/src/deno.ts index ddda277..9ab0cf1 100644 --- a/src/deno.ts +++ b/src/deno.ts @@ -1,9 +1,8 @@ import { acceptCaptunTunnelFromSocket } from "./server-core.js"; -import type { CaptunServerAcceptTunnelOptions, CaptunServerTunnel } from "./types.js"; export function acceptCaptunDenoTunnel( socket: WebSocket, - options: CaptunServerAcceptTunnelOptions = {}, -): CaptunServerTunnel { + options: { onDisconnect?: () => void } = {}, +) { return acceptCaptunTunnelFromSocket(socket, options); } diff --git a/src/index.ts b/src/index.ts index da7d48a..665531e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,17 +1,17 @@ import { newWebSocketRpcSession, RpcTarget } from "capnweb"; import { acceptCaptunTunnelFromSocket } from "./server-core.js"; -import type { - CaptunClientCreateTunnelOptions, - CaptunClientRemoteFetcher, - CaptunServerAcceptTunnelOptions, -} from "./types.js"; -export type { - CaptunClientCreateTunnelOptions, - CaptunServerAcceptTunnelOptions, - CaptunServerTunnel, - Fetcher, -} from "./types.js"; +/** Fetch is all you need! + * + * Cap'n Web let us pass this fetcher from the + * tunnel client to the server via fetch (via websockets) + * Then the server can just fetch into the client like normal. + * This is all possible because Cap'n Web can pass Request and Response object + * across the websocket RPC boundary transparently + **/ +export interface Fetcher { + fetch(request: Request): Response | Promise; +} /** Creates a tunnel from a public Worker URL to a local fetch implementation. * @@ -23,10 +23,13 @@ export type { * https://github.com/cloudflare/capnweb#websocket-client */ export async function createCaptunTunnel( - options: CaptunClientCreateTunnelOptions, + options: Fetcher & { + url: string | URL; + headers?: Record; + }, ): Promise { const socket = createWebSocket(options); - const session = newWebSocketRpcSession(socket, new LocalFetcher(options)); + const session = newWebSocketRpcSession(socket, new TunnelTargetFetcher({ fetch: options.fetch })); await waitUntilOpen(socket); return { @@ -34,20 +37,20 @@ export async function createCaptunTunnel( }; } -class LocalFetcher extends RpcTarget implements CaptunClientRemoteFetcher { - private options: CaptunClientCreateTunnelOptions; +class TunnelTargetFetcher extends RpcTarget implements Fetcher { + private fetcher: Fetcher; - constructor(options: CaptunClientCreateTunnelOptions) { + constructor(fetcher: Fetcher) { super(); - this.options = options; + this.fetcher = fetcher; } fetch(request: Request) { - return this.options.fetch(request); + return this.fetcher.fetch(request); } } -function createWebSocket(options: CaptunClientCreateTunnelOptions) { +function createWebSocket(options: { url: string | URL; headers?: Record }) { const connectUrl = new URL(options.url); connectUrl.protocol = connectUrl.protocol === "https:" ? "wss:" : "ws:"; // TypeScript sees the standard DOM/Workers constructor here, where the second @@ -93,7 +96,7 @@ async function waitUntilOpen(socket: WebSocket) { } /** Creates a Worker WebSocket upgrade response and matching tunnel handle. */ -export function acceptCaptunTunnel(options: CaptunServerAcceptTunnelOptions = {}) { +export function acceptCaptunTunnel(options: { onDisconnect?: () => void } = {}) { const pair = new WebSocketPair(); const clientSocket = pair[0]; const serverSocket = pair[1]; diff --git a/src/node.ts b/src/node.ts index 8c60b54..92e38ff 100644 --- a/src/node.ts +++ b/src/node.ts @@ -1,5 +1,4 @@ import { acceptCaptunTunnelFromSocket } from "./server-core.js"; -import type { CaptunServerAcceptTunnelOptions, CaptunServerTunnel } from "./types.js"; /** A type `import('ws').WebSocket` conforms to. This will be cast internally before passing to `capnweb` */ export interface WSWebSocketLike { @@ -11,7 +10,7 @@ export interface WSWebSocketLike { export function acceptCaptunNodeTunnel( socket: WSWebSocketLike, - options: CaptunServerAcceptTunnelOptions = {}, -): CaptunServerTunnel { + options: { onDisconnect?: () => void } = {}, +) { return acceptCaptunTunnelFromSocket(socket as unknown as WebSocket, options); } diff --git a/src/server-core.ts b/src/server-core.ts index 8d28008..1cd7fcf 100644 --- a/src/server-core.ts +++ b/src/server-core.ts @@ -1,32 +1,30 @@ import { newWebSocketRpcSession } from "capnweb"; -import type { - CaptunClientRemoteFetcher, - CaptunServerAcceptTunnelOptions, - CaptunServerTunnel, -} from "./types.js"; -export interface CaptunRemoteClient extends CaptunClientRemoteFetcher, Disposable { - onRpcBroken(callback: () => void): void; -} +type Fetcher = { + fetch(request: Request): Response | Promise; +}; + +export type CaptunRemoteClient = Fetcher & + Disposable & { + onRpcBroken(callback: () => void): void; + }; export function captunTunnelFromRemoteClient( remoteClient: CaptunRemoteClient, - options: CaptunServerAcceptTunnelOptions, -): CaptunServerTunnel { + options: { onDisconnect?: () => void }, +) { remoteClient.onRpcBroken(() => options.onDisconnect?.()); return { - fetch: (request) => remoteClient.fetch(request), + fetch: (request: Request) => remoteClient.fetch(request), [Symbol.dispose]: () => remoteClient[Symbol.dispose](), }; } export function acceptCaptunTunnelFromSocket( socket: WebSocket, - options: CaptunServerAcceptTunnelOptions = {}, -): CaptunServerTunnel { - const remoteClient = newWebSocketRpcSession( - socket, - ) as CaptunRemoteClient; + options: { onDisconnect?: () => void } = {}, +) { + const remoteClient = newWebSocketRpcSession(socket) as CaptunRemoteClient; return captunTunnelFromRemoteClient(remoteClient, options); } diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index f2c2518..0000000 --- a/src/types.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { RpcTarget } from "capnweb"; - -/** Something that can handle a Fetch API request. */ -export interface Fetcher { - fetch(request: Request): Response | Promise; -} - -/** Options for opening a local process to public Captun tunnel connection. */ -export interface CaptunClientCreateTunnelOptions extends Fetcher { - /** Exact WebSocket-capable connect URL, including the app's connect route. */ - url: string | URL; - /** Headers sent on the WebSocket upgrade request, for auth or routing metadata. */ - headers?: Record; -} - -/** Client-side fetcher exposed to the server over the WebSocket RPC session. */ -export interface CaptunClientRemoteFetcher extends Fetcher, RpcTarget {} - -/** Options for accepting a client WebSocket as a server-side tunnel. */ -export interface CaptunServerAcceptTunnelOptions { - /** Called when the underlying RPC connection breaks. */ - onDisconnect?: () => void; -} - -/** Server-side handle for forwarding HTTP requests through an accepted tunnel. */ -export interface CaptunServerTunnel extends Fetcher, Disposable {} diff --git a/src/worker.ts b/src/worker.ts index a641610..56dc387 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -1,5 +1,5 @@ import { DurableObject } from "cloudflare:workers"; -import { acceptCaptunTunnel, type Fetcher } from "./index.js"; +import { acceptCaptunTunnel } from "./index.js"; import { captunShardName, getTunnelNameFromUrl, @@ -25,7 +25,7 @@ const TUNNEL_NAME_HEADER = "x-captun-tunnel-name"; * aggregate throughput for lots of concurrent large responses. */ export class CaptunServerShard extends DurableObject { - private tunnels = new Map(); + private tunnels = new Map["tunnel"]>(); // The DO's `fetch` only handles the WebSocket upgrade. The upgrade hand-off // is special-cased by the Workers runtime around `stub.fetch(...)`: a 101 diff --git a/tasks/complete/2026-05-19-runtime-server-adapters.md b/tasks/complete/2026-05-19-runtime-server-adapters.md index 4bb6528..b43db44 100644 --- a/tasks/complete/2026-05-19-runtime-server-adapters.md +++ b/tasks/complete/2026-05-19-runtime-server-adapters.md @@ -34,7 +34,7 @@ Captun should support the weather-reporter pattern outside Cloudflare Workers. T - 2026-05-19: Follow-up review asked for the Node example to use a real Fetch adapter and for the runtime servers to avoid extra health endpoints. Switched Node to `@whatwg-node/server`, removed `/__health__` from Bun/Deno/Node, and changed the runtime test helpers to wait for the TCP listener instead. - 2026-05-19: Adding `@whatwg-node/server` exposed the Worker's untyped `crypto.subtle.timingSafeEqual` usage during root typecheck. Replaced it with a small local byte comparison in `src/worker.ts`. - 2026-05-19: Follow-up review asked to remove local Bun and Deno ambient declarations from the server examples. Added runtime-specific example typecheck setup instead: Bun uses `@types/bun`, Deno uses `deno check`, and the shared socket accept helper moved to `src/server-core.ts` so Deno does not need to typecheck Cloudflare's `WebSocketPair` route. -- 2026-05-19: Follow-up review questioned repeated runtime subpath type re-exports. Moved shared public types to the root `captun` export and removed `CaptunServerTunnel`/`CaptunServerAcceptTunnelOptions` re-exports from `captun/server`, `captun/bun`, `captun/deno`, and `captun/node`. +- 2026-05-21: After merging PR #12, removed the reintroduced `src/types.ts` module and switched the runtime examples to infer tunnel handle types from the accept helpers instead of importing boilerplate public interfaces. - 2026-05-19: Follow-up review asked why the Bun adapter was much larger than the Cap'n Web README example. Simplified `src/bun.ts` to wrap `newBunWebSocketRpcHandler()` instead of manually storing sessions and dispatching message/close/error events. - 2026-05-19: Follow-up review asked to remove the remaining Bun type-interface wall. Collapsed the Bun/Cap'n Web callback glue to inferred/`any` types and kept only the meaningful Captun tunnel/option types. - 2026-05-19: Verification passed: