diff --git a/packages/server/service-worker-lifecycle/resident-browser-proxy.ts b/packages/server/service-worker-lifecycle/resident-browser-proxy.ts new file mode 100644 index 000000000..d813432dc --- /dev/null +++ b/packages/server/service-worker-lifecycle/resident-browser-proxy.ts @@ -0,0 +1,86 @@ +import { z } from "zod/v4"; + +const BrowserVersionSchema = z.object({ + webSocketDebuggerUrl: z.string().min(1), +}); + +const DEFAULT_RESOLVE_TIMEOUT_MS = 1_000; + +export type ResidentBrowserProxyResolverOptions = { + fetch?: typeof globalThis.fetch; + timeoutMs?: number; +}; + +export async function resolveResidentBrowserWebSocketUrl( + browserProxyUrl: string, + options: ResidentBrowserProxyResolverOptions = {}, +): Promise { + const proxyUrl = parseBrowserProxyUrl(browserProxyUrl); + const abortController = new AbortController(); + const timeout = setTimeout( + () => abortController.abort(), + options.timeoutMs ?? DEFAULT_RESOLVE_TIMEOUT_MS, + ); + + try { + const versionUrl = new URL("/json/version", proxyUrl); + const response = await (options.fetch ?? globalThis.fetch)(versionUrl, { + signal: abortController.signal, + }); + if (!response.ok) { + throw new Error(`Browser proxy version request failed with HTTP ${response.status}`); + } + + const { webSocketDebuggerUrl } = BrowserVersionSchema.parse(await response.json()); + return rewriteDebuggerUrl(webSocketDebuggerUrl, proxyUrl); + } finally { + clearTimeout(timeout); + } +} + +function parseBrowserProxyUrl(rawUrl: string): URL { + const url = new URL(rawUrl); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("Resident browser proxy URL must use http: or https:"); + } + if (!isLoopbackHostname(url.hostname)) { + throw new Error("Resident browser proxy URL must point to loopback"); + } + if (url.username || url.password || url.search || url.hash) { + throw new Error("Resident browser proxy URL must not include credentials, query, or fragment"); + } + if (url.pathname !== "/") { + throw new Error("Resident browser proxy URL must contain only an origin"); + } + return url; +} + +function rewriteDebuggerUrl(rawUrl: string, proxyUrl: URL): string { + const debuggerUrl = new URL(rawUrl); + if (debuggerUrl.protocol !== "ws:") { + throw new Error("Chromium webSocketDebuggerUrl must use ws:"); + } + if (!isLoopbackHostname(debuggerUrl.hostname)) { + throw new Error("Chromium webSocketDebuggerUrl must point to loopback"); + } + if (debuggerUrl.username || debuggerUrl.password || debuggerUrl.hash) { + throw new Error("Chromium webSocketDebuggerUrl must not include credentials or a fragment"); + } + if ( + !debuggerUrl.pathname.startsWith("/devtools/browser/") || + debuggerUrl.pathname.endsWith("/") + ) { + throw new Error("Chromium webSocketDebuggerUrl must identify a browser target"); + } + + const authorityEnd = rawUrl.indexOf("/", "ws://".length); + if (authorityEnd < 0) { + throw new Error("Chromium webSocketDebuggerUrl must include a browser target path"); + } + const websocketProtocol = proxyUrl.protocol === "https:" ? "wss:" : "ws:"; + return `${websocketProtocol}//${proxyUrl.host}${rawUrl.slice(authorityEnd)}`; +} + +function isLoopbackHostname(hostname: string): boolean { + return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "[::1]"; +} diff --git a/packages/server/tests/resident-browser-proxy.test.ts b/packages/server/tests/resident-browser-proxy.test.ts new file mode 100644 index 000000000..4d5de6a61 --- /dev/null +++ b/packages/server/tests/resident-browser-proxy.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from "vitest"; +import { resolveResidentBrowserWebSocketUrl } from "../service-worker-lifecycle/resident-browser-proxy.js"; + +function response(body: unknown, init: { ok?: boolean; status?: number } = {}): Response { + return { + ok: init.ok ?? true, + status: init.status ?? 200, + json: async () => body, + } as Response; +} + +describe("resident browser proxy resolver", () => { + it("resolves Chromium and preserves the exact browser target path and query", async () => { + const fetch = vi.fn(async () => + response({ + webSocketDebuggerUrl: + "ws://127.0.0.1:9222/devtools/browser/a%2Fb?token=one%2Ftwo&token=three", + }), + ); + + await expect( + resolveResidentBrowserWebSocketUrl("http://127.0.0.1:9333", { fetch }), + ).resolves.toBe("ws://127.0.0.1:9333/devtools/browser/a%2Fb?token=one%2Ftwo&token=three"); + expect(fetch).toHaveBeenCalledWith( + new URL("http://127.0.0.1:9333/json/version"), + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + + it("uses secure WebSockets for an HTTPS proxy and supports default ports", async () => { + await expect( + resolveResidentBrowserWebSocketUrl("https://127.0.0.1", { + fetch: async () => + response({ + webSocketDebuggerUrl: "ws://localhost/devtools/browser/browser-id?token=one", + }), + }), + ).resolves.toBe("wss://127.0.0.1/devtools/browser/browser-id?token=one"); + }); + + it("rejects malformed JSON", async () => { + const fetch = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => { + throw new SyntaxError("Unexpected token"); + }, + })) as unknown as typeof globalThis.fetch; + + await expect( + resolveResidentBrowserWebSocketUrl("http://127.0.0.1:9333", { fetch }), + ).rejects.toThrow("Unexpected token"); + }); + + it.each([ + [{}, "webSocketDebuggerUrl"], + [{ webSocketDebuggerUrl: "http://127.0.0.1:9222/devtools/browser/id" }, "must use ws:"], + [{ webSocketDebuggerUrl: "ws://browser.example:9222/devtools/browser/id" }, "loopback"], + [{ webSocketDebuggerUrl: "ws://127.0.0.1:9222/devtools/page/id" }, "browser target"], + ])("rejects invalid version metadata %#", async (body, message) => { + await expect( + resolveResidentBrowserWebSocketUrl("http://127.0.0.1:9333", { + fetch: async () => response(body), + }), + ).rejects.toThrow(message); + }); + + it("rejects HTTP failures", async () => { + await expect( + resolveResidentBrowserWebSocketUrl("http://127.0.0.1:9333", { + fetch: async () => response({}, { ok: false, status: 503 }), + }), + ).rejects.toThrow("HTTP 503"); + }); + + it("aborts a request after the bounded timeout", async () => { + const fetch = vi.fn( + async (_input: URL | RequestInfo, init?: RequestInit): Promise => + await new Promise((_, reject) => { + init?.signal?.addEventListener("abort", () => reject(new Error("aborted")), { + once: true, + }); + }), + ); + + await expect( + resolveResidentBrowserWebSocketUrl("http://127.0.0.1:9333", { fetch, timeoutMs: 1 }), + ).rejects.toThrow("aborted"); + }); + + it.each([ + "ws://127.0.0.1:9333", + "http://browser.example:9333", + "http://127.0.0.1:9333/path", + "http://user:secret@127.0.0.1:9333", + ])("rejects invalid proxy configuration %s", async (browserProxyUrl) => { + await expect(resolveResidentBrowserWebSocketUrl(browserProxyUrl)).rejects.toThrow(); + }); +});