diff --git a/src/cli/bin.ts b/src/cli/bin.ts index 4450353..da59b02 100755 --- a/src/cli/bin.ts +++ b/src/cli/bin.ts @@ -12,7 +12,7 @@ import { createCli, yamlTableConsoleLogger } from "trpc-cli"; import { z } from "zod/v4"; import { color } from "./ansi.js"; import { CliFriendlyError } from "./cli-error.js"; -import { createCaptunTunnel } from "../index.js"; +import { CaptunTunnelConnectError, createCaptunTunnel } from "../index.js"; import { assertLocalTargetAcceptingConnections } from "./local-target.js"; import { withSpinner } from "./spinner.js"; import { @@ -570,6 +570,14 @@ function tunnelConnectError(tunnel: ResolvedTunnel, cause: unknown) { const hostname = new URL(tunnel.tunnel).hostname; const message = cause instanceof Error ? cause.message : String(cause); const lines = [`Could not connect tunnel to ${color.cyan(tunnel.tunnel)} (${message}).`]; + if (isActiveTunnelConflict(cause)) { + lines.push( + ``, + `The tunnel name appears to be in use by another active anonymous client.`, + `Pick a different ${color.cyan("--name")} or stop the existing tunnel and retry.`, + ); + return new CliFriendlyError(lines.join("\n")); + } 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`. @@ -588,6 +596,18 @@ function tunnelConnectError(tunnel: ResolvedTunnel, cause: unknown) { return new CliFriendlyError(lines.join("\n")); } +function isActiveTunnelConflict(cause: unknown) { + if (cause instanceof CaptunTunnelConnectError && cause.response) { + return cause.response.status === 409 && isActiveTunnelConflictMessage(cause.response.body); + } + const message = cause instanceof Error ? cause.message : String(cause); + return isActiveTunnelConflictMessage(message); +} + +function isActiveTunnelConflictMessage(message: string) { + return /tunnel name is already connected|tunnel name .*in use/i.test(message); +} + function sleep(ms: number) { return new Promise((resolveSleep) => setTimeout(resolveSleep, ms)); } diff --git a/src/index.ts b/src/index.ts index 8e1c512..89fdd0b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,6 +36,21 @@ export type CaptunTunnel = Disposable & { ownerToken: string; }; +export class CaptunTunnelConnectError extends Error { + response: { status: number; statusText: string; body: string } | undefined; + + constructor( + message: string, + response: { status: number; statusText: string; body: string } | undefined, + ) { + super(message); + this.name = "CaptunTunnelConnectError"; + this.response = response; + } +} + +const WEBSOCKET_REJECTION_PROBE_TIMEOUT_MS = 500; + export async function createCaptunTunnel( options: Fetcher & { url?: string | URL; @@ -56,7 +71,12 @@ export async function createCaptunTunnel( // 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); + try { + await waitUntilOpen(socket, { connectUrl: ownership.connectUrl, headers: options.headers }); + } catch (error) { + session[Symbol.dispose](); + throw error; + } return { url: endpoint.publicUrl, @@ -154,7 +174,10 @@ function createWebSocket(options: { url: string | URL; headers?: Record | undefined }, +) { if (socket.readyState === WebSocket.OPEN) return; if (socket.readyState !== WebSocket.CONNECTING) { throw new Error("WebSocket closed before opening"); @@ -169,7 +192,10 @@ async function waitUntilOpen(socket: WebSocket) { socket.addEventListener("open", () => settle(resolve), { signal: listeners.signal }); socket.addEventListener( "error", - () => settle(() => reject(new Error("WebSocket connection failed"))), + () => + settle(() => { + void webSocketConnectionFailedError(options).then(reject); + }), { signal: listeners.signal }, ); socket.addEventListener( @@ -183,6 +209,43 @@ async function waitUntilOpen(socket: WebSocket) { }); } +async function webSocketConnectionFailedError(options: { + connectUrl: string; + headers: Record | undefined; +}) { + const response = await readWebSocketRejection(options); + if (!response) return new CaptunTunnelConnectError("WebSocket connection failed", undefined); + return new CaptunTunnelConnectError( + `WebSocket connection failed: ${response.status} ${response.statusText}: ${response.body}`.trim(), + response, + ); +} + +async function readWebSocketRejection(options: { + connectUrl: string; + headers: Record | undefined; +}) { + const abort = new AbortController(); + const timeout = setTimeout(() => abort.abort(), WEBSOCKET_REJECTION_PROBE_TIMEOUT_MS); + try { + const response = await fetch(options.connectUrl, { + headers: options.headers, + signal: abort.signal, + }); + if (response.ok) return undefined; + const body = (await response.text()).trim(); + return { + status: response.status, + statusText: response.statusText || "Rejected", + body: body || "No response body", + }; + } catch { + return undefined; + } finally { + clearTimeout(timeout); + } +} + // --------------------------------------------------------------------------- // Tunnel server (formerly src/server.ts) // --------------------------------------------------------------------------- diff --git a/tasks/complete/2026-05-24-hosted-connect-conflict-message.md b/tasks/complete/2026-05-24-hosted-connect-conflict-message.md new file mode 100644 index 0000000..da59af9 --- /dev/null +++ b/tasks/complete/2026-05-24-hosted-connect-conflict-message.md @@ -0,0 +1,26 @@ +status: complete +size: small + +# Hosted Connect Conflict Messages + +Status summary: Complete and locally verified. `createCaptunTunnel` now surfaces deterministic HTTP rejection details when available without hanging on the diagnostic probe, and the CLI treats only Captun active-owner conflicts as name-in-use errors instead of DNS setup failures. + +## Checklist + +- [x] Add a regression test for a rejected WebSocket upgrade body. _`test/worker.test.ts` covers a 409 connect rejection and asserts `createCaptunTunnel` reports `Tunnel name is already connected`._ +- [x] Improve library connect errors for pre-open WebSocket failures. _`src/index.ts` now probes the connect URL after a pre-open WebSocket error, aborts the probe after a short timeout, and throws `CaptunTunnelConnectError` with status/body details when the server exposes them._ +- [x] Improve CLI tunnel connect messaging. _`src/cli/bin.ts` detects the known Captun 409/name-in-use body and prints an active anonymous client explanation instead of DNS guidance._ +- [x] Add review regression coverage. _`test/worker.test.ts` covers an unresponsive diagnostic probe; `test/cli.test.ts` covers unrelated 409 responses retaining DNS guidance._ +- [x] Run focused and full verification. _Verified with focused Vitest files, `pnpm run check`, `pnpm test`, and `pnpm run build`._ + +## Notes + +- This is stacked on `mmkal/26/05/24/hosted-ownership-tokens`. +- The target conflict body from the Worker is `Tunnel name is already connected`. +- Keep self-hosted DNS/certificate guidance for ordinary connection failures. + +## Implementation Notes + +- 2026-05-24: Created as a follow-up to the hosted ownership-token PR after review found anonymous active-owner conflicts were being reported as generic WebSocket/DNS failures. +- 2026-05-24: Node's WebSocket `ErrorEvent` does not expose the rejected upgrade status/body directly, so the library performs a follow-up `fetch` to the same connect URL. This is deterministic for the hosted Worker conflict because the Worker returns `409` before creating the WebSocket upgrade response. +- 2026-05-24: Review follow-up added a timeout around the diagnostic HTTP probe and tightened CLI conflict classification so arbitrary `409` responses keep the generic troubleshooting path. diff --git a/test/cli.test.ts b/test/cli.test.ts new file mode 100644 index 0000000..86f1da9 --- /dev/null +++ b/test/cli.test.ts @@ -0,0 +1,133 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; + +import { createRouterClient } from "@orpc/server"; +import { expect, test } from "vitest"; + +import { createCaptunCliRouter } from "../src/cli/bin.js"; + +test("CLI tunnel connect errors do not blame DNS for active-owner conflicts", async () => { + await using target = await createTestServer((_request, response) => { + response.end("ok\n"); + }); + await using rejection = await createRejectedTunnelServer("Tunnel name is already connected\n"); + + const router = createCaptunCliRouter({ readConfig: async () => undefined }); + const client = createRouterClient(router); + + let caught: unknown; + try { + await client.tunnel({ + target: String(target.port), + serverUrl: rejection.origin, + name: "demo", + requestLogs: false, + }); + } catch (error) { + caught = error; + } + + expect(caught).toMatchObject({ + message: expect.stringContaining("Tunnel name is already connected"), + }); + expect(caught).not.toMatchObject({ + message: expect.stringContaining("DNS for"), + }); +}); + +test("CLI tunnel connect errors keep DNS guidance for unrelated 409 responses", async () => { + await using target = await createTestServer((_request, response) => { + response.end("ok\n"); + }); + await using rejection = await createRejectedTunnelServer("Some other conflict\n"); + + const router = createCaptunCliRouter({ readConfig: async () => undefined }); + const client = createRouterClient(router); + + let caught: unknown; + try { + await client.tunnel({ + target: String(target.port), + serverUrl: rejection.origin, + name: "demo", + requestLogs: false, + }); + } catch (error) { + caught = error; + } + + expect(caught).toMatchObject({ + message: expect.stringContaining("Some other conflict"), + }); + expect(caught).toMatchObject({ + message: expect.stringContaining("DNS for"), + }); + expect(caught).not.toMatchObject({ + message: expect.stringContaining("active anonymous client"), + }); +}); + +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 createRejectedTunnelServer(body: string) { + const sockets = new Set<{ destroy: () => void }>(); + const server = createServer((_request, response) => { + response.writeHead(409, { + "content-type": "text/plain; charset=utf-8", + "cache-control": "no-store", + }); + response.end(body); + }); + server.on("upgrade", (_request, socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + socket.write( + [ + "HTTP/1.1 409 Conflict", + "Content-Type: text/plain; charset=utf-8", + "Cache-Control: no-store", + `Content-Length: ${Buffer.byteLength(body)}`, + "Connection: close", + "", + body, + ].join("\r\n"), + ); + socket.destroy(); + }); + 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 start test server"); + + return { + origin: `http://127.0.0.1:${address.port}`, + async [Symbol.asyncDispose]() { + for (const socket of sockets) socket.destroy(); + await new Promise((resolveClose) => server.close(() => resolveClose())); + }, + }; +} diff --git a/test/worker.test.ts b/test/worker.test.ts index 0c067b6..ef36827 100644 --- a/test/worker.test.ts +++ b/test/worker.test.ts @@ -608,6 +608,40 @@ test("Captun clients can reuse a returned anonymous ownership token", async () = ]); }); +test("createCaptunTunnel surfaces rejected WebSocket upgrade response details", async () => { + await using rejection = await createRejectedWebSocketUpgradeServer({ + status: 409, + body: "Tunnel name is already connected\n", + }); + + await expect( + createCaptunTunnel({ + url: `${rejection.origin}/demo/__captun-connect`, + fetch: () => new Response("unused\n"), + }), + ).rejects.toThrow(/409 Conflict: Tunnel name is already connected/); +}); + +test("createCaptunTunnel falls back when the rejected upgrade probe does not respond", async () => { + await using rejection = await createRejectedWebSocketUpgradeServer({ + status: 409, + body: "Tunnel name is already connected\n", + neverRespondToHttp: true, + }); + + let caught: unknown; + try { + await createCaptunTunnel({ + url: `${rejection.origin}/demo/__captun-connect`, + fetch: () => new Response("unused\n"), + }); + } catch (error) { + caught = error; + } + + expect(caught).toMatchObject({ message: "WebSocket connection failed" }); +}); + test("Captun Worker rejects missing tunnel names before Durable Object dispatch", async () => { await using fixture = await createCaptunWorkerFixture({}); @@ -682,6 +716,55 @@ async function createWebSocketUpgradeRecorder() { }; } +async function createRejectedWebSocketUpgradeServer(options: { + status: number; + body: string; + neverRespondToHttp?: boolean; +}) { + const sockets = new Set<{ destroy: () => void }>(); + const statusText = options.status === 409 ? "Conflict" : "Rejected"; + const server = createServer((_request, response) => { + if (options.neverRespondToHttp) return; + response.writeHead(options.status, { + "content-type": "text/plain; charset=utf-8", + "cache-control": "no-store", + }); + response.end(options.body); + }); + server.on("connection", (socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + }); + server.on("upgrade", (_request, socket) => { + socket.write( + [ + `HTTP/1.1 ${options.status} ${statusText}`, + "Content-Type: text/plain; charset=utf-8", + "Cache-Control: no-store", + `Content-Length: ${Buffer.byteLength(options.body)}`, + "Connection: close", + "", + options.body, + ].join("\r\n"), + ); + socket.destroy(); + }); + 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 start test server"); + + return { + origin: `http://127.0.0.1:${address.port}`, + async [Symbol.asyncDispose]() { + for (const socket of sockets) socket.destroy(); + await new Promise((resolveClose) => server.close(() => resolveClose())); + }, + }; +} + async function createDirectWorkerTunnel(options: { fixture: any; url: string;