feat: relay transport (plaintext)
This commit is contained in:
@@ -274,9 +274,9 @@ export class DaemonClientV2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const targetUrl = this.conversationId
|
const targetUrl = this.conversationId
|
||||||
? `${this.config.url}?conversationId=${encodeURIComponent(
|
? `${this.config.url}${
|
||||||
this.conversationId
|
this.config.url.includes("?") ? "&" : "?"
|
||||||
)}`
|
}conversationId=${encodeURIComponent(this.conversationId)}`
|
||||||
: this.config.url;
|
: this.config.url;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import {
|
|||||||
createConnectionOfferV1,
|
createConnectionOfferV1,
|
||||||
encodeOfferToFragmentUrl,
|
encodeOfferToFragmentUrl,
|
||||||
} from "./connection-offer.js";
|
} from "./connection-offer.js";
|
||||||
|
import { startRelayTransport, type RelayTransportController } from "./relay-transport.js";
|
||||||
import type {
|
import type {
|
||||||
AgentClient,
|
AgentClient,
|
||||||
AgentControlMcpConfig,
|
AgentControlMcpConfig,
|
||||||
@@ -100,6 +101,8 @@ export async function createPaseoDaemon(
|
|||||||
rootLogger: Logger
|
rootLogger: Logger
|
||||||
): Promise<PaseoDaemon> {
|
): Promise<PaseoDaemon> {
|
||||||
const logger = rootLogger.child({ module: "bootstrap" });
|
const logger = rootLogger.child({ module: "bootstrap" });
|
||||||
|
const connectionSessionId = randomUUID();
|
||||||
|
let relayTransport: RelayTransportController | null = null;
|
||||||
|
|
||||||
const agentMcpRoute = config.agentMcpRoute;
|
const agentMcpRoute = config.agentMcpRoute;
|
||||||
const staticDir = config.staticDir;
|
const staticDir = config.staticDir;
|
||||||
@@ -433,12 +436,22 @@ export async function createPaseoDaemon(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const offer = await createConnectionOfferV1({
|
const offer = await createConnectionOfferV1({
|
||||||
sessionId: randomUUID(),
|
sessionId: connectionSessionId,
|
||||||
endpoints,
|
endpoints,
|
||||||
});
|
});
|
||||||
|
|
||||||
const url = encodeOfferToFragmentUrl({ offer, appBaseUrl });
|
const url = encodeOfferToFragmentUrl({ offer, appBaseUrl });
|
||||||
logger.info({ url }, "pairing_offer");
|
logger.info({ url }, "pairing_offer");
|
||||||
|
|
||||||
|
if (relayEnabled) {
|
||||||
|
relayTransport?.stop().catch(() => undefined);
|
||||||
|
relayTransport = startRelayTransport({
|
||||||
|
logger,
|
||||||
|
attachSocket: (ws) => wsServer.attachExternalSocket(ws),
|
||||||
|
relayEndpoint,
|
||||||
|
sessionId: connectionSessionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.info({ path: listenTarget.path }, `Server listening on ${listenTarget.path}`);
|
logger.info({ path: listenTarget.path }, `Server listening on ${listenTarget.path}`);
|
||||||
}
|
}
|
||||||
@@ -465,6 +478,7 @@ export async function createPaseoDaemon(
|
|||||||
await closeAllAgents(logger, agentManager);
|
await closeAllAgents(logger, agentManager);
|
||||||
await shutdownProviders(logger);
|
await shutdownProviders(logger);
|
||||||
terminalManager.killAll();
|
terminalManager.killAll();
|
||||||
|
await relayTransport?.stop().catch(() => undefined);
|
||||||
await wsServer.close();
|
await wsServer.close();
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
httpServer.close(() => resolve());
|
httpServer.close(() => resolve());
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ describe("ConnectionOfferV1 (daemon E2E)", () => {
|
|||||||
const daemon = await createTestPaseoDaemon({
|
const daemon = await createTestPaseoDaemon({
|
||||||
listen: "0.0.0.0",
|
listen: "0.0.0.0",
|
||||||
logger,
|
logger,
|
||||||
|
relayEnabled: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import WebSocket from "ws";
|
||||||
|
import pino from "pino";
|
||||||
|
import { Writable } from "node:stream";
|
||||||
|
|
||||||
|
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||||
|
|
||||||
|
function createCapturingLogger() {
|
||||||
|
const lines: string[] = [];
|
||||||
|
const stream = new Writable({
|
||||||
|
write(chunk, _enc, cb) {
|
||||||
|
lines.push(chunk.toString("utf8"));
|
||||||
|
cb();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const logger = pino({ level: "info" }, stream);
|
||||||
|
return { logger, lines };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOfferUrlFromLogs(lines: string[]): string {
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.includes("pairing_offer")) continue;
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(line) as { msg?: string; url?: string };
|
||||||
|
if (obj.msg === "pairing_offer" && typeof obj.url === "string") {
|
||||||
|
return obj.url;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`pairing_offer log not found. saw ${lines.length} lines`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeOfferFromFragmentUrl(url: string): { sessionId: string } {
|
||||||
|
const marker = "#offer=";
|
||||||
|
const idx = url.indexOf(marker);
|
||||||
|
if (idx === -1) {
|
||||||
|
throw new Error(`missing ${marker} fragment: ${url}`);
|
||||||
|
}
|
||||||
|
const encoded = url.slice(idx + marker.length);
|
||||||
|
const json = Buffer.from(encoded, "base64url").toString("utf8");
|
||||||
|
const offer = JSON.parse(json) as { sessionId?: string };
|
||||||
|
if (!offer.sessionId) throw new Error("offer.sessionId missing");
|
||||||
|
return { sessionId: offer.sessionId };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Relay transport (plaintext) - daemon E2E", () => {
|
||||||
|
test(
|
||||||
|
"daemon connects to relay and client ping/pong works through relay",
|
||||||
|
async () => {
|
||||||
|
process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12";
|
||||||
|
|
||||||
|
const { logger, lines } = createCapturingLogger();
|
||||||
|
const daemon = await createTestPaseoDaemon({
|
||||||
|
listen: "127.0.0.1",
|
||||||
|
logger,
|
||||||
|
relayEnabled: true,
|
||||||
|
relayEndpoint: "relay.paseo.sh:443",
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const offerUrl = parseOfferUrlFromLogs(lines);
|
||||||
|
const { sessionId } = decodeOfferFromFragmentUrl(offerUrl);
|
||||||
|
|
||||||
|
const ws = new WebSocket(
|
||||||
|
`wss://relay.paseo.sh/ws?session=${encodeURIComponent(
|
||||||
|
sessionId
|
||||||
|
)}&role=client`
|
||||||
|
);
|
||||||
|
|
||||||
|
const received = await new Promise<unknown>((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
ws.close();
|
||||||
|
reject(new Error("timed out waiting for pong"));
|
||||||
|
}, 20000);
|
||||||
|
|
||||||
|
ws.on("open", () => {
|
||||||
|
ws.send(JSON.stringify({ type: "ping" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("message", (data) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(data.toString()));
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
} finally {
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("error", (err) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(received).toEqual({ type: "pong" });
|
||||||
|
} finally {
|
||||||
|
await daemon.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
60000
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import WebSocket from "ws";
|
||||||
|
import type pino from "pino";
|
||||||
|
|
||||||
|
type RelayTransportOptions = {
|
||||||
|
logger: pino.Logger;
|
||||||
|
attachSocket: (ws: WebSocket) => Promise<void>;
|
||||||
|
relayEndpoint: string; // "host:port"
|
||||||
|
sessionId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RelayTransportController = {
|
||||||
|
stop: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function startRelayTransport({
|
||||||
|
logger,
|
||||||
|
attachSocket,
|
||||||
|
relayEndpoint,
|
||||||
|
sessionId,
|
||||||
|
}: RelayTransportOptions): RelayTransportController {
|
||||||
|
const relayLogger = logger.child({ module: "relay-transport" });
|
||||||
|
|
||||||
|
let stopped = false;
|
||||||
|
let ws: WebSocket | null = null;
|
||||||
|
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let reconnectAttempt = 0;
|
||||||
|
|
||||||
|
const stop = async (): Promise<void> => {
|
||||||
|
stopped = true;
|
||||||
|
if (reconnectTimeout) {
|
||||||
|
clearTimeout(reconnectTimeout);
|
||||||
|
reconnectTimeout = null;
|
||||||
|
}
|
||||||
|
if (ws) {
|
||||||
|
try {
|
||||||
|
ws.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
ws = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const connect = (): void => {
|
||||||
|
if (stopped) return;
|
||||||
|
|
||||||
|
const url = buildRelayWebSocketUrl(relayEndpoint, sessionId, "server");
|
||||||
|
const socket = new WebSocket(url);
|
||||||
|
ws = socket;
|
||||||
|
|
||||||
|
let attached = false;
|
||||||
|
|
||||||
|
socket.on("open", () => {
|
||||||
|
reconnectAttempt = 0;
|
||||||
|
relayLogger.info({ url }, "relay_connected");
|
||||||
|
|
||||||
|
if (attached) return;
|
||||||
|
attached = true;
|
||||||
|
void attachSocket(socket);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("close", (code, reason) => {
|
||||||
|
relayLogger.warn(
|
||||||
|
{ code, reason: reason?.toString?.(), url },
|
||||||
|
"relay_disconnected"
|
||||||
|
);
|
||||||
|
scheduleReconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("error", (err) => {
|
||||||
|
relayLogger.warn({ err, url }, "relay_error");
|
||||||
|
// close event will schedule reconnect
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleReconnect = (): void => {
|
||||||
|
if (stopped) return;
|
||||||
|
if (reconnectTimeout) return;
|
||||||
|
|
||||||
|
reconnectAttempt += 1;
|
||||||
|
const delayMs = Math.min(30000, 1000 * reconnectAttempt);
|
||||||
|
reconnectTimeout = setTimeout(() => {
|
||||||
|
reconnectTimeout = null;
|
||||||
|
connect();
|
||||||
|
}, delayMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return { stop };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRelayWebSocketUrl(
|
||||||
|
relayEndpoint: string,
|
||||||
|
sessionId: string,
|
||||||
|
role: "server" | "client"
|
||||||
|
): string {
|
||||||
|
const { host, port } = parseHostPort(relayEndpoint);
|
||||||
|
const protocol = port === 443 ? "wss" : "ws";
|
||||||
|
return `${protocol}://${host}:${port}/ws?session=${encodeURIComponent(
|
||||||
|
sessionId
|
||||||
|
)}&role=${role}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHostPort(input: string): { host: string; port: number } {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
|
||||||
|
if (trimmed.startsWith("[")) {
|
||||||
|
const endIdx = trimmed.indexOf("]");
|
||||||
|
if (endIdx === -1) {
|
||||||
|
throw new Error(`Invalid relay endpoint: ${input}`);
|
||||||
|
}
|
||||||
|
const host = trimmed.slice(1, endIdx);
|
||||||
|
const rest = trimmed.slice(endIdx + 1);
|
||||||
|
if (!rest.startsWith(":")) {
|
||||||
|
throw new Error(`Invalid relay endpoint: ${input}`);
|
||||||
|
}
|
||||||
|
const port = Number.parseInt(rest.slice(1), 10);
|
||||||
|
if (!Number.isFinite(port)) {
|
||||||
|
throw new Error(`Invalid relay port: ${input}`);
|
||||||
|
}
|
||||||
|
return { host: `[${host}]`, port };
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = trimmed.lastIndexOf(":");
|
||||||
|
if (idx === -1) {
|
||||||
|
throw new Error(`Invalid relay endpoint (expected host:port): ${input}`);
|
||||||
|
}
|
||||||
|
const host = trimmed.slice(0, idx);
|
||||||
|
const port = Number.parseInt(trimmed.slice(idx + 1), 10);
|
||||||
|
if (!host || !Number.isFinite(port)) {
|
||||||
|
throw new Error(`Invalid relay endpoint: ${input}`);
|
||||||
|
}
|
||||||
|
return { host, port };
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ type TestPaseoDaemonOptions = {
|
|||||||
corsAllowedOrigins?: string[];
|
corsAllowedOrigins?: string[];
|
||||||
listen?: string;
|
listen?: string;
|
||||||
logger?: Parameters<typeof createPaseoDaemon>[1];
|
logger?: Parameters<typeof createPaseoDaemon>[1];
|
||||||
|
relayEnabled?: boolean;
|
||||||
|
relayEndpoint?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TestPaseoDaemon = {
|
export type TestPaseoDaemon = {
|
||||||
@@ -63,8 +65,8 @@ export async function createTestPaseoDaemon(
|
|||||||
agentControlMcp: {
|
agentControlMcp: {
|
||||||
url: `http://127.0.0.1:${port}/mcp/agents`,
|
url: `http://127.0.0.1:${port}/mcp/agents`,
|
||||||
},
|
},
|
||||||
relayEnabled: true,
|
relayEnabled: options.relayEnabled ?? false,
|
||||||
relayEndpoint: "relay.paseo.sh:443",
|
relayEndpoint: options.relayEndpoint ?? "relay.paseo.sh:443",
|
||||||
appBaseUrl: "https://app.paseo.sh",
|
appBaseUrl: "https://app.paseo.sh",
|
||||||
openai: openaiApiKey ? { apiKey: openaiApiKey } : undefined,
|
openai: openaiApiKey ? { apiKey: openaiApiKey } : undefined,
|
||||||
downloadTokenTtlMs: options.downloadTokenTtlMs,
|
downloadTokenTtlMs: options.downloadTokenTtlMs,
|
||||||
|
|||||||
@@ -75,9 +75,17 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
this.bridge.broadcast(message);
|
this.bridge.broadcast(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async attachExternalSocket(ws: Parameters<WebSocketSessionBridge["attach"]>[0]): Promise<void> {
|
||||||
|
const fakeRequest = {
|
||||||
|
url: "/ws",
|
||||||
|
headers: {},
|
||||||
|
method: "GET",
|
||||||
|
} as unknown as Parameters<WebSocketSessionBridge["attach"]>[1];
|
||||||
|
await this.bridge.attach(ws, fakeRequest);
|
||||||
|
}
|
||||||
|
|
||||||
public async close(): Promise<void> {
|
public async close(): Promise<void> {
|
||||||
await this.bridge.closeAll();
|
await this.bridge.closeAll();
|
||||||
this.wss.close();
|
this.wss.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user