Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 3 additions & 28 deletions src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<void>((resolvePromise) => {
const done = () => resolvePromise();
Expand Down
58 changes: 58 additions & 0 deletions src/tunnel-addressing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/** 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) {
assertRootServerPath(baseUrl);
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(/\/$/, "");
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) {
return url.replace(/\/$/, "");
}
13 changes: 2 additions & 11 deletions src/worker-routing.ts
Original file line number Diff line number Diff line change
@@ -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)) {
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
status: done
size: medium
kind: bedtime-architecture
---

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, 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._

## 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 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.
11 changes: 2 additions & 9 deletions test/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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) {
Expand Down
32 changes: 32 additions & 0 deletions test/worker.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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://{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",
);
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("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 () => {
await using fixture = await createCaptunWorkerFixture({});
using _tunnel = await createCaptunTunnel({
Expand Down
Loading