diff --git a/.github/workflows/pkg-pr-new.yml b/.github/workflows/pkg-pr-new.yml index dbe1d35..ada908e 100644 --- a/.github/workflows/pkg-pr-new.yml +++ b/.github/workflows/pkg-pr-new.yml @@ -15,4 +15,4 @@ jobs: - run: corepack enable - run: pnpm install --frozen-lockfile - run: pnpm run build - - run: pnpm dlx pkg-pr-new publish --pnpm + - run: pnpm dlx pkg-pr-new publish --pnpm --bin diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..85753ae --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,161 @@ +# Captun + +Captun exposes HTTP request handlers through public tunnel URLs. Its domain separates the low-level Cap'n Web fetcher capability from the gateway, deployment, hosted product, and future control-plane concerns built on top of it. + +## Language + +**Fetcher**: +A `fetch(request)` request handler that can produce a `Response`. +_Avoid_: Server, app + +**Fetcher Capability**: +A **Fetcher** exposed over Cap'n Web so another runtime can call it. +_Avoid_: Fetch Tunnel, transport, client/server connection + +**Fetcher Stub**: +The local Cap'n Web proxy for a remote **Fetcher Capability**. +_Avoid_: Tunnel, client, fetcher + +**Tunnel**: +An active **Tunnel Gateway** registration with a public URL, lifecycle, and optional reusable **Connect Token**. +_Avoid_: Fetcher Capability, WebSocket session + +**Tunnel Client**: +The process, browser tab, test, or agent environment that exposes a **Fetcher Capability** to a **Tunnel Gateway**. +_Avoid_: Local server, node process + +**Tunnel Gateway**: +A public ingress that resolves incoming HTTP requests to active **Tunnels**. +_Avoid_: Server, Worker, hosted server + +**Cloudflare Tunnel Gateway**: +A **Tunnel Gateway** implemented with a Cloudflare Worker and Durable Object. +_Avoid_: Server, Worker + +**Tunnel Name**: +The public routing key a **Tunnel Gateway** uses to select an active **Tunnel**. +_Avoid_: Subdomain, path segment, slug + +**Reserved Tunnel Name**: +A **Tunnel Name** held back for gateway, product, documentation, or future control-plane use. +_Avoid_: Blocked subdomain, reserved subdomain + +**Tunnel Addressing**: +The **Tunnel Gateway**'s own scheme for turning a **Tunnel Name** into a public tunnel URL. +_Avoid_: Client routing mode, URL pattern + +**Gateway Connect Request**: +The internal WebSocket request a **Tunnel Client** opens to a **Gateway** with Captun query parameters that register a **Tunnel**. +_Avoid_: Magic connect path, server URL + +**Gateway Policy**: +Rules a **Tunnel Gateway** applies to **Gateway Connect Requests**, active **Tunnels**, and forwarded public requests. +_Avoid_: Hosted safety, middleware + +**Trusted Gateway Policy**: +**Gateway Policy** for cooperative **Self-Hosted Deployments**, usually based on a **Gateway Secret**. +_Avoid_: Self-hosted policy, private policy + +**Public Gateway Policy**: +**Gateway Policy** for untrusted public tunnel creation, including reserved names, anonymous ownership, and rate limits. +_Avoid_: Hosted policy, safety policy + +**Tunnel Admission**: +The **Tunnel Gateway** policy decision that accepts, rejects, or diagnoses a **Gateway Connect Request** before it becomes an active **Tunnel**. +_Avoid_: Hosted admission, auth check + +**Connect Token**: +A credential carried on a **Gateway Connect Request** and interpreted by **Tunnel Admission**. +_Avoid_: Secret, owner token + +**Gateway Secret**: +A **Connect Token** that authorizes use of a whole **Self-Hosted Deployment**. +_Avoid_: Captun secret, auth secret + +**Ownership Token**: +A **Connect Token** that preserves claim over one active anonymous **Tunnel Name**. +_Avoid_: User token, auth token + +**Gateway**: +The public API option naming a **Tunnel Gateway** by URL. +_Avoid_: serverUrl, gatewayUrl + +**Runtime Adapter**: +An integration that accepts **Fetcher Capabilities** in a specific WebSocket runtime without necessarily providing a full **Tunnel Gateway**. +_Avoid_: Gateway, server + +**Self-Hosted Deployment**: +A user-controlled **Tunnel Gateway** deployment. +_Avoid_: Private hosted service, user server + +**Hosted Service**: +The Iterate-operated `captun.sh` **Tunnel Gateway** for public, untrusted tunnel creation. +_Avoid_: Default server, public Worker + +**Hosted Site**: +The `www.captun.sh` documentation and browser-demo surface for the **Hosted Service**. +_Avoid_: Landing page, marketing site + +**Control Plane**: +The future account, authentication, billing, reservation, and policy system for the **Hosted Service**. +_Avoid_: Dashboard, app, auth layer + +**Agent Preview Use Case**: +A use case where an agent creates a public URL through the **Hosted Service** so a human can inspect work quickly. +_Avoid_: Agent layer, agent product + +## Relationships + +- A **Tunnel Client** exposes a **Fetcher Capability**. +- A **Tunnel Gateway** receives a **Fetcher Stub** for the **Tunnel Client**'s **Fetcher Capability**. +- A **Tunnel** is backed by one active **Fetcher Stub**. +- A **Tunnel Gateway** stores active **Tunnels**; the **Fetcher Stub** is the backing capability, not the gateway's domain object. +- Low-level accepting APIs should be named `acceptFetcherCapability` and `acceptFetcherCapabilityFromSocket`. +- `createCaptunTunnel` is the high-level public API for creating a **Tunnel**. +- `connectFetcherCapability` is the preferred internal name for the client-side primitive that opens a **Gateway Connect Request** and exposes the **Fetcher Capability**. +- Do not export `connectFetcherCapability` until there is a concrete non-gateway use case. +- A **Tunnel Gateway** maps each active **Tunnel Name** to at most one **Tunnel**. +- The current concrete **Tunnel Gateway** implementation is the **Cloudflare Tunnel Gateway**. +- A **Tunnel Gateway** owns **Tunnel Addressing**; **Tunnel Clients** should not need to know whether public tunnel URLs use paths or subdomains. +- A **Tunnel Client** builds a **Gateway Connect Request** from the user-supplied **Gateway** URL, the optional **Tunnel Name**, and Captun-owned query parameters. +- **Tunnel Admission** is **Gateway Policy** for **Gateway Connect Requests**. +- **Self-Hosted Deployments** created by the wizard use **Trusted Gateway Policy** by default. +- The **Hosted Service** uses **Public Gateway Policy**. +- **Gateway Policy** must be configured explicitly and not inferred from **Tunnel Addressing** or hostnames such as `captun.sh`. +- Renaming the current `CUSTOM_HOSTNAME` addressing env var is deferred. The priority is separating **Gateway Policy** from **Tunnel Addressing** first. +- Public Captun APIs should call the user-supplied **Tunnel Gateway** URL `gateway`. +- Public Captun APIs should call the user-supplied **Connect Token** `token`. **Tunnel Admission** decides whether that token is a **Gateway Secret**, **Ownership Token**, or future **Control Plane** credential. +- `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. +- 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. +- The **Hosted Service** is currently the **Cloudflare Tunnel Gateway** running with public hosted policy and the `captun.sh` product surface. +- The **Hosted Site** is part of the **Hosted Service** product surface, but it is not tunnel routing or **Tunnel Admission**. +- **Hosted Site** code should not live in **Cloudflare Tunnel Gateway** core. A real browser package can wait until the demo surface needs it. +- The **Control Plane** governs future **Hosted Service** accounts, reservations, billing, and policy. +- The **Agent Preview Use Case** uses the **Hosted Service** and may later use the **Control Plane**. +- The **Agent Preview Use Case** should not shape the current gateway/core split until **Control Plane** support exists. +- A **Runtime Adapter** can be used to build a **Tunnel Gateway**, but accepting a WebSocket and producing a **Fetcher Stub** is not the same as managing named **Tunnels**. + +## Example Dialogue + +> **Dev:** "Does the Node adapter make Node a Tunnel Gateway?" +> **Domain expert:** "No. The adapter accepts a Fetcher Capability in Node and produces a Fetcher Stub, but a Tunnel Gateway also needs named public routing and active tunnel management." + +> **Dev:** "When `npx captun 3000` has no local config, is that still self-hosted?" +> **Domain expert:** "No. That uses the Hosted Service. Self-Hosted Deployment starts after `npx captun deploy` writes a user-controlled gateway URL and token." + +## Flagged Ambiguities + +- "Server" has meant the **Tunnel Gateway**, the **Fetcher**, and the **Hosted Service**. Use the precise term. +- "Hosted" has meant both **Self-Hosted Deployment** and the public **Hosted Service**. Use **Self-Hosted Deployment** for user-owned infrastructure and **Hosted Service** for `captun.sh`. +- Node, Bun, and Deno support should be described as **Runtime Adapters** unless they also provide named public routing and active tunnel management. +- "Fetch Tunnel" sounded natural but conflicts with Cap'n Web vocabulary. Use **Fetcher Capability** and **Fetcher Stub** for the low-level Cap'n Web layer, and **Tunnel** for the gateway/product registration. +- "serverUrl" currently means a client-side tunnel URL construction pattern. The preferred model is a **Gateway** URL plus gateway-owned **Tunnel Addressing**. +- "secret" and "ownerToken" are implementation-specific kinds of **Connect Token**. Public APIs should prefer `token`. +- `/__captun-connect` encoded connect intent as a path. This is rejected for the pre-user API because it makes the public gateway URL model harder to understand. +- Apex gateway URLs such as `https://example.com` are not the default deploy-wizard path. Users who want them can compose the exported pieces and Cloudflare routes themselves. diff --git a/README.md b/README.md index 251c66a..1b7256a 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,23 @@ -# Captun (cap[tainweb] tun[nel]) +# Captun (cap[nweb] tun[nel]) 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, but [faster](#performance): - -`deploy` expects Cloudflare auth to already be available. Run `npx wrangler login` once, or set `CLOUDFLARE_API_TOKEN` for CI and other non-interactive shells. +Expose a local HTTP server with the hosted `captun.sh` tunnel service: ```bash -npx captun deploy +npx captun 3000 ``` -Then tunnel to it: +That prints a public URL like `https://abc123.captun.sh` and forwards requests to `localhost:3000`. + +If you want your own tunnel server, deploy a captun Worker to your Cloudflare account. You can think of this like your own personal ngrok server, but [faster](#performance): + +`deploy` expects Cloudflare auth to already be available. Run `npx wrangler login` once, or set `CLOUDFLARE_API_TOKEN` for CI and other non-interactive shells. ```bash -npx captun 3000 +npx captun deploy ``` -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. +The deploy command uses `wrangler` under the hood to deploy an opinionated captun Tunnel Gateway to your Cloudflare account, then stores its gateway URL and token in an XDG config file for later tunnel commands. >Server: Response - Server-->>HTTP: Response + Client->>Gateway: WebSocket RPC connect to ?captun-connect=1&captun-name=demo with fetcher as main capability + Gateway-->>Client: ready({ url }) + HTTP->>Gateway: GET /demo/report + Gateway->>Client: fetch(request) + Client-->>Gateway: Response + Gateway-->>HTTP: Response ``` See [examples/weather-reporter](./examples/weather-reporter) for a small workspace package that imports `captun` and has its own e2e tests. @@ -257,7 +258,7 @@ pnpm run build pnpm run dev ``` -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. +Run tests with `pnpm test`. The root e2e suite uses Miniflare by default; set `CAPTUN_GATEWAY`, with optional `CAPTUN_TOKEN`, to run the same cases against a deployed Worker. End-to-end smoke tests for build, dry-run deploy, local `wrangler dev`, tunnel, and `curl` live in [`scripts/smoke/`](./scripts/smoke) with documentation in [docs/smoke-test.md](./docs/smoke-test.md): diff --git a/docs/adr/0001-gateway-owned-tunnel-addressing.md b/docs/adr/0001-gateway-owned-tunnel-addressing.md new file mode 100644 index 0000000..a410dc7 --- /dev/null +++ b/docs/adr/0001-gateway-owned-tunnel-addressing.md @@ -0,0 +1,10 @@ +# Gateway-Owned Tunnel Addressing + +Captun clients should connect to a user-supplied `gateway` URL with Captun-owned query parameters for connect intent, tunnel name, and token; they should not construct public tunnel URLs or dial a magic `/__captun-connect` path. The Tunnel Gateway owns Tunnel Addressing and returns the active Tunnel's public URL and reusable token over the Cap'n Web session before `createCaptunTunnel` resolves. + +## Consequences + +- The public API can use one address concept: `gateway`. +- `serverUrl`, `url`, and `/__captun-connect` are legacy implementation shapes to remove before the pre-user API hardens. +- Custom-domain deployments need a stable gateway hostname inside the wildcard route by default, with that hostname reserved as a Tunnel Name. +- Gateway Policy must be configured explicitly; it must not be inferred from hostname or addressing mode. diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 50c50ea..d2bea54 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -5,7 +5,7 @@ These are pragmatic benchmark notes, not a formal benchmark suite. The goal is t The startup benchmark in [scripts/benchmark-startup.ts](../scripts/benchmark-startup.ts) measures from "start creating a tunnel" to the first successful public HTTP fetch through that tunnel: ```bash -CAPTUN_SERVER_URL=https://{name}.tunnels.example.com \ +CAPTUN_GATEWAY=https://gateway.tunnels.example.com \ PROVIDERS=captun,ngrok,cloudflared,wrangler-tunnel \ COUNTS=1 \ OUT=docs/performance/captun-startup.json \ @@ -15,7 +15,7 @@ node scripts/benchmark-startup.ts The stream benchmark in [scripts/benchmark-streams.ts](../scripts/benchmark-streams.ts) creates many named tunnels and fetches large responses through them: ```bash -CAPTUN_SERVER_URL=https://captun.example.workers.dev \ +CAPTUN_GATEWAY=https://captun.example.workers.dev \ COUNTS=100 \ BYTES=2097152 \ OUT=docs/performance/captun-streams.json \ diff --git a/docs/smoke-test.md b/docs/smoke-test.md index 671b2c9..469c532 100644 --- a/docs/smoke-test.md +++ b/docs/smoke-test.md @@ -46,12 +46,12 @@ pnpm run build npx captun deploy ``` -The CLI writes `serverUrl` and `secret` to `~/.config/captun/config.json` automatically. +The CLI writes `gateway` and `token` to `~/.config/captun/config.json` automatically. Non-interactive: ```bash -npx captun deploy --secret "$(openssl rand -base64 32 | tr -d '\n=' | tr '/+' '_-')" +npx captun deploy --token "$(openssl rand -base64 32 | tr -d '\n=' | tr '/+' '_-')" ``` Requires `CLOUDFLARE_API_TOKEN` or prior `wrangler login`. @@ -59,10 +59,10 @@ Requires `CLOUDFLARE_API_TOKEN` or prior `wrangler login`. ### B. Wildcard route deploy ```bash -npx captun deploy --route '*.tunnels.yourdomain.com/*' --zone yourdomain.com --secret "$SECRET" +npx captun deploy --route '*.tunnels.yourdomain.com/*' --zone yourdomain.com --token "$TOKEN" ``` -Config `serverUrl` becomes `https://{name}.tunnels.yourdomain.com`. +Config `gateway` becomes `https://gateway.tunnels.yourdomain.com`. The gateway returns each tunnel's public URL when the client connects. ### C. Tunnel port 3000 @@ -71,22 +71,22 @@ After deploy, config is enough: ```bash python3 -m http.server 3000 # terminal 1 npx captun 3000 --name demo # terminal 2 (reads ~/.config/captun/config.json) -curl -i "$(jq -r .serverUrl ~/.config/captun/config.json)demo/" +curl -i "https://demo.tunnels.yourdomain.com/" ``` Or step **6** against an existing Worker: ```bash -export SMOKE_SERVER_URL='https://captun..workers.dev' -export CAPTUN_SECRET='' +export SMOKE_GATEWAY='https://captun..workers.dev' +export CAPTUN_TOKEN='' ./scripts/smoke-test.sh step-6-tunnel-remote ``` Wildcard: ```bash -export SMOKE_SERVER_URL='https://{name}.tunnels.templestein.com' -export CAPTUN_SECRET='...' +export SMOKE_GATEWAY='https://gateway.tunnels.templestein.com' +export CAPTUN_TOKEN='...' ./scripts/smoke-test.sh step-6-tunnel-remote curl -i "https://smoke-test.tunnels.templestein.com/" ``` @@ -112,7 +112,7 @@ doppler run -p os -c dev_jonas -- sh -c ' npx captun deploy \ --route "*.tunnels.templestein.com/*" \ --zone templestein.com \ - --secret "$(openssl rand -base64 32 | tr -d "\n=" | tr "/+" "_-")" + --token "$(openssl rand -base64 32 | tr -d "\n=" | tr "/+" "_-")" ' # Terminal 1 @@ -125,7 +125,7 @@ npx captun 3000 --name banana curl https://banana.tunnels.templestein.com/ ``` -**Proved 2026-05-18:** deploy to `*.tunnels.templestein.com/*`, tunnel `banana` → local `:3000`, `curl` returned `200` with body `

