-
Notifications
You must be signed in to change notification settings - Fork 0
Surface hosted tunnel conflict messages #19
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
mmkal
wants to merge
3
commits into
mmkal/26/05/24/hosted-ownership-tokens
from
mmkal/26/05/24/hosted-connect-conflict-message
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
26 changes: 26 additions & 0 deletions
26
tasks/complete/2026-05-24-hosted-connect-conflict-message.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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())); | ||
| }, | ||
| }; | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.