From babe3a5a57096b8c5681fcb8988d3218ea6ecd05 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Sun, 24 May 2026 00:28:48 +0100 Subject: [PATCH 1/3] Specify hosted connect conflict messages --- tasks/hosted-connect-conflict-message.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 tasks/hosted-connect-conflict-message.md diff --git a/tasks/hosted-connect-conflict-message.md b/tasks/hosted-connect-conflict-message.md new file mode 100644 index 0000000..35db9f6 --- /dev/null +++ b/tasks/hosted-connect-conflict-message.md @@ -0,0 +1,23 @@ +status: ready +size: small + +# Hosted Connect Conflict Messages + +Status summary: Spec is ready. Implementation should keep the stacked PR narrow: surface hosted WebSocket upgrade rejection details in library/CLI errors and avoid presenting DNS setup as the primary explanation for active-owner conflicts. + +## Checklist + +- [ ] Add a regression test for a rejected WebSocket upgrade body. _Pending; should show `createCaptunTunnel` callers see the Worker rejection text instead of only `WebSocket connection failed`._ +- [ ] Improve library connect errors for pre-open WebSocket failures. _Pending; include deterministic status/body details when the runtime exposes them, or a clear hosted conflict message when it does not._ +- [ ] Improve CLI tunnel connect messaging. _Pending; active-owner conflicts should mention the tunnel name is already connected or in use and should not lead with DNS hints._ +- [ ] Run focused and full verification. _Pending; run focused tests plus `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. From e0475bae84cd8642dbe2a26f70f29d2cc544d64f Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Sun, 24 May 2026 00:32:40 +0100 Subject: [PATCH 2/3] Surface hosted connect rejection details --- src/cli/bin.ts | 16 ++- src/index.ts | 60 ++++++++++- ...6-05-24-hosted-connect-conflict-message.md | 24 +++++ tasks/hosted-connect-conflict-message.md | 23 ---- test/cli.test.ts | 101 ++++++++++++++++++ test/worker.test.ts | 56 ++++++++++ 6 files changed, 253 insertions(+), 27 deletions(-) create mode 100644 tasks/complete/2026-05-24-hosted-connect-conflict-message.md delete mode 100644 tasks/hosted-connect-conflict-message.md create mode 100644 test/cli.test.ts diff --git a/src/cli/bin.ts b/src/cli/bin.ts index 4450353..50c5566 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,12 @@ function tunnelConnectError(tunnel: ResolvedTunnel, cause: unknown) { return new CliFriendlyError(lines.join("\n")); } +function isActiveTunnelConflict(cause: unknown) { + if (cause instanceof CaptunTunnelConnectError && cause.response?.status === 409) return true; + const message = cause instanceof Error ? cause.message : String(cause); + 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..8c7b399 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,6 +36,19 @@ 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; + } +} + export async function createCaptunTunnel( options: Fetcher & { url?: string | URL; @@ -56,7 +69,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 +172,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 +190,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 +207,36 @@ 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; +}) { + try { + const response = await fetch(options.connectUrl, { headers: options.headers }); + 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; + } +} + // --------------------------------------------------------------------------- // 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..b514815 --- /dev/null +++ b/tasks/complete/2026-05-24-hosted-connect-conflict-message.md @@ -0,0 +1,24 @@ +status: complete +size: small + +# Hosted Connect Conflict Messages + +Status summary: Complete and locally verified. `createCaptunTunnel` now surfaces deterministic HTTP rejection details when available, and the CLI treats hosted 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 and throws `CaptunTunnelConnectError` with status/body details when the server exposes them._ +- [x] Improve CLI tunnel connect messaging. _`src/cli/bin.ts` detects 409/name-in-use connect failures and prints an active anonymous client explanation instead of 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. diff --git a/tasks/hosted-connect-conflict-message.md b/tasks/hosted-connect-conflict-message.md deleted file mode 100644 index 35db9f6..0000000 --- a/tasks/hosted-connect-conflict-message.md +++ /dev/null @@ -1,23 +0,0 @@ -status: ready -size: small - -# Hosted Connect Conflict Messages - -Status summary: Spec is ready. Implementation should keep the stacked PR narrow: surface hosted WebSocket upgrade rejection details in library/CLI errors and avoid presenting DNS setup as the primary explanation for active-owner conflicts. - -## Checklist - -- [ ] Add a regression test for a rejected WebSocket upgrade body. _Pending; should show `createCaptunTunnel` callers see the Worker rejection text instead of only `WebSocket connection failed`._ -- [ ] Improve library connect errors for pre-open WebSocket failures. _Pending; include deterministic status/body details when the runtime exposes them, or a clear hosted conflict message when it does not._ -- [ ] Improve CLI tunnel connect messaging. _Pending; active-owner conflicts should mention the tunnel name is already connected or in use and should not lead with DNS hints._ -- [ ] Run focused and full verification. _Pending; run focused tests plus `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. diff --git a/test/cli.test.ts b/test/cli.test.ts new file mode 100644 index 0000000..1e3dc89 --- /dev/null +++ b/test/cli.test.ts @@ -0,0 +1,101 @@ +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"), + }); +}); + +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..a5d86c7 100644 --- a/test/worker.test.ts +++ b/test/worker.test.ts @@ -608,6 +608,20 @@ 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("Captun Worker rejects missing tunnel names before Durable Object dispatch", async () => { await using fixture = await createCaptunWorkerFixture({}); @@ -682,6 +696,48 @@ async function createWebSocketUpgradeRecorder() { }; } +async function createRejectedWebSocketUpgradeServer(options: { status: number; body: string }) { + const sockets = new Set<{ destroy: () => void }>(); + const statusText = options.status === 409 ? "Conflict" : "Rejected"; + const server = createServer((_request, response) => { + response.writeHead(options.status, { + "content-type": "text/plain; charset=utf-8", + "cache-control": "no-store", + }); + response.end(options.body); + }); + server.on("upgrade", (_request, socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(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; From 04328e3f409ded241ccbadf8cfbaa122381f3901 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Sun, 24 May 2026 00:37:30 +0100 Subject: [PATCH 3/3] Harden hosted connect rejection diagnostics --- src/cli/bin.ts | 8 ++++- src/index.ts | 11 ++++++- ...6-05-24-hosted-connect-conflict-message.md | 8 +++-- test/cli.test.ts | 32 +++++++++++++++++++ test/worker.test.ts | 31 ++++++++++++++++-- 5 files changed, 83 insertions(+), 7 deletions(-) diff --git a/src/cli/bin.ts b/src/cli/bin.ts index 50c5566..da59b02 100755 --- a/src/cli/bin.ts +++ b/src/cli/bin.ts @@ -597,8 +597,14 @@ function tunnelConnectError(tunnel: ResolvedTunnel, cause: unknown) { } function isActiveTunnelConflict(cause: unknown) { - if (cause instanceof CaptunTunnelConnectError && cause.response?.status === 409) return true; + 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); } diff --git a/src/index.ts b/src/index.ts index 8c7b399..89fdd0b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -49,6 +49,8 @@ export class CaptunTunnelConnectError extends Error { } } +const WEBSOCKET_REJECTION_PROBE_TIMEOUT_MS = 500; + export async function createCaptunTunnel( options: Fetcher & { url?: string | URL; @@ -223,8 +225,13 @@ 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 }); + const response = await fetch(options.connectUrl, { + headers: options.headers, + signal: abort.signal, + }); if (response.ok) return undefined; const body = (await response.text()).trim(); return { @@ -234,6 +241,8 @@ async function readWebSocketRejection(options: { }; } catch { return undefined; + } finally { + clearTimeout(timeout); } } diff --git a/tasks/complete/2026-05-24-hosted-connect-conflict-message.md b/tasks/complete/2026-05-24-hosted-connect-conflict-message.md index b514815..da59af9 100644 --- a/tasks/complete/2026-05-24-hosted-connect-conflict-message.md +++ b/tasks/complete/2026-05-24-hosted-connect-conflict-message.md @@ -3,13 +3,14 @@ size: small # Hosted Connect Conflict Messages -Status summary: Complete and locally verified. `createCaptunTunnel` now surfaces deterministic HTTP rejection details when available, and the CLI treats hosted active-owner conflicts as name-in-use errors instead of DNS setup failures. +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 and throws `CaptunTunnelConnectError` with status/body details when the server exposes them._ -- [x] Improve CLI tunnel connect messaging. _`src/cli/bin.ts` detects 409/name-in-use connect failures and prints an active anonymous client explanation instead of DNS guidance._ +- [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 @@ -22,3 +23,4 @@ Status summary: Complete and locally verified. `createCaptunTunnel` now surfaces - 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 index 1e3dc89..86f1da9 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -34,6 +34,38 @@ test("CLI tunnel connect errors do not blame DNS for active-owner conflicts", as }); }); +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, ) { diff --git a/test/worker.test.ts b/test/worker.test.ts index a5d86c7..ef36827 100644 --- a/test/worker.test.ts +++ b/test/worker.test.ts @@ -622,6 +622,26 @@ test("createCaptunTunnel surfaces rejected WebSocket upgrade response details", ).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({}); @@ -696,19 +716,26 @@ async function createWebSocketUpgradeRecorder() { }; } -async function createRejectedWebSocketUpgradeServer(options: { status: number; body: string }) { +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("upgrade", (_request, socket) => { + 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}`,