fix(app): keep browser relay WebSockets protocol-neutral (#2976)
Use the standard browser WebSocket constructor without the Node/React Native options object, while retaining the options-capable factory for supported runtimes and the desktop bridge.
This commit is contained in:
@@ -58,6 +58,7 @@ import { encodeImages } from "@/utils/encode-images";
|
|||||||
import { DirectorySync, type RefreshAgentDirectoryResult } from "@/runtime/directory-sync";
|
import { DirectorySync, type RefreshAgentDirectoryResult } from "@/runtime/directory-sync";
|
||||||
import { ReplicaCache } from "@/runtime/replica-cache";
|
import { ReplicaCache } from "@/runtime/replica-cache";
|
||||||
import { nativePerformanceTrace } from "@/performance/native-trace";
|
import { nativePerformanceTrace } from "@/performance/native-trace";
|
||||||
|
import { createAppWebSocketFactory } from "./websocket-factory";
|
||||||
|
|
||||||
export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
|
export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
|
||||||
export type HostRegistryStatus = "loading" | "ready";
|
export type HostRegistryStatus = "loading" | "ready";
|
||||||
@@ -479,6 +480,9 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
|||||||
createClient: ({ host, connection, clientId, runtimeGeneration }) => {
|
createClient: ({ host, connection, clientId, runtimeGeneration }) => {
|
||||||
const localTransportFactory = createDesktopLocalDaemonTransportFactory();
|
const localTransportFactory = createDesktopLocalDaemonTransportFactory();
|
||||||
const webSocketTransportFactory = createDesktopWebSocketTransportFactory();
|
const webSocketTransportFactory = createDesktopWebSocketTransportFactory();
|
||||||
|
const webSocketConfig = webSocketTransportFactory
|
||||||
|
? { transportFactory: webSocketTransportFactory }
|
||||||
|
: { webSocketFactory: createAppWebSocketFactory() };
|
||||||
const base = {
|
const base = {
|
||||||
suppressSendErrors: true,
|
suppressSendErrors: true,
|
||||||
clientId,
|
clientId,
|
||||||
@@ -501,7 +505,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
|||||||
if (connection.type === "directTcp") {
|
if (connection.type === "directTcp") {
|
||||||
return new DaemonClient({
|
return new DaemonClient({
|
||||||
...base,
|
...base,
|
||||||
...(webSocketTransportFactory ? { transportFactory: webSocketTransportFactory } : {}),
|
...webSocketConfig,
|
||||||
url: buildDaemonWebSocketUrl(connection.endpoint, {
|
url: buildDaemonWebSocketUrl(connection.endpoint, {
|
||||||
useTls: connection.useTls ?? false,
|
useTls: connection.useTls ?? false,
|
||||||
}),
|
}),
|
||||||
@@ -511,6 +515,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
|||||||
}
|
}
|
||||||
return new DaemonClient({
|
return new DaemonClient({
|
||||||
...base,
|
...base,
|
||||||
|
...webSocketConfig,
|
||||||
url: buildRelayWebSocketUrl({
|
url: buildRelayWebSocketUrl({
|
||||||
endpoint: connection.relayEndpoint,
|
endpoint: connection.relayEndpoint,
|
||||||
useTls: connection.useTls ?? shouldUseTlsForDefaultHostedRelay(connection.relayEndpoint),
|
useTls: connection.useTls ?? shouldUseTlsForDefaultHostedRelay(connection.relayEndpoint),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<void>> {
|
||||||
|
const server = createServer();
|
||||||
|
const clients = new Set<Duplex>();
|
||||||
|
|
||||||
|
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<void>((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<void>((resolve) => {
|
||||||
|
server.close(() => resolve());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<WebSocket> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -41,6 +41,7 @@ export default defineConfig({
|
|||||||
instances: [{ browser: "chromium" }],
|
instances: [{ browser: "chromium" }],
|
||||||
screenshotDirectory: ".vitest-screenshots",
|
screenshotDirectory: ".vitest-screenshots",
|
||||||
},
|
},
|
||||||
|
globalSetup: path.resolve(__dirname, "src/runtime/websocket-test-global-setup.ts"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -20,6 +20,10 @@
|
|||||||
"./internal/daemon-client-transport-types": {
|
"./internal/daemon-client-transport-types": {
|
||||||
"types": "./dist/daemon-client-transport-types.d.ts",
|
"types": "./dist/daemon-client-transport-types.d.ts",
|
||||||
"default": "./dist/daemon-client-transport-types.js"
|
"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": {
|
"publishConfig": {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
encodeUtf8String,
|
encodeUtf8String,
|
||||||
extractRelayMessage,
|
extractRelayMessage,
|
||||||
} from "./daemon-client-transport.js";
|
} from "./daemon-client-transport.js";
|
||||||
import { defaultWebSocketFactory } from "./daemon-client-websocket-transport.js";
|
|
||||||
|
|
||||||
const createClientChannelMock = vi.hoisted(() => vi.fn());
|
const createClientChannelMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
@@ -18,28 +17,6 @@ vi.mock("@getpaseo/relay/e2ee", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe("daemon-client transport helpers", () => {
|
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<string, string> },
|
|
||||||
) {
|
|
||||||
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 () => {
|
test("createEncryptedTransport closes handshake failures with browser-safe code", async () => {
|
||||||
createClientChannelMock.mockReset();
|
createClientChannelMock.mockReset();
|
||||||
createClientChannelMock.mockRejectedValueOnce(new Error("handshake failed"));
|
createClientChannelMock.mockRejectedValueOnce(new Error("handshake failed"));
|
||||||
|
|||||||
@@ -5,23 +5,38 @@ import type {
|
|||||||
} from "./daemon-client-transport-types.js";
|
} from "./daemon-client-transport-types.js";
|
||||||
import { extractRelayMessage } from "./daemon-client-transport-utils.js";
|
import { extractRelayMessage } from "./daemon-client-transport-utils.js";
|
||||||
|
|
||||||
|
type GlobalWebSocketConstructor = new (
|
||||||
|
url: string,
|
||||||
|
protocols?: string | string[],
|
||||||
|
options?: { headers?: Record<string, string> },
|
||||||
|
) => 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(
|
export function defaultWebSocketFactory(
|
||||||
url: string,
|
url: string,
|
||||||
options?: { headers?: Record<string, string>; protocols?: string[] },
|
options?: { headers?: Record<string, string>; protocols?: string[] },
|
||||||
): WebSocketLike {
|
): WebSocketLike {
|
||||||
const globalWs = (
|
const globalWs = getGlobalWebSocket();
|
||||||
globalThis as {
|
if (options?.protocols === undefined) {
|
||||||
WebSocket?: new (
|
return new globalWs(url);
|
||||||
url: string,
|
|
||||||
protocols?: string | string[],
|
|
||||||
options?: { headers?: Record<string, string> },
|
|
||||||
) => WebSocketLike;
|
|
||||||
}
|
|
||||||
).WebSocket;
|
|
||||||
if (!globalWs) {
|
|
||||||
throw new Error("WebSocket is not available in this runtime");
|
|
||||||
}
|
}
|
||||||
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<string, string>; protocols?: string[] },
|
||||||
|
): WebSocketLike {
|
||||||
|
return new (getGlobalWebSocket())(url, options?.protocols, { headers: options?.headers });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createWebSocketTransportFactory(factory: WebSocketFactory): DaemonTransportFactory {
|
export function createWebSocketTransportFactory(factory: WebSocketFactory): DaemonTransportFactory {
|
||||||
|
|||||||
Reference in New Issue
Block a user