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:
Mohamed Boudra
2026-08-07 17:30:43 +08:00
committed by GitHub
parent 75a5065bb3
commit a5184fd08a
9 changed files with 169 additions and 36 deletions
+6 -1
View File
@@ -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),
@@ -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();
}
});
});