Gateway: discriminated protocol schema + CLI updates

This commit is contained in:
Peter Steinberger
2025-12-09 15:01:13 +01:00
parent 2746efeb25
commit 172ce6c79f
23 changed files with 2001 additions and 477 deletions

View File

@@ -1,10 +1,10 @@
import type { AddressInfo } from "node:net";
import { describe, expect, test } from "vitest";
import { WebSocket } from "ws";
import {
__forceWebChatSnapshotForTests,
startWebChatServer,
stopWebChatServer,
__forceWebChatSnapshotForTests,
__broadcastGatewayEventForTests,
} from "./server.js";
async function getFreePort(): Promise<number> {
@@ -12,80 +12,83 @@ async function getFreePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const server = createServer();
server.listen(0, "127.0.0.1", () => {
const port = (server.address() as any).port as number;
const address = server.address() as AddressInfo;
const port = address.port as number;
server.close((err: Error | null) => (err ? reject(err) : resolve(port)));
});
});
}
function onceMessage<T = any>(ws: WebSocket, filter: (obj: any) => boolean, timeoutMs = 8000) {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("timeout")), timeoutMs);
const closeHandler = (code: number, reason: Buffer) => {
clearTimeout(timer);
ws.off("message", handler);
reject(new Error(`closed ${code}: ${reason.toString()}`));
};
const handler = (data: WebSocket.RawData) => {
const obj = JSON.parse(String(data));
if (filter(obj)) {
clearTimeout(timer);
ws.off("message", handler);
ws.off("close", closeHandler);
resolve(obj as T);
}
};
ws.on("message", handler);
ws.once("close", closeHandler);
});
}
type SnapshotMessage = {
type?: string;
snapshot?: { stateVersion?: { presence?: number } };
};
type SessionMessage = { type?: string };
describe("webchat server", () => {
test("hydrates snapshot to new sockets (offline mock)", { timeout: 8000 }, async () => {
const wPort = await getFreePort();
await startWebChatServer(wPort, undefined, { disableGateway: true });
const ws = new WebSocket(`ws://127.0.0.1:${wPort}/webchat/socket?session=test`);
const messages: any[] = [];
ws.on("message", (data) => {
try {
messages.push(JSON.parse(String(data)));
} catch {
/* ignore */
}
});
try {
await new Promise<void>((resolve) => ws.once("open", resolve));
__forceWebChatSnapshotForTests({
presence: [],
health: {},
stateVersion: { presence: 1, health: 1 },
uptimeMs: 0,
test(
"hydrates snapshot to new sockets (offline mock)",
{ timeout: 8000 },
async () => {
const wPort = await getFreePort();
await startWebChatServer(wPort, undefined, { disableGateway: true });
const ws = new WebSocket(
`ws://127.0.0.1:${wPort}/webchat/socket?session=test`,
);
const messages: unknown[] = [];
ws.on("message", (data) => {
try {
messages.push(JSON.parse(String(data)));
} catch {
/* ignore */
}
});
const waitFor = async (pred: (m: any) => boolean, label: string) => {
const start = Date.now();
while (Date.now() - start < 3000) {
const found = messages.find((m) => {
try {
return pred(m);
} catch {
return false;
}
});
if (found) return found;
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error(`timeout waiting for ${label}`);
};
try {
await new Promise<void>((resolve) => ws.once("open", resolve));
await waitFor((m) => m?.type === "session", "session");
const snap = await waitFor((m) => m?.type === "gateway-snapshot", "snapshot");
expect(snap.snapshot?.stateVersion?.presence).toBe(1);
} finally {
ws.close();
await stopWebChatServer();
}
});
__forceWebChatSnapshotForTests({
presence: [],
health: {},
stateVersion: { presence: 1, health: 1 },
uptimeMs: 0,
});
const waitFor = async <T>(
pred: (m: unknown) => m is T,
label: string,
): Promise<T> => {
const start = Date.now();
while (Date.now() - start < 3000) {
const found = messages.find((m): m is T => {
try {
return pred(m);
} catch {
return false;
}
});
if (found) return found;
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error(`timeout waiting for ${label}`);
};
const isSessionMessage = (m: unknown): m is SessionMessage =>
typeof m === "object" &&
m !== null &&
(m as SessionMessage).type === "session";
const isSnapshotMessage = (m: unknown): m is SnapshotMessage =>
typeof m === "object" &&
m !== null &&
(m as SnapshotMessage).type === "gateway-snapshot";
await waitFor(isSessionMessage, "session");
const snap = await waitFor(isSnapshotMessage, "snapshot");
expect(snap.snapshot?.stateVersion?.presence).toBe(1);
} finally {
ws.close();
await stopWebChatServer();
}
},
);
});

View File

@@ -1,19 +1,18 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { type WebSocket, WebSocketServer } from "ws";
import { loadConfig } from "../config/config.js";
import {
loadSessionStore,
resolveStorePath,
type SessionEntry,
} from "../config/sessions.js";
import { logDebug, logError } from "../logger.js";
import { GatewayClient } from "../gateway/client.js";
import { randomUUID } from "node:crypto";
import { logDebug, logError } from "../logger.js";
const WEBCHAT_DEFAULT_PORT = 18788;
@@ -338,10 +337,20 @@ export async function startWebChatServer(
gatewayReady = true;
latestSnapshot = hello.snapshot as Record<string, unknown>;
latestPolicy = hello.policy as Record<string, unknown>;
broadcastAll({ type: "gateway-snapshot", snapshot: hello.snapshot, policy: hello.policy });
broadcastAll({
type: "gateway-snapshot",
snapshot: hello.snapshot,
policy: hello.policy,
});
},
onEvent: (evt) => {
broadcastAll({ type: "gateway-event", event: evt.event, payload: evt.payload, seq: evt.seq, stateVersion: evt.stateVersion });
broadcastAll({
type: "gateway-event",
event: evt.event,
payload: evt.payload,
seq: evt.seq,
stateVersion: evt.stateVersion,
});
},
onClose: () => {
gatewayReady = false;
@@ -517,10 +526,17 @@ export function __forceWebChatSnapshotForTests(
latestSnapshot = snapshot;
latestPolicy = policy ?? null;
gatewayReady = true;
broadcastAll({ type: "gateway-snapshot", snapshot: latestSnapshot, policy: latestPolicy });
broadcastAll({
type: "gateway-snapshot",
snapshot: latestSnapshot,
policy: latestPolicy,
});
}
export function __broadcastGatewayEventForTests(event: string, payload: unknown) {
export function __broadcastGatewayEventForTests(
event: string,
payload: unknown,
) {
broadcastAll({ type: "gateway-event", event, payload });
}