banana tunnel works

`. Secret stored in `~/.config/captun/config.json` and set as Worker `CAPTUN_SECRET`. +**Proved 2026-05-18:** deploy to `*.tunnels.templestein.com/*`, tunnel `banana` → local `:3000`, `curl` returned `200` with body `

banana tunnel works

`. Token stored in `~/.config/captun/config.json` and set as Worker `CAPTUN_TOKEN`. `--zone` is required when Wrangler cannot infer the zone from the route pattern alone. diff --git a/examples/weather-reporter/e2e.test.ts b/examples/weather-reporter/e2e.test.ts index cffb101..f25f5dc 100644 --- a/examples/weather-reporter/e2e.test.ts +++ b/examples/weather-reporter/e2e.test.ts @@ -8,7 +8,7 @@ vi.setConfig({ testTimeout: 15_000 }); test("returns nicely formatted weather report", async () => { await using app = await createWeatherReporterFixture(); using _tunnel = await createCaptunTunnel({ - url: `${app.url}/__intercept-egress-traffic`, + gateway: `${app.url}/__intercept-egress-traffic`, fetch(request) { if (request.url === "https://wttr.in/london?format=j1") { return Response.json({ current_condition: [{ temp_C: "18" }] }); diff --git a/examples/weather-reporter/worker.ts b/examples/weather-reporter/worker.ts index 98e83c4..d120253 100644 --- a/examples/weather-reporter/worker.ts +++ b/examples/weather-reporter/worker.ts @@ -1,12 +1,12 @@ import { DurableObject } from "cloudflare:workers"; -import { acceptCaptunTunnel } from "captun"; +import { acceptFetcherCapability, type FetcherStub } from "captun"; type WeatherReporterEnv = Env & { WEATHER_REPORTER_EGRESS: DurableObjectNamespace; }; export class WeatherReporterEgressTunnel extends DurableObject { - private egressTunnel: ReturnType["tunnel"] | undefined; + private egressTunnel: FetcherStub | undefined; async fetch(request: Request) { const url = new URL(request.url); @@ -24,12 +24,15 @@ export class WeatherReporterEgressTunnel extends DurableObject { - if (this.egressTunnel === tunnel) this.egressTunnel = undefined; + if (this.egressTunnel === fetcher) this.egressTunnel = undefined; }, }); - this.egressTunnel = tunnel; + this.egressTunnel = fetcher; + queueMicrotask(() => { + void fetcher.ready({ url: new URL(request.url).origin }); + }); return response; } diff --git a/scripts/benchmark-startup.ts b/scripts/benchmark-startup.ts index 2981068..cf20a56 100644 --- a/scripts/benchmark-startup.ts +++ b/scripts/benchmark-startup.ts @@ -38,7 +38,7 @@ const providers = (process.env.PROVIDERS ?? "captun") const counts = (process.env.COUNTS ?? "1,10,100,500,1000,2000") .split(",") .map((value) => Number(value.trim())); -const captunUrl = process.env.CAPTUN_SERVER_URL ?? "https://{name}.tunnels.example.com"; +const captunGateway = process.env.CAPTUN_GATEWAY ?? "https://gateway.tunnels.example.com"; const out = process.env.OUT ?? `docs/performance/startup-${new Date().toISOString().replace(/[:.]/g, "-")}.json`; @@ -73,7 +73,7 @@ try { await mkdir("docs/performance", { recursive: true }); await writeFile( out, - `${JSON.stringify({ originUrl, captunUrl, timeoutMs, results }, null, 2)}\n`, + `${JSON.stringify({ originUrl, captunGateway, timeoutMs, results }, null, 2)}\n`, ); console.log(`wrote ${out}`); } finally { @@ -111,22 +111,20 @@ async function measure(provider: Provider, index: number, originUrl: string): Pr async function measureCaptun(index: number, originUrl: string): Promise { const name = `bench-${randomBytes(8).toString("hex")}`; - const url = tunnelUrl(captunUrl, name); const started = performance.now(); - let tunnel: Disposable | undefined; + let tunnel: Awaited> | undefined; try { tunnel = await createCaptunTunnel({ - url: `${url}/__captun-connect`, - headers: process.env.CAPTUN_SECRET - ? { authorization: `Bearer ${process.env.CAPTUN_SECRET}` } - : undefined, + gateway: captunGateway, + name, + token: process.env.CAPTUN_TOKEN, fetch: (request) => { const incoming = new URL(request.url); return fetch(`${originUrl}${incoming.pathname}${incoming.search}`, request); }, }); - await waitForFetch(url, started); - return { index, ok: true, ms: performance.now() - started, url }; + await waitForFetch(tunnel.url, started); + return { index, ok: true, ms: performance.now() - started, url: tunnel.url }; } finally { tunnel?.[Symbol.dispose](); } @@ -227,13 +225,6 @@ async function waitForFetch(url: string | URL, started: number) { throw new Error(`first fetch timed out: ${lastError}`); } -function tunnelUrl(base: string, name: string) { - if (base.includes("{name}")) return base.replaceAll("{name}", name).replace(/\/$/, ""); - const url = new URL(base); - url.pathname = `/${name}`; - return url.toString().replace(/\/$/, ""); -} - function parseCloudflareTunnelUrl(output: string) { const match = output.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/i); return match ? new URL(match[0]) : undefined; diff --git a/scripts/benchmark-streams.ts b/scripts/benchmark-streams.ts index 7b52f9c..e4603ac 100644 --- a/scripts/benchmark-streams.ts +++ b/scripts/benchmark-streams.ts @@ -35,7 +35,7 @@ type Result = { const counts = (process.env.COUNTS ?? "1,10,25,50,100") .split(",") .map((value) => Number(value.trim())); -const serverUrl = process.env.CAPTUN_SERVER_URL ?? "https://captun.example.workers.dev"; +const gateway = process.env.CAPTUN_GATEWAY ?? "https://captun.example.workers.dev"; const out = process.env.OUT ?? "docs/performance/captun-streams.json"; const bytes = Number(process.env.BYTES ?? 2 * 1024 * 1024); const chunkBytes = Number(process.env.CHUNK_BYTES ?? 64 * 1024); @@ -63,8 +63,9 @@ if (warmupCount > 0) { Math.min(warmupCount, Math.max(connectConcurrency, 25)), async (index) => { const session = await createCaptunTunnel({ - url: `${tunnelUrl(`${namePrefix}-warm-${index}`)}/__captun-connect`, - headers: captunHeaders(), + gateway, + name: `${namePrefix}-warm-${index}`, + token: process.env.CAPTUN_TOKEN, fetch: () => testResponse("stream"), }); session[Symbol.dispose](); @@ -85,7 +86,7 @@ for (const count of counts) { await mkdir("docs/performance", { recursive: true }); await writeFile( out, - `${JSON.stringify({ serverUrl, bytes, chunkBytes, modes, readMode, timeoutMs, results }, null, 2)}\n`, + `${JSON.stringify({ gateway, bytes, chunkBytes, modes, readMode, timeoutMs, results }, null, 2)}\n`, ); console.log(`wrote ${out}`); @@ -127,19 +128,19 @@ async function runPool(count: number, concurrency: number, task: (index: numb async function measure(index: number, mode: string): Promise { // Deterministic names make runs reproducible and let us intentionally spread // or collide names across shards by changing NAME_PREFIX and SHARD_COUNT. - const url = tunnelUrl(`${namePrefix}-${index}`); const started = performance.now(); const cpuStarted = process.cpuUsage(); const eventLoopStarted = performance.eventLoopUtilization(); - let tunnel: Disposable | undefined; + let tunnel: Awaited> | undefined; try { tunnel = await createCaptunTunnel({ - url: `${url}/__captun-connect`, - headers: captunHeaders(), + gateway, + name: `${namePrefix}-${index}`, + token: process.env.CAPTUN_TOKEN, fetch: () => testResponse(mode), }); const connectedAt = performance.now(); - const response = await withTimeout(fetch(url), timeoutMs, "fetch timed out"); + const response = await withTimeout(fetch(tunnel.url), timeoutMs, "fetch timed out"); const responseAt = performance.now(); if (!response.ok) throw new Error(`HTTP ${response.status}`); const received = await readBytes(response); @@ -172,12 +173,6 @@ async function measure(index: number, mode: string): Promise { } } -function captunHeaders() { - return process.env.CAPTUN_SECRET - ? { authorization: `Bearer ${process.env.CAPTUN_SECRET}` } - : undefined; -} - function testResponse(mode: string) { if (mode === "bytes") { return new Response(new Uint8Array(bytes), { @@ -216,14 +211,6 @@ async function readBytes(response: Response) { return total; } -function tunnelUrl(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(/\/$/, ""); -} - async function withTimeout(promise: Promise, ms: number, message: string) { let timer: ReturnType | undefined; try { diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 69df6b7..956adb3 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -19,11 +19,11 @@ Steps (run in order for local smoke): step-3-wrangler-dev background wrangler dev (:8787) step-4-http-origin background python http.server (:3456) step-5-tunnel-local captun + curl via local wrangler dev - step-6-tunnel-remote captun + curl via SMOKE_SERVER_URL (deployed Worker) + step-6-tunnel-remote captun + curl via SMOKE_GATEWAY (deployed Worker) step-stop kill background processes Local all-in-one: ./scripts/smoke-test.sh -Remote tunnel only: SMOKE_SERVER_URL=https://... ./scripts/smoke-test.sh step-6 +Remote tunnel only: SMOKE_GATEWAY=https://... ./scripts/smoke-test.sh step-6 State/logs: $CAPTUN_SMOKE_DIR (default /tmp/captun-smoke) EOF diff --git a/scripts/smoke/step-1-deploy-dry-run-workers.sh b/scripts/smoke/step-1-deploy-dry-run-workers.sh index 9aa2415..d81c952 100755 --- a/scripts/smoke/step-1-deploy-dry-run-workers.sh +++ b/scripts/smoke/step-1-deploy-dry-run-workers.sh @@ -4,6 +4,6 @@ source "$(dirname "$0")/lib.sh" cd "$ROOT" LOG="$STATE_DIR/step-1-workers-dev-dry-run.log" log "captun deploy --dry-run (workers.dev)" -$CAPTUN_BIN deploy --dry-run --secret smoke-dry-run-secret >"$LOG" 2>&1 +$CAPTUN_BIN deploy --dry-run --token smoke-dry-run-token >"$LOG" 2>&1 grep -q "Dry run complete" "$LOG" || fail "see $LOG" pass "workers.dev dry-run (log: $LOG)" diff --git a/scripts/smoke/step-2-deploy-dry-run-wildcard.sh b/scripts/smoke/step-2-deploy-dry-run-wildcard.sh index 83af06f..6e03e41 100755 --- a/scripts/smoke/step-2-deploy-dry-run-wildcard.sh +++ b/scripts/smoke/step-2-deploy-dry-run-wildcard.sh @@ -4,7 +4,7 @@ source "$(dirname "$0")/lib.sh" cd "$ROOT" LOG="$STATE_DIR/step-2-wildcard-dry-run.log" log "captun deploy --dry-run (wildcard route)" -$CAPTUN_BIN deploy --dry-run --route '*.captun.example.com/*' --secret smoke-dry-run-secret >"$LOG" 2>&1 +$CAPTUN_BIN deploy --dry-run --route '*.captun.example.com/*' --token smoke-dry-run-token >"$LOG" 2>&1 grep -q "Dry run complete" "$LOG" || fail "see $LOG" -grep -q '{name}.captun.example.com' "$LOG" || fail "wildcard URL not inferred — see $LOG" +grep -q 'https://gateway.captun.example.com' "$LOG" || fail "wildcard gateway not inferred — see $LOG" pass "wildcard dry-run (log: $LOG)" diff --git a/scripts/smoke/step-5-tunnel-local.sh b/scripts/smoke/step-5-tunnel-local.sh index 207d66f..8f45afd 100755 --- a/scripts/smoke/step-5-tunnel-local.sh +++ b/scripts/smoke/step-5-tunnel-local.sh @@ -4,14 +4,14 @@ set -euo pipefail source "$(dirname "$0")/lib.sh" cd "$ROOT" -SERVER_URL="${SMOKE_SERVER_URL:-http://127.0.0.1:$WRANGLER_PORT}" -CURL_URL="${SERVER_URL%/}/${SMOKE_NAME}/" +GATEWAY="${SMOKE_GATEWAY:-http://127.0.0.1:$WRANGLER_PORT}" +CURL_URL="${GATEWAY%/}/${SMOKE_NAME}/" PIDFILE="$STATE_DIR/tunnel.pid" LOG="$STATE_DIR/tunnel.log" stop_pid "$PIDFILE" -log "captun $HTTP_PORT --name $SMOKE_NAME --server-url $SERVER_URL" -$CAPTUN_BIN "$HTTP_PORT" --name "$SMOKE_NAME" --server-url "$SERVER_URL" >"$LOG" 2>&1 & +log "captun $HTTP_PORT --name $SMOKE_NAME --gateway $GATEWAY" +$CAPTUN_BIN "$HTTP_PORT" --name "$SMOKE_NAME" --gateway "$GATEWAY" >"$LOG" 2>&1 & echo $! >"$PIDFILE" wait_for_log "$LOG" "Press Ctrl+C to close tunnel" 30 diff --git a/scripts/smoke/step-6-tunnel-remote.sh b/scripts/smoke/step-6-tunnel-remote.sh index b10ef29..44b3c1e 100755 --- a/scripts/smoke/step-6-tunnel-remote.sh +++ b/scripts/smoke/step-6-tunnel-remote.sh @@ -1,30 +1,26 @@ #!/usr/bin/env bash -# Tunnel to a deployed Worker (set SMOKE_SERVER_URL and optional CAPTUN_SECRET). +# Tunnel to a deployed Worker (set SMOKE_GATEWAY and optional CAPTUN_TOKEN). set -euo pipefail source "$(dirname "$0")/lib.sh" cd "$ROOT" -SERVER_URL="${SMOKE_SERVER_URL:?Set SMOKE_SERVER_URL (e.g. https://captun.account.workers.dev)}" - -if [[ "$SERVER_URL" == *"{name}"* ]]; then - CURL_URL="${SERVER_URL//\{name\}/$SMOKE_NAME}" -else - CURL_URL="${SERVER_URL%/}/${SMOKE_NAME}/" -fi -CURL_URL="${CURL_URL%/}/" +GATEWAY="${SMOKE_GATEWAY:?Set SMOKE_GATEWAY (e.g. https://captun.account.workers.dev)}" PIDFILE="$STATE_DIR/tunnel-remote.pid" LOG="$STATE_DIR/tunnel-remote.log" stop_pid "$PIDFILE" -log "captun $HTTP_PORT --name $SMOKE_NAME --server-url $SERVER_URL" -if [[ -n "${CAPTUN_SECRET:-}" ]]; then - $CAPTUN_BIN "$HTTP_PORT" --name "$SMOKE_NAME" --server-url "$SERVER_URL" --secret "$CAPTUN_SECRET" >"$LOG" 2>&1 & +log "captun $HTTP_PORT --name $SMOKE_NAME --gateway $GATEWAY" +if [[ -n "${CAPTUN_TOKEN:-}" ]]; then + $CAPTUN_BIN "$HTTP_PORT" --name "$SMOKE_NAME" --gateway "$GATEWAY" --token "$CAPTUN_TOKEN" >"$LOG" 2>&1 & else - $CAPTUN_BIN "$HTTP_PORT" --name "$SMOKE_NAME" --server-url "$SERVER_URL" >"$LOG" 2>&1 & + $CAPTUN_BIN "$HTTP_PORT" --name "$SMOKE_NAME" --gateway "$GATEWAY" >"$LOG" 2>&1 & fi echo $! >"$PIDFILE" wait_for_log "$LOG" "Press Ctrl+C to close tunnel" 30 +CURL_URL="${SMOKE_TUNNEL_URL:-$(grep -Eo 'https?://[^[:space:]]+' "$LOG" | tail -n 1 || true)}" +CURL_URL="${CURL_URL%/}/" +[[ -n "$CURL_URL" ]] || fail "could not infer tunnel URL from $LOG" log "curl $CURL_URL" BODY="$STATE_DIR/curl-remote-body.txt" diff --git a/src/cli/bin.ts b/src/cli/bin.ts index f8c3e22..266a174 100755 --- a/src/cli/bin.ts +++ b/src/cli/bin.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { realpathSync } from "node:fs"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { createServer, type Server } from "node:http"; import { homedir } from "node:os"; @@ -15,7 +16,7 @@ import { CliFriendlyError } from "./cli-error.js"; import { createCaptunTunnel } from "../index.js"; import { assertLocalTargetAcceptingConnections } from "./local-target.js"; import { withSpinner } from "./spinner.js"; -import { TUNNEL_URL_HEADER } from "../routing.js"; +import { HOSTED_CAPTUN_GATEWAY } from "../routing.js"; import { captunHealthResponse, confirmTunnelHealth, @@ -23,26 +24,37 @@ import { } from "./tunnel-health.js"; import { deployWorker, openInBrowser, runDeployWizard, waitForCertWithSpinner } from "./deploy.js"; -type Config = { - serverUrl: string; - secret?: string; +export type Config = { + gateway: string; + token?: string; }; type TunnelCliInput = { target: string; name?: string; - serverUrl?: string; - secret?: string; + gateway?: string; + token?: string; requestLogs: boolean; }; -type ResolvedTunnel = { +export type ResolvedTunnel = { name: string; - serverUrl: string; + gateway: string; target: string; - secret?: string; + token?: string; requestLogs: boolean; - tunnel: string; +}; + +export type TunnelReady = { + url: string; + tunnel: ResolvedTunnel; +}; + +export type CaptunCliRouterOptions = { + readConfig?: () => Promise; + writeConfig?: (config: Config) => Promise; + waitForShutdown?: () => Promise; + onTunnelReady?: (ready: TunnelReady) => void | Promise; }; const adjectives = @@ -62,142 +74,151 @@ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..") const xdgConfigHome = process.env.XDG_CONFIG_HOME; const configPath = resolve(xdgConfigHome || resolve(homedir(), ".config"), "captun", "config.json"); -const router = os.router({ - tunnel: os - .meta({ - default: true, - description: "Expose a local HTTP server through your Captun tunnel Worker.", - examples: ["captun 3000", "captun 0.0.0.0:5173 --name my-app"], - }) - .input( - z.object({ - target: z - .string() - .trim() - .min(1) - .default("3000") - .describe("Local target to expose, as a port, host:port, or URL") - .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"), - requestLogs: z.boolean().default(true).describe("Print basic request logs"), - }), - ) - .handler(async ({ input }) => { - const config = await readConfig(); - if (config) console.log(`${color.dim("Using")} ${color.cyan(configPath)}\n`); - - const tunnel = resolveTunnel(input, config); - printTunnelOpening(tunnel); - await runTunnelSession(tunnel); - }), - - deploy: os - .meta({ - description: "Deploy the Captun tunnel Worker with Wrangler and save local CLI config.", - prompt: false, - examples: [ - "captun deploy", - "captun deploy --route '*.captun.example.com/*'", - "captun deploy --shards 16", - ], - }) - .input( - z.object({ - name: z - .string() - .optional() - .describe("Worker name (defaults to the value in wrangler.jsonc, which is 'captun')"), - route: z - .string() - .optional() - .describe("Optional Worker route, for example *.captun.example.com/*"), - zone: z - .string() - .optional() - .describe("Cloudflare zone name for the route, for example example.com"), - secret: z - .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"), - dryRun: z - .boolean() - .optional() - .describe("Compile and validate the deploy without uploading"), +export function createCaptunCliRouter(options: CaptunCliRouterOptions = {}) { + const readCliConfig = options.readConfig || readConfig; + const writeCliConfig = options.writeConfig || writeConfig; + return os.router({ + tunnel: os + .meta({ + default: true, + description: "Expose a local HTTP server through your Captun tunnel Worker.", + examples: ["captun 3000", "captun 0.0.0.0:5173 --name my-app"], + }) + .input( + z.object({ + target: z + .string() + .trim() + .min(1) + .default("3000") + .describe("Local target to expose, as a port, host:port, or URL") + .meta({ positional: true }), + name: z.string().optional().describe("Tunnel name"), + gateway: z.url().optional().describe("Tunnel Gateway URL"), + token: z.string().optional().describe("Tunnel Gateway token"), + requestLogs: z.boolean().default(true).describe("Print basic request logs"), + }), + ) + .handler(async ({ input }) => { + const config = await readCliConfig(); + if (config) console.log(`${color.dim("Using")} ${color.cyan(configPath)}\n`); + + const tunnel = resolveTunnel(input, config); + printTunnelOpening(tunnel); + await runTunnelSession(tunnel, { + waitForShutdown: options.waitForShutdown, + onReady: options.onTunnelReady, + }); }), - ) - .handler(async ({ input }) => { - const wizardResult = await runDeployWizard(input, { packageRoot }); - const secret = wizardResult.secret; - const serverUrl = await deployWorker( - { - name: wizardResult.name, - route: wizardResult.route, - zone: wizardResult.zone, - secret, - shards: wizardResult.shards, - accountId: wizardResult.accountId, - customHostname: wizardResult.customHostname, - dryRun: input.dryRun, - }, - { packageRoot }, - ); - if (input.dryRun) { - console.log("\nDry run complete (no upload, config not written)."); - console.log(`Expected server URL pattern: ${serverUrl}`); - return { serverUrl, dryRun: true }; - } - // Worker is live now — persist config before anything that can fail later - // (e.g. cert provisioning can time out, but the deploy is already complete). - await writeConfig({ serverUrl, secret }); - - if (wizardResult.certWait) { - try { - await waitForCertWithSpinner(wizardResult.certWait); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.log( - `\n${color.yellow("!")} Certificate provisioning is still pending: ${message}`, - ); - console.log( - ` ${color.dim("Config has been saved. Re-run `captun deploy` later (or check the Cloudflare dashboard) once the cert is active.")}`, - ); + deploy: os + .meta({ + description: "Deploy the Captun tunnel Worker with Wrangler and save local CLI config.", + prompt: false, + examples: [ + "captun deploy", + "captun deploy --route '*.captun.example.com/*'", + "captun deploy --shards 16", + ], + }) + .input( + z.object({ + name: z + .string() + .optional() + .describe("Worker name (defaults to the value in wrangler.jsonc, which is 'captun')"), + route: z + .string() + .optional() + .describe("Optional Worker route, for example *.captun.example.com/*"), + zone: z + .string() + .optional() + .describe("Cloudflare zone name for the route, for example example.com"), + token: z + .string() + .optional() + .describe("Token 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"), + dryRun: z + .boolean() + .optional() + .describe("Compile and validate the deploy without uploading"), + }), + ) + .handler(async ({ input }) => { + const wizardResult = await runDeployWizard(input, { packageRoot }); + const token = wizardResult.token; + const gateway = await deployWorker( + { + name: wizardResult.name, + route: wizardResult.route, + zone: wizardResult.zone, + token, + shards: wizardResult.shards, + accountId: wizardResult.accountId, + customHostname: wizardResult.customHostname, + dryRun: input.dryRun, + }, + { packageRoot }, + ); + if (input.dryRun) { + console.log("\nDry run complete (no upload, config not written)."); + console.log(`Expected gateway: ${gateway}`); + return { gateway, dryRun: true }; } - } - printDeploySummary({ - serverUrl, - workerName: wizardResult.name ?? "captun", - route: wizardResult.route, - zone: wizardResult.zone, - shards: wizardResult.shards ?? 1, - }); + // Worker is live now — persist config before anything that can fail later + // (e.g. cert provisioning can time out, but the deploy is already complete). + await writeCliConfig({ gateway, token }); + + if (wizardResult.certWait) { + try { + await waitForCertWithSpinner(wizardResult.certWait); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.log( + `\n${color.yellow("!")} Certificate provisioning is still pending: ${message}`, + ); + console.log( + ` ${color.dim("Config has been saved. Re-run `captun deploy` later (or check the Cloudflare dashboard) once the cert is active.")}`, + ); + } + } - if (process.stdin.isTTY) { - await postDeploySelfTest({ - serverUrl, - secret, - workerName: wizardResult.name ?? "captun", + printDeploySummary({ + gateway, + workerName: wizardResult.name || "captun", route: wizardResult.route, zone: wizardResult.zone, - shards: wizardResult.shards ?? 1, + shards: wizardResult.shards || 1, }); - } - return { serverUrl, configPath }; - }), -}); + if (process.stdin.isTTY) { + await postDeploySelfTest({ + gateway, + token, + workerName: wizardResult.name || "captun", + route: wizardResult.route, + zone: wizardResult.zone, + shards: wizardResult.shards || 1, + }); + } + + return { gateway, configPath }; + }), + }); +} + +export const router = createCaptunCliRouter(); type DeployedSummary = { - serverUrl: string; + gateway: string; workerName: string; route?: string; zone?: string; @@ -209,12 +230,12 @@ function printDeploySummary(summary: DeployedSummary) { tunnelInfoRow("worker", color.cyan(summary.workerName)); if (summary.route) tunnelInfoRow("route", color.cyan(summary.route)); if (summary.zone) tunnelInfoRow("zone", color.cyan(summary.zone)); - tunnelInfoRow("server url", color.cyan(summary.serverUrl)); + tunnelInfoRow("gateway", color.cyan(summary.gateway)); tunnelInfoRow("shards", color.cyan(String(summary.shards))); tunnelInfoRow("config", color.cyan(configPath)); } -async function postDeploySelfTest(opts: DeployedSummary & { secret: string }) { +async function postDeploySelfTest(opts: DeployedSummary & { token: string }) { const server = createServer((_req, res) => { res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); res.end(renderSuccessPage(opts)); @@ -233,11 +254,10 @@ async function postDeploySelfTest(opts: DeployedSummary & { secret: string }) { const name = randomName(); const tunnel: ResolvedTunnel = { name, - serverUrl: opts.serverUrl, + gateway: opts.gateway, target, - secret: opts.secret, + token: opts.token, requestLogs: true, - tunnel: tunnelUrl(opts.serverUrl, name), }; printTunnelOpening(tunnel); @@ -248,7 +268,7 @@ async function postDeploySelfTest(opts: DeployedSummary & { secret: string }) { try { await runTunnelSession(tunnel, { retries: 6, - onReady: () => openInBrowser(tunnel.tunnel), + onReady: ({ url }) => openInBrowser(url), }); } finally { await new Promise((closeResolve) => server.close(() => closeResolve())); @@ -268,7 +288,7 @@ function renderSuccessPage(opts: DeployedSummary): string { ["Worker name", opts.workerName], ...(opts.route ? [["Route", opts.route] as [string, string]] : []), ...(opts.zone ? [["Zone", opts.zone] as [string, string]] : []), - ["Server URL", opts.serverUrl], + ["Gateway", opts.gateway], ["Shards", String(opts.shards)], ["Config file", configPath], ]; @@ -377,16 +397,6 @@ async function writeConfig(config: Config) { await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }); } -function tunnelUrl(baseUrl: string, name: string) { - // The wizard writes serverUrl with a `{name}` placeholder when the Worker is - // on a custom hostname (subdomain routing). Plain URLs (workers.dev or local - // wrangler dev) use folder routing — append the tunnel name as a path segment. - if (baseUrl.includes("{name}")) return removeTrailingSlash(baseUrl.replaceAll("{name}", name)); - const url = new URL(baseUrl); - url.pathname = `${url.pathname.replace(/\/$/, "")}/${encodeURIComponent(name)}`; - return removeTrailingSlash(url.toString()); -} - function removeTrailingSlash(url: string) { return url.replace(/\/$/, ""); } @@ -424,21 +434,17 @@ function colorStatus(status: number) { } function resolveTunnel(input: TunnelCliInput, config?: Config): ResolvedTunnel { - const serverUrl = input.serverUrl ?? config?.serverUrl; - if (!serverUrl) { - throw new Error(`No tunnel server configured. Run "captun deploy" first or pass --server-url.`); - } + const gateway = input.gateway || config?.gateway || HOSTED_CAPTUN_GATEWAY; - const name = input.name ?? randomName(); + const name = input.name || randomName(); const target = normalizeTarget(input.target); return { name, - serverUrl, + gateway, target, - secret: input.secret ?? config?.secret, + token: input.token || config?.token, requestLogs: input.requestLogs, - tunnel: tunnelUrl(serverUrl, name), }; } @@ -457,49 +463,50 @@ function normalizeTarget(target: string) { async function runTunnelSession( tunnel: ResolvedTunnel, - opts: { retries?: number; onReady?: () => void } = {}, + opts: { + retries?: number; + waitForShutdown?: () => Promise; + onReady?: (ready: TunnelReady) => void | Promise; + } = {}, ) { const startedAt = performance.now(); await assertLocalTargetAcceptingConnections(tunnel.target); - // Filled in from the `x-captun-tunnel-url` header on the first forwarded - // request — the Worker is the source of truth for the public URL. - const advertisedUrl: { current: string | undefined } = { current: undefined }; - const session = await connectTunnelWithRetry(tunnel, opts.retries ?? 0, advertisedUrl); + const session = await connectTunnelWithRetry(tunnel, opts.retries || 0); try { - await confirmTunnelHealth(tunnel.tunnel); - const tunnelUrlForDisplay = advertisedUrl.current ?? tunnel.tunnel; + await confirmTunnelHealth(session.url); console.log( `\n${color.green("Ready")} ${color.dim(`in ${Math.round(performance.now() - startedAt)}ms`)}\n`, ); - console.log(color.cyan(tunnelUrlForDisplay)); + console.log(color.cyan(session.url)); console.log(` ${color.dim("->")} ${color.cyan(tunnel.target)}`); console.log(`\n${color.dim("Press Ctrl+C to close tunnel")}\n`); - opts.onReady?.(); - await waitForShutdown(); + await opts.onReady?.({ url: session.url, tunnel }); + await (opts.waitForShutdown || waitForShutdown)(); } finally { session[Symbol.dispose](); } } -async function connectTunnelWithRetry( - tunnel: ResolvedTunnel, - retries: number, - advertisedUrl: { current: string | undefined }, -) { - const url = `${tunnel.tunnel}/__captun-connect`; - const headers = tunnel.secret ? { authorization: `Bearer ${tunnel.secret}` } : undefined; - const fetcher = makeTunnelFetcher(tunnel, advertisedUrl); +async function connectTunnelWithRetry(tunnel: ResolvedTunnel, retries: number) { + const fetcher = makeTunnelFetcher(tunnel); const maxAttempts = retries + 1; let delay = 2000; for (let attempt = 1; attempt <= maxAttempts; attempt++) { const label = attempt === 1 - ? `Connecting to ${tunnel.tunnel}` - : `Connecting to ${tunnel.tunnel} (retry ${attempt - 1}/${retries})`; + ? `Connecting to ${tunnel.gateway}` + : `Connecting to ${tunnel.gateway} (retry ${attempt - 1}/${retries})`; try { - return await withSpinner(label, () => createCaptunTunnel({ url, headers, fetch: fetcher })); + return await withSpinner(label, () => + createCaptunTunnel({ + gateway: tunnel.gateway, + name: tunnel.name, + token: tunnel.token, + fetch: fetcher, + }), + ); } catch (error) { if (attempt === maxAttempts) { throw tunnelConnectError(tunnel, error); @@ -511,11 +518,8 @@ async function connectTunnelWithRetry( throw new Error("unreachable"); } -function makeTunnelFetcher(tunnel: ResolvedTunnel, advertisedUrl: { current: string | undefined }) { +function makeTunnelFetcher(tunnel: ResolvedTunnel) { return async (request: Request) => { - const advertised = request.headers.get(TUNNEL_URL_HEADER); - if (advertised) advertisedUrl.current = advertised; - if (isCaptunHealthRequest(request)) return captunHealthResponse(); const url = new URL(request.url); @@ -551,9 +555,9 @@ function makeTunnelFetcher(tunnel: ResolvedTunnel, advertisedUrl: { current: str } function tunnelConnectError(tunnel: ResolvedTunnel, cause: unknown) { - const hostname = new URL(tunnel.tunnel).hostname; + const hostname = new URL(tunnel.gateway).hostname; const message = cause instanceof Error ? cause.message : String(cause); - const lines = [`Could not connect tunnel to ${color.cyan(tunnel.tunnel)} (${message}).`]; + const lines = [`Could not connect tunnel to ${color.cyan(tunnel.gateway)} (${message}).`]; if (!hostname.endsWith(".workers.dev")) { // Dropping the leftmost label gives the zone-side wildcard parent — // `tunnel.mispwoso.com` -> `mispwoso.com`, `t.captun.example.com` -> `captun.example.com`. @@ -585,10 +589,10 @@ function tunnelOpeningRows(tunnel: ResolvedTunnel) { const rows = [ { label: "target", value: color.cyan(tunnel.target) }, { label: "--name", value: color.cyan(tunnel.name) }, - { label: "--server-url", value: color.cyan(tunnel.serverUrl) }, + { label: "--gateway", value: color.cyan(tunnel.gateway) }, { - label: "--secret", - value: tunnel.secret ? color.dim(secretPreview(tunnel.secret)) : color.dim("none"), + label: "--token", + value: tunnel.token ? color.dim(tokenPreview(tunnel.token)) : color.dim("none"), }, ]; if (tunnel.requestLogs) rows.push({ label: "--request-logs", value: color.dim("true") }); @@ -599,9 +603,9 @@ function tunnelInfoRow(label: string, value: string) { console.log(` ${color.dim(label.padEnd(16))}${value}`); } -function secretPreview(secret: string, visibleChars = 6) { - if (secret.length <= visibleChars) return secret; - return `${secret.slice(0, visibleChars)}…`; +function tokenPreview(token: string, visibleChars = 6) { + if (token.length <= visibleChars) return token; + return `${token.slice(0, visibleChars)}…`; } function randomName() { @@ -614,16 +618,28 @@ function pick(words: string[]) { return word; } -const cli = createCli({ - router, - name: "captun", - version: "0.0.0", - description: "Expose local HTTP servers through a tiny Cloudflare Worker tunnel.", -}); - -await cli.run({ - prompts, - logger: yamlTableConsoleLogger, - formatError: (error) => - error instanceof CliFriendlyError ? `\n${error.message}\n` : inspect(error), -}); +if (isMainModule()) { + const cli = createCli({ + router, + name: "captun", + version: "0.0.0", + description: "Expose local HTTP servers through a tiny Cloudflare Worker tunnel.", + }); + + await cli.run({ + prompts, + logger: yamlTableConsoleLogger, + formatError: (error) => + error instanceof CliFriendlyError ? `\n${error.message}\n` : inspect(error), + }); +} + +function isMainModule() { + const entry = process.argv[1]; + if (!entry) return false; + try { + return realpathSync(entry) === fileURLToPath(import.meta.url); + } catch { + return resolve(entry) === fileURLToPath(import.meta.url); + } +} diff --git a/src/cli/deploy.ts b/src/cli/deploy.ts index be024fb..b54cb03 100644 --- a/src/cli/deploy.ts +++ b/src/cli/deploy.ts @@ -30,7 +30,7 @@ export type DeployInput = { name?: string; route?: string; zone?: string; - secret?: string; + token?: string; shards?: number; dryRun?: boolean; }; @@ -40,7 +40,7 @@ export type DeployWizardResult = { route?: string; zone?: string; shards?: number; - secret: string; + token: string; accountId?: string; customHostname?: string; certWait?: { @@ -61,9 +61,9 @@ export async function runDeployWizard( route: input.route, zone: input.zone, shards: input.shards, - secret: input.secret ?? randomSecret(), - // Without this, non-interactive `--route` deploys fall through to folder - // routing while serverUrlFromRoute still hands out subdomain URLs. + token: input.token ?? randomToken(), + // Without this, non-interactive `--route` deploys would accept tunnel + // connects on the route host but still parse forwarded requests in folder mode. customHostname: input.route ? customHostnameFromRoute(input.route) : undefined, }; } @@ -211,12 +211,12 @@ export async function runDeployWizard( }); const shards = Number(shardsAnswer); - const secret = await prompts.input({ - message: "Tunnel secret (leave empty to allow anyone to create tunnels on your captun server)", - default: input.secret ?? randomSecret(), + const token = await prompts.input({ + message: "Tunnel token (leave empty to allow anyone to create tunnels on your Captun gateway)", + default: input.token ?? randomToken(), }); - return { name, route, zone, shards, certWait, secret, accountId, customHostname }; + return { name, route, zone, shards, certWait, token, accountId, customHostname }; } async function pickAccount(packageRoot: string): Promise { @@ -509,10 +509,11 @@ export async function deployWorker( name?: string; route?: string; zone?: string; - secret: string; + token: string; shards?: number; accountId?: string; customHostname?: string; + additionalRoutes?: string[]; dryRun?: boolean; }, options: { packageRoot: string }, @@ -521,7 +522,7 @@ export async function deployWorker( 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 }); + await writeFile(secretsFile, JSON.stringify({ CAPTUN_TOKEN: input.token }), { mode: 0o600 }); const baseConfigPath = resolve(packageRoot, "wrangler.jsonc"); const baseConfig = parseJsonc(await readFile(baseConfigPath, "utf8")) as Record< @@ -534,7 +535,11 @@ export async function deployWorker( if (input.accountId) baseConfig.account_id = input.accountId; if (input.route && input.zone) { const existingRoutes = Array.isArray(baseConfig.routes) ? baseConfig.routes : []; - baseConfig.routes = [...existingRoutes, { pattern: input.route, zone_name: input.zone }]; + const routes = [input.route, ...(input.additionalRoutes || [])]; + baseConfig.routes = [ + ...existingRoutes, + ...routes.map((route) => ({ pattern: route, zone_name: input.zone })), + ]; baseConfig.workers_dev = false; } const deployConfig = resolve(tempDir, "wrangler.json"); @@ -546,7 +551,7 @@ export async function deployWorker( if (input.shards) args.push("--var", `SHARD_COUNT:${input.shards}`); // Always set CUSTOM_HOSTNAME (empty string = folder routing) so `--keep-vars` // doesn't leave a stale value behind when switching modes on redeploy. - args.push("--var", `CUSTOM_HOSTNAME:${input.customHostname ?? ""}`); + args.push("--var", `CUSTOM_HOSTNAME:${input.customHostname || ""}`); if (input.dryRun) args.push("--dry-run"); if (!input.dryRun) await assertWranglerAuthenticated({ cwd: packageRoot }); @@ -554,17 +559,17 @@ export async function deployWorker( const output = await runWrangler(args, { cwd: packageRoot, tty: !input.dryRun }); if (input.dryRun) { return input.route - ? serverUrlFromRoute(input.route) + ? gatewayFromRoute(input.route, input.zone) : "https://captun..workers.dev"; } - const serverUrl = input.route - ? serverUrlFromRoute(input.route) - : serverUrlFromWranglerOutput(output); - if (!serverUrl) { + const gateway = input.route + ? gatewayFromRoute(input.route, input.zone) + : gatewayFromWranglerOutput(output); + if (!gateway) { throw new Error("Wrangler deploy succeeded, but the Worker URL was not found in its output."); } - return serverUrl; + return gateway; } finally { await rm(tempDir, { force: true, recursive: true }); } @@ -591,16 +596,23 @@ export async function waitForCertWithSpinner( } } -function serverUrlFromRoute(route: string) { +function gatewayFromRoute(route: string, zone: string | undefined) { 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 host = gatewayHostFromRouteHost(hostPart || "", zone); + if (!host) throw new Error(`Cannot infer gateway from route: ${route}`); const path = pathParts.join("/").replace(/\*.*$/, "").replace(/\/$/, ""); return `https://${host}${path ? `/${path}` : ""}`; } +function gatewayHostFromRouteHost(hostPart: string, zone: string | undefined) { + if (!hostPart.startsWith("*.")) return hostPart; + const wildcardParent = hostPart.slice(2); + const gatewayLabel = zone && wildcardParent === zone ? "captun" : "gateway"; + return `${gatewayLabel}.${wildcardParent}`; +} + /** `*.captun.example.com/*` -> `captun.example.com`; `*.example.com/*` -> `example.com`. */ function customHostnameFromRoute(route: string): string | undefined { const host = route.replace(/^https?:\/\//, "").split("/")[0]; @@ -608,7 +620,7 @@ function customHostnameFromRoute(route: string): string | undefined { return host.slice(2); } -function serverUrlFromWranglerOutput(output: string) { +function gatewayFromWranglerOutput(output: string) { // Wrangler colorizes URLs with ANSI escapes (which would attach to the match // tail) and also prints a `Worker Version Preview URL` with a literal // `-` placeholder we want to skip. @@ -616,7 +628,7 @@ function serverUrlFromWranglerOutput(output: string) { return stripped.match(/https:\/\/[^\s<>]+\.workers\.dev/)?.[0]; } -function randomSecret() { +function randomToken() { return randomBytes(32).toString("base64url"); } @@ -624,7 +636,7 @@ function randomSecret() { function parseJsonc(input: string): unknown { const withoutComments = input.replace( /("(?:[^"\\]|\\.)*")|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, - (_match, str: string | undefined) => str ?? "", + (_match, str: string | undefined) => str || "", ); const withoutTrailingCommas = withoutComments.replace(/,(\s*[}\]])/g, "$1"); return JSON.parse(withoutTrailingCommas); diff --git a/src/index.ts b/src/index.ts index ef9c97a..0589490 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,75 +1,108 @@ import { newWebSocketRpcSession, RpcTarget } from "capnweb"; +import { + CONNECT_TOKEN_QUERY_PARAM, + GATEWAY_CONNECT_QUERY_PARAM, + HOSTED_CAPTUN_GATEWAY, + TUNNEL_NAME_QUERY_PARAM, +} from "./routing.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 + * Cap'n Web lets us pass this fetcher from the tunnel client to the gateway. + * The gateway can then call fetch on the client like normal, with Request and + * Response objects crossing the WebSocket RPC boundary transparently. **/ export interface Fetcher { fetch(request: Request): Response | Promise; } -// --------------------------------------------------------------------------- -// Tunnel client (formerly src/client.ts) -// --------------------------------------------------------------------------- +export type CaptunTunnel = Disposable & { + url: string; + token?: string; +}; -/** Creates a tunnel from a public Worker URL to a local fetch implementation. - * - * Captun gives us one WebSocket RPC session. The client exposes its fetcher as - * the session's main object, and the server calls that object through a remote - * stub when forwarding HTTP requests. - * - * Cap'n Web WebSocket sessions: - * https://github.com/cloudflare/capnweb#websocket-client - */ +export type FetcherStub = Fetcher & + Disposable & { + ready(tunnel: { url: string; token?: string }): void | Promise; + }; + +type TunnelClientCapability = Fetcher & { + ready(tunnel: { url: string; token?: string }): void | Promise; +}; + +const TUNNEL_READY_TIMEOUT_MS = 5_000; + +/** Creates a public tunnel by exposing a local fetch implementation to a Tunnel Gateway. */ export async function createCaptunTunnel( options: Fetcher & { - url: string | URL; - headers?: Record; + gateway?: string | URL; + name?: string; + token?: string; }, -): Promise { - const socket = createWebSocket(options); - // tunnelTargetFetcher is the "main object" that comes out on the other side in acceptCaptunTunnel - // as a capnweb rpc stub that the server can just call fetch on - const tunnelTargetFetcher = new TunnelTargetFetcher({ fetch: options.fetch }); - const session = newWebSocketRpcSession(socket, tunnelTargetFetcher); - await waitUntilOpen(socket); +): Promise { + const connect = gatewayConnectRequest(options); + const ready = Promise.withResolvers<{ url: string; token?: string }>(); + const socket = createWebSocket(connect.url); + const fetcher = new TunnelTargetFetcher({ + fetch: options.fetch, + ready: (tunnel) => ready.resolve(tunnel), + }); + const session = newWebSocketRpcSession(socket, fetcher); + try { + await waitUntilOpen(socket); + const tunnel = await waitUntilReady(ready.promise); + return { + url: tunnel.url, + token: tunnel.token || connect.token, + [Symbol.dispose]: () => session[Symbol.dispose](), + }; + } catch (error) { + session[Symbol.dispose](); + throw error; + } +} - return { - [Symbol.dispose]: () => session[Symbol.dispose](), - }; +function gatewayConnectRequest(options: { gateway?: string | URL; name?: string; token?: string }) { + const name = options.name || randomTunnelName(); + const url = new URL(options.gateway || HOSTED_CAPTUN_GATEWAY); + url.searchParams.set(GATEWAY_CONNECT_QUERY_PARAM, "1"); + url.searchParams.set(TUNNEL_NAME_QUERY_PARAM, name); + if (options.token) url.searchParams.set(CONNECT_TOKEN_QUERY_PARAM, options.token); + return { url: url.toString(), name, token: options.token }; } -class TunnelTargetFetcher extends RpcTarget implements Fetcher { +function randomTunnelName() { + const bytes = new Uint8Array(8); + crypto.getRandomValues(bytes); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +class TunnelTargetFetcher extends RpcTarget implements TunnelClientCapability { private fetcher: Fetcher; + private onReady: (tunnel: { url: string; token?: string }) => void; - constructor(fetcher: Fetcher) { + constructor(options: { + fetch: Fetcher["fetch"]; + ready: (tunnel: { url: string; token?: string }) => void; + }) { super(); - this.fetcher = fetcher; + this.fetcher = { fetch: options.fetch }; + this.onReady = options.ready; } fetch(request: Request) { return this.fetcher.fetch(request); } + + ready(tunnel: { url: string; token?: string }) { + this.onReady(tunnel); + } } -function createWebSocket(options: { url: string | URL; headers?: Record }) { - const connectUrl = new URL(options.url); +function createWebSocket(url: string | URL) { + const connectUrl = new URL(url); connectUrl.protocol = connectUrl.protocol === "https:" ? "wss:" : "ws:"; - // TypeScript sees the standard DOM/Workers constructor here, where the second - // argument is only WebSocket protocols. Node's CLI WebSocket runtime also - // accepts a headers init object, which we need for tunnel auth. - const WebSocketWithHeaders = WebSocket as unknown as new ( - url: string, - init?: string | string[] | { headers: Record }, - ) => WebSocket; - return new WebSocketWithHeaders( - connectUrl.href, - options.headers ? { headers: options.headers } : undefined, - ); + return new WebSocket(connectUrl.href); } async function waitUntilOpen(socket: WebSocket) { @@ -101,36 +134,50 @@ async function waitUntilOpen(socket: WebSocket) { }); } -// --------------------------------------------------------------------------- -// Tunnel server (formerly src/server.ts) -// --------------------------------------------------------------------------- +async function waitUntilReady(promise: Promise<{ url: string; token?: string }>) { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timeout = setTimeout( + () => reject(new Error("Timed out waiting for tunnel gateway ready message")), + TUNNEL_READY_TIMEOUT_MS, + ); + }), + ]); + } finally { + if (timeout) clearTimeout(timeout); + } +} -/** Creates a Worker WebSocket upgrade response and matching tunnel handle. */ -export function acceptCaptunTunnel(options: { onDisconnect?: () => void } = {}) { +/** Creates a Worker WebSocket upgrade response and matching fetcher stub. */ +export function acceptFetcherCapability(options: { onDisconnect?: () => void } = {}) { const pair = new WebSocketPair(); const clientSocket = pair[0]; const serverSocket = pair[1]; serverSocket.accept(); - const tunnel = acceptCaptunTunnelFromSocket(serverSocket, options); + const fetcher = acceptFetcherCapabilityFromSocket(serverSocket, options); return { - tunnel, + fetcher, response: new Response(null, { status: 101, webSocket: clientSocket }), }; } -export function acceptCaptunTunnelFromSocket( +export function acceptFetcherCapabilityFromSocket( socket: WebSocket, options: { onDisconnect?: () => void } = {}, -): Fetcher & Disposable { - // The generic describes the peer's main object; Cap'n Web still returns a - // stub with lifecycle methods like onRpcBroken() and Symbol.dispose. - const tunnelTargetFetcher = newWebSocketRpcSession(socket); - tunnelTargetFetcher.onRpcBroken(() => options.onDisconnect?.()); +): FetcherStub { + const remote = newWebSocketRpcSession(socket) as FetcherStub & { + onRpcBroken(callback: () => void): void; + }; + remote.onRpcBroken(() => options.onDisconnect?.()); return { - fetch: (request) => tunnelTargetFetcher.fetch(request), - [Symbol.dispose]: () => tunnelTargetFetcher[Symbol.dispose](), + fetch: (request) => remote.fetch(request), + ready: (tunnel) => remote.ready(tunnel), + [Symbol.dispose]: () => remote[Symbol.dispose](), }; } diff --git a/src/routing.ts b/src/routing.ts index f7d53f8..bfb8b58 100644 --- a/src/routing.ts +++ b/src/routing.ts @@ -1,3 +1,33 @@ +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 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. * @@ -58,9 +88,9 @@ export function getTunnelNameFromUrl({ * tunnel. `reqUrl` is any URL that hits the same Worker — we only use its * protocol (and, in folder mode, its host). * - * The Worker calls this and advertises the result back to the tunnel client - * via the `x-captun-tunnel-url` header on each forwarded request, so the CLI - * doesn't have to know the routing convention to print the right URL. + * The Worker calls this before accepting a tunnel client and returns the result + * through the Cap'n Web ready callback, so the client doesn't have to know the + * gateway's routing convention to print the right URL. */ export function getTunnelUrl({ reqUrl, @@ -78,15 +108,11 @@ export function getTunnelUrl({ return `${parsed.protocol}//${parsed.host}/${encodeURIComponent(tunnelName)}`; } -/** Header used by the Worker to advertise a tunnel's canonical URL to its client. */ +/** Internal header used by the top-level Worker to pass the canonical tunnel URL to the DO. */ export const TUNNEL_URL_HEADER = "x-captun-tunnel-url"; -/** Reserved path used by tunnel clients to open the WebSocket; not a tunnel name. */ -const CONNECT_PATH_SEGMENT = "__captun-connect"; - -function isValidTunnelName(name: string): boolean { +export function isValidTunnelName(name: string): boolean { if (!name) return false; - if (name === CONNECT_PATH_SEGMENT) return false; return true; } diff --git a/src/worker.ts b/src/worker.ts index 7c8f3cf..db85036 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -1,15 +1,21 @@ import { DurableObject } from "cloudflare:workers"; -import { acceptCaptunTunnel, type Fetcher } from "./index.js"; +import { acceptFetcherCapability, type FetcherStub } from "./index.js"; import { captunShardName, + CONNECT_TOKEN_QUERY_PARAM, + GATEWAY_CONNECT_QUERY_PARAM, + HOSTED_CAPTUN_HOSTNAME, getTunnelNameFromUrl, getTunnelUrl, + isValidTunnelName, + RESERVED_TUNNEL_NAMES, + TUNNEL_NAME_QUERY_PARAM, TUNNEL_URL_HEADER, } from "./routing.js"; type CaptunEnv = { CaptunServerShard: DurableObjectNamespace; - CAPTUN_SECRET?: string; + CAPTUN_TOKEN?: string; SHARD_COUNT?: string; CUSTOM_HOSTNAME?: string; }; @@ -17,6 +23,12 @@ 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"; +type ActiveTunnel = { + url: string; + token?: string; + fetcher: FetcherStub; +}; + /** * A shard Durable Object owns many named tunnels. * @@ -26,7 +38,7 @@ const TUNNEL_NAME_HEADER = "x-captun-tunnel-name"; * aggregate throughput for lots of concurrent large responses. */ export class CaptunServerShard extends DurableObject { - private readonly tunnels = new Map(); + private readonly tunnels = new Map(); // 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 @@ -38,28 +50,36 @@ export class CaptunServerShard extends DurableObject { const tunnelName = request.headers.get(TUNNEL_NAME_HEADER); if (!tunnelName) return new Response("Missing tunnel name\n", { status: 404 }); - const expected = this.env.CAPTUN_SECRET ? `Bearer ${this.env.CAPTUN_SECRET}` : undefined; + const tunnelUrl = request.headers.get(TUNNEL_URL_HEADER); + if (!tunnelUrl) return new Response("Missing tunnel URL\n", { status: 404 }); + + const expected = this.env.CAPTUN_TOKEN; if (expected) { - // Constant-time comparison to avoid leaking the secret via timing. - const actual = new TextEncoder().encode(request.headers.get("authorization") ?? ""); + // Constant-time comparison to avoid leaking the gateway token via timing. + const actual = new TextEncoder().encode(connectToken(request) || ""); const want = new TextEncoder().encode(expected); if (actual.length !== want.length || !crypto.subtle.timingSafeEqual(actual, want)) { return new Response("Unauthorized\n", { status: 401 }); } } - this.tunnels.get(tunnelName)?.[Symbol.dispose](); - const { response, tunnel } = acceptCaptunTunnel({ + const token = expected ? connectToken(request) || undefined : undefined; + this.tunnels.get(tunnelName)?.fetcher[Symbol.dispose](); + const { response, fetcher } = acceptFetcherCapability({ onDisconnect: () => { - if (this.tunnels.get(tunnelName) === tunnel) this.tunnels.delete(tunnelName); + if (this.tunnels.get(tunnelName)?.fetcher === fetcher) this.tunnels.delete(tunnelName); }, }); + const tunnel = { url: tunnelUrl, token, fetcher }; this.tunnels.set(tunnelName, tunnel); + queueMicrotask(() => { + void fetcher.ready({ url: tunnel.url, token: tunnel.token }); + }); return response; } async forward(tunnelName: string, request: Request): Promise { - const tunnel = this.tunnels.get(tunnelName); + const tunnel = this.tunnels.get(tunnelName)?.fetcher; if (!tunnel) return new Response("No tunnel client connected\n", { status: 503 }); try { return await tunnel.fetch(request); @@ -71,6 +91,13 @@ export class CaptunServerShard extends DurableObject { export default { fetch(request: Request, env: CaptunEnv): Response | Promise { + if (isGatewayConnectRequest(request)) { + return connectTunnel(request, env); + } + + const hostedResponse = hostedCaptunResponse(request, env); + if (hostedResponse) return hostedResponse; + const tunnelName = getTunnelNameFromUrl({ customHostname: env.CUSTOM_HOSTNAME, url: request.url, @@ -83,23 +110,19 @@ export default { const url = new URL(request.url); const forwardedPath = env.CUSTOM_HOSTNAME ? url.pathname - : (url.pathname.match(/^\/[^/]+(\/.*)?$/)?.[1] ?? "/"); + : url.pathname.match(/^\/[^/]+(\/.*)?$/)?.[1] || "/"; url.pathname = forwardedPath; + if (RESERVED_TUNNEL_NAMES.includes(tunnelName)) { + return new Response("Reserved Captun tunnel name\n", { status: 404 }); + } + const shard = env.CaptunServerShard.getByName( captunShardName(tunnelName, Number(env.SHARD_COUNT || 1)), ); - const forwarded = new Request(url, request); - if (forwardedPath === "/__captun-connect") { - const headers = new Headers(forwarded.headers); - headers.set(TUNNEL_NAME_HEADER, tunnelName); - return shard.fetch(new Request(forwarded, { headers })); - } - - // Advertise the canonical tunnel URL back to the tunnel client. The CLI - // reads this so it doesn't have to mirror the Worker's routing convention. + // Keep the canonical tunnel URL attached while crossing into the DO. const tunnelUrl = getTunnelUrl({ reqUrl: request.url, customHostname: env.CUSTOM_HOSTNAME, @@ -110,3 +133,373 @@ export default { return shard.forward(tunnelName, new Request(forwarded, { headers })); }, } satisfies ExportedHandler; + +function connectTunnel(request: Request, env: CaptunEnv) { + if (request.headers.get("upgrade") !== "websocket") { + return new Response("Expected WebSocket upgrade\n", { status: 400 }); + } + + const url = new URL(request.url); + const tunnelName = url.searchParams.get(TUNNEL_NAME_QUERY_PARAM) || ""; + if (!isValidTunnelName(tunnelName) || RESERVED_TUNNEL_NAMES.includes(tunnelName)) { + return new Response("Missing tunnel name\n", { status: 404 }); + } + + const tunnelUrl = getTunnelUrl({ + reqUrl: request.url, + customHostname: env.CUSTOM_HOSTNAME, + tunnelName, + }); + const shard = env.CaptunServerShard.getByName( + captunShardName(tunnelName, Number(env.SHARD_COUNT || 1)), + ); + const headers = new Headers(request.headers); + headers.set(TUNNEL_NAME_HEADER, tunnelName); + headers.set(TUNNEL_URL_HEADER, tunnelUrl); + return shard.fetch(new Request(request, { headers })); +} + +function isGatewayConnectRequest(request: Request) { + return new URL(request.url).searchParams.get(GATEWAY_CONNECT_QUERY_PARAM) === "1"; +} + +function connectToken(request: Request) { + return new URL(request.url).searchParams.get(CONNECT_TOKEN_QUERY_PARAM); +} + +function hostedCaptunResponse(request: Request, env: CaptunEnv): Response | undefined { + if (env.CUSTOM_HOSTNAME !== HOSTED_CAPTUN_HOSTNAME) return undefined; + + const url = new URL(request.url); + if (url.hostname === HOSTED_CAPTUN_HOSTNAME) { + return Response.redirect( + `https://www.${HOSTED_CAPTUN_HOSTNAME}${url.pathname}${url.search}`, + 308, + ); + } + + const suffix = `.${HOSTED_CAPTUN_HOSTNAME}`; + if (!url.hostname.endsWith(suffix)) return undefined; + + const labels = url.hostname.slice(0, -suffix.length).split("."); + const subdomain = labels[labels.length - 1] || ""; + if (subdomain === "www") { + return wwwCaptunResponse(url); + } + if (RESERVED_TUNNEL_NAMES.includes(subdomain)) { + return new Response("Reserved Captun tunnel name\n", { status: 404 }); + } +} + +function wwwCaptunResponse(url: URL): Response { + if (url.pathname === "/captun.browser.js") { + return new Response(WWW_BROWSER_MODULE, { + headers: { + "content-type": "application/javascript; charset=utf-8", + "cache-control": "no-store", + }, + }); + } + + return new Response(WWW_LANDING_PAGE, { + headers: { + "content-type": "text/html; charset=utf-8", + "cache-control": "no-store", + }, + }); +} + +const WWW_LANDING_PAGE = ` + + + + + captun + + + +

captun

+

cap[nweb] tun[nel]: a tiny, fast public tunnel for local HTTP servers.

+

Run this with something listening on port 3000:

+
npx captun 3000
+

You get a URL like:

+
https://abc123.captun.sh
+

Requests to that URL are forwarded to your local server until you stop the process.

+ +

From code

+

You don't need to run a local server. Just a fetch function:

+ +
+ +

Try it in this tab

+

This works in any environment supported by capnweb, so you can run a "server" basically anywhere, even the browser.

+

Edit the fetch function, create a tunnel, then the iframe below will load the public URL.

+ +
+
+ + + idle + + +
+ +

+ + +

Bring your own Cloudflare account

+
npx captun deploy
+

Source: github.com/iterate/captun

+ + + +`; + +const WWW_BROWSER_MODULE = `import { newWebSocketRpcSession, RpcTarget } from "https://esm.sh/capnweb@0.8.0"; + +export async function createCaptunTunnel(options) { + const socket = new WebSocket(gatewayConnectUrl(options)); + const readyPromise = waitForReady(); + const tunnelTargetFetcher = new TunnelTargetFetcher(options.fetch, readyPromise.ready); + const session = newWebSocketRpcSession(socket, tunnelTargetFetcher); + await waitUntilOpen(socket); + const tunnel = await readyPromise.promise; + return { + url: tunnel.url, + token: tunnel.token || options.token, + close: () => disposeSession(session), + }; +} + +class TunnelTargetFetcher extends RpcTarget { + constructor(fetcher, ready) { + super(); + this.fetcher = fetcher; + this.readyCallback = ready; + } + + fetch(request) { + return this.fetcher(request); + } + + ready(tunnel) { + return this.readyCallback(tunnel); + } +} + +function gatewayConnectUrl(options) { + const url = new URL(options.gateway || "https://captun.sh"); + url.protocol = url.protocol === "http:" ? "ws:" : "wss:"; + url.searchParams.set("captun-connect", "1"); + url.searchParams.set("captun-name", options.name || randomTunnelName()); + if (options.token) url.searchParams.set("captun-token", options.token); + return url; +} + +function waitUntilOpen(socket) { + if (socket.readyState === WebSocket.OPEN) return Promise.resolve(); + if (socket.readyState !== WebSocket.CONNECTING) { + return Promise.reject(new Error("WebSocket closed before opening")); + } + + return new Promise((resolve, reject) => { + const listeners = new AbortController(); + const settle = (callback) => { + listeners.abort(); + callback(); + }; + socket.addEventListener("open", () => settle(resolve), { signal: listeners.signal }); + socket.addEventListener("error", () => settle(() => reject(new Error("WebSocket connection failed"))), { signal: listeners.signal }); + socket.addEventListener("close", (event) => settle(() => reject(new Error("WebSocket closed before opening: " + event.code + " " + event.reason))), { signal: listeners.signal }); + }); +} + +function waitForReady() { + let timer; + let resolveReady; + let rejectReady; + const promise = new Promise((resolve, reject) => { + resolveReady = resolve; + rejectReady = reject; + timer = setTimeout(() => reject(new Error("Timed out waiting for tunnel gateway")), 5000); + }); + return { + promise, + ready: (tunnel) => { + clearTimeout(timer); + resolveReady(tunnel); + }, + reject: rejectReady, + }; +} + +function randomTunnelName() { + const bytes = new Uint8Array(8); + crypto.getRandomValues(bytes); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +function disposeSession(session) { + const disposeSymbol = Symbol.dispose; + if (disposeSymbol && typeof session[disposeSymbol] === "function") session[disposeSymbol](); +} +`; diff --git a/tasks/complete/2026-05-26-gateway-owned-addressing.md b/tasks/complete/2026-05-26-gateway-owned-addressing.md new file mode 100644 index 0000000..759d1b2 --- /dev/null +++ b/tasks/complete/2026-05-26-gateway-owned-addressing.md @@ -0,0 +1,27 @@ +--- +status: complete +size: large +--- + +# Gateway-Owned Addressing Refactor + +Status summary: Complete on #16. The API, Worker connect protocol, CLI/deploy config, hosted browser module, README, and tests now use gateway-owned addressing; #20 has been closed as superseded. + +## Checklist + +- [x] Replace public `url`/`serverUrl`/`secret` options with `gateway`/`token`. _`createCaptunTunnel`, the CLI router, config file, deploy wizard, benchmarks, and smoke scripts now take `gateway` and `token`; the hosted service remains the default gateway._ +- [x] Replace `/__captun-connect` with Captun query parameters on the Gateway URL. _Clients now open the gateway URL with `captun-connect=1`, `captun-name`, and optional `captun-token`; the Worker no longer treats the old path as a connect route._ +- [x] Make the Tunnel Gateway return `{ url, token }` through the internal Cap'n Web ready callback before `createCaptunTunnel` resolves. _`createCaptunTunnel` waits for `ready(...)`, and the Worker calls it after storing the active tunnel._ +- [x] Rename low-level accept APIs to `acceptFetcherCapability` and `acceptFetcherCapabilityFromSocket`. _The public entrypoint and weather-reporter example now use Fetcher Capability/Fetcher Stub terminology._ +- [x] Update the Cloudflare Tunnel Gateway to store active Tunnels backed by Fetcher Stubs. _`CaptunServerShard` stores `{ url, token, fetcher }` ActiveTunnel records and forwards through the Fetcher Stub._ +- [x] Update CLI config, flags, deploy summary, and self-test to use `gateway` and `token`. _`--gateway`, `--token`, `CAPTUN_TOKEN`, deploy dry-runs, config writes, and post-deploy self-test all use the new names._ +- [x] Update README, Hosted Site snippets, and tests to use the new shape. _README, `www.captun.sh` snippets, the browser helper, e2e tests, worker tests, public-hosted tests, and smoke docs now describe gateway-owned tunnel URLs._ +- [x] Close or supersede PR #20 after #16 has the new shape. _Closed #20 with a note that hosted safety/rate-limit work should be rebuilt on top of #16's gateway/token/connect-query protocol._ + +## Implementation Notes + +- 2026-05-26: Grill-with-docs resolved the language in `CONTEXT.md` and recorded ADR-0001 for gateway-owned addressing. +- 2026-05-26: Focused tests passed: `pnpm exec vitest run test/worker.test.ts test/e2e.test.ts examples/weather-reporter/e2e.test.ts`. +- 2026-05-26: Full local checks passed: `pnpm run check`, `pnpm test`, and `pnpm run build`. +- 2026-05-26: Pushed #16 and closed #20 as superseded. +- 2026-05-26: Deployed `captun-public` to Iterate prd with `captun.sh/*` and `*.captun.sh/*`; live public e2e passed with `CAPTUN_PUBLIC_E2E=1 pnpm exec vitest run test/public-hosted.test.ts`. diff --git a/tasks/hosted-captun-sh.md b/tasks/hosted-captun-sh.md new file mode 100644 index 0000000..aff3226 --- /dev/null +++ b/tasks/hosted-captun-sh.md @@ -0,0 +1,36 @@ +--- +status: initial-hosted-deployed +size: medium +--- + +# Hosted captun.sh + +Status summary: Initial hosted deployment is live on `captun.sh`. The CLI, library, and browser landing-page demo can create hosted random tunnels; the main missing work is a proper free/paid control plane, tunnel ownership, and throttling. + +## Initial public-hosted slice + +- [x] Default unconfigured CLI usage to the hosted gateway so `npx captun 3000` uses a random `https://.captun.sh` URL. _`resolveTunnel` now falls back to `HOSTED_CAPTUN_GATEWAY` when no config or `--gateway` is present._ +- [x] Let library users call `createCaptunTunnel({ fetch })` without passing a deployed Worker URL. _`createCaptunTunnel` now connects to the default hosted gateway and resolves with the gateway-returned `tunnel.url`._ +- [x] Add public-hosted e2e coverage for the library API and CLI router. _Added gated `CAPTUN_PUBLIC_E2E=1` tests in `test/public-hosted.test.ts`, plus local Miniflare coverage for the new gateway path._ +- [x] Deploy the initial Worker route on `*.captun.sh/*`. _Deployed `captun-public` to Iterate prd with route `*.captun.sh/*`, `CUSTOM_HOSTNAME=captun.sh`, empty `CAPTUN_TOKEN`, and wildcard DNS `*.captun.sh -> 100::` proxied._ +- [x] Reserve product/control-plane subdomains on hosted `captun.sh`. _The Worker now blocks `app`, `login`, `dash`, `dashboard`, `captun`, `tunnel`, and `iterate` before Durable Object dispatch._ +- [x] Serve a dead-simple landing page on `www.captun.sh`. _The hosted Worker returns a static HTML string with CLI and API examples._ +- [x] Redirect the apex domain to `www.captun.sh`. _Added apex DNS `captun.sh -> 100::` proxied and redeployed with route `captun.sh/*`; the Worker returns a 308 preserving path and query._ +- [x] Add an in-browser tunnel demo to `www.captun.sh`. _The landing page serves `/captun.browser.js`, lets the user edit a fetch function, creates a hosted tunnel, and loads it in an iframe._ + +## Safety and product follow-up + +- [ ] Use cryptographic random names for free hosted tunnels and keep friendly/custom subdomains behind auth or a paid reservation model. +- [ ] Add per-session tunnel ownership: first client claims a tunnel name, the same token can reconnect, and a different token gets `409` instead of evicting the active tunnel. +- [ ] Add Cloudflare Rate Limiting bindings for cheap edge throttles on connect attempts and forwarded requests. +- [ ] Add Durable Object backed global-ish limits for active tunnels, concurrent tunnels per IP/account, and suspicious reconnect churn. +- [ ] Add basic resource caps: max tunnel lifetime, idle timeout, in-flight request cap, request body size limit, and response streaming guardrails. +- [ ] Add observability for rejected connects, `429`s, high-volume tunnel names, high-volume IPs, and top error classes. +- [ ] Document an emergency shutdown path for disabling hosted `captun.sh` without affecting self-hosted deployments. + +## Implementation Notes + +- 2026-05-23: User explicitly accepted an initial unsafe/obscure-only deploy: no rate limiting and anyone can still evict anyone. Keep the above follow-ups visible before publicising the domain. +- 2026-05-23: Public e2e passed against the live service with `CAPTUN_PUBLIC_E2E=1 pnpm vitest run test/public-hosted.test.ts`. +- 2026-05-23: Reserved names and `www.captun.sh` verified against the live Worker. Apex redirect verified with `curl --resolve` against Cloudflare's authoritative A record while local resolver propagation was still uneven. +- 2026-05-23: Browser demo deployed and manually verified with Playwriter. Clicking "create tunnel" produced a random `captun.sh` URL, and `curl` to that URL returned the browser-defined response. diff --git a/test/e2e.test.ts b/test/e2e.test.ts index 7f71826..aa572a0 100644 --- a/test/e2e.test.ts +++ b/test/e2e.test.ts @@ -18,6 +18,33 @@ test.concurrent("forwards HTTP", async ({ task }) => { expect(await response.json()).toMatchObject({ body: "hello through tunnel" }); }); +test.concurrent("creates a named tunnel from a gateway", async ({ task }) => { + await using server = await createServerFixture(); + const name = tunnelName(task.name); + using tunnel = await createCaptunTunnel({ + gateway: server.gateway, + name, + token: server.token, + fetch: async (request) => { + const url = new URL(request.url); + return Response.json({ path: url.pathname, body: await request.text() }); + }, + }); + + expect(tunnel).toMatchObject({ token: server.token }); + if (server.tunnelUrl) expect(tunnel).toMatchObject({ url: server.tunnelUrl(name) }); + + const response = await fetch(`${tunnel.url}/gateway-api`, { + method: "POST", + body: "hello through gateway", + }); + + expect(await response.json()).toMatchObject({ + path: "/gateway-api", + body: "hello through gateway", + }); +}); + test.concurrent("streams a binary response", async ({ task }) => { await using tunnel = await createTunnelFixture(task.name, () => { let sent = 0; @@ -151,14 +178,14 @@ async function createTunnelFixture( const server = await createServerFixture(); const name = tunnelName(testName); try { - const url = tunnelUrl(server.url, name); const tunnel = await createCaptunTunnel({ - url: `${url}/__captun-connect`, - headers: server.headers, + gateway: server.gateway, + name, + token: server.token, fetch, }); return { - url, + url: tunnel.url, async [Symbol.asyncDispose]() { tunnel[Symbol.dispose](); await server[Symbol.asyncDispose](); @@ -171,20 +198,20 @@ async function createTunnelFixture( } async function createServerFixture() { - if (process.env.CAPTUN_SERVER_URL) { + if (process.env.CAPTUN_GATEWAY) { return { - url: process.env.CAPTUN_SERVER_URL, - headers: process.env.CAPTUN_SECRET - ? { authorization: `Bearer ${process.env.CAPTUN_SECRET}` } - : undefined, + gateway: process.env.CAPTUN_GATEWAY, + token: process.env.CAPTUN_TOKEN, + tunnelUrl: undefined, async [Symbol.asyncDispose]() {}, }; } const worker = await createCaptunWorkerFixture({}); return { - url: worker.origin, - headers: undefined, + gateway: worker.origin, + token: undefined, + tunnelUrl: (name: string) => `${worker.origin}/${name}`, async [Symbol.asyncDispose]() { await worker[Symbol.asyncDispose](); }, @@ -202,18 +229,6 @@ function tunnelName(testName: string) { return `${prefix}-${hash}`; } -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(/\/$/, ""); -} - function makeBytes(size: number) { const bytes = new Uint8Array(new ArrayBuffer(size)); for (let i = 0; i < bytes.length; i++) bytes[i] = i % 251; diff --git a/test/public-hosted.test.ts b/test/public-hosted.test.ts new file mode 100644 index 0000000..cdb1d9e --- /dev/null +++ b/test/public-hosted.test.ts @@ -0,0 +1,109 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; + +import { createRouterClient } from "@orpc/server"; +import { expect, test } from "vitest"; + +import { createCaptunTunnel } from "../src/index.js"; +import { createCaptunCliRouter } from "../src/cli/bin.js"; + +const publicHostedTest = process.env.CAPTUN_PUBLIC_E2E === "1" ? test : test.skip; + +publicHostedTest( + "createCaptunTunnel({ fetch }) registers a public captun.sh tunnel", + async () => { + const reached = Promise.withResolvers<{ path: string; body: string }>(); + + await using tunnel = await createCaptunTunnel({ + fetch: async (request) => { + const url = new URL(request.url); + const body = await request.text(); + reached.resolve({ path: url.pathname, body }); + return Response.json({ path: url.pathname, body }); + }, + }); + + expect(tunnel.url).toMatch(/^https:\/\/[a-z0-9-]+\.captun\.sh$/); + + const response = await fetch(`${tunnel.url}/library-api`, { + method: "POST", + body: "hello from vitest", + }); + + expect(await response.json()).toMatchObject({ + path: "/library-api", + body: "hello from vitest", + }); + await expect(reached.promise).resolves.toMatchObject({ + path: "/library-api", + body: "hello from vitest", + }); + }, + 15_000, +); + +publicHostedTest("CLI router tunnels a local test server through captun.sh", async () => { + const reached = Promise.withResolvers<{ path: string; body: string }>(); + await using server = await createTestServer(async (req, res) => { + const body = await readBody(req); + const path = req.url || "/"; + reached.resolve({ path, body }); + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ path, body })); + }); + + const shutdown = Promise.withResolvers(); + const router = createCaptunCliRouter({ + readConfig: async () => undefined, + waitForShutdown: () => shutdown.promise, + onTunnelReady: async ({ url }) => { + const response = await fetch(`${url}/cli-router`, { + method: "POST", + body: "hello from local server", + }); + expect(await response.json()).toMatchObject({ + path: "/cli-router", + body: "hello from local server", + }); + shutdown.resolve(); + }, + }); + const client = createRouterClient(router); + + await client.tunnel({ target: String(server.port), requestLogs: false }); + + await expect(reached.promise).resolves.toMatchObject({ + path: "/cli-router", + body: "hello from local server", + }); +}); + +async function createTestServer( + handler: (req: IncomingMessage, res: ServerResponse) => void | Promise, +) { + const server = createServer((req, res) => { + void Promise.resolve(handler(req, res)).catch((error: unknown) => { + res.writeHead(500, { "content-type": "text/plain" }); + res.end(error instanceof Error ? error.stack : String(error)); + }); + }); + await new Promise((resolveListen, rejectListen) => { + server.once("error", rejectListen); + server.listen(0, "127.0.0.1", resolveListen); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Could not determine test-server port."); + } + return { + port: address.port, + async [Symbol.asyncDispose]() { + await new Promise((resolveClose) => server.close(() => resolveClose())); + }, + }; +} + +async function readBody(req: IncomingMessage) { + let body = ""; + for await (const chunk of req) body += chunk; + return body; +} diff --git a/test/worker.test.ts b/test/worker.test.ts index 6ac4e71..1926ac2 100644 --- a/test/worker.test.ts +++ b/test/worker.test.ts @@ -14,8 +14,7 @@ const tunnelNameCases: Array< > = [ // Folder routing (no customHostname): first path segment is the tunnel name. ["https://captun.account.workers.dev/my-test/hello", undefined, "my-test"], - ["https://captun.account.workers.dev/my-test/__captun-connect", undefined, "my-test"], - ["https://captun.account.workers.dev/__captun-connect", undefined, null], + ["https://captun.account.workers.dev/my-test/nested/path", undefined, "my-test"], ["https://captun.account.workers.dev/", undefined, null], ["http://localhost:8787/my-test/hello", undefined, "my-test"], ["https://captun.account.workers.dev/bad%/hello", undefined, null], @@ -23,7 +22,7 @@ const tunnelNameCases: Array< // Subdomain routing: tunnel name is the last label before customHostname. ["https://my-test.captun.example.com/hello", "captun.example.com", "my-test"], ["https://my-test.my-tunnels.com/hello", "my-tunnels.com", "my-test"], - ["https://my-test.my-tunnels.com/__captun-connect", "my-tunnels.com", "my-test"], + ["https://my-test.my-tunnels.com/nested/path", "my-tunnels.com", "my-test"], ["https://my-test.mysubdomain.mydomain.com/hello", "mysubdomain.mydomain.com", "my-test"], // Nested wildcard: the *last* label before customHostname wins. Anything to @@ -56,7 +55,7 @@ const tunnelUrlCases: Array< > = [ // Folder mode: protocol + host from reqUrl, name appended as a path segment. [ - "https://captun.acct.workers.dev/banana/__captun-connect", + "https://captun.acct.workers.dev/banana/nested/path", undefined, "banana", "https://captun.acct.workers.dev/banana", @@ -116,7 +115,8 @@ test("Captun Worker keeps a tunnel name on a stable shard", () => { 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/__captun-connect`, + gateway: fixture.origin, + name: "demo", fetch: async (request) => { const url = new URL(request.url); return Response.json({ @@ -142,7 +142,8 @@ test("Captun Worker forwards requests through a real Durable Object tunnel", asy test("Captun Worker verifies health through a connected tunnel client", async () => { await using fixture = await createCaptunWorkerFixture({}); using _tunnel = await createCaptunTunnel({ - url: `${fixture.origin}/demo/__captun-connect`, + gateway: fixture.origin, + name: "demo", fetch: (request) => { if (isCaptunHealthRequest(request)) return captunHealthResponse(); return new Response("unexpected\n", { status: 500 }); @@ -158,7 +159,8 @@ test("Captun Worker verifies health through a connected tunnel client", async () test("Captun Worker returns 502 when the tunnel client fetch throws", async () => { await using fixture = await createCaptunWorkerFixture({}); using _tunnel = await createCaptunTunnel({ - url: `${fixture.origin}/demo/__captun-connect`, + gateway: fixture.origin, + name: "demo", fetch: () => { throw new Error("local target unavailable"); }, @@ -188,10 +190,155 @@ test("Captun Worker routes subdomain tunnel requests when CUSTOM_HOSTNAME is set expect(await response.text()).toBe("No tunnel client connected\n"); }); +test("Hosted Captun redirects the apex hostname to www", async () => { + await using fixture = await createCaptunWorkerFixture({ CUSTOM_HOSTNAME: "captun.sh" }); + + const response = await fixture.worker.fetch("https://captun.sh/docs?x=1", { + redirect: "manual", + }); + + expect(response).toMatchObject({ status: 308 }); + expect(response.headers.get("location")).toBe("https://www.captun.sh/docs?x=1"); +}); + +test("Hosted Captun serves a static landing page on www", async () => { + await using fixture = await createCaptunWorkerFixture({ CUSTOM_HOSTNAME: "captun.sh" }); + + const response = await fixture.worker.fetch("https://www.captun.sh/"); + + expect(response).toMatchObject({ status: 200 }); + expect(response.headers.get("content-type")).toContain("text/html"); + expect(response.headers.get("cache-control")).toBe("no-store"); + const html = await response.text(); + + expect(html).toEqual( + expect.stringContaining( + 'cap[nweb] tun[nel]', + ), + ); + expect(html).toEqual( + expect.stringContaining('fast'), + ); + expect(html).toEqual(expect.stringContaining("Run this with something listening on port 3000:")); + expect(html).toEqual(expect.stringContaining("npx captun 3000")); + expect(html).toEqual( + expect.stringContaining("You don't need to run a local server. Just a fetch function:"), + ); +}); + +test("Hosted Captun serves the browser demo module on www", async () => { + await using fixture = await createCaptunWorkerFixture({ CUSTOM_HOSTNAME: "captun.sh" }); + + const response = await fixture.worker.fetch("https://www.captun.sh/captun.browser.js"); + + expect(response).toMatchObject({ status: 200 }); + expect(response.headers.get("content-type")).toContain("application/javascript"); + const module = await response.text(); + expect(module).toEqual(expect.stringContaining("createCaptunTunnel")); + expect(module).toEqual(expect.stringContaining("captun-connect")); + expect(module).not.toEqual(expect.stringContaining("__captun-connect")); +}); + +test("Hosted Captun landing page includes an in-browser tunnel demo", async () => { + await using fixture = await createCaptunWorkerFixture({ CUSTOM_HOSTNAME: "captun.sh" }); + + const response = await fixture.worker.fetch("https://www.captun.sh/"); + + const html = await response.text(); + + expect(html.indexOf("

Try it in this tab

")).toBeLessThan( + html.indexOf("

Bring your own Cloudflare account

"), + ); + expect( + html.indexOf( + 'This works in any environment supported by capnweb', + ), + ).toBeLessThan( + html.indexOf("Edit the fetch function, create a tunnel, then the iframe below will load"), + ); + expect(html).toEqual( + expect.stringContaining( + 'This works in any environment supported by capnweb', + ), + ); + expect(html).toEqual(expect.stringContaining('// your "server" is this browser tab!')); + expect(html).toEqual(expect.stringContaining("window.chatMessages")); + expect(html).toEqual(expect.stringContaining("document.cookie")); + expect(html).toEqual(expect.stringContaining("username ||= ")); + expect(html).toEqual(expect.stringContaining("function send(form)")); + expect(html).toEqual(expect.stringContaining('onsubmit="send(this); return false"')); + expect(html).toEqual(expect.stringContaining("")); + expect(html).toEqual(expect.stringContaining("Response.json({ ok: true })")); + expect(html).toEqual(expect.stringContaining('