simplify: remove ws bridge; wrangler-based relay tests

This commit is contained in:
Mohamed Boudra
2026-02-05 10:45:51 +07:00
parent 36e105ed30
commit 574bdc16f6
16 changed files with 785 additions and 1276 deletions
+8 -6
View File
@@ -54,11 +54,13 @@ interface DurableObjectStub {
/**
* Durable Object that handles WebSocket relay for a single session.
*
* Two WebSockets connect to this DO:
* - role=server: The Paseo daemon
* - role=client: The mobile/web app
* WebSockets connect to this DO in three shapes:
* - role=server (no clientId): daemon control socket (one per serverId)
* - role=server&clientId=...: daemon per-client data socket (one per clientId)
* - role=client&clientId=...: app/client socket (one per clientId)
*
* Messages are forwarded between them. The DO hibernates when idle.
* Messages are forwarded between the per-client data sockets and their matching
* client sockets. The DO hibernates when idle.
*/
interface CFResponseInit extends ResponseInit {
webSocket?: WebSocket;
@@ -139,8 +141,8 @@ export class RelayDurableObject {
return new Response("Missing serverId parameter", { status: 400 });
}
// v2 relay protocol: clients must provide a clientId so the daemon can create
// an independent E2EE channel per client connection.
// Clients must provide a clientId so the daemon can create an independent
// E2EE channel per client connection.
if (role === "client" && !clientId) {
return new Response("Missing clientId parameter", { status: 400 });
}
+111 -29
View File
@@ -1,6 +1,8 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { WebSocket } from "ws";
import { createRelayServer, type RelayServer } from "./node-adapter.js";
import net from "node:net";
import { spawn, type ChildProcess } from "node:child_process";
import { Buffer } from "node:buffer";
import {
generateKeyPair,
exportPublicKey,
@@ -10,21 +12,83 @@ import {
decrypt,
} from "./crypto.js";
const TEST_PORT = 19999;
async function getAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to acquire port")));
return;
}
server.close(() => resolve(address.port));
});
});
}
async function waitForServer(port: number, timeout = 15000): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeout) {
try {
await new Promise<void>((resolve, reject) => {
const socket = net.connect(port, "127.0.0.1", () => {
socket.end();
resolve();
});
socket.on("error", reject);
});
return;
} catch {
await new Promise((r) => setTimeout(r, 100));
}
}
throw new Error(`Server did not start on port ${port} within ${timeout}ms`);
}
describe("E2E Relay with E2EE", () => {
let relay: RelayServer;
let relayPort: number;
let relayProcess: ChildProcess | null = null;
beforeAll(async () => {
relay = createRelayServer({ port: TEST_PORT, host: "127.0.0.1" });
await relay.start();
relayPort = await getAvailablePort();
relayProcess = spawn(
"npx",
["wrangler", "dev", "--local", "--ip", "127.0.0.1", "--port", String(relayPort)],
{
cwd: process.cwd(),
env: { ...process.env },
stdio: ["ignore", "pipe", "pipe"],
detached: false,
}
);
relayProcess.stdout?.on("data", (data: Buffer) => {
const lines = data.toString().split("\n").filter((l) => l.trim());
for (const line of lines) {
// eslint-disable-next-line no-console
console.log(`[relay] ${line}`);
}
});
relayProcess.stderr?.on("data", (data: Buffer) => {
const lines = data.toString().split("\n").filter((l) => l.trim());
for (const line of lines) {
// eslint-disable-next-line no-console
console.error(`[relay] ${line}`);
}
});
await waitForServer(relayPort, 30000);
});
afterAll(async () => {
await relay.stop();
if (relayProcess) {
relayProcess.kill("SIGTERM");
relayProcess = null;
}
});
it("full flow: daemon and client exchange encrypted messages through relay", async () => {
it("full flow: daemon and client exchange encrypted messages through relay", { timeout: 20_000 }, async () => {
const serverId = "test-session-" + Date.now();
const clientId = "clt_test_" + Date.now() + "_" + Math.random().toString(36).slice(2);
@@ -37,7 +101,7 @@ describe("E2E Relay with E2EE", () => {
// Daemon connects to relay as "server" control role
const daemonControlWs = new WebSocket(
`ws://127.0.0.1:${TEST_PORT}/ws?serverId=${serverId}&role=server`
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=server`
);
await new Promise<void>((resolve, reject) => {
@@ -58,9 +122,41 @@ describe("E2E Relay with E2EE", () => {
daemonPubKeyOnClient
);
// Client connects to relay as "client" role (must include clientId in v2)
const waitForClientSeen = new Promise<void>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("timed out waiting for client_connected")),
5000
);
const onMessage = (raw: unknown) => {
try {
const text =
typeof raw === "string"
? raw
: raw && typeof (raw as any).toString === "function"
? (raw as any).toString()
: "";
const msg = JSON.parse(text);
if (msg?.type === "client_connected" && msg.clientId === clientId) {
clearTimeout(timeout);
daemonControlWs.off("message", onMessage);
resolve();
return;
}
if (msg?.type === "sync" && Array.isArray(msg.clientIds) && msg.clientIds.includes(clientId)) {
clearTimeout(timeout);
daemonControlWs.off("message", onMessage);
resolve();
}
} catch {
// ignore
}
};
daemonControlWs.on("message", onMessage);
});
// Client connects to relay as "client" role (must include clientId)
const clientWs = new WebSocket(
`ws://127.0.0.1:${TEST_PORT}/ws?serverId=${serverId}&role=client&clientId=${clientId}`
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=client&clientId=${clientId}`
);
await new Promise<void>((resolve, reject) => {
@@ -68,24 +164,10 @@ describe("E2E Relay with E2EE", () => {
clientWs.on("error", reject);
});
// Wait for relay to notify daemon control socket, then open the per-client server-data socket.
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error("timed out waiting for client_connected")), 5000);
daemonControlWs.on("message", (raw) => {
try {
const msg = JSON.parse(raw.toString());
if (msg && msg.type === "client_connected" && msg.clientId === clientId) {
clearTimeout(timeout);
resolve();
}
} catch {
// ignore
}
});
});
await waitForClientSeen;
const daemonWs = new WebSocket(
`ws://127.0.0.1:${TEST_PORT}/ws?serverId=${serverId}&role=server&clientId=${clientId}`
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=server&clientId=${clientId}`
);
await new Promise<void>((resolve, reject) => {
daemonWs.on("open", resolve);
@@ -197,7 +279,7 @@ describe("E2E Relay with E2EE", () => {
);
const daemonControlWs = new WebSocket(
`ws://127.0.0.1:${TEST_PORT}/ws?serverId=${serverId}&role=server`
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=server`
);
await new Promise<void>((r) => daemonControlWs.on("open", r));
@@ -234,13 +316,13 @@ describe("E2E Relay with E2EE", () => {
});
const clientWs = new WebSocket(
`ws://127.0.0.1:${TEST_PORT}/ws?serverId=${serverId}&role=client&clientId=${clientId}`
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=client&clientId=${clientId}`
);
await new Promise<void>((r) => clientWs.on("open", r));
await waitForClientSeen;
const daemonWs = new WebSocket(
`ws://127.0.0.1:${TEST_PORT}/ws?serverId=${serverId}&role=server&clientId=${clientId}`
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=server&clientId=${clientId}`
);
await new Promise<void>((r) => daemonWs.on("open", r));
-4
View File
@@ -1,9 +1,5 @@
export { Relay } from "./relay.js";
export type {
ConnectionRole,
RelayConnection,
RelayEvents,
RelaySession,
RelaySessionAttachment,
} from "./types.js";
-387
View File
@@ -1,387 +0,0 @@
import http from "http";
import type { Socket } from "net";
import { WebSocketServer, WebSocket as NodeWebSocket } from "ws";
import type { ConnectionRole } from "./types.js";
export interface NodeRelayServerConfig {
port: number;
host?: string;
}
/**
* Standalone Node.js relay server for self-hosting.
*
* v2 protocol:
* - Daemon connects a single control socket:
* ws://host:port/ws?serverId=abc&role=server
* - Each app client connects with a clientId:
* ws://host:port/ws?serverId=abc&role=client&clientId=clt_...
* - For every connected clientId, the daemon opens a dedicated server-data socket:
* ws://host:port/ws?serverId=abc&role=server&clientId=clt_...
*
* This allows multiple independent clients, each with its own E2EE handshake.
*/
export interface RelayServer {
start(): Promise<void>;
stop(): Promise<void>;
}
type Session = {
serverId: string;
control: NodeWebSocket | null;
clients: Map<string, Set<NodeWebSocket>>; // clientId -> sockets
servers: Map<string, NodeWebSocket>; // clientId -> server-data socket
pending: Map<string, Array<string | ArrayBuffer>>; // clientId -> buffered frames
};
function bufferFromWsData(data: unknown): Buffer {
if (Buffer.isBuffer(data)) return data;
if (Array.isArray(data)) {
return Buffer.concat(data.map(bufferFromWsData));
}
if (data instanceof ArrayBuffer) {
return Buffer.from(data);
}
if (ArrayBuffer.isView(data)) {
return Buffer.from(data.buffer, data.byteOffset, data.byteLength);
}
if (typeof data === "string") {
return Buffer.from(data, "utf8");
}
return Buffer.from(String(data), "utf8");
}
function normalizeWsMessage(data: unknown, isBinary: boolean): string | ArrayBuffer {
if (!isBinary) {
if (typeof data === "string") return data;
return bufferFromWsData(data).toString("utf8");
}
if (data instanceof ArrayBuffer) return data;
const buffer = bufferFromWsData(data);
const out = new Uint8Array(buffer.byteLength);
out.set(buffer);
return out.buffer;
}
export function createRelayServer(config: NodeRelayServerConfig): RelayServer {
const { port, host = "0.0.0.0" } = config;
const logFrames = process.env.PASEO_RELAY_LOG_FRAMES === "1";
const logUpgrades = process.env.PASEO_RELAY_LOG_UPGRADES === "1";
const sessions = new Map<string, Session>();
const getSession = (serverId: string): Session => {
let session = sessions.get(serverId);
if (!session) {
session = {
serverId,
control: null,
clients: new Map(),
servers: new Map(),
pending: new Map(),
};
sessions.set(serverId, session);
console.log(`[Relay] Session created: ${serverId}`);
}
return session;
};
const notifyControl = (session: Session, msg: unknown) => {
if (!session.control || session.control.readyState !== NodeWebSocket.OPEN) return;
try {
session.control.send(JSON.stringify(msg));
} catch {
// ignore
}
};
const listClientIds = (session: Session): string[] => Array.from(session.clients.keys());
const bufferFrame = (session: Session, clientId: string, frame: string | ArrayBuffer) => {
const existing = session.pending.get(clientId) ?? [];
existing.push(frame);
if (existing.length > 200) existing.splice(0, existing.length - 200);
session.pending.set(clientId, existing);
};
const flushFrames = (session: Session, clientId: string) => {
const server = session.servers.get(clientId);
if (!server || server.readyState !== NodeWebSocket.OPEN) return;
const frames = session.pending.get(clientId);
if (!frames || frames.length === 0) return;
session.pending.delete(clientId);
for (const frame of frames) {
try {
server.send(frame);
} catch {
bufferFrame(session, clientId, frame);
break;
}
}
};
const httpServer = http.createServer((req, res) => {
if (req.url === "/health") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok" }));
return;
}
res.writeHead(404);
res.end("Not found");
});
const wss = new WebSocketServer({ noServer: true });
httpServer.on("upgrade", (req, socket: Socket, head) => {
if (logUpgrades) {
try {
console.log(
`[Relay] upgrade url=${JSON.stringify(req.url)} host=${JSON.stringify(req.headers.host)}`
);
} catch {
// ignore
}
}
try {
const url = new URL(req.url ?? "", `http://${req.headers.host}`);
if (url.pathname !== "/ws") {
socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
socket.destroy();
return;
}
const serverId = url.searchParams.get("serverId");
const role = url.searchParams.get("role");
const clientId = (url.searchParams.get("clientId") ?? "").trim();
if (!serverId || !role || (role !== "server" && role !== "client")) {
socket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
socket.destroy();
return;
}
if (role === "client" && !clientId) {
socket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req);
});
} catch {
socket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
socket.destroy();
}
});
wss.on("connection", (ws, req) => {
const url = new URL(req.url ?? "", `http://${req.headers.host}`);
const serverId = url.searchParams.get("serverId")!;
const role = url.searchParams.get("role") as ConnectionRole;
const clientId = (url.searchParams.get("clientId") ?? "").trim();
const session = getSession(serverId);
const isControl = role === "server" && !clientId;
const isServerData = role === "server" && !!clientId;
if (isControl) {
try {
session.control?.close(1008, "Replaced by new connection");
} catch {
// ignore
}
session.control = ws;
try {
ws.send(JSON.stringify({ type: "sync", clientIds: listClientIds(session) }));
} catch {
// ignore
}
}
if (role === "client") {
const set = session.clients.get(clientId) ?? new Set();
set.add(ws);
session.clients.set(clientId, set);
notifyControl(session, { type: "client_connected", clientId });
}
if (isServerData) {
const prev = session.servers.get(clientId);
if (prev && prev !== ws) {
try {
prev.close(1008, "Replaced by new connection");
} catch {
// ignore
}
}
session.servers.set(clientId, ws);
flushFrames(session, clientId);
}
ws.on("message", (data, isBinary) => {
const normalized = normalizeWsMessage(data, isBinary);
if (logFrames) {
const preview = (() => {
try {
if (isBinary) return "<binary>";
return bufferFromWsData(data).toString("utf8").slice(0, 200);
} catch {
return "<unavailable>";
}
})();
const len = (() => {
try {
return bufferFromWsData(data).byteLength;
} catch {
return -1;
}
})();
console.log(
`[Relay] frame ${serverId}/${role}${clientId ? `(${clientId})` : ""} binary=${isBinary} len=${len} preview=${JSON.stringify(preview)}`
);
}
if (role === "client") {
const server = session.servers.get(clientId);
if (!server || server.readyState !== NodeWebSocket.OPEN) {
bufferFrame(session, clientId, normalized);
return;
}
try {
server.send(normalized);
} catch {
// ignore
}
return;
}
if (isServerData) {
const targets = session.clients.get(clientId);
if (!targets) return;
for (const target of targets) {
if (target.readyState !== NodeWebSocket.OPEN) continue;
try {
target.send(normalized);
} catch {
// ignore
}
}
}
});
ws.on("close", (code, reasonBuf) => {
const reason = reasonBuf?.toString?.() ?? "";
if (isControl) {
if (session.control === ws) session.control = null;
} else if (role === "client") {
const set = session.clients.get(clientId);
if (set) {
set.delete(ws);
if (set.size === 0) {
session.clients.delete(clientId);
session.pending.delete(clientId);
const server = session.servers.get(clientId);
if (server) {
try {
server.close(1001, "Client disconnected");
} catch {
// ignore
}
session.servers.delete(clientId);
}
notifyControl(session, { type: "client_disconnected", clientId });
}
}
} else if (isServerData) {
if (session.servers.get(clientId) === ws) {
session.servers.delete(clientId);
}
const targets = session.clients.get(clientId);
if (targets) {
for (const target of targets) {
try {
target.close(1012, "Server disconnected");
} catch {
// ignore
}
}
}
}
if (logUpgrades) {
console.log(
`[Relay] close ${serverId}/${role}${clientId ? `(${clientId})` : ""} code=${code} reason=${JSON.stringify(reason)}`
);
}
if (session.control === null && session.clients.size === 0 && session.servers.size === 0) {
sessions.delete(serverId);
console.log(`[Relay] Session closed: ${serverId}`);
}
});
ws.on("error", (error) => {
console.error(`[Relay] WebSocket error for ${serverId}/${role}${clientId ? `(${clientId})` : ""}:`, error);
});
});
return {
start() {
return new Promise((resolve, reject) => {
httpServer.on("error", reject);
httpServer.listen(port, host, () => {
console.log(`[Relay] Listening on ${host}:${port}`);
resolve();
});
});
},
stop() {
return new Promise((resolve) => {
try {
httpServer.close(() => undefined);
} catch {
// ignore
}
for (const ws of wss.clients) {
try {
(ws as unknown as { terminate?: () => void }).terminate?.();
} catch {
try {
ws.close(1001, "Server shutting down");
} catch {
// ignore
}
}
}
let finished = false;
const finish = () => {
if (finished) return;
finished = true;
resolve();
};
const timeout = setTimeout(() => finish(), 1500);
try {
wss.close(() => {
clearTimeout(timeout);
finish();
});
} catch {
clearTimeout(timeout);
finish();
}
});
},
};
}
-152
View File
@@ -1,152 +0,0 @@
import type {
ConnectionRole,
RelayConnection,
RelayEvents,
RelaySession,
} from "./types.js";
/**
* Core relay logic for bridging server and client WebSocket connections.
*
* This class is platform-agnostic and works with any WebSocket implementation
* that conforms to the RelayConnection interface.
*/
export class Relay {
private sessions = new Map<string, RelaySession>();
private events: RelayEvents;
constructor(events: RelayEvents = {}) {
this.events = events;
}
/**
* Register a connection for a session.
* If both server and client are connected, messages are bridged.
*/
addConnection(
serverId: string,
role: ConnectionRole,
connection: RelayConnection
): void {
let session = this.sessions.get(serverId);
if (!session) {
session = {
serverId,
server: null,
client: null,
createdAt: Date.now(),
};
this.sessions.set(serverId, session);
this.events.onSessionCreated?.(serverId);
}
const previousServer = session.server;
const previousClient = session.client;
const existingConnection = session[role];
if (existingConnection) {
existingConnection.close(1008, "Replaced by new connection");
}
session[role] = connection;
// Important: the E2EE handshake is tied to a specific server↔client socket pair.
// If the daemon reconnects (server socket changes) while the client socket stays
// open, the client will continue sending encrypted frames using its existing
// channel state — but the new daemon socket hasn't handshaked yet. This leads
// to immediate decrypt/handshake failures.
//
// To keep the protocol simple and robust, whenever a new *server* connection
// arrives while a client socket is still connected, force the client to reconnect.
if (role === "server" && previousClient) {
// Only close if the server socket actually changed (or was previously missing).
const serverChanged = !previousServer || previousServer !== connection;
if (serverChanged) {
session.client = null;
previousClient.close(1012, "Server reconnected");
}
}
if (session.server && session.client) {
this.events.onSessionBridged?.(serverId);
}
}
/**
* Remove a connection from a session.
* If both connections are gone, the session is cleaned up.
*/
removeConnection(serverId: string, role: ConnectionRole): void {
const session = this.sessions.get(serverId);
if (!session) return;
session[role] = null;
if (!session.server && !session.client) {
this.sessions.delete(serverId);
this.events.onSessionClosed?.(serverId);
}
}
/**
* Forward a message from one side to the other.
*/
forward(
serverId: string,
fromRole: ConnectionRole,
data: string | ArrayBuffer
): void {
const session = this.sessions.get(serverId);
if (!session) return;
const target = fromRole === "server" ? session.client : session.server;
if (target) {
try {
target.send(data);
} catch (error) {
this.events.onError?.(
serverId,
error instanceof Error ? error : new Error(String(error))
);
}
}
}
/**
* Get session info for debugging/monitoring.
*/
getSession(serverId: string): RelaySession | undefined {
return this.sessions.get(serverId);
}
/**
* List all active sessions.
*/
listSessions(): RelaySession[] {
return Array.from(this.sessions.values());
}
/**
* Close a session and both connections.
*/
closeSession(serverId: string, code = 1000, reason = "Session closed"): void {
const session = this.sessions.get(serverId);
if (!session) return;
session.server?.close(code, reason);
session.client?.close(code, reason);
this.sessions.delete(serverId);
this.events.onSessionClosed?.(serverId);
}
/**
* Restore sessions from persisted state (for Durable Objects hibernation).
*/
restoreSessions(sessions: RelaySession[]): void {
for (const session of sessions) {
this.sessions.set(session.serverId, session);
}
}
}
+2 -22
View File
@@ -10,33 +10,13 @@
export type ConnectionRole = "server" | "client";
export interface RelaySession {
serverId: string;
server: RelayConnection | null;
client: RelayConnection | null;
createdAt: number;
}
export interface RelayConnection {
role: ConnectionRole;
send(data: string | ArrayBuffer): void;
close(code?: number, reason?: string): void;
}
export interface RelaySessionAttachment {
serverId: string;
role: ConnectionRole;
/**
* v2 relay: unique id for the client connection. Allows the daemon to create
* an independent socket + E2EE channel per connected client.
* Unique id for the client connection. Allows the daemon to create an
* independent socket + E2EE channel per connected client.
*/
clientId?: string | null;
createdAt: number;
}
export interface RelayEvents {
onSessionCreated?(serverId: string): void;
onSessionBridged?(serverId: string): void;
onSessionClosed?(serverId: string): void;
onError?(serverId: string, error: Error): void;
}