From 8f0cc1fcaa3028fa36093b36946cdef9232b2363 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Wed, 20 May 2026 00:43:31 +0100 Subject: [PATCH 1/4] Specify tunnel addressing architecture task --- .../bedtime-architecture-tunnel-addressing.md | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 tasks/bedtime-architecture-tunnel-addressing.md diff --git a/tasks/bedtime-architecture-tunnel-addressing.md b/tasks/bedtime-architecture-tunnel-addressing.md new file mode 100644 index 0000000..1bc2933 --- /dev/null +++ b/tasks/bedtime-architecture-tunnel-addressing.md @@ -0,0 +1,30 @@ +--- +status: in-progress +size: medium +kind: bedtime-architecture +--- + +Summary: Architecture pass selected **Named Tunnel Addressing** as the highest-impact deepening opportunity. Current routing/addressing behavior is split across Worker routing, CLI tunnel URL construction, and tests; tonight's selector URL follow-up exposed real drift. This task will concentrate that behavior behind one small module interface. + +- [ ] Add a named tunnel addressing module for folder/subdomain classification and public tunnel/connect URL construction. +- [ ] Move CLI tunnel URL construction to the addressing module instead of keeping private URL helpers in `src/bin.ts`. +- [ ] Keep Worker route parsing behavior unchanged while making the shared classification explicit. +- [ ] Add focused unit coverage for public URL and connect URL construction, including folder, path-prefixed folder, `{name}` template, and subdomain-style hosts. +- [ ] Update the PR body with replacement compare branches for the open bedtime PR queue. +- [ ] Verify with typecheck and tests. + +## Architecture Decision + +Candidate chosen: **Named Tunnel Addressing**. + +Why this one: + +- It has the best locality payoff for tonight's work. Cookie-rooted folder routing, browser demo URLs, and CLI output all need the same definition of "folder-routed" versus "subdomain-routed". +- It is small enough to land safely but deep enough to prevent future routing drift. +- It improves the interface test surface: callers can ask for public/connect URLs without reimplementing URL rules. + +## Assumptions + +- This PR should not change runtime behavior by itself; it should move behavior behind a deeper module. +- The new module can be source-level internal for now, exported only as needed inside the package. +- Broader deploy planning and Durable Object lifecycle seams are valid later candidates, but less directly connected to tonight's queue. From c8e5d3a21372806e48875b5738b5507a66719f0e Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Wed, 20 May 2026 00:47:40 +0100 Subject: [PATCH 2/4] Centralize named tunnel addressing --- src/bin.ts | 31 ++----------- src/tunnel-addressing.ts | 43 +++++++++++++++++++ src/worker-routing.ts | 13 +----- .../bedtime-architecture-tunnel-addressing.md | 16 +++---- test/e2e.test.ts | 11 +---- test/worker.test.ts | 32 ++++++++++++++ 6 files changed, 90 insertions(+), 56 deletions(-) create mode 100644 src/tunnel-addressing.ts diff --git a/src/bin.ts b/src/bin.ts index b56b565..015812e 100755 --- a/src/bin.ts +++ b/src/bin.ts @@ -12,6 +12,7 @@ import { createCli, yamlTableConsoleLogger } from "trpc-cli"; import { z } from "zod/v4"; import { createCaptunTunnel } from "./client.js"; import { CommandNotFoundError, ExecError, exec } from "./exec.js"; +import { publicTunnelUrl, serverUrlFromRoute, tunnelConnectUrl } from "./tunnel-addressing.js"; type Config = { serverUrl: string; @@ -59,11 +60,11 @@ const router = os.router({ const secret = input.secret || config?.secret; const name = input.name || randomName(); - const tunnel = tunnelUrl(serverUrl, name); + const tunnel = publicTunnelUrl(serverUrl, name); const origin = `http://127.0.0.1:${input.port}`; using _tunnelSession = await createCaptunTunnel({ - url: `${tunnel}/__captun-connect`, + url: tunnelConnectUrl(serverUrl, name), headers: secret ? { authorization: `Bearer ${secret}` } : undefined, fetch: (request) => { const url = new URL(request.url); @@ -227,36 +228,10 @@ async function writeConfig(config: Config) { await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }); } -function tunnelUrl(baseUrl: string, name: string) { - 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)}`; - } - return removeTrailingSlash(url.toString()); -} - -function serverUrlFromRoute(route: string) { - const withoutProtocol = route.replace(/^https?:\/\//, ""); - const [hostPart, ...pathParts] = withoutProtocol.split("/"); - const host = hostPart?.startsWith("*.") ? `{name}.${hostPart.slice(2)}` : hostPart; - if (!host) throw new Error(`Cannot infer server URL from route: ${route}`); - - const path = pathParts.join("/").replace(/\*.*$/, "").replace(/\/$/, ""); - return `https://${host}${path ? `/${path}` : ""}`; -} - function serverUrlFromWranglerOutput(output: string) { return output.match(/https:\/\/[^\s]+\.workers\.dev[^\s]*/)?.[0]; } -function removeTrailingSlash(url: string) { - return url.replace(/\/$/, ""); -} - function waitForShutdown() { return new Promise((resolvePromise) => { const done = () => resolvePromise(); diff --git a/src/tunnel-addressing.ts b/src/tunnel-addressing.ts new file mode 100644 index 0000000..b5ff8ad --- /dev/null +++ b/src/tunnel-addressing.ts @@ -0,0 +1,43 @@ +/** Chooses folder routing for Worker preview hosts, apex domains, and local dev. */ +export function usesFolderRouting(hostname: string) { + return ( + hostname === "localhost" || + /^\d+\.\d+\.\d+\.\d+$/.test(hostname) || + hostname.endsWith(".workers.dev") || + hostname.startsWith("tunnels.") || + hostname.split(".").length < 3 + ); +} + +/** Builds the public URL for a named tunnel from a configured server URL. */ +export function publicTunnelUrl(baseUrl: string, name: string) { + if (baseUrl.includes("{name}")) return removeTrailingSlash(baseUrl.replaceAll("{name}", name)); + + const url = new URL(baseUrl); + if (usesFolderRouting(url.hostname)) { + url.pathname = `${url.pathname.replace(/\/$/, "")}/${encodeURIComponent(name)}`; + } else { + url.pathname = "/"; + } + return removeTrailingSlash(url.toString()); +} + +/** Builds the WebSocket connect endpoint for a named tunnel. */ +export function tunnelConnectUrl(baseUrl: string, name: string) { + return `${publicTunnelUrl(baseUrl, name)}/__captun-connect`; +} + +/** Infers Captun's server URL pattern from a Cloudflare route pattern. */ +export function serverUrlFromRoute(route: string) { + const withoutProtocol = route.replace(/^https?:\/\//, ""); + const [hostPart, ...pathParts] = withoutProtocol.split("/"); + const host = hostPart?.startsWith("*.") ? `{name}.${hostPart.slice(2)}` : hostPart; + if (!host) throw new Error(`Cannot infer server URL from route: ${route}`); + + const path = pathParts.join("/").replace(/\*.*$/, "").replace(/\/$/, ""); + return `https://${host}${path ? `/${path}` : ""}`; +} + +function removeTrailingSlash(url: string) { + return url.replace(/\/$/, ""); +} diff --git a/src/worker-routing.ts b/src/worker-routing.ts index 17ed9df..aacff17 100644 --- a/src/worker-routing.ts +++ b/src/worker-routing.ts @@ -1,3 +1,5 @@ +import { usesFolderRouting } from "./tunnel-addressing.js"; + /** Extracts the tunnel name and forwarded path from just the hostname and path. */ export function captunRouteParts(hostname: string, pathname: string) { if (!usesFolderRouting(hostname)) { @@ -23,17 +25,6 @@ export function captunShardName(tunnelName: string, shardCount: number) { 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 { diff --git a/tasks/bedtime-architecture-tunnel-addressing.md b/tasks/bedtime-architecture-tunnel-addressing.md index 1bc2933..2af53e6 100644 --- a/tasks/bedtime-architecture-tunnel-addressing.md +++ b/tasks/bedtime-architecture-tunnel-addressing.md @@ -1,17 +1,17 @@ --- -status: in-progress +status: implementation-done size: medium kind: bedtime-architecture --- -Summary: Architecture pass selected **Named Tunnel Addressing** as the highest-impact deepening opportunity. Current routing/addressing behavior is split across Worker routing, CLI tunnel URL construction, and tests; tonight's selector URL follow-up exposed real drift. This task will concentrate that behavior behind one small module interface. +Summary: Core implementation is done and verified. Named tunnel addressing now lives behind a shared module used by the CLI, Worker routing, and E2E helpers; the remaining work is to push replacement compare branches for the open bedtime PR queue and link them from the architecture PR. -- [ ] Add a named tunnel addressing module for folder/subdomain classification and public tunnel/connect URL construction. -- [ ] Move CLI tunnel URL construction to the addressing module instead of keeping private URL helpers in `src/bin.ts`. -- [ ] Keep Worker route parsing behavior unchanged while making the shared classification explicit. -- [ ] Add focused unit coverage for public URL and connect URL construction, including folder, path-prefixed folder, `{name}` template, and subdomain-style hosts. +- [x] Add a named tunnel addressing module for folder/subdomain classification and public tunnel/connect URL construction. _Implemented in `src/tunnel-addressing.ts`._ +- [x] Move CLI tunnel URL construction to the addressing module instead of keeping private URL helpers in `src/bin.ts`. _`captun tunnel` now calls `publicTunnelUrl()` and `tunnelConnectUrl()`._ +- [x] Keep Worker route parsing behavior unchanged while making the shared classification explicit. _`captunRouteParts()` now imports `usesFolderRouting()` from the addressing module._ +- [x] Add focused unit coverage for public URL and connect URL construction, including folder, path-prefixed folder, `{name}` template, and subdomain-style hosts. _Added named tunnel addressing cases in `test/worker.test.ts`._ - [ ] Update the PR body with replacement compare branches for the open bedtime PR queue. -- [ ] Verify with typecheck and tests. +- [x] Verify with typecheck and tests. _Ran `pnpm run typecheck` and `pnpm test` in the architecture worktree._ ## Architecture Decision @@ -25,6 +25,6 @@ Why this one: ## Assumptions -- This PR should not change runtime behavior by itself; it should move behavior behind a deeper module. +- This PR should avoid product-facing churn, but the shared classifier should align CLI URL construction with Worker subdomain routing. That intentionally fixes the concrete custom-subdomain connect URL case that previously drifted from Worker routing. - The new module can be source-level internal for now, exported only as needed inside the package. - Broader deploy planning and Durable Object lifecycle seams are valid later candidates, but less directly connected to tonight's queue. diff --git a/test/e2e.test.ts b/test/e2e.test.ts index 9d2c4ff..c260526 100644 --- a/test/e2e.test.ts +++ b/test/e2e.test.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import { expect, test, vi } from "vitest"; import { createCaptunTunnel } from "../src/client.js"; +import { publicTunnelUrl } from "../src/tunnel-addressing.js"; import { createCaptunWorkerFixture } from "./miniflare.js"; vi.setConfig({ testTimeout: 15_000 }); @@ -203,15 +204,7 @@ function tunnelName(testName: string) { } function tunnelUrl(serverUrl: string, name: string) { - 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.toString().replace(/\/$/, ""); + return publicTunnelUrl(serverUrl, name); } function makeBytes(size: number) { diff --git a/test/worker.test.ts b/test/worker.test.ts index 7f54c5f..2a4c6d4 100644 --- a/test/worker.test.ts +++ b/test/worker.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "vitest"; import { createCaptunTunnel } from "../src/client.js"; +import { publicTunnelUrl, serverUrlFromRoute, tunnelConnectUrl } from "../src/tunnel-addressing.js"; import { captunRouteParts, captunShardName } from "../src/worker-routing.js"; import { createCaptunWorkerFixture } from "./miniflare.js"; @@ -43,6 +44,37 @@ test("Captun Worker keeps a tunnel name on a stable shard", () => { expect(captunShardName("my-test", 16)).toMatch(/^tunnel-shard-(?:[0-9]|1[0-5])$/); }); +test("Captun named tunnel addressing builds public and connect URLs", () => { + expect(publicTunnelUrl("https://captun.account.workers.dev", "my-test")).toBe( + "https://captun.account.workers.dev/my-test", + ); + expect(publicTunnelUrl("https://captun.account.workers.dev/prefix", "my test")).toBe( + "https://captun.account.workers.dev/prefix/my%20test", + ); + expect(publicTunnelUrl("https://{name}.tunnels.example.com", "my-test")).toBe( + "https://my-test.tunnels.example.com", + ); + expect(publicTunnelUrl("https://my-test.tunnels.example.com", "ignored")).toBe( + "https://my-test.tunnels.example.com", + ); + expect(publicTunnelUrl("https://my-test.my-tunnels.com", "ignored")).toBe( + "https://my-test.my-tunnels.com", + ); + expect(tunnelConnectUrl("https://captun.account.workers.dev", "my-test")).toBe( + "https://captun.account.workers.dev/my-test/__captun-connect", + ); +}); + +test("Captun named tunnel addressing infers server URLs from route patterns", () => { + expect(serverUrlFromRoute("*.tunnels.example.com/*")).toBe( + "https://{name}.tunnels.example.com", + ); + expect(serverUrlFromRoute("https://*.my-tunnels.com/path/*")).toBe( + "https://{name}.my-tunnels.com/path", + ); + expect(serverUrlFromRoute("captun.example.com/*")).toBe("https://captun.example.com"); +}); + test("Captun Worker forwards requests through a real Durable Object tunnel", async () => { await using fixture = await createCaptunWorkerFixture({}); using _tunnel = await createCaptunTunnel({ From a5fda918d317a2abd6d7b806a0c5b94afe25ec9e Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Wed, 20 May 2026 00:55:53 +0100 Subject: [PATCH 3/4] Complete tunnel addressing architecture task --- .../2026-05-19-bedtime-architecture-tunnel-addressing.md} | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) rename tasks/{bedtime-architecture-tunnel-addressing.md => complete/2026-05-19-bedtime-architecture-tunnel-addressing.md} (82%) diff --git a/tasks/bedtime-architecture-tunnel-addressing.md b/tasks/complete/2026-05-19-bedtime-architecture-tunnel-addressing.md similarity index 82% rename from tasks/bedtime-architecture-tunnel-addressing.md rename to tasks/complete/2026-05-19-bedtime-architecture-tunnel-addressing.md index 2af53e6..d352e46 100644 --- a/tasks/bedtime-architecture-tunnel-addressing.md +++ b/tasks/complete/2026-05-19-bedtime-architecture-tunnel-addressing.md @@ -1,16 +1,16 @@ --- -status: implementation-done +status: done size: medium kind: bedtime-architecture --- -Summary: Core implementation is done and verified. Named tunnel addressing now lives behind a shared module used by the CLI, Worker routing, and E2E helpers; the remaining work is to push replacement compare branches for the open bedtime PR queue and link them from the architecture PR. +Summary: Done. Named tunnel addressing now lives behind a shared module used by the CLI, Worker routing, and E2E helpers; replacement compare branches were pushed for the open PR queue and linked from the architecture PR body. - [x] Add a named tunnel addressing module for folder/subdomain classification and public tunnel/connect URL construction. _Implemented in `src/tunnel-addressing.ts`._ - [x] Move CLI tunnel URL construction to the addressing module instead of keeping private URL helpers in `src/bin.ts`. _`captun tunnel` now calls `publicTunnelUrl()` and `tunnelConnectUrl()`._ - [x] Keep Worker route parsing behavior unchanged while making the shared classification explicit. _`captunRouteParts()` now imports `usesFolderRouting()` from the addressing module._ - [x] Add focused unit coverage for public URL and connect URL construction, including folder, path-prefixed folder, `{name}` template, and subdomain-style hosts. _Added named tunnel addressing cases in `test/worker.test.ts`._ -- [ ] Update the PR body with replacement compare branches for the open bedtime PR queue. +- [x] Update the PR body with replacement compare branches for the open bedtime PR queue. _Pushed replacement branches for PRs #3-#9 and linked them in PR #10._ - [x] Verify with typecheck and tests. _Ran `pnpm run typecheck` and `pnpm test` in the architecture worktree._ ## Architecture Decision From 953de11ed07efa2d1b6f371a4446cf4cefd1ca81 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Wed, 20 May 2026 01:00:20 +0100 Subject: [PATCH 4/4] Reject path-prefixed tunnel server URLs --- src/tunnel-addressing.ts | 17 ++++++++++++++++- ...19-bedtime-architecture-tunnel-addressing.md | 3 ++- test/worker.test.ts | 12 ++++++------ 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/tunnel-addressing.ts b/src/tunnel-addressing.ts index b5ff8ad..a6912e0 100644 --- a/src/tunnel-addressing.ts +++ b/src/tunnel-addressing.ts @@ -11,6 +11,7 @@ export function usesFolderRouting(hostname: string) { /** Builds the public URL for a named tunnel from a configured server URL. */ export function publicTunnelUrl(baseUrl: string, name: string) { + assertRootServerPath(baseUrl); if (baseUrl.includes("{name}")) return removeTrailingSlash(baseUrl.replaceAll("{name}", name)); const url = new URL(baseUrl); @@ -35,7 +36,21 @@ export function serverUrlFromRoute(route: string) { if (!host) throw new Error(`Cannot infer server URL from route: ${route}`); const path = pathParts.join("/").replace(/\*.*$/, "").replace(/\/$/, ""); - return `https://${host}${path ? `/${path}` : ""}`; + if (path) { + throw new Error( + `Path-prefixed Captun routes are not supported yet: ${route}. Use a host-root route like ${host}/* instead.`, + ); + } + return `https://${host}`; +} + +function assertRootServerPath(baseUrl: string) { + const parsedUrl = new URL(baseUrl.replaceAll("{name}", "captun")); + if (parsedUrl.pathname === "/") return; + + throw new Error( + `Path-prefixed Captun server URLs are not supported yet: ${baseUrl}. Use a host-root server URL instead.`, + ); } function removeTrailingSlash(url: string) { diff --git a/tasks/complete/2026-05-19-bedtime-architecture-tunnel-addressing.md b/tasks/complete/2026-05-19-bedtime-architecture-tunnel-addressing.md index d352e46..dd43aa6 100644 --- a/tasks/complete/2026-05-19-bedtime-architecture-tunnel-addressing.md +++ b/tasks/complete/2026-05-19-bedtime-architecture-tunnel-addressing.md @@ -9,7 +9,7 @@ Summary: Done. Named tunnel addressing now lives behind a shared module used by - [x] Add a named tunnel addressing module for folder/subdomain classification and public tunnel/connect URL construction. _Implemented in `src/tunnel-addressing.ts`._ - [x] Move CLI tunnel URL construction to the addressing module instead of keeping private URL helpers in `src/bin.ts`. _`captun tunnel` now calls `publicTunnelUrl()` and `tunnelConnectUrl()`._ - [x] Keep Worker route parsing behavior unchanged while making the shared classification explicit. _`captunRouteParts()` now imports `usesFolderRouting()` from the addressing module._ -- [x] Add focused unit coverage for public URL and connect URL construction, including folder, path-prefixed folder, `{name}` template, and subdomain-style hosts. _Added named tunnel addressing cases in `test/worker.test.ts`._ +- [x] Add focused unit coverage for public URL and connect URL construction, including folder, unsupported path-prefixed bases, `{name}` template, and subdomain-style hosts. _Added named tunnel addressing cases in `test/worker.test.ts`._ - [x] Update the PR body with replacement compare branches for the open bedtime PR queue. _Pushed replacement branches for PRs #3-#9 and linked them in PR #10._ - [x] Verify with typecheck and tests. _Ran `pnpm run typecheck` and `pnpm test` in the architecture worktree._ @@ -26,5 +26,6 @@ Why this one: ## Assumptions - This PR should avoid product-facing churn, but the shared classifier should align CLI URL construction with Worker subdomain routing. That intentionally fixes the concrete custom-subdomain connect URL case that previously drifted from Worker routing. +- Path-prefixed Worker routes are not supported by Captun's current router, so the shared addressing API now fails fast for path-prefixed server URLs instead of generating connect URLs that cannot attach to a tunnel. - The new module can be source-level internal for now, exported only as needed inside the package. - Broader deploy planning and Durable Object lifecycle seams are valid later candidates, but less directly connected to tonight's queue. diff --git a/test/worker.test.ts b/test/worker.test.ts index 2a4c6d4..4b7b333 100644 --- a/test/worker.test.ts +++ b/test/worker.test.ts @@ -48,9 +48,6 @@ test("Captun named tunnel addressing builds public and connect URLs", () => { expect(publicTunnelUrl("https://captun.account.workers.dev", "my-test")).toBe( "https://captun.account.workers.dev/my-test", ); - expect(publicTunnelUrl("https://captun.account.workers.dev/prefix", "my test")).toBe( - "https://captun.account.workers.dev/prefix/my%20test", - ); expect(publicTunnelUrl("https://{name}.tunnels.example.com", "my-test")).toBe( "https://my-test.tunnels.example.com", ); @@ -63,16 +60,19 @@ test("Captun named tunnel addressing builds public and connect URLs", () => { expect(tunnelConnectUrl("https://captun.account.workers.dev", "my-test")).toBe( "https://captun.account.workers.dev/my-test/__captun-connect", ); + expect(() => publicTunnelUrl("https://captun.account.workers.dev/prefix", "my-test")).toThrow( + "Path-prefixed Captun server URLs are not supported yet", + ); }); test("Captun named tunnel addressing infers server URLs from route patterns", () => { expect(serverUrlFromRoute("*.tunnels.example.com/*")).toBe( "https://{name}.tunnels.example.com", ); - expect(serverUrlFromRoute("https://*.my-tunnels.com/path/*")).toBe( - "https://{name}.my-tunnels.com/path", - ); expect(serverUrlFromRoute("captun.example.com/*")).toBe("https://captun.example.com"); + expect(() => serverUrlFromRoute("https://*.my-tunnels.com/path/*")).toThrow( + "Path-prefixed Captun routes are not supported yet", + ); }); test("Captun Worker forwards requests through a real Durable Object tunnel", async () => {