diff --git a/.oxlintrc.json b/.oxlintrc.json index 8555d6e..7be8dd6 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -99,6 +99,37 @@ "valid-typeof": "error" }, "overrides": [ + { + "files": ["src/**"], + "rules": { + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "regex": ".*", + "message": "Root library source may only import capnweb." + } + ] + } + ] + } + }, + { + "files": [ + "examples/**", + "oxlint-plugin-captun.js", + "scripts/**", + "src/cli/**", + "src/hosted/**", + "src/server/**", + "src/worker/**", + "test/**" + ], + "rules": { + "no-restricted-imports": "off" + } + }, { "files": ["**/test/**", "**/*.{test,spec}.{js,jsx,ts,tsx,mjs,cjs,mts,cts}"], "rules": { diff --git a/CONTEXT.md b/CONTEXT.md index f596da6..db0f22f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -128,8 +128,8 @@ _Avoid_: Agent layer, agent product - `createCaptunTunnel` should return a reusable `token` when the **Tunnel Gateway** provides or accepts one. - New code should not support `/__captun-connect`; connect intent belongs in Captun query parameters on the **Gateway Connect Request**. - Default custom-domain **Self-Hosted Deployments** should choose a **Gateway** hostname inside the wildcard tunnel route and make that hostname a **Reserved Tunnel Name**. -- `captun`, `gateway`, and `tunnel` should be **Reserved Tunnel Names** by default, along with a small set of likely future **Control Plane** names. -- **Reserved Tunnel Names** apply to the **Hosted Service** and to wizard-generated **Self-Hosted Deployments**. Manual/custom deployments may change the list. +- `captun` and `gateway` should be **Reserved Tunnel Names** for custom-domain **Self-Hosted Deployments** created by the wizard, because those labels can be the **Gateway** hostname inside the wildcard route. +- The **Hosted Service** owns a broader **Reserved Tunnel Name** list, including likely future **Control Plane** names such as `billing`, `dashboard`, and `tunnel`. - A **Self-Hosted Deployment** runs a **Tunnel Gateway** in a user's own infrastructure. - The current deploy wizard creates a **Cloudflare Tunnel Gateway**, but future runtime gateways could also be **Self-Hosted Deployments**. - The **Hosted Service** is a public **Tunnel Gateway** operated for untrusted users. diff --git a/README.md b/README.md index 3f15436..337b587 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ 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: +The captun [worker.ts](./src/server/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"; @@ -124,10 +124,10 @@ export default { } satisfies ExportedHandler; ``` -The core client/server pieces (`createCaptunTunnel`, `acceptFetcherCapability`, `acceptFetcherCapabilityFromSocket`, `Fetcher`, and `FetcherStub`) live in [src/index.ts](./src/index.ts) — small TypeScript wrappers around [Cap'n Web](https://github.com/cloudflare/capnweb). For a self-hosted Cloudflare Tunnel Gateway, copy or adapt [src/worker.ts](./src/worker.ts) and the Durable Object binding in [wrangler.jsonc](./wrangler.jsonc). The Iterate-operated hosted service is separate: its product surface lives under [src/hosted](./src/hosted), with [wrangler.hosted.jsonc](./wrangler.hosted.jsonc) as its deployment config. +The core client/server pieces (`createCaptunTunnel`, `acceptFetcherCapability`, `acceptFetcherCapabilityFromSocket`, `Fetcher`, and `FetcherStub`) live in [src/index.ts](./src/index.ts) — small TypeScript wrappers around [Cap'n Web](https://github.com/cloudflare/capnweb). For a self-hosted Cloudflare Tunnel Gateway, copy or adapt [src/server/worker.ts](./src/server/worker.ts) and the Durable Object binding in [wrangler.jsonc](./wrangler.jsonc). The Iterate-operated hosted service is separate: its product surface lives under [src/hosted](./src/hosted), with [wrangler.hosted.jsonc](./wrangler.hosted.jsonc) as its deployment config. -Runtime adapters for accepting Fetcher Capabilities outside Cloudflare Workers live under -`captun/node`, `captun/bun`, and `captun/deno`. See [examples/node](./examples/node), +Runtime Adapters for accepting Fetcher Capabilities outside Cloudflare Workers are implemented under +`src/server` and exported as `captun/node`, `captun/bun`, and `captun/deno`. See [examples/node](./examples/node), [examples/bun](./examples/bun), and [examples/deno](./examples/deno) for the same small weather egress test running in each runtime. @@ -151,7 +151,7 @@ By default the worker routes `/my-tunnel/foo/bar` to the capnweb session for "my Running `npx captun deploy` interactively walks you through where the tunnel URLs should live. There are four options, and which one is best for you depends on the kind of apps you want to tunnel to and whether you already have a domain on Cloudflare. -Routing is controlled by a single Worker env var, `CUSTOM_HOSTNAME`. When unset (workers.dev deploys), tunnels use folder routing: the first path segment is the tunnel name. When set (custom-domain deploys), tunnels use subdomain routing — the _last_ DNS label before `CUSTOM_HOSTNAME` is the tunnel name, and anything to the left of it is ignored. The deploy wizard sets `CUSTOM_HOSTNAME` for you; the parsing logic lives in `getTunnelNameFromUrl` in [src/routing.ts](./src/routing.ts). +Routing is controlled by a single Worker env var, `CUSTOM_HOSTNAME`. When unset (workers.dev deploys), tunnels use folder routing: the first path segment is the tunnel name. When set (custom-domain deploys), tunnels use subdomain routing — the _last_ DNS label before `CUSTOM_HOSTNAME` is the tunnel name, and anything to the left of it is ignored. The deploy wizard sets `CUSTOM_HOSTNAME` for you; the parsing logic lives in `getTunnelNameFromUrl` in [src/server/tunnel-addressing.ts](./src/server/tunnel-addressing.ts). #### 1. `..workers.dev/` (default) diff --git a/examples/deno/deno.json b/examples/deno/deno.json index 8018afe..1080eac 100644 --- a/examples/deno/deno.json +++ b/examples/deno/deno.json @@ -1,6 +1,6 @@ { "imports": { - "captun/deno": "../../src/deno.ts", + "captun/deno": "../../src/server/deno.ts", "capnweb": "npm:capnweb@0.8.0" } } diff --git a/package.json b/package.json index a9d7c8c..3cbbcec 100644 --- a/package.json +++ b/package.json @@ -23,20 +23,20 @@ "import": "./src/index.ts" }, "./bun": { - "types": "./src/bun.ts", - "import": "./src/bun.ts" + "types": "./src/server/bun.ts", + "import": "./src/server/bun.ts" }, "./deno": { - "types": "./src/deno.ts", - "import": "./src/deno.ts" + "types": "./src/server/deno.ts", + "import": "./src/server/deno.ts" }, "./node": { - "types": "./src/node.ts", - "import": "./src/node.ts" + "types": "./src/server/node.ts", + "import": "./src/server/node.ts" }, "./worker": { - "types": "./src/worker.ts", - "import": "./src/worker.ts" + "types": "./src/server/worker.ts", + "import": "./src/server/worker.ts" } }, "publishConfig": { @@ -49,20 +49,20 @@ "import": "./dist/index.js" }, "./bun": { - "types": "./dist/bun.d.ts", - "import": "./dist/bun.js" + "types": "./dist/server/bun.d.ts", + "import": "./dist/server/bun.js" }, "./deno": { - "types": "./dist/deno.d.ts", - "import": "./dist/deno.js" + "types": "./dist/server/deno.d.ts", + "import": "./dist/server/deno.js" }, "./node": { - "types": "./dist/node.d.ts", - "import": "./dist/node.js" + "types": "./dist/server/node.d.ts", + "import": "./dist/server/node.js" }, "./worker": { - "types": "./dist/worker.d.ts", - "import": "./dist/worker.js" + "types": "./dist/server/worker.d.ts", + "import": "./dist/server/worker.js" } } }, diff --git a/src/cli/bin.ts b/src/cli/bin.ts index c29f790..d7c3833 100755 --- a/src/cli/bin.ts +++ b/src/cli/bin.ts @@ -13,11 +13,14 @@ import { createCli, yamlTableConsoleLogger } from "trpc-cli"; import { z } from "zod/v4"; import { color } from "./ansi.js"; import { CliFriendlyError } from "./cli-error.js"; -import { CaptunTunnelConnectError, createCaptunTunnel } from "../index.js"; +import { + CaptunTunnelConnectError, + createCaptunTunnel, + HOSTED_CAPTUN_GATEWAY, + randomConnectToken, +} from "../index.js"; import { assertLocalTargetAcceptingConnections } from "./local-target.js"; import { withSpinner } from "./spinner.js"; -import { HOSTED_CAPTUN_GATEWAY, HOSTED_CAPTUN_HOSTNAME } from "../routing.js"; -import { randomConnectToken } from "../token.js"; import { captunHealthResponse, confirmTunnelHealth, @@ -447,7 +450,7 @@ function resolveTunnel(input: TunnelCliInput, config?: Config): ResolvedTunnel { const name = input.name || randomName(); const target = normalizeTarget(input.target); - const token = input.token || config?.token || hostedGatewayToken(gateway); + const token = input.token || config?.token || randomConnectToken(); return { name, @@ -458,11 +461,6 @@ function resolveTunnel(input: TunnelCliInput, config?: Config): ResolvedTunnel { }; } -function hostedGatewayToken(gateway: string) { - if (new URL(gateway).hostname !== HOSTED_CAPTUN_HOSTNAME) return undefined; - return randomConnectToken(); -} - function normalizeTarget(target: string) { const value = target.trim(); if (/^\d+$/.test(value)) return `http://127.0.0.1:${value}`; diff --git a/src/cli/deploy.ts b/src/cli/deploy.ts index b54cb03..4171a23 100644 --- a/src/cli/deploy.ts +++ b/src/cli/deploy.ts @@ -529,7 +529,7 @@ export async function deployWorker( string, unknown >; - const worker = resolve(packageRoot, "dist/worker.js"); + const worker = resolve(packageRoot, "dist/server/worker.js"); baseConfig.main = worker; if (input.name) baseConfig.name = input.name; if (input.accountId) baseConfig.account_id = input.accountId; diff --git a/src/hosted/browser-module.generated.ts b/src/hosted/browser-module.generated.ts index 8ddb4f1..f2b9448 100644 --- a/src/hosted/browser-module.generated.ts +++ b/src/hosted/browser-module.generated.ts @@ -1,4 +1,4 @@ // Generated by scripts/build-hosted-browser-module.ts. // Do not edit by hand. export const WWW_BROWSER_MODULE = - '// src/index.ts\nimport { newWebSocketRpcSession as newWebSocketRpcSession2, RpcTarget } from "https://esm.sh/capnweb@0.8.0";\n\n// src/routing.ts\nvar HOSTED_CAPTUN_HOSTNAME = "captun.sh";\nvar HOSTED_CAPTUN_GATEWAY = "https://captun.sh";\nvar GATEWAY_CONNECT_QUERY_PARAM = "captun-connect";\nvar TUNNEL_NAME_QUERY_PARAM = "captun-name";\nvar CONNECT_TOKEN_QUERY_PARAM = "captun-token";\nvar TUNNEL_CONNECT_DIAGNOSTIC_HEADER = "x-captun-connect-diagnostic";\n\n// src/server-core.ts\nimport { newWebSocketRpcSession } from "https://esm.sh/capnweb@0.8.0";\nfunction fetcherStubFromRemoteCapability(remote, options) {\n remote.onRpcBroken(() => options.onDisconnect?.());\n return {\n fetch: (request) => remote.fetch(request),\n ready: (tunnel) => remote.ready(tunnel),\n [Symbol.dispose]: () => remote[Symbol.dispose]()\n };\n}\nfunction acceptFetcherCapabilityFromSocket(socket, options = {}) {\n const remote = newWebSocketRpcSession(socket);\n return fetcherStubFromRemoteCapability(remote, options);\n}\n\n// src/token.ts\nfunction randomConnectToken() {\n const bytes = new Uint8Array(16);\n crypto.getRandomValues(bytes);\n return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");\n}\n\n// src/index.ts\nvar CaptunTunnelConnectError = class extends Error {\n response;\n constructor(message, response) {\n super(message);\n this.name = "CaptunTunnelConnectError";\n this.response = response;\n }\n};\nvar TUNNEL_READY_TIMEOUT_MS = 5e3;\nvar WEBSOCKET_REJECTION_PROBE_TIMEOUT_MS = 500;\nasync function createCaptunTunnel(options) {\n const connect = gatewayConnectRequest(options);\n const ready = Promise.withResolvers();\n const socket = createWebSocket(connect.url);\n const fetcher = new TunnelTargetFetcher({\n fetch: options.fetch,\n ready: (tunnel) => ready.resolve(tunnel)\n });\n const session = newWebSocketRpcSession2(socket, fetcher);\n try {\n await waitUntilOpen(socket, connect.url);\n const tunnel = await waitUntilReady(ready.promise);\n return {\n url: tunnel.url,\n token: tunnel.token || connect.token,\n [Symbol.dispose]: () => session[Symbol.dispose]()\n };\n } catch (error) {\n session[Symbol.dispose]();\n throw error;\n }\n}\nfunction gatewayConnectRequest(options) {\n const name = options.name || randomTunnelName();\n const url = new URL(options.gateway || HOSTED_CAPTUN_GATEWAY);\n const token = options.token || (isHostedCaptunGateway(url) ? randomConnectToken() : void 0);\n url.searchParams.set(GATEWAY_CONNECT_QUERY_PARAM, "1");\n url.searchParams.set(TUNNEL_NAME_QUERY_PARAM, name);\n if (token) url.searchParams.set(CONNECT_TOKEN_QUERY_PARAM, token);\n return { url: url.toString(), name, token };\n}\nfunction isHostedCaptunGateway(url) {\n return url.hostname === HOSTED_CAPTUN_HOSTNAME;\n}\nfunction randomTunnelName() {\n const bytes = new Uint8Array(8);\n crypto.getRandomValues(bytes);\n return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");\n}\nvar TunnelTargetFetcher = class extends RpcTarget {\n fetcher;\n onReady;\n constructor(options) {\n super();\n this.fetcher = { fetch: options.fetch };\n this.onReady = options.ready;\n }\n fetch(request) {\n return this.fetcher.fetch(request);\n }\n ready(tunnel) {\n this.onReady(tunnel);\n }\n};\nfunction createWebSocket(url) {\n const connectUrl = new URL(url);\n connectUrl.protocol = connectUrl.protocol === "https:" ? "wss:" : "ws:";\n return new WebSocket(connectUrl.href);\n}\nasync function waitUntilOpen(socket, connectUrl) {\n if (socket.readyState === WebSocket.OPEN) return;\n if (socket.readyState !== WebSocket.CONNECTING) {\n throw new Error("WebSocket closed before opening");\n }\n const listeners = new AbortController();\n await new Promise((resolve, reject) => {\n const settle = (callback) => {\n listeners.abort();\n callback();\n };\n socket.addEventListener("open", () => settle(resolve), { signal: listeners.signal });\n socket.addEventListener(\n "error",\n () => settle(() => {\n void webSocketConnectionFailedError(connectUrl).then(reject);\n }),\n { signal: listeners.signal }\n );\n socket.addEventListener(\n "close",\n (event) => {\n listeners.abort();\n void webSocketConnectionFailedError(connectUrl).then((error) => {\n reject(\n error.response ? error : new Error(`WebSocket closed before opening: ${event.code} ${event.reason}`)\n );\n });\n },\n { signal: listeners.signal }\n );\n });\n}\nasync function webSocketConnectionFailedError(connectUrl) {\n const response = await readWebSocketRejection(connectUrl);\n if (!response) return new CaptunTunnelConnectError("WebSocket connection failed", void 0);\n return new CaptunTunnelConnectError(\n `WebSocket connection failed: ${response.status} ${response.statusText}: ${response.body}`.trim(),\n response\n );\n}\nasync function readWebSocketRejection(connectUrl) {\n const abort = new AbortController();\n const timeout = setTimeout(() => abort.abort(), WEBSOCKET_REJECTION_PROBE_TIMEOUT_MS);\n try {\n const response = await fetch(connectUrl, {\n headers: { [TUNNEL_CONNECT_DIAGNOSTIC_HEADER]: "1" },\n signal: abort.signal\n });\n if (response.ok) return void 0;\n return {\n status: response.status,\n statusText: response.statusText || "Rejected",\n body: (await response.text()).trim()\n };\n } catch {\n return void 0;\n } finally {\n clearTimeout(timeout);\n }\n}\nasync function waitUntilReady(promise) {\n let timeout;\n try {\n return await Promise.race([\n promise,\n new Promise((_, reject) => {\n timeout = setTimeout(\n () => reject(new Error("Timed out waiting for tunnel gateway ready message")),\n TUNNEL_READY_TIMEOUT_MS\n );\n })\n ]);\n } finally {\n if (timeout) clearTimeout(timeout);\n }\n}\nfunction acceptFetcherCapability(options = {}) {\n const pair = new WebSocketPair();\n const clientSocket = pair[0];\n const serverSocket = pair[1];\n serverSocket.accept();\n const fetcher = acceptFetcherCapabilityFromSocket(serverSocket, options);\n return {\n fetcher,\n response: new Response(null, { status: 101, webSocket: clientSocket })\n };\n}\nexport {\n CaptunTunnelConnectError,\n acceptFetcherCapability,\n acceptFetcherCapabilityFromSocket,\n createCaptunTunnel\n};\n'; + '// src/index.ts\nimport { newWebSocketRpcSession, RpcTarget } from "https://esm.sh/capnweb@0.8.0";\nvar HOSTED_CAPTUN_GATEWAY = "https://captun.sh";\nvar GATEWAY_CONNECT_QUERY_PARAM = "captun-connect";\nvar TUNNEL_NAME_QUERY_PARAM = "captun-name";\nvar CONNECT_TOKEN_QUERY_PARAM = "captun-token";\nvar TUNNEL_CONNECT_DIAGNOSTIC_HEADER = "x-captun-connect-diagnostic";\nfunction fetcherStubFromRemoteCapability(remote, options) {\n remote.onRpcBroken(() => options.onDisconnect?.());\n return {\n fetch: (request) => remote.fetch(request),\n ready: (tunnel) => remote.ready(tunnel),\n [Symbol.dispose]: () => remote[Symbol.dispose]()\n };\n}\nfunction acceptFetcherCapabilityFromSocket(socket, options = {}) {\n const remote = newWebSocketRpcSession(socket);\n return fetcherStubFromRemoteCapability(remote, options);\n}\nfunction randomConnectToken() {\n const bytes = new Uint8Array(16);\n crypto.getRandomValues(bytes);\n return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");\n}\nvar CaptunTunnelConnectError = class extends Error {\n response;\n constructor(message, response) {\n super(message);\n this.name = "CaptunTunnelConnectError";\n this.response = response;\n }\n};\nvar TUNNEL_READY_TIMEOUT_MS = 5e3;\nvar WEBSOCKET_REJECTION_PROBE_TIMEOUT_MS = 500;\nasync function createCaptunTunnel(options) {\n const connectUrl = gatewayConnectRequest(options);\n const ready = Promise.withResolvers();\n const socket = createWebSocket(connectUrl);\n const fetcher = new TunnelTargetFetcher({\n fetch: options.fetch,\n ready: (tunnel) => ready.resolve(tunnel)\n });\n const session = newWebSocketRpcSession(socket, fetcher);\n try {\n await waitUntilOpen(socket, connectUrl);\n const tunnel = await waitUntilReady(ready.promise);\n return {\n ...tunnel,\n [Symbol.dispose]: () => session[Symbol.dispose]()\n };\n } catch (error) {\n session[Symbol.dispose]();\n throw error;\n }\n}\nfunction gatewayConnectRequest(options) {\n const name = options.name || randomTunnelName();\n const url = new URL(options.gateway || HOSTED_CAPTUN_GATEWAY);\n const token = options.token || randomConnectToken();\n url.searchParams.set(GATEWAY_CONNECT_QUERY_PARAM, "1");\n url.searchParams.set(TUNNEL_NAME_QUERY_PARAM, name);\n url.searchParams.set(CONNECT_TOKEN_QUERY_PARAM, token);\n return url.toString();\n}\nfunction randomTunnelName() {\n const bytes = new Uint8Array(8);\n crypto.getRandomValues(bytes);\n return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");\n}\nvar TunnelTargetFetcher = class extends RpcTarget {\n fetcher;\n onReady;\n constructor(options) {\n super();\n this.fetcher = { fetch: options.fetch };\n this.onReady = options.ready;\n }\n fetch(request) {\n return this.fetcher.fetch(request);\n }\n ready(tunnel) {\n this.onReady(tunnel);\n }\n};\nfunction createWebSocket(url) {\n const connectUrl = new URL(url);\n connectUrl.protocol = connectUrl.protocol === "https:" ? "wss:" : "ws:";\n return new WebSocket(connectUrl.href);\n}\nasync function waitUntilOpen(socket, connectUrl) {\n if (socket.readyState === WebSocket.OPEN) return;\n if (socket.readyState !== WebSocket.CONNECTING) {\n throw new Error("WebSocket closed before opening");\n }\n const listeners = new AbortController();\n await new Promise((resolve, reject) => {\n const settle = (callback) => {\n listeners.abort();\n callback();\n };\n socket.addEventListener("open", () => settle(resolve), { signal: listeners.signal });\n socket.addEventListener(\n "error",\n () => settle(() => {\n void webSocketConnectionFailedError(connectUrl).then(reject);\n }),\n { signal: listeners.signal }\n );\n socket.addEventListener(\n "close",\n (event) => {\n listeners.abort();\n void webSocketConnectionFailedError(connectUrl).then((error) => {\n reject(\n error.response ? error : new Error(`WebSocket closed before opening: ${event.code} ${event.reason}`)\n );\n });\n },\n { signal: listeners.signal }\n );\n });\n}\nasync function webSocketConnectionFailedError(connectUrl) {\n const response = await readWebSocketRejection(connectUrl);\n if (!response) return new CaptunTunnelConnectError("WebSocket connection failed", void 0);\n return new CaptunTunnelConnectError(\n `WebSocket connection failed: ${response.status} ${response.statusText}: ${response.body}`.trim(),\n response\n );\n}\nasync function readWebSocketRejection(connectUrl) {\n const abort = new AbortController();\n const timeout = setTimeout(() => abort.abort(), WEBSOCKET_REJECTION_PROBE_TIMEOUT_MS);\n try {\n const response = await fetch(connectUrl, {\n headers: { [TUNNEL_CONNECT_DIAGNOSTIC_HEADER]: "1" },\n signal: abort.signal\n });\n if (response.ok) return void 0;\n return {\n status: response.status,\n statusText: response.statusText || "Rejected",\n body: (await response.text()).trim()\n };\n } catch {\n return void 0;\n } finally {\n clearTimeout(timeout);\n }\n}\nasync function waitUntilReady(promise) {\n let timeout;\n try {\n return await Promise.race([\n promise,\n new Promise((_, reject) => {\n timeout = setTimeout(\n () => reject(new Error("Timed out waiting for tunnel gateway ready message")),\n TUNNEL_READY_TIMEOUT_MS\n );\n })\n ]);\n } finally {\n if (timeout) clearTimeout(timeout);\n }\n}\nfunction acceptFetcherCapability(options = {}) {\n const WorkerWebSocketPair = globalThis.WebSocketPair;\n const pair = new WorkerWebSocketPair();\n const clientSocket = pair[0];\n const serverSocket = pair[1];\n const responseInit = { status: 101, webSocket: clientSocket };\n serverSocket.accept();\n const fetcher = acceptFetcherCapabilityFromSocket(serverSocket, options);\n return {\n fetcher,\n response: new Response(null, responseInit)\n };\n}\nexport {\n CONNECT_TOKEN_QUERY_PARAM,\n CaptunTunnelConnectError,\n GATEWAY_CONNECT_QUERY_PARAM,\n HOSTED_CAPTUN_GATEWAY,\n TUNNEL_CONNECT_DIAGNOSTIC_HEADER,\n TUNNEL_NAME_QUERY_PARAM,\n acceptFetcherCapability,\n acceptFetcherCapabilityFromSocket,\n createCaptunTunnel,\n fetcherStubFromRemoteCapability,\n randomConnectToken\n};\n'; diff --git a/src/hosted/reserved-tunnel-names.ts b/src/hosted/reserved-tunnel-names.ts new file mode 100644 index 0000000..8439cf1 --- /dev/null +++ b/src/hosted/reserved-tunnel-names.ts @@ -0,0 +1,28 @@ +export const HOSTED_RESERVED_TUNNEL_NAMES = [ + "account", + "accounts", + "admin", + "api", + "app", + "auth", + "billing", + "captun", + "dash", + "dashboard", + "docs", + "gateway", + "gateways", + "iterate", + "login", + "payment", + "payments", + "status", + "support", + "tunnel", + "tunnels", + "www", +]; + +export function isHostedReservedTunnelName(name: string) { + return HOSTED_RESERVED_TUNNEL_NAMES.includes(name); +} diff --git a/src/hosted/site.ts b/src/hosted/site.ts index 2c3bc52..0a58cb9 100644 --- a/src/hosted/site.ts +++ b/src/hosted/site.ts @@ -1,5 +1,7 @@ -import { HOSTED_CAPTUN_HOSTNAME, isLoopbackHostname, RESERVED_TUNNEL_NAMES } from "../routing.js"; import { WWW_BROWSER_MODULE } from "./browser-module.generated.js"; +import { isHostedReservedTunnelName } from "./reserved-tunnel-names.js"; + +export const HOSTED_CAPTUN_HOSTNAME = "captun.sh"; declare const createCaptunTunnel: typeof import("../index.js").createCaptunTunnel; @@ -38,11 +40,20 @@ export function hostedCaptunResponse(request: Request): Response | undefined { if (subdomain === "www") { return wwwCaptunResponse(url); } - if (RESERVED_TUNNEL_NAMES.includes(subdomain)) { + if (isHostedReservedTunnelName(subdomain)) { return new Response("Reserved Captun tunnel name\n", { status: 404 }); } } +export function isLoopbackHostname(hostname: string): boolean { + return ( + hostname === "localhost" || + hostname === "127.0.0.1" || + hostname === "0.0.0.0" || + hostname === "[::1]" + ); +} + function isWwwCaptunPath(pathname: string) { return pathname === "/" || pathname === "/captun.browser.js" || pathname === "/favicon.svg"; } diff --git a/src/hosted/worker.ts b/src/hosted/worker.ts index 5a44108..73386a6 100644 --- a/src/hosted/worker.ts +++ b/src/hosted/worker.ts @@ -5,26 +5,26 @@ import { CaptunServerShard as CloudflareTunnelGatewayShard, type TunnelAdmission, type TunnelAdmissionInput, -} from "../worker.js"; +} from "../server/worker.js"; import { CONNECT_TOKEN_QUERY_PARAM, GATEWAY_CONNECT_QUERY_PARAM, + TUNNEL_CONNECT_DIAGNOSTIC_HEADER, + TUNNEL_NAME_QUERY_PARAM, +} from "../index.js"; +import { getTunnelNameFromUrl, getTunnelUrl, - HOSTED_CAPTUN_HOSTNAME, - isLoopbackHostname, isValidTunnelName, - RESERVED_TUNNEL_NAMES, - TUNNEL_CONNECT_DIAGNOSTIC_HEADER, - TUNNEL_NAME_QUERY_PARAM, -} from "../routing.js"; +} from "../server/tunnel-addressing.js"; import { HostedRateLimiter, hostedRateLimitDiagnosticResponse, hostedRateLimitResponse, type HostedRateLimitEnv, } from "./rate-limit.js"; -import { hostedCaptunResponse } from "./site.js"; +import { isHostedReservedTunnelName } from "./reserved-tunnel-names.js"; +import { hostedCaptunResponse, HOSTED_CAPTUN_HOSTNAME, isLoopbackHostname } from "./site.js"; export class CaptunServerShard extends CloudflareTunnelGatewayShard { protected decideTunnelAdmission(input: TunnelAdmissionInput): TunnelAdmission { @@ -80,7 +80,7 @@ export default { }); if (!tunnelName) return new Response("Missing tunnel name\n", { status: 404 }); - if (RESERVED_TUNNEL_NAMES.includes(tunnelName)) { + if (isHostedReservedTunnelName(tunnelName)) { return new Response("Reserved Captun tunnel name\n", { status: 404 }); } @@ -115,7 +115,7 @@ async function connectTunnel(request: Request, env: HostedCaptunEnv) { const url = new URL(request.url); const tunnelName = url.searchParams.get(TUNNEL_NAME_QUERY_PARAM) || ""; - if (!isValidTunnelName(tunnelName) || RESERVED_TUNNEL_NAMES.includes(tunnelName)) { + if (!isValidTunnelName(tunnelName) || isHostedReservedTunnelName(tunnelName)) { return new Response("Missing tunnel name\n", { status: 404 }); } diff --git a/src/index.ts b/src/index.ts index 605415d..dac055b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,17 +1,55 @@ +// oxlint-disable-next-line no-restricted-imports -- this is the only import we do import { newWebSocketRpcSession, RpcTarget } from "capnweb"; -import { - CONNECT_TOKEN_QUERY_PARAM, - GATEWAY_CONNECT_QUERY_PARAM, - HOSTED_CAPTUN_GATEWAY, - HOSTED_CAPTUN_HOSTNAME, - TUNNEL_CONNECT_DIAGNOSTIC_HEADER, - TUNNEL_NAME_QUERY_PARAM, -} from "./routing.js"; -import { acceptFetcherCapabilityFromSocket } from "./server-core.js"; -import type { Fetcher, TunnelReady } from "./server-core.js"; -import { randomConnectToken } from "./token.js"; -export type { Fetcher, FetcherStub } from "./server-core.js"; -export { acceptFetcherCapabilityFromSocket } from "./server-core.js"; + +export const HOSTED_CAPTUN_GATEWAY = "https://captun.sh"; +export const GATEWAY_CONNECT_QUERY_PARAM = "captun-connect"; +export const TUNNEL_NAME_QUERY_PARAM = "captun-name"; +export const CONNECT_TOKEN_QUERY_PARAM = "captun-token"; +export const TUNNEL_CONNECT_DIAGNOSTIC_HEADER = "x-captun-connect-diagnostic"; + +export interface Fetcher { + fetch(request: Request): Response | Promise; +} + +export type TunnelReady = { + url: string; + token?: string; +}; + +export interface FetcherStub extends Fetcher, Disposable { + ready(tunnel: TunnelReady): void | Promise; +} + +export interface RemoteFetcherCapability extends FetcherStub { + onRpcBroken(callback: () => void): void; +} + +export function fetcherStubFromRemoteCapability( + remote: RemoteFetcherCapability, + options: { onDisconnect?: () => void }, +): FetcherStub { + remote.onRpcBroken(() => options.onDisconnect?.()); + + return { + fetch: (request) => remote.fetch(request), + ready: (tunnel) => remote.ready(tunnel), + [Symbol.dispose]: () => remote[Symbol.dispose](), + }; +} + +export function acceptFetcherCapabilityFromSocket( + socket: WebSocket, + options: { onDisconnect?: () => void } = {}, +): FetcherStub { + const remote = newWebSocketRpcSession(socket); + return fetcherStubFromRemoteCapability(remote, options); +} + +export function randomConnectToken() { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} /** Fetch is all you need! * @@ -41,6 +79,19 @@ type TunnelClientCapability = Fetcher & { ready(tunnel: TunnelReady): void | Promise; }; +type WorkerWebSocket = WebSocket & { + accept(): void; +}; + +type WorkerWebSocketPairConstructor = new () => { + 0: WorkerWebSocket; + 1: WorkerWebSocket; +}; + +type WebSocketResponseInit = ResponseInit & { + webSocket: WebSocket; +}; + const TUNNEL_READY_TIMEOUT_MS = 5_000; const WEBSOCKET_REJECTION_PROBE_TIMEOUT_MS = 500; @@ -52,20 +103,19 @@ export async function createCaptunTunnel( token?: string; }, ): Promise { - const connect = gatewayConnectRequest(options); + const connectUrl = gatewayConnectRequest(options); const ready = Promise.withResolvers(); - const socket = createWebSocket(connect.url); + const socket = createWebSocket(connectUrl); const fetcher = new TunnelTargetFetcher({ fetch: options.fetch, ready: (tunnel) => ready.resolve(tunnel), }); const session = newWebSocketRpcSession(socket, fetcher); try { - await waitUntilOpen(socket, connect.url); + await waitUntilOpen(socket, connectUrl); const tunnel = await waitUntilReady(ready.promise); return { - url: tunnel.url, - token: tunnel.token || connect.token, + ...tunnel, [Symbol.dispose]: () => session[Symbol.dispose](), }; } catch (error) { @@ -77,15 +127,11 @@ export async function createCaptunTunnel( function gatewayConnectRequest(options: { gateway?: string | URL; name?: string; token?: string }) { const name = options.name || randomTunnelName(); const url = new URL(options.gateway || HOSTED_CAPTUN_GATEWAY); - const token = options.token || (isHostedCaptunGateway(url) ? randomConnectToken() : undefined); + const token = options.token || randomConnectToken(); url.searchParams.set(GATEWAY_CONNECT_QUERY_PARAM, "1"); url.searchParams.set(TUNNEL_NAME_QUERY_PARAM, name); - if (token) url.searchParams.set(CONNECT_TOKEN_QUERY_PARAM, token); - return { url: url.toString(), name, token }; -} - -function isHostedCaptunGateway(url: URL) { - return url.hostname === HOSTED_CAPTUN_HOSTNAME; + url.searchParams.set(CONNECT_TOKEN_QUERY_PARAM, token); + return url.toString(); } function randomTunnelName() { @@ -206,15 +252,19 @@ async function waitUntilReady(promise: Promise) { /** Creates a Worker WebSocket upgrade response and matching fetcher stub. */ export function acceptFetcherCapability(options: { onDisconnect?: () => void } = {}) { - const pair = new WebSocketPair(); + const WorkerWebSocketPair = ( + globalThis as typeof globalThis & { WebSocketPair: WorkerWebSocketPairConstructor } + ).WebSocketPair; + const pair = new WorkerWebSocketPair(); const clientSocket = pair[0]; const serverSocket = pair[1]; + const responseInit: WebSocketResponseInit = { status: 101, webSocket: clientSocket }; serverSocket.accept(); const fetcher = acceptFetcherCapabilityFromSocket(serverSocket, options); return { fetcher, - response: new Response(null, { status: 101, webSocket: clientSocket }), + response: new Response(null, responseInit), }; } diff --git a/src/server-core.ts b/src/server-core.ts deleted file mode 100644 index 8bc0754..0000000 --- a/src/server-core.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { newWebSocketRpcSession } from "capnweb"; - -export interface Fetcher { - fetch(request: Request): Response | Promise; -} - -export type TunnelReady = { - url: string; - token?: string; -}; - -export type FetcherStub = Fetcher & - Disposable & { - ready(tunnel: TunnelReady): void | Promise; - }; - -type FetcherCapability = Fetcher & { - ready(tunnel: TunnelReady): void | Promise; -}; - -export type RemoteFetcherCapability = FetcherCapability & - Disposable & { - onRpcBroken(callback: () => void): void; - }; - -export function fetcherStubFromRemoteCapability( - remote: RemoteFetcherCapability, - options: { onDisconnect?: () => void }, -): FetcherStub { - remote.onRpcBroken(() => options.onDisconnect?.()); - - return { - fetch: (request) => remote.fetch(request), - ready: (tunnel) => remote.ready(tunnel), - [Symbol.dispose]: () => remote[Symbol.dispose](), - }; -} - -export function acceptFetcherCapabilityFromSocket( - socket: WebSocket, - options: { onDisconnect?: () => void } = {}, -): FetcherStub { - const remote = newWebSocketRpcSession(socket) as RemoteFetcherCapability; - return fetcherStubFromRemoteCapability(remote, options); -} diff --git a/src/bun.ts b/src/server/bun.ts similarity index 99% rename from src/bun.ts rename to src/server/bun.ts index 8a95dc8..ff9c976 100644 --- a/src/bun.ts +++ b/src/server/bun.ts @@ -2,7 +2,7 @@ import { fetcherStubFromRemoteCapability, type RemoteFetcherCapability, type TunnelReady, -} from "./server-core.js"; +} from "../index.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"; diff --git a/src/deno.ts b/src/server/deno.ts similarity index 73% rename from src/deno.ts rename to src/server/deno.ts index cc2db21..0f415d7 100644 --- a/src/deno.ts +++ b/src/server/deno.ts @@ -1,4 +1,4 @@ -import { acceptFetcherCapabilityFromSocket } from "./server-core.js"; +import { acceptFetcherCapabilityFromSocket } from "../index.js"; export function acceptFetcherCapabilityFromDenoSocket( socket: WebSocket, diff --git a/src/node.ts b/src/server/node.ts similarity index 88% rename from src/node.ts rename to src/server/node.ts index 6f95bc7..56817ff 100644 --- a/src/node.ts +++ b/src/server/node.ts @@ -1,4 +1,4 @@ -import { acceptFetcherCapabilityFromSocket } from "./server-core.js"; +import { acceptFetcherCapabilityFromSocket } from "../index.js"; /** A type `import('ws').WebSocket` conforms to. This will be cast internally before passing to `capnweb` */ export interface WSWebSocketLike { diff --git a/src/routing.ts b/src/server/tunnel-addressing.ts similarity index 77% rename from src/routing.ts rename to src/server/tunnel-addressing.ts index 197b00c..e3e6d13 100644 --- a/src/routing.ts +++ b/src/server/tunnel-addressing.ts @@ -1,34 +1,3 @@ -export const HOSTED_CAPTUN_HOSTNAME = "captun.sh"; -export const HOSTED_CAPTUN_GATEWAY = "https://captun.sh"; -export const GATEWAY_CONNECT_QUERY_PARAM = "captun-connect"; -export const TUNNEL_NAME_QUERY_PARAM = "captun-name"; -export const CONNECT_TOKEN_QUERY_PARAM = "captun-token"; -export const TUNNEL_CONNECT_DIAGNOSTIC_HEADER = "x-captun-connect-diagnostic"; -export const RESERVED_TUNNEL_NAMES = [ - "account", - "accounts", - "admin", - "api", - "app", - "auth", - "billing", - "captun", - "dash", - "dashboard", - "docs", - "gateway", - "gateways", - "iterate", - "login", - "payment", - "payments", - "status", - "support", - "tunnel", - "tunnels", - "www", -]; - /** * Extracts a tunnel name from an incoming request URL. * @@ -44,8 +13,8 @@ export const RESERVED_TUNNEL_NAMES = [ * cert (Cloudflare ACM) lets every subdomain land in one named tunnel. * * With `customHostname = "tunnels.mydomain.com"`: - * `https://banana.tunnels.mydomain.com/x` → `banana` - * `https://some-subdomain.banana.tunnels.mydomain.com/x` → `banana` + * `https://banana.tunnels.mydomain.com/x` -> `banana` + * `https://some-subdomain.banana.tunnels.mydomain.com/x` -> `banana` * * With `customHostname = "banana.tunnels.mydomain.com"` instead, the same URL * maps to `some-subdomain` — useful for routing arbitrary subdomains into a @@ -117,15 +86,6 @@ export function isValidTunnelName(name: string): boolean { return true; } -export function isLoopbackHostname(hostname: string): boolean { - return ( - hostname === "localhost" || - hostname === "127.0.0.1" || - hostname === "0.0.0.0" || - hostname === "[::1]" - ); -} - /** 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"; diff --git a/src/worker.ts b/src/server/worker.ts similarity index 93% rename from src/worker.ts rename to src/server/worker.ts index a49ee13..1a94d2c 100644 --- a/src/worker.ts +++ b/src/server/worker.ts @@ -1,16 +1,18 @@ import { DurableObject } from "cloudflare:workers"; -import { acceptFetcherCapability, type FetcherStub } from "./index.js"; import { - captunShardName, + acceptFetcherCapability, CONNECT_TOKEN_QUERY_PARAM, GATEWAY_CONNECT_QUERY_PARAM, + TUNNEL_NAME_QUERY_PARAM, + type FetcherStub, +} from "../index.js"; +import { + captunShardName, getTunnelNameFromUrl, getTunnelUrl, isValidTunnelName, - RESERVED_TUNNEL_NAMES, - TUNNEL_NAME_QUERY_PARAM, TUNNEL_URL_HEADER, -} from "./routing.js"; +} from "./tunnel-addressing.js"; export type CaptunEnv = { CaptunServerShard: DurableObjectNamespace>; @@ -22,6 +24,7 @@ export type CaptunEnv = { /** Set by the top-level Worker on the WebSocket-upgrade request so the DO knows the tunnel. */ const TUNNEL_NAME_HEADER = "x-captun-tunnel-name"; +const CUSTOM_HOSTNAME_RESERVED_TUNNEL_NAMES = ["captun", "gateway"]; type CaptunShardBindingEnv = { CaptunServerShard: DurableObjectNamespace>; @@ -156,7 +159,7 @@ export default { : url.pathname.match(/^\/[^/]+(\/.*)?$/)?.[1] || "/"; url.pathname = forwardedPath; - if (RESERVED_TUNNEL_NAMES.includes(tunnelName)) { + if (isCustomHostnameReservedTunnelName(tunnelName, env)) { return new Response("Reserved Captun tunnel name\n", { status: 404 }); } @@ -180,7 +183,7 @@ function connectTunnel(request: Request, env: CaptunEnv) { const url = new URL(request.url); const tunnelName = url.searchParams.get(TUNNEL_NAME_QUERY_PARAM) || ""; - if (!isValidTunnelName(tunnelName) || RESERVED_TUNNEL_NAMES.includes(tunnelName)) { + if (!isValidTunnelName(tunnelName) || isCustomHostnameReservedTunnelName(tunnelName, env)) { return new Response("Missing tunnel name\n", { status: 404 }); } @@ -201,6 +204,11 @@ function connectToken(request: Request) { return new URL(request.url).searchParams.get(CONNECT_TOKEN_QUERY_PARAM); } +function isCustomHostnameReservedTunnelName(tunnelName: string, env: { CUSTOM_HOSTNAME?: string }) { + if (!env.CUSTOM_HOSTNAME) return false; + return CUSTOM_HOSTNAME_RESERVED_TUNNEL_NAMES.includes(tunnelName); +} + export function captunServerShard( env: CaptunShardBindingEnv, tunnelName: string, diff --git a/src/token.ts b/src/token.ts deleted file mode 100644 index af97b94..0000000 --- a/src/token.ts +++ /dev/null @@ -1,5 +0,0 @@ -export function randomConnectToken() { - const bytes = new Uint8Array(16); - crypto.getRandomValues(bytes); - return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); -} diff --git a/tasks/complete/2026-05-22-export-worker-module.md b/tasks/complete/2026-05-22-export-worker-module.md index 557fd99..f54fe53 100644 --- a/tasks/complete/2026-05-22-export-worker-module.md +++ b/tasks/complete/2026-05-22-export-worker-module.md @@ -9,14 +9,14 @@ Status summary: Done. The package now exposes a `captun/worker` subpath for sour ## Assumptions -- `captun/worker` should expose the existing `src/worker.ts` module directly during local source usage. -- The published package should expose the compiled `dist/worker.js` and `dist/worker.d.ts` files for the same subpath. +- `captun/worker` should expose the Captun Worker module directly during local source usage. After the 2026-05-27 source layout refactor it lives at `src/server/worker.ts`. +- The published package should expose the compiled Worker files for the same subpath. After the 2026-05-27 source layout refactor they live at `dist/server/worker.js` and `dist/server/worker.d.ts`. - No new wrapper API is needed; consumers should get the current default Worker handler and `CaptunServerShard` export. ## Checklist -- [x] Add the `./worker` package export for source and published package manifests. _Implemented in `package.json` with source exports pointing at `src/worker.ts` and publish exports pointing at `dist/worker.js`/`dist/worker.d.ts`._ -- [x] Build the package and confirm `dist/worker.js` and `dist/worker.d.ts` are emitted. _Verified with `pnpm build`; `dist/worker.d.ts` now declares the Captun Worker env shape directly._ +- [x] Add the `./worker` package export for source and published package manifests. _Implemented in `package.json`; after the 2026-05-27 source layout refactor, source exports point at `src/server/worker.ts` and publish exports point at `dist/server/worker.js`/`dist/server/worker.d.ts`._ +- [x] Build the package and confirm the compiled Worker files are emitted. _Verified with `pnpm build`; after the 2026-05-27 source layout refactor, `dist/server/worker.d.ts` declares the Captun Worker env shape directly._ - [x] Validate the packed/published manifest exposes `captun/worker`. _Verified with `pnpm pack --pack-destination ignoreme-pack` and `tar -xOf ignoreme-pack/captun-0.0.1.tgz package/package.json`._ - [x] Open a pull request so the `pkg-pr-new` job can produce an installable package for Iterate to test. _Opened https://github.com/iterate/captun/pull/14 after the task-spec commit._ @@ -24,3 +24,4 @@ Status summary: Done. The package now exposes a `captun/worker` subpath for sour - 2026-05-22: User wants a smaller approach than threading `egressFetch` through the Iterate e2e stack. This package export should let Iterate deploy Captun's real Durable Object in its own stack and expose an internet-accessible tunnel URL for the MCP test server. - 2026-05-22: `pnpm publish --dry-run --no-git-checks` ran `check`, `test`, and `prepack`, then failed only at registry validation because `captun@0.0.1` already exists. +- 2026-05-27: The source layout refactor moved the `captun/worker` source implementation to `src/server/worker.ts` and the published files to `dist/server/worker.js` and `dist/server/worker.d.ts`. diff --git a/tasks/complete/2026-05-27-refactor-src-layout.md b/tasks/complete/2026-05-27-refactor-src-layout.md new file mode 100644 index 0000000..2b02643 --- /dev/null +++ b/tasks/complete/2026-05-27-refactor-src-layout.md @@ -0,0 +1,25 @@ +--- +status: done +size: medium +--- + +# Refactor `src/` Layout + +Status summary: Done. The root library surface lives directly in `src/index.ts`, Runtime Adapters and the Cloudflare Tunnel Gateway live under `src/server`, and hosted-only product policy lives under `src/hosted`. + +- [x] Keep the public root library code in `src/index.ts`. _Merged the temporary `src/lib/index.ts`, `src/lib/fetcher-capability.ts`, `src/lib/routing.ts`, and `src/lib/token.ts` modules back into `src/index.ts`._ +- [x] Move Runtime Adapters and the Cloudflare Tunnel Gateway worker implementation into `src/server`. _Moved `node.ts`, `bun.ts`, `deno.ts`, and `worker.ts` under `src/server`; package subpath exports now point there._ +- [x] Keep hosted app and CLI code under `src/hosted` and `src/cli`. _No hosted or CLI files moved out of their existing folders; imports were updated to the new root/server paths._ +- [x] Retarget the import-boundary lint rule to the root library surface. _Replaced `captun/lib-import-boundary` with built-in `no-restricted-imports`, kept strict root-library defaults, and disabled it for `src/cli`, `src/server`, `src/hosted`, `src/worker`, tests, scripts, and examples._ +- [x] Update package exports, deployment config, tests, docs, and generated browser module paths. _Updated `package.json`, `wrangler.jsonc`, Miniflare fixtures, README links, Deno import map, deploy worker path, and regenerated `src/hosted/browser-module.generated.ts`._ +- [x] Verify typecheck, lint, build, and tests. _Verified with `pnpm run typecheck`, `pnpm run lint`, `pnpm run format:check`, `pnpm run build`, and `pnpm test`._ + +## Implementation Notes + +- Started from a clean `main` worktree. +- Domain notes confirm Fetcher Capability primitives are library-facing, while Cloudflare Tunnel Gateway code should be separate from hosted product code. +- Kept the shared gateway protocol constants in `src/index.ts` because they are part of the root client API surface and are consumed by the Cloudflare Tunnel Gateway, hosted service, and CLI. +- Moved the broad product/control-plane reserved-name list to `src/hosted`; `src/server/worker.ts` only reserves `captun` and `gateway` for custom-domain self-hosted deployments where those labels can collide with the gateway hostname. +- Moved Tunnel Gateway addressing helpers to `src/server/tunnel-addressing.ts`; the remaining gateway connect constants now live in `src/index.ts`. +- Moved Node, Bun, and Deno adapters to `src/server` after clarifying that exported package subpaths are not the same as the root `captun` library surface. +- `createCaptunTunnel` now always sends a client-side token when none is supplied, but returns only the gateway-confirmed `ready` payload. The CLI reuses one generated token across retries for any gateway. diff --git a/test/cli.test.ts b/test/cli.test.ts index fdd4fca..366d36c 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -79,7 +79,7 @@ test("CLI tunnel connect errors keep DNS guidance for unrelated 409 responses", }); }); -test("CLI tunnel retries reuse the same generated hosted token", async () => { +test("CLI tunnel retries reuse the same generated client token for any gateway", async () => { await using target = await createTestServer(defaultTargetHandler); const tokens: Array = []; @@ -101,7 +101,7 @@ test("CLI tunnel retries reuse the same generated hosted token", async () => { await client.tunnel({ target: String(target.port), - gateway: "https://captun.sh", + gateway: "https://custom.example.com", name: "demo", requestLogs: false, }); diff --git a/test/hosted-worker.test.ts b/test/hosted-worker.test.ts index 93733b4..2acd082 100644 --- a/test/hosted-worker.test.ts +++ b/test/hosted-worker.test.ts @@ -1,6 +1,6 @@ import { newWebSocketRpcSession, RpcTarget } from "capnweb"; import { expect, test } from "vitest"; -import { TUNNEL_CONNECT_DIAGNOSTIC_HEADER } from "../src/routing.js"; +import { TUNNEL_CONNECT_DIAGNOSTIC_HEADER } from "../src/index.js"; import { createHostedCaptunWorkerFixture, createMiniflareWorkerFixture } from "./miniflare.js"; test("Hosted Captun redirects the apex hostname to www", async () => { diff --git a/test/miniflare.ts b/test/miniflare.ts index 50f2024..0836483 100644 --- a/test/miniflare.ts +++ b/test/miniflare.ts @@ -50,7 +50,7 @@ export async function createMiniflareWorkerFixture(options: { export function createCaptunWorkerFixture(bindings: Record) { return createMiniflareWorkerFixture({ - entryPoint: "src/worker.ts", + entryPoint: "src/server/worker.ts", durableObjects: { CaptunServerShard: { className: "CaptunServerShard" }, }, diff --git a/test/worker.test.ts b/test/worker.test.ts index 020cf2d..ad56717 100644 --- a/test/worker.test.ts +++ b/test/worker.test.ts @@ -8,7 +8,7 @@ import { getTunnelNameFromUrl, getTunnelUrl, TUNNEL_URL_HEADER, -} from "../src/routing.js"; +} from "../src/server/tunnel-addressing.js"; import { createCaptunWorkerFixture } from "./miniflare.js"; const tunnelNameCases: Array< @@ -158,6 +158,22 @@ test("Captun Worker verifies health through a connected tunnel client", async () expect(await response.json()).toEqual({ ok: true }); }); +test("createCaptunTunnel only returns a gateway-confirmed token", async () => { + await using fixture = await createCaptunWorkerFixture({}); + + using tunnel = await createCaptunTunnel({ + gateway: fixture.origin, + name: "demo", + token: "client-generated-token", + fetch: () => new Response("pong\n"), + }); + + expect(tunnel).toMatchObject({ + url: `${fixture.origin}/demo`, + token: undefined, + }); +}); + test("Captun Worker returns 502 when the tunnel client fetch throws", async () => { await using fixture = await createCaptunWorkerFixture({}); using _tunnel = await createCaptunTunnel({ @@ -205,6 +221,29 @@ test("Captun Worker ignores hosted rate-limit bindings in self-hosted folder rou expect(second).toMatchObject({ status: 503 }); }); +test("Captun Worker does not apply hosted reserved names to self-hosted folder routing", async () => { + await using fixture = await createCaptunWorkerFixture({}); + + const response = await fixture.worker.fetch(`${fixture.origin}/billing/hello`); + + expect(response).toMatchObject({ status: 503 }); + expect(await response.text()).toBe("No tunnel client connected\n"); +}); + +test.each(["captun", "gateway"])( + "Captun Worker reserves %s for self-hosted custom-domain routing", + async (tunnelName) => { + await using fixture = await createCaptunWorkerFixture({ + CUSTOM_HOSTNAME: "example.com", + }); + + const response = await fixture.worker.fetch(`https://${tunnelName}.example.com/hello`); + + expect(response).toMatchObject({ status: 404 }); + expect(await response.text()).toBe("Reserved Captun tunnel name\n"); + }, +); + test("Captun Worker rejects missing tunnel names before Durable Object dispatch", async () => { await using fixture = await createCaptunWorkerFixture({}); diff --git a/wrangler.jsonc b/wrangler.jsonc index 5ca49da..cefe42b 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -1,7 +1,7 @@ { "$schema": "node_modules/wrangler/config-schema.json", "name": "captun", - "main": "src/worker.ts", + "main": "src/server/worker.ts", "compatibility_date": "2026-05-15", "workers_dev": true, "preview_urls": false,