diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index f0b6652e7..7e82f9e76 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -58,6 +58,7 @@ import { encodeImages } from "@/utils/encode-images"; import { DirectorySync, type RefreshAgentDirectoryResult } from "@/runtime/directory-sync"; import { ReplicaCache } from "@/runtime/replica-cache"; import { nativePerformanceTrace } from "@/performance/native-trace"; +import { createAppWebSocketFactory } from "./websocket-factory"; export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error"; export type HostRegistryStatus = "loading" | "ready"; @@ -479,6 +480,9 @@ function createDefaultDeps(): HostRuntimeControllerDeps { createClient: ({ host, connection, clientId, runtimeGeneration }) => { const localTransportFactory = createDesktopLocalDaemonTransportFactory(); const webSocketTransportFactory = createDesktopWebSocketTransportFactory(); + const webSocketConfig = webSocketTransportFactory + ? { transportFactory: webSocketTransportFactory } + : { webSocketFactory: createAppWebSocketFactory() }; const base = { suppressSendErrors: true, clientId, @@ -501,7 +505,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps { if (connection.type === "directTcp") { return new DaemonClient({ ...base, - ...(webSocketTransportFactory ? { transportFactory: webSocketTransportFactory } : {}), + ...webSocketConfig, url: buildDaemonWebSocketUrl(connection.endpoint, { useTls: connection.useTls ?? false, }), @@ -511,6 +515,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps { } return new DaemonClient({ ...base, + ...webSocketConfig, url: buildRelayWebSocketUrl({ endpoint: connection.relayEndpoint, useTls: connection.useTls ?? shouldUseTlsForDefaultHostedRelay(connection.relayEndpoint), diff --git a/packages/app/src/runtime/websocket-factory.ts b/packages/app/src/runtime/websocket-factory.ts new file mode 100644 index 000000000..fccb9ac2f --- /dev/null +++ b/packages/app/src/runtime/websocket-factory.ts @@ -0,0 +1,6 @@ +import { nativeWebSocketFactory } from "@getpaseo/client/internal/daemon-client-websocket-transport"; +import type { WebSocketFactory } from "@getpaseo/client/internal/daemon-client-transport-types"; + +export function createAppWebSocketFactory(): WebSocketFactory { + return nativeWebSocketFactory; +} diff --git a/packages/app/src/runtime/websocket-factory.web.ts b/packages/app/src/runtime/websocket-factory.web.ts new file mode 100644 index 000000000..6d2ea0f6c --- /dev/null +++ b/packages/app/src/runtime/websocket-factory.web.ts @@ -0,0 +1,6 @@ +import { defaultWebSocketFactory } from "@getpaseo/client/internal/daemon-client-websocket-transport"; +import type { WebSocketFactory } from "@getpaseo/client/internal/daemon-client-transport-types"; + +export function createAppWebSocketFactory(): WebSocketFactory { + return defaultWebSocketFactory; +} diff --git a/packages/app/src/runtime/websocket-test-global-setup.ts b/packages/app/src/runtime/websocket-test-global-setup.ts new file mode 100644 index 000000000..9ed6f5344 --- /dev/null +++ b/packages/app/src/runtime/websocket-test-global-setup.ts @@ -0,0 +1,75 @@ +import { createHash } from "node:crypto"; +import { createServer } from "node:http"; +import type { Duplex } from "node:stream"; + +interface VitestProject { + provide: (key: "passwordlessRelayUrl", value: string) => void; +} + +export default async function setup(project: VitestProject): Promise<() => Promise> { + const server = createServer(); + const clients = new Set(); + + server.on("upgrade", (request, socket, head) => { + if (request.url !== "/relay") { + socket.destroy(); + return; + } + + const key = request.headers["sec-websocket-key"]; + if (typeof key !== "string") { + socket.destroy(); + return; + } + + const accept = createHash("sha1") + .update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`) + .digest("base64"); + // This relay-style server deliberately selects no subprotocol. + socket.write( + [ + "HTTP/1.1 101 Switching Protocols", + "Upgrade: websocket", + "Connection: Upgrade", + `Sec-WebSocket-Accept: ${accept}`, + "", + "", + ].join("\r\n"), + ); + clients.add(socket); + socket.on("close", () => clients.delete(socket)); + socket.on("error", () => clients.delete(socket)); + if (head.length > 0) { + socket.unshift(head); + } + }); + + await new Promise((resolve, reject) => { + const onError = (error: Error) => { + server.off("listening", onListening); + reject(error); + }; + const onListening = () => { + server.off("error", onError); + resolve(); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(0, "127.0.0.1"); + }); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Browser WebSocket regression server did not expose a TCP address"); + } + project.provide("passwordlessRelayUrl", `ws://127.0.0.1:${address.port}/relay`); + + return async () => { + for (const client of clients) { + client.destroy(); + } + await new Promise((resolve) => { + server.close(() => resolve()); + }); + }; +} diff --git a/packages/app/src/runtime/websocket-transport.browser.test.ts b/packages/app/src/runtime/websocket-transport.browser.test.ts new file mode 100644 index 000000000..f96c9dfbc --- /dev/null +++ b/packages/app/src/runtime/websocket-transport.browser.test.ts @@ -0,0 +1,44 @@ +import { inject } from "vitest"; +import { describe, expect, test } from "vitest"; +import { createAppWebSocketFactory } from "./websocket-factory"; + +declare module "vitest" { + interface ProvidedContext { + passwordlessRelayUrl: string; + } +} + +function openPasswordlessRelayConnection(url: string): Promise { + const socket = createAppWebSocketFactory()(url, { + headers: {}, + protocols: undefined, + }); + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + socket.close(); + reject(new Error("Timed out waiting for the passwordless relay connection")); + }, 2_000); + + socket.addEventListener?.("open", () => { + clearTimeout(timeout); + resolve(socket as WebSocket); + }); + socket.addEventListener?.("error", () => { + clearTimeout(timeout); + reject(new Error("Passwordless relay connection failed before open")); + }); + }); +} + +describe("browser WebSocket transport", () => { + test("opens a passwordless relay-style connection when the server selects no subprotocol", async () => { + const socket = await openPasswordlessRelayConnection(inject("passwordlessRelayUrl")); + + try { + expect(socket.protocol).toBe(""); + } finally { + socket.close(); + } + }); +}); diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts index da38efb81..fa3ec72e0 100644 --- a/packages/app/vitest.config.ts +++ b/packages/app/vitest.config.ts @@ -41,6 +41,7 @@ export default defineConfig({ instances: [{ browser: "chromium" }], screenshotDirectory: ".vitest-screenshots", }, + globalSetup: path.resolve(__dirname, "src/runtime/websocket-test-global-setup.ts"), }, }, ], diff --git a/packages/client/package.json b/packages/client/package.json index 396a58c15..8bd0080e0 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -20,6 +20,10 @@ "./internal/daemon-client-transport-types": { "types": "./dist/daemon-client-transport-types.d.ts", "default": "./dist/daemon-client-transport-types.js" + }, + "./internal/daemon-client-websocket-transport": { + "types": "./dist/daemon-client-websocket-transport.d.ts", + "default": "./dist/daemon-client-websocket-transport.js" } }, "publishConfig": { diff --git a/packages/client/src/daemon-client-transport.test.ts b/packages/client/src/daemon-client-transport.test.ts index 347d94302..378275c67 100644 --- a/packages/client/src/daemon-client-transport.test.ts +++ b/packages/client/src/daemon-client-transport.test.ts @@ -9,7 +9,6 @@ import { encodeUtf8String, extractRelayMessage, } from "./daemon-client-transport.js"; -import { defaultWebSocketFactory } from "./daemon-client-websocket-transport.js"; const createClientChannelMock = vi.hoisted(() => vi.fn()); @@ -18,28 +17,6 @@ vi.mock("@getpaseo/relay/e2ee", () => ({ })); describe("daemon-client transport helpers", () => { - test("defaultWebSocketFactory passes custom headers to native WebSocket options", () => { - const constructorSpy = vi.fn(); - function MockWebSocket( - url: string, - protocols?: string | string[], - options?: { headers?: Record }, - ) { - constructorSpy(url, protocols, options); - } - vi.stubGlobal("WebSocket", MockWebSocket); - - defaultWebSocketFactory("ws://example.test", { - protocols: ["paseo.test"], - headers: { "X-Tenant": "acme" }, - }); - - expect(constructorSpy).toHaveBeenCalledWith("ws://example.test", ["paseo.test"], { - headers: { "X-Tenant": "acme" }, - }); - vi.unstubAllGlobals(); - }); - test("createEncryptedTransport closes handshake failures with browser-safe code", async () => { createClientChannelMock.mockReset(); createClientChannelMock.mockRejectedValueOnce(new Error("handshake failed")); diff --git a/packages/client/src/daemon-client-websocket-transport.ts b/packages/client/src/daemon-client-websocket-transport.ts index 449d97890..f5734b4b5 100644 --- a/packages/client/src/daemon-client-websocket-transport.ts +++ b/packages/client/src/daemon-client-websocket-transport.ts @@ -5,23 +5,38 @@ import type { } from "./daemon-client-transport-types.js"; import { extractRelayMessage } from "./daemon-client-transport-utils.js"; +type GlobalWebSocketConstructor = new ( + url: string, + protocols?: string | string[], + options?: { headers?: Record }, +) => WebSocketLike; + +function getGlobalWebSocket(): GlobalWebSocketConstructor { + const globalWs = (globalThis as { WebSocket?: GlobalWebSocketConstructor }).WebSocket; + if (!globalWs) { + throw new Error("WebSocket is not available in this runtime"); + } + return globalWs; +} + export function defaultWebSocketFactory( url: string, options?: { headers?: Record; protocols?: string[] }, ): WebSocketLike { - const globalWs = ( - globalThis as { - WebSocket?: new ( - url: string, - protocols?: string | string[], - options?: { headers?: Record }, - ) => WebSocketLike; - } - ).WebSocket; - if (!globalWs) { - throw new Error("WebSocket is not available in this runtime"); + const globalWs = getGlobalWebSocket(); + if (options?.protocols === undefined) { + return new globalWs(url); } - return new globalWs(url, options?.protocols, { headers: options?.headers }); + return new globalWs(url, options.protocols); +} + +// React Native and Node-compatible WebSocket implementations accept this extra options object. +// Keep it out of the standard browser factory above. +export function nativeWebSocketFactory( + url: string, + options?: { headers?: Record; protocols?: string[] }, +): WebSocketLike { + return new (getGlobalWebSocket())(url, options?.protocols, { headers: options?.headers }); } export function createWebSocketTransportFactory(factory: WebSocketFactory): DaemonTransportFactory {