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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion src/cli/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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`.
Expand All @@ -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<void>((resolveSleep) => setTimeout(resolveSleep, ms));
}
Expand Down
69 changes: 66 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -154,7 +174,10 @@ function createWebSocket(options: { url: string | URL; headers?: Record<string,
);
}

async function waitUntilOpen(socket: WebSocket) {
async function waitUntilOpen(
socket: WebSocket,
options: { connectUrl: string; headers: Record<string, string> | undefined },
) {
if (socket.readyState === WebSocket.OPEN) return;
if (socket.readyState !== WebSocket.CONNECTING) {
throw new Error("WebSocket closed before opening");
Expand All @@ -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);
}),
Comment thread
cursor[bot] marked this conversation as resolved.
{ signal: listeners.signal },
);
socket.addEventListener(
Expand All @@ -183,6 +209,43 @@ async function waitUntilOpen(socket: WebSocket) {
});
}

async function webSocketConnectionFailedError(options: {
connectUrl: string;
headers: Record<string, string> | 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<string, string> | 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)
// ---------------------------------------------------------------------------
Expand Down
26 changes: 26 additions & 0 deletions tasks/complete/2026-05-24-hosted-connect-conflict-message.md
Original file line number Diff line number Diff line change
@@ -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.
133 changes: 133 additions & 0 deletions test/cli.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>,
) {
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<void>((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<void>((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<void>((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<void>((resolveClose) => server.close(() => resolveClose()));
},
};
}
Loading
Loading