relay(v2): support multiple clients per serverId

This commit is contained in:
Mohamed Boudra
2026-02-05 09:48:43 +07:00
parent 98896bfb37
commit 36e105ed30
9 changed files with 663 additions and 169 deletions
@@ -294,9 +294,25 @@ export class DaemonClient {
>();
private logger: Logger;
private pendingSendQueue: PendingSend[] = [];
private relayClientId: string | null = null;
constructor(private config: DaemonClientConfig) {
this.logger = config.logger ?? consoleLogger;
// Relay v2 requires a clientId so the daemon can create an independent
// socket + E2EE channel per connected client. Generate one per DaemonClient
// instance (stable across reconnects in this tab/app session).
if (isRelayClientWebSocketUrl(this.config.url)) {
try {
const parsed = new URL(this.config.url);
if (!parsed.searchParams.get("clientId")) {
this.relayClientId = `clt_${safeRandomId()}`;
parsed.searchParams.set("clientId", this.relayClientId);
this.config.url = parsed.toString();
}
} catch {
// ignore - invalid URL will be handled on connect
}
}
}
// ============================================================================
@@ -2916,6 +2932,17 @@ function describeTransportError(event?: unknown): string {
return "Transport error";
}
function safeRandomId(): string {
try {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
} catch {
// ignore
}
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}
function decodeMessageData(data: unknown): string | null {
if (data === null || data === undefined) {
return null;
@@ -91,11 +91,13 @@ describe("Relay transport (E2EE) - daemon E2E", () => {
const offerUrl = parseOfferUrlFromLogs(lines);
const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl);
const clientId = `clt_test_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
const ws = new WebSocket(
buildRelayWebSocketUrl({
endpoint: `127.0.0.1:${relayPort}`,
serverId,
role: "client",
clientId,
})
);
@@ -209,6 +211,7 @@ describe("Relay transport (E2EE) - daemon E2E", () => {
endpoint: `127.0.0.1:${relayPort}`,
serverId,
role: "client",
clientId: `clt_test_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`,
})
);
+113 -19
View File
@@ -30,6 +30,33 @@ type RelaySocketLike = {
once: (event: "close" | "error", listener: (...args: any[]) => void) => void;
};
type ControlMessage =
| { type: "sync"; clientIds: string[] }
| { type: "client_connected"; clientId: string }
| { type: "client_disconnected"; clientId: string };
function tryParseControlMessage(raw: unknown): ControlMessage | null {
try {
const text =
typeof raw === "string" ? raw : Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw);
const parsed = JSON.parse(text) as any;
if (!parsed || typeof parsed !== "object") return null;
if (parsed.type === "sync" && Array.isArray(parsed.clientIds)) {
const clientIds = parsed.clientIds.filter((id: unknown) => typeof id === "string" && id.trim().length > 0);
return { type: "sync", clientIds };
}
if (parsed.type === "client_connected" && typeof parsed.clientId === "string" && parsed.clientId.trim()) {
return { type: "client_connected", clientId: parsed.clientId.trim() };
}
if (parsed.type === "client_disconnected" && typeof parsed.clientId === "string" && parsed.clientId.trim()) {
return { type: "client_disconnected", clientId: parsed.clientId.trim() };
}
return null;
} catch {
return null;
}
}
export function startRelayTransport({
logger,
attachSocket,
@@ -40,9 +67,10 @@ export function startRelayTransport({
const relayLogger = logger.child({ module: "relay-transport" });
let stopped = false;
let ws: WebSocket | null = null;
let controlWs: WebSocket | null = null;
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
let reconnectAttempt = 0;
const dataSockets = new Map<string, WebSocket>(); // clientId -> ws
const stop = async (): Promise<void> => {
stopped = true;
@@ -50,17 +78,25 @@ export function startRelayTransport({
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
}
if (ws) {
if (controlWs) {
try {
controlWs.close();
} catch {
// ignore
}
controlWs = null;
}
for (const ws of dataSockets.values()) {
try {
ws.close();
} catch {
// ignore
}
ws = null;
}
dataSockets.clear();
};
const connect = (): void => {
const connectControl = (): void => {
if (stopped) return;
const url = buildRelayWebSocketUrl({
@@ -69,27 +105,17 @@ export function startRelayTransport({
role: "server",
});
const socket = new WebSocket(url);
ws = socket;
let attached = false;
controlWs = socket;
socket.on("open", () => {
reconnectAttempt = 0;
relayLogger.info({ url }, "relay_connected");
if (attached) return;
attached = true;
if (daemonKeyPair) {
void attachEncryptedSocket(socket, daemonKeyPair, relayLogger, attachSocket);
} else {
void attachSocket(socket);
}
relayLogger.info({ url }, "relay_control_connected");
});
socket.on("close", (code, reason) => {
relayLogger.warn(
{ code, reason: reason?.toString?.(), url },
"relay_disconnected"
"relay_control_disconnected"
);
scheduleReconnect();
});
@@ -98,6 +124,32 @@ export function startRelayTransport({
relayLogger.warn({ err, url }, "relay_error");
// close event will schedule reconnect
});
socket.on("message", (data) => {
const msg = tryParseControlMessage(data);
if (!msg) return;
if (msg.type === "sync") {
for (const clientId of msg.clientIds) {
ensureClientDataSocket(clientId);
}
return;
}
if (msg.type === "client_connected") {
ensureClientDataSocket(msg.clientId);
return;
}
if (msg.type === "client_disconnected") {
const existing = dataSockets.get(msg.clientId);
if (existing) {
try {
existing.close(1001, "Client disconnected");
} catch {
// ignore
}
dataSockets.delete(msg.clientId);
}
}
});
};
const scheduleReconnect = (): void => {
@@ -108,11 +160,53 @@ export function startRelayTransport({
const delayMs = Math.min(30000, 1000 * reconnectAttempt);
reconnectTimeout = setTimeout(() => {
reconnectTimeout = null;
connect();
connectControl();
}, delayMs);
};
connect();
const ensureClientDataSocket = (clientId: string): void => {
if (stopped) return;
if (!clientId) return;
if (dataSockets.has(clientId)) return;
const url = buildRelayWebSocketUrl({
endpoint: relayEndpoint,
serverId,
role: "server",
clientId,
});
const socket = new WebSocket(url);
dataSockets.set(clientId, socket);
let attached = false;
socket.on("open", () => {
relayLogger.info({ url, clientId }, "relay_data_connected");
if (attached) return;
attached = true;
if (daemonKeyPair) {
void attachEncryptedSocket(socket, daemonKeyPair, relayLogger.child({ clientId }), attachSocket);
} else {
void attachSocket(socket);
}
});
socket.on("close", (code, reason) => {
relayLogger.warn(
{ code, reason: reason?.toString?.(), url, clientId },
"relay_data_disconnected"
);
if (dataSockets.get(clientId) === socket) {
dataSockets.delete(clientId);
}
});
socket.on("error", (err) => {
relayLogger.warn({ err, url, clientId }, "relay_data_error");
});
};
connectControl();
return { stop };
}
@@ -71,6 +71,7 @@ export function buildRelayWebSocketUrl(params: {
endpoint: string;
serverId: string;
role: RelayRole;
clientId?: string;
}): string {
const { host, port, isIpv6 } = parseHostPort(params.endpoint);
const protocol = shouldUseSecureWebSocket(port) ? "wss" : "ws";
@@ -78,6 +79,9 @@ export function buildRelayWebSocketUrl(params: {
const url = new URL(`${protocol}://${hostPart}:${port}/ws`);
url.searchParams.set("serverId", params.serverId);
url.searchParams.set("role", params.role);
if (params.clientId) {
url.searchParams.set("clientId", params.clientId);
}
return url.toString();
}