Gateway: finalize WS control plane

This commit is contained in:
Peter Steinberger
2025-12-09 14:41:41 +01:00
parent 9ef1545d06
commit b2e7fb01a9
23 changed files with 5209 additions and 2495 deletions

70
src/gateway/call.ts Normal file
View File

@@ -0,0 +1,70 @@
import { randomUUID } from "node:crypto";
import { GatewayClient } from "./client.js";
export type CallGatewayOptions = {
url?: string;
token?: string;
method: string;
params?: unknown;
expectFinal?: boolean;
timeoutMs?: number;
clientName?: string;
clientVersion?: string;
platform?: string;
mode?: string;
instanceId?: string;
minProtocol?: number;
maxProtocol?: number;
};
export async function callGateway<T = unknown>(opts: CallGatewayOptions): Promise<T> {
const timeoutMs = opts.timeoutMs ?? 10_000;
return await new Promise<T>((resolve, reject) => {
let settled = false;
const stop = (err?: Error, value?: T) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (err) reject(err);
else resolve(value as T);
};
const client = new GatewayClient({
url: opts.url,
token: opts.token,
instanceId: opts.instanceId ?? randomUUID(),
clientName: opts.clientName ?? "cli",
clientVersion: opts.clientVersion ?? "dev",
platform: opts.platform,
mode: opts.mode ?? "cli",
minProtocol: opts.minProtocol ?? 1,
maxProtocol: opts.maxProtocol ?? 1,
onHelloOk: async () => {
try {
const result = await client.request<T>(opts.method, opts.params, {
expectFinal: opts.expectFinal,
});
client.stop();
stop(undefined, result);
} catch (err) {
client.stop();
stop(err as Error);
}
},
onClose: (code, reason) => {
stop(new Error(`gateway closed (${code}): ${reason}`));
},
});
const timer = setTimeout(() => {
client.stop();
stop(new Error("gateway timeout"));
}, timeoutMs);
client.start();
});
}
export function randomIdempotencyKey() {
return randomUUID();
}

173
src/gateway/client.ts Normal file
View File

@@ -0,0 +1,173 @@
import { randomUUID } from "node:crypto";
import { WebSocket } from "ws";
import { logDebug, logError } from "../logger.js";
import {
type EventFrame,
type Hello,
type HelloOk,
type RequestFrame,
validateRequestFrame,
} from "./protocol/index.js";
type Pending = {
resolve: (value: any) => void;
reject: (err: Error) => void;
expectFinal: boolean;
};
export type GatewayClientOptions = {
url?: string; // ws://127.0.0.1:18789
token?: string;
instanceId?: string;
clientName?: string;
clientVersion?: string;
platform?: string;
mode?: string;
minProtocol?: number;
maxProtocol?: number;
onEvent?: (evt: EventFrame) => void;
onHelloOk?: (hello: HelloOk) => void;
onClose?: (code: number, reason: string) => void;
onGap?: (info: { expected: number; received: number }) => void;
};
export class GatewayClient {
private ws: WebSocket | null = null;
private opts: GatewayClientOptions;
private pending = new Map<string, Pending>();
private backoffMs = 1000;
private closed = false;
private lastSeq: number | null = null;
constructor(opts: GatewayClientOptions) {
this.opts = opts;
}
start() {
if (this.closed) return;
const url = this.opts.url ?? "ws://127.0.0.1:18789";
this.ws = new WebSocket(url, { maxPayload: 512 * 1024 });
this.ws.on("open", () => this.sendHello());
this.ws.on("message", (data) => this.handleMessage(data.toString()));
this.ws.on("close", (code, reason) => {
this.ws = null;
this.flushPendingErrors(
new Error(`gateway closed (${code}): ${reason.toString()}`),
);
this.scheduleReconnect();
this.opts.onClose?.(code, reason.toString());
});
this.ws.on("error", (err) => {
logDebug(`gateway client error: ${String(err)}`);
});
}
stop() {
this.closed = true;
this.ws?.close();
this.ws = null;
this.flushPendingErrors(new Error("gateway client stopped"));
}
private sendHello() {
const hello: Hello = {
type: "hello",
minProtocol: this.opts.minProtocol ?? 1,
maxProtocol: this.opts.maxProtocol ?? 1,
client: {
name: this.opts.clientName ?? "webchat-backend",
version: this.opts.clientVersion ?? "dev",
platform: this.opts.platform ?? process.platform,
mode: this.opts.mode ?? "backend",
instanceId: this.opts.instanceId,
},
caps: [],
auth: this.opts.token ? { token: this.opts.token } : undefined,
};
this.ws?.send(JSON.stringify(hello));
}
private handleMessage(raw: string) {
try {
const parsed = JSON.parse(raw);
if (parsed?.type === "hello-ok") {
this.backoffMs = 1000;
this.opts.onHelloOk?.(parsed as HelloOk);
return;
}
if (parsed?.type === "hello-error") {
logError(`gateway hello-error: ${parsed.reason}`);
this.ws?.close(1008, "hello-error");
return;
}
if (parsed?.type === "event") {
const evt = parsed as EventFrame;
const seq = typeof evt.seq === "number" ? evt.seq : null;
if (seq !== null) {
if (this.lastSeq !== null && seq > this.lastSeq + 1) {
this.opts.onGap?.({ expected: this.lastSeq + 1, received: seq });
}
this.lastSeq = seq;
}
this.opts.onEvent?.(evt);
return;
}
if (parsed?.type === "res") {
const pending = this.pending.get(parsed.id);
if (!pending) return;
// If the payload is an ack with status accepted, keep waiting for final.
const status = parsed.payload?.status;
if (pending.expectFinal && status === "accepted") {
return;
}
this.pending.delete(parsed.id);
if (parsed.ok) pending.resolve(parsed.payload);
else pending.reject(new Error(parsed.error?.message ?? "unknown error"));
}
} catch (err) {
logDebug(`gateway client parse error: ${String(err)}`);
}
}
private scheduleReconnect() {
if (this.closed) return;
const delay = this.backoffMs;
this.backoffMs = Math.min(this.backoffMs * 2, 30_000);
setTimeout(() => this.start(), delay).unref();
}
private flushPendingErrors(err: Error) {
for (const [, p] of this.pending) {
p.reject(err);
}
this.pending.clear();
}
async request<T = unknown>(
method: string,
params?: unknown,
opts?: { expectFinal?: boolean },
): Promise<T> {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
throw new Error("gateway not connected");
}
const id = randomUUID();
const frame: RequestFrame = { type: "req", id, method, params };
if (!validateRequestFrame(frame)) {
throw new Error(
`invalid request frame: ${JSON.stringify(
validateRequestFrame.errors,
null,
2,
)}`,
);
}
const expectFinal = opts?.expectFinal === true;
const p = new Promise<T>((resolve, reject) => {
this.pending.set(id, { resolve, reject, expectFinal });
});
this.ws.send(JSON.stringify(frame));
return p;
}
}

View File

@@ -0,0 +1,79 @@
import AjvPkg, { type ErrorObject } from "ajv";
import {
AgentEventSchema,
AgentParamsSchema,
ErrorCodes,
ErrorShapeSchema,
EventFrameSchema,
HelloErrorSchema,
HelloOkSchema,
HelloSchema,
PresenceEntrySchema,
ProtocolSchemas,
RequestFrameSchema,
ResponseFrameSchema,
SendParamsSchema,
SnapshotSchema,
StateVersionSchema,
errorShape,
type AgentEvent,
type ErrorShape,
type EventFrame,
type Hello,
type HelloError,
type HelloOk,
type PresenceEntry,
type RequestFrame,
type ResponseFrame,
type Snapshot,
type StateVersion,
} from "./schema.js";
const ajv = new (AjvPkg as unknown as new (opts?: object) => import("ajv").default)({
allErrors: true,
strict: false,
removeAdditional: false,
});
export const validateHello = ajv.compile<Hello>(HelloSchema);
export const validateRequestFrame = ajv.compile<RequestFrame>(RequestFrameSchema);
export const validateSendParams = ajv.compile(SendParamsSchema);
export const validateAgentParams = ajv.compile(AgentParamsSchema);
export function formatValidationErrors(errors: ErrorObject[] | null | undefined) {
if (!errors) return "unknown validation error";
return ajv.errorsText(errors, { separator: "; " });
}
export {
HelloSchema,
HelloOkSchema,
HelloErrorSchema,
RequestFrameSchema,
ResponseFrameSchema,
EventFrameSchema,
PresenceEntrySchema,
SnapshotSchema,
ErrorShapeSchema,
StateVersionSchema,
AgentEventSchema,
SendParamsSchema,
AgentParamsSchema,
ProtocolSchemas,
ErrorCodes,
errorShape,
};
export type {
Hello,
HelloOk,
HelloError,
RequestFrame,
ResponseFrame,
EventFrame,
PresenceEntry,
Snapshot,
ErrorShape,
StateVersion,
AgentEvent,
};

View File

@@ -0,0 +1,239 @@
import { Type, type Static, type TSchema } from "@sinclair/typebox";
const NonEmptyString = Type.String({ minLength: 1 });
export const PresenceEntrySchema = Type.Object(
{
host: Type.Optional(NonEmptyString),
ip: Type.Optional(NonEmptyString),
version: Type.Optional(NonEmptyString),
mode: Type.Optional(NonEmptyString),
lastInputSeconds: Type.Optional(Type.Integer({ minimum: 0 })),
reason: Type.Optional(NonEmptyString),
tags: Type.Optional(Type.Array(NonEmptyString)),
text: Type.Optional(Type.String()),
ts: Type.Integer({ minimum: 0 }),
instanceId: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },
);
export const HealthSnapshotSchema = Type.Any();
export const StateVersionSchema = Type.Object(
{
presence: Type.Integer({ minimum: 0 }),
health: Type.Integer({ minimum: 0 }),
},
{ additionalProperties: false },
);
export const SnapshotSchema = Type.Object(
{
presence: Type.Array(PresenceEntrySchema),
health: HealthSnapshotSchema,
stateVersion: StateVersionSchema,
uptimeMs: Type.Integer({ minimum: 0 }),
},
{ additionalProperties: false },
);
export const HelloSchema = Type.Object(
{
type: Type.Literal("hello"),
minProtocol: Type.Integer({ minimum: 1 }),
maxProtocol: Type.Integer({ minimum: 1 }),
client: Type.Object(
{
name: NonEmptyString,
version: NonEmptyString,
platform: NonEmptyString,
mode: NonEmptyString,
instanceId: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },
),
caps: Type.Optional(Type.Array(NonEmptyString, { default: [] })),
auth: Type.Optional(
Type.Object(
{
token: Type.Optional(Type.String()),
},
{ additionalProperties: false },
),
),
locale: Type.Optional(Type.String()),
userAgent: Type.Optional(Type.String()),
},
{ additionalProperties: false },
);
export const HelloOkSchema = Type.Object(
{
type: Type.Literal("hello-ok"),
protocol: Type.Integer({ minimum: 1 }),
server: Type.Object(
{
version: NonEmptyString,
commit: Type.Optional(NonEmptyString),
host: Type.Optional(NonEmptyString),
connId: NonEmptyString,
},
{ additionalProperties: false },
),
features: Type.Object(
{
methods: Type.Array(NonEmptyString),
events: Type.Array(NonEmptyString),
},
{ additionalProperties: false },
),
snapshot: SnapshotSchema,
policy: Type.Object(
{
maxPayload: Type.Integer({ minimum: 1 }),
maxBufferedBytes: Type.Integer({ minimum: 1 }),
tickIntervalMs: Type.Integer({ minimum: 1 }),
},
{ additionalProperties: false },
),
},
{ additionalProperties: false },
);
export const HelloErrorSchema = Type.Object(
{
type: Type.Literal("hello-error"),
reason: NonEmptyString,
expectedProtocol: Type.Optional(Type.Integer({ minimum: 1 })),
minClient: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },
);
export const ErrorShapeSchema = Type.Object(
{
code: NonEmptyString,
message: NonEmptyString,
details: Type.Optional(Type.Unknown()),
retryable: Type.Optional(Type.Boolean()),
retryAfterMs: Type.Optional(Type.Integer({ minimum: 0 })),
},
{ additionalProperties: false },
);
export const RequestFrameSchema = Type.Object(
{
type: Type.Literal("req"),
id: NonEmptyString,
method: NonEmptyString,
params: Type.Optional(Type.Unknown()),
},
{ additionalProperties: false },
);
export const ResponseFrameSchema = Type.Object(
{
type: Type.Literal("res"),
id: NonEmptyString,
ok: Type.Boolean(),
payload: Type.Optional(Type.Unknown()),
error: Type.Optional(ErrorShapeSchema),
},
{ additionalProperties: false },
);
export const EventFrameSchema = Type.Object(
{
type: Type.Literal("event"),
event: NonEmptyString,
payload: Type.Optional(Type.Unknown()),
seq: Type.Optional(Type.Integer({ minimum: 0 })),
stateVersion: Type.Optional(StateVersionSchema),
},
{ additionalProperties: false },
);
export const AgentEventSchema = Type.Object(
{
runId: NonEmptyString,
seq: Type.Integer({ minimum: 0 }),
stream: NonEmptyString,
ts: Type.Integer({ minimum: 0 }),
data: Type.Record(Type.String(), Type.Unknown()),
},
{ additionalProperties: false },
);
export const SendParamsSchema = Type.Object(
{
to: NonEmptyString,
message: NonEmptyString,
mediaUrl: Type.Optional(Type.String()),
provider: Type.Optional(Type.String()),
idempotencyKey: NonEmptyString,
},
{ additionalProperties: false },
);
export const AgentParamsSchema = Type.Object(
{
message: NonEmptyString,
to: Type.Optional(Type.String()),
sessionId: Type.Optional(Type.String()),
thinking: Type.Optional(Type.String()),
deliver: Type.Optional(Type.Boolean()),
timeout: Type.Optional(Type.Integer({ minimum: 0 })),
idempotencyKey: NonEmptyString,
},
{ additionalProperties: false },
);
export const ProtocolSchemas: Record<string, TSchema> = {
Hello: HelloSchema,
HelloOk: HelloOkSchema,
HelloError: HelloErrorSchema,
RequestFrame: RequestFrameSchema,
ResponseFrame: ResponseFrameSchema,
EventFrame: EventFrameSchema,
PresenceEntry: PresenceEntrySchema,
StateVersion: StateVersionSchema,
Snapshot: SnapshotSchema,
ErrorShape: ErrorShapeSchema,
AgentEvent: AgentEventSchema,
SendParams: SendParamsSchema,
AgentParams: AgentParamsSchema,
};
export type Hello = Static<typeof HelloSchema>;
export type HelloOk = Static<typeof HelloOkSchema>;
export type HelloError = Static<typeof HelloErrorSchema>;
export type RequestFrame = Static<typeof RequestFrameSchema>;
export type ResponseFrame = Static<typeof ResponseFrameSchema>;
export type EventFrame = Static<typeof EventFrameSchema>;
export type Snapshot = Static<typeof SnapshotSchema>;
export type PresenceEntry = Static<typeof PresenceEntrySchema>;
export type ErrorShape = Static<typeof ErrorShapeSchema>;
export type StateVersion = Static<typeof StateVersionSchema>;
export type AgentEvent = Static<typeof AgentEventSchema>;
export const ErrorCodes = {
NOT_LINKED: "NOT_LINKED",
AGENT_TIMEOUT: "AGENT_TIMEOUT",
INVALID_REQUEST: "INVALID_REQUEST",
UNAVAILABLE: "UNAVAILABLE",
} as const;
export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];
export function errorShape(
code: ErrorCode,
message: string,
opts?: { details?: unknown; retryable?: boolean; retryAfterMs?: number },
): ErrorShape {
return {
code,
message,
...opts,
};
}

427
src/gateway/server.test.ts Normal file
View File

@@ -0,0 +1,427 @@
import { describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws";
import { AddressInfo, createServer } from "node:net";
import { startGatewayServer } from "./server.js";
import { emitAgentEvent } from "../infra/agent-events.js";
vi.mock("../commands/health.js", () => ({
getHealthSnapshot: vi.fn().mockResolvedValue({ ok: true, stub: true }),
}));
vi.mock("../commands/status.js", () => ({
getStatusSummary: vi.fn().mockResolvedValue({ ok: true }),
}));
vi.mock("../web/outbound.js", () => ({
sendMessageWhatsApp: vi.fn().mockResolvedValue({ messageId: "msg-1", toJid: "jid-1" }),
}));
vi.mock("../commands/agent.js", () => ({
agentCommand: vi.fn().mockResolvedValue(undefined),
}));
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 AddressInfo).port;
server.close((err) => (err ? reject(err) : resolve(port)));
});
});
}
function onceMessage<T = any>(ws: WebSocket, filter: (obj: any) => boolean, timeoutMs = 3000) {
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);
});
}
async function startServerWithClient(token?: string) {
const port = await getFreePort();
const prev = process.env.CLAWDIS_GATEWAY_TOKEN;
if (token === undefined) {
delete process.env.CLAWDIS_GATEWAY_TOKEN;
} else {
process.env.CLAWDIS_GATEWAY_TOKEN = token;
}
const server = await startGatewayServer(port);
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => ws.once("open", resolve));
return { server, ws, port, prevToken: prev };
}
describe("gateway server", () => {
test("rejects protocol mismatch", async () => {
const { server, ws } = await startServerWithClient();
ws.send(
JSON.stringify({
type: "hello",
minProtocol: 2,
maxProtocol: 3,
client: { name: "test", version: "1", platform: "test", mode: "test" },
caps: [],
}),
);
const res = await onceMessage(ws, () => true);
expect(res.type).toBe("hello-error");
expect(res.reason).toContain("protocol mismatch");
ws.close();
await server.close();
});
test("rejects invalid token", async () => {
const { server, ws, prevToken } = await startServerWithClient("secret");
ws.send(
JSON.stringify({
type: "hello",
minProtocol: 1,
maxProtocol: 1,
client: { name: "test", version: "1", platform: "test", mode: "test" },
caps: [],
auth: { token: "wrong" },
}),
);
const res = await onceMessage(ws, () => true);
expect(res.type).toBe("hello-error");
expect(res.reason).toContain("unauthorized");
ws.close();
await server.close();
process.env.CLAWDIS_GATEWAY_TOKEN = prevToken;
});
test("closes silent handshakes after timeout", async () => {
const { server, ws } = await startServerWithClient();
const closed = await new Promise<boolean>((resolve) => {
const timer = setTimeout(() => resolve(false), 4000);
ws.once("close", () => {
clearTimeout(timer);
resolve(true);
});
});
expect(closed).toBe(true);
await server.close();
});
test("hello + health + presence + status succeed", { timeout: 8000 }, async () => {
const { server, ws } = await startServerWithClient();
ws.send(
JSON.stringify({
type: "hello",
minProtocol: 1,
maxProtocol: 1,
client: { name: "test", version: "1.0.0", platform: "test", mode: "test" },
caps: [],
}),
);
await onceMessage(ws, (o) => o.type === "hello-ok");
const healthP = onceMessage(ws, (o) => o.type === "res" && o.id === "health1");
const statusP = onceMessage(ws, (o) => o.type === "res" && o.id === "status1");
const presenceP = onceMessage(ws, (o) => o.type === "res" && o.id === "presence1");
const sendReq = (id: string, method: string) =>
ws.send(JSON.stringify({ type: "req", id, method }));
sendReq("health1", "health");
sendReq("status1", "status");
sendReq("presence1", "system-presence");
const health = await healthP;
const status = await statusP;
const presence = await presenceP;
expect(health.ok).toBe(true);
expect(status.ok).toBe(true);
expect(presence.ok).toBe(true);
expect(Array.isArray(presence.payload)).toBe(true);
ws.close();
await server.close();
});
test("presence events carry seq + stateVersion", { timeout: 8000 }, async () => {
const { server, ws } = await startServerWithClient();
ws.send(
JSON.stringify({
type: "hello",
minProtocol: 1,
maxProtocol: 1,
client: { name: "test", version: "1.0.0", platform: "test", mode: "test" },
caps: [],
}),
);
await onceMessage(ws, (o) => o.type === "hello-ok");
const presenceEventP = onceMessage(ws, (o) => o.type === "event" && o.event === "presence");
ws.send(
JSON.stringify({
type: "req",
id: "evt-1",
method: "system-event",
params: { text: "note from test" },
}),
);
const evt = await presenceEventP;
expect(typeof evt.seq).toBe("number");
expect(evt.stateVersion?.presence).toBeGreaterThan(0);
expect(Array.isArray(evt.payload?.presence)).toBe(true);
ws.close();
await server.close();
});
test("agent events stream with seq", { timeout: 8000 }, async () => {
const { server, ws } = await startServerWithClient();
ws.send(
JSON.stringify({
type: "hello",
minProtocol: 1,
maxProtocol: 1,
client: { name: "test", version: "1.0.0", platform: "test", mode: "test" },
caps: [],
}),
);
await onceMessage(ws, (o) => o.type === "hello-ok");
// Emit a fake agent event directly through the shared emitter.
const evtPromise = onceMessage(ws, (o) => o.type === "event" && o.event === "agent");
emitAgentEvent({ runId: "run-1", stream: "job", data: { msg: "hi" } });
const evt = await evtPromise;
expect(evt.payload.runId).toBe("run-1");
expect(typeof evt.seq).toBe("number");
expect(evt.payload.data.msg).toBe("hi");
ws.close();
await server.close();
});
test("agent ack then final response", { timeout: 8000 }, async () => {
const { server, ws } = await startServerWithClient();
ws.send(
JSON.stringify({
type: "hello",
minProtocol: 1,
maxProtocol: 1,
client: { name: "test", version: "1.0.0", platform: "test", mode: "test" },
caps: [],
}),
);
await onceMessage(ws, (o) => o.type === "hello-ok");
const ackP = onceMessage(ws, (o) => o.type === "res" && o.id === "ag1" && o.payload?.status === "accepted");
const finalP = onceMessage(ws, (o) => o.type === "res" && o.id === "ag1" && o.payload?.status !== "accepted");
ws.send(
JSON.stringify({
type: "req",
id: "ag1",
method: "agent",
params: { message: "hi", idempotencyKey: "idem-ag" },
}),
);
const ack = await ackP;
const final = await finalP;
expect(ack.payload.runId).toBeDefined();
expect(final.payload.runId).toBe(ack.payload.runId);
expect(final.payload.status).toBe("ok");
ws.close();
await server.close();
});
test("agent dedupes by idempotencyKey after completion", { timeout: 8000 }, async () => {
const { server, ws } = await startServerWithClient();
ws.send(
JSON.stringify({
type: "hello",
minProtocol: 1,
maxProtocol: 1,
client: { name: "test", version: "1.0.0", platform: "test", mode: "test" },
caps: [],
}),
);
await onceMessage(ws, (o) => o.type === "hello-ok");
const firstFinalP = onceMessage(ws, (o) => o.type === "res" && o.id === "ag1" && o.payload?.status !== "accepted");
ws.send(
JSON.stringify({
type: "req",
id: "ag1",
method: "agent",
params: { message: "hi", idempotencyKey: "same-agent" },
}),
);
const firstFinal = await firstFinalP;
const secondP = onceMessage(ws, (o) => o.type === "res" && o.id === "ag2");
ws.send(
JSON.stringify({
type: "req",
id: "ag2",
method: "agent",
params: { message: "hi again", idempotencyKey: "same-agent" },
}),
);
const second = await secondP;
expect(second.payload).toEqual(firstFinal.payload);
ws.close();
await server.close();
});
test("shutdown event is broadcast on close", { timeout: 8000 }, async () => {
const { server, ws } = await startServerWithClient();
ws.send(
JSON.stringify({
type: "hello",
minProtocol: 1,
maxProtocol: 1,
client: { name: "test", version: "1.0.0", platform: "test", mode: "test" },
caps: [],
}),
);
await onceMessage(ws, (o) => o.type === "hello-ok");
const shutdownP = onceMessage(ws, (o) => o.type === "event" && o.event === "shutdown", 5000);
await server.close();
const evt = await shutdownP;
expect(evt.payload?.reason).toBeDefined();
});
test("presence broadcast reaches multiple clients", { timeout: 8000 }, async () => {
const port = await getFreePort();
const server = await startGatewayServer(port);
const mkClient = async () => {
const c = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => c.once("open", resolve));
c.send(
JSON.stringify({
type: "hello",
minProtocol: 1,
maxProtocol: 1,
client: { name: "test", version: "1.0.0", platform: "test", mode: "test" },
caps: [],
}),
);
await onceMessage(c, (o) => o.type === "hello-ok");
return c;
};
const clients = await Promise.all([mkClient(), mkClient(), mkClient()]);
const waits = clients.map((c) => onceMessage(c, (o) => o.type === "event" && o.event === "presence"));
clients[0].send(
JSON.stringify({
type: "req",
id: "broadcast",
method: "system-event",
params: { text: "fanout" },
}),
);
const events = await Promise.all(waits);
for (const evt of events) {
expect(evt.payload?.presence?.length).toBeGreaterThan(0);
expect(typeof evt.seq).toBe("number");
}
for (const c of clients) c.close();
await server.close();
});
test("send dedupes by idempotencyKey", { timeout: 8000 }, async () => {
const { server, ws } = await startServerWithClient();
ws.send(
JSON.stringify({
type: "hello",
minProtocol: 1,
maxProtocol: 1,
client: { name: "test", version: "1.0.0", platform: "test", mode: "test" },
caps: [],
}),
);
await onceMessage(ws, (o) => o.type === "hello-ok");
const idem = "same-key";
const res1P = onceMessage(ws, (o) => o.type === "res" && o.id === "a1");
const res2P = onceMessage(ws, (o) => o.type === "res" && o.id === "a2");
const sendReq = (id: string) =>
ws.send(
JSON.stringify({
type: "req",
id,
method: "send",
params: { to: "+15550000000", message: "hi", idempotencyKey: idem },
}),
);
sendReq("a1");
sendReq("a2");
const res1 = await res1P;
const res2 = await res2P;
expect(res1.ok).toBe(true);
expect(res2.ok).toBe(true);
expect(res1.payload).toEqual(res2.payload);
ws.close();
await server.close();
});
test("agent dedupe survives reconnect", { timeout: 15000 }, async () => {
const port = await getFreePort();
const server = await startGatewayServer(port);
const dial = async () => {
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => ws.once("open", resolve));
ws.send(
JSON.stringify({
type: "hello",
minProtocol: 1,
maxProtocol: 1,
client: { name: "test", version: "1.0.0", platform: "test", mode: "test" },
caps: [],
}),
);
await onceMessage(ws, (o) => o.type === "hello-ok");
return ws;
};
const idem = "reconnect-agent";
const ws1 = await dial();
const final1P = onceMessage(ws1, (o) => o.type === "res" && o.id === "ag1" && o.payload?.status !== "accepted", 6000);
ws1.send(
JSON.stringify({
type: "req",
id: "ag1",
method: "agent",
params: { message: "hi", idempotencyKey: idem },
}),
);
const final1 = await final1P;
ws1.close();
const ws2 = await dial();
const final2P = onceMessage(ws2, (o) => o.type === "res" && o.id === "ag2", 6000);
ws2.send(
JSON.stringify({
type: "req",
id: "ag2",
method: "agent",
params: { message: "hi again", idempotencyKey: idem },
}),
);
const res = await final2P;
expect(res.payload).toEqual(final1.payload);
ws2.close();
await server.close();
});
});

479
src/gateway/server.ts Normal file
View File

@@ -0,0 +1,479 @@
import os from "node:os";
import { randomUUID } from "node:crypto";
import { WebSocketServer, type WebSocket } from "ws";
import { getHealthSnapshot } from "../commands/health.js";
import { getStatusSummary } from "../commands/status.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
import { listSystemPresence, upsertPresence } from "../infra/system-presence.js";
import { logError } from "../logger.js";
import { defaultRuntime } from "../runtime.js";
import {
ErrorCodes,
type ErrorShape,
type Hello,
type RequestFrame,
type Snapshot,
errorShape,
formatValidationErrors,
validateAgentParams,
validateHello,
validateRequestFrame,
validateSendParams,
} from "./protocol/index.js";
import { sendMessageWhatsApp } from "../web/outbound.js";
import { createDefaultDeps } from "../cli/deps.js";
import { agentCommand } from "../commands/agent.js";
import { onAgentEvent } from "../infra/agent-events.js";
type Client = {
socket: WebSocket;
hello: Hello;
connId: string;
};
const METHODS = [
"health",
"status",
"system-presence",
"system-event",
"set-heartbeats",
"send",
"agent",
];
const EVENTS = ["agent", "presence", "tick", "shutdown"];
export type GatewayServer = {
close: () => Promise<void>;
};
let presenceVersion = 1;
let healthVersion = 1;
let seq = 0;
function buildSnapshot(): Snapshot {
const presence = listSystemPresence();
const uptimeMs = Math.round(process.uptime() * 1000);
// Health is async; caller should await getHealthSnapshot and replace later if needed.
const emptyHealth: unknown = {};
return {
presence,
health: emptyHealth,
stateVersion: { presence: presenceVersion, health: healthVersion },
uptimeMs,
};
}
const MAX_PAYLOAD_BYTES = 512 * 1024; // cap incoming frame size
const MAX_BUFFERED_BYTES = 1.5 * 1024 * 1024; // per-connection send buffer limit
const HANDSHAKE_TIMEOUT_MS = 3000;
const TICK_INTERVAL_MS = 30_000;
const DEDUPE_TTL_MS = 5 * 60_000;
const DEDUPE_MAX = 1000;
const SERVER_PROTO = 1;
type DedupeEntry = { ts: number; ok: boolean; payload?: unknown; error?: ErrorShape };
const dedupe = new Map<string, DedupeEntry>();
const getGatewayToken = () => process.env.CLAWDIS_GATEWAY_TOKEN;
export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
const wss = new WebSocketServer({ port, host: "127.0.0.1", maxPayload: MAX_PAYLOAD_BYTES });
const clients = new Set<Client>();
const broadcast = (
event: string,
payload: unknown,
opts?: { dropIfSlow?: boolean; stateVersion?: { presence?: number; health?: number } },
) => {
const frame = JSON.stringify({
type: "event",
event,
payload,
seq: ++seq,
stateVersion: opts?.stateVersion,
});
for (const c of clients) {
const slow = c.socket.bufferedAmount > MAX_BUFFERED_BYTES;
if (slow && opts?.dropIfSlow) continue;
if (slow) {
try {
c.socket.close(1008, "slow consumer");
} catch {
/* ignore */
}
continue;
}
try {
c.socket.send(frame);
} catch {
/* ignore */
}
}
};
// periodic keepalive
const tickInterval = setInterval(() => {
broadcast("tick", { ts: Date.now() }, { dropIfSlow: true });
}, TICK_INTERVAL_MS);
// dedupe cache cleanup
const dedupeCleanup = setInterval(() => {
const now = Date.now();
for (const [k, v] of dedupe) {
if (now - v.ts > DEDUPE_TTL_MS) dedupe.delete(k);
}
if (dedupe.size > DEDUPE_MAX) {
const entries = [...dedupe.entries()].sort((a, b) => a[1].ts - b[1].ts);
for (let i = 0; i < dedupe.size - DEDUPE_MAX; i++) {
dedupe.delete(entries[i][0]);
}
}
}, 60_000);
const agentUnsub = onAgentEvent((evt) => {
broadcast("agent", evt);
});
wss.on("connection", (socket) => {
let client: Client | null = null;
let closed = false;
const connId = randomUUID();
const deps = createDefaultDeps();
const send = (obj: unknown) => {
try {
socket.send(JSON.stringify(obj));
} catch {
/* ignore */
}
};
const close = () => {
if (closed) return;
closed = true;
clearTimeout(handshakeTimer);
if (client) clients.delete(client);
try {
socket.close(1000);
} catch {
/* ignore */
}
};
socket.once("error", () => close());
socket.once("close", () => {
if (client) {
// mark presence as disconnected
const key = client.hello.client.instanceId || connId;
upsertPresence(key, {
reason: "disconnect",
});
presenceVersion += 1;
broadcast(
"presence",
{ presence: listSystemPresence() },
{
dropIfSlow: true,
stateVersion: { presence: presenceVersion, health: healthVersion },
},
);
}
close();
});
const handshakeTimer = setTimeout(() => {
if (!client) close();
}, HANDSHAKE_TIMEOUT_MS);
socket.on("message", async (data) => {
if (closed) return;
const text = data.toString();
try {
const parsed = JSON.parse(text);
if (!client) {
// Expect hello
if (!validateHello(parsed)) {
send({
type: "hello-error",
reason: `invalid hello: ${formatValidationErrors(validateHello.errors)}`,
});
socket.close(1008, "invalid hello");
close();
return;
}
const hello = parsed as Hello;
// protocol negotiation
const { minProtocol, maxProtocol } = hello;
if (maxProtocol < SERVER_PROTO || minProtocol > SERVER_PROTO) {
send({
type: "hello-error",
reason: "protocol mismatch",
expectedProtocol: SERVER_PROTO,
});
socket.close(1002, "protocol mismatch");
close();
return;
}
// token auth if required
const token = getGatewayToken();
if (token && hello.auth?.token !== token) {
send({
type: "hello-error",
reason: "unauthorized",
});
socket.close(1008, "unauthorized");
close();
return;
}
client = { socket, hello, connId };
clients.add(client);
// synthesize presence entry for this connection
const presenceKey = hello.client.instanceId || connId;
upsertPresence(presenceKey, {
host: os.hostname(),
version:
process.env.CLAWDIS_VERSION ??
process.env.npm_package_version ??
"dev",
mode: hello.client.mode,
instanceId: hello.client.instanceId,
reason: "connect",
});
presenceVersion += 1;
const snapshot = buildSnapshot();
// Fill health asynchronously for snapshot
const health = await getHealthSnapshot();
snapshot.health = health;
snapshot.stateVersion.health = ++healthVersion;
const helloOk = {
type: "hello-ok",
protocol: SERVER_PROTO,
server: {
version: process.env.CLAWDIS_VERSION ?? process.env.npm_package_version ?? "dev",
commit: process.env.GIT_COMMIT,
host: os.hostname(),
connId,
},
features: { methods: METHODS, events: EVENTS },
snapshot,
policy: {
maxPayload: MAX_PAYLOAD_BYTES,
maxBufferedBytes: MAX_BUFFERED_BYTES,
tickIntervalMs: TICK_INTERVAL_MS,
},
};
clearTimeout(handshakeTimer);
send(helloOk);
return;
}
// After handshake, accept only req frames
if (!validateRequestFrame(parsed)) {
send({
type: "res",
id: (parsed as { id?: unknown })?.id ?? "invalid",
ok: false,
error: errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid request frame: ${formatValidationErrors(validateRequestFrame.errors)}`,
),
});
return;
}
const req = parsed as RequestFrame;
const respond = (
ok: boolean,
payload?: unknown,
error?: ErrorShape,
) => send({ type: "res", id: req.id, ok, payload, error });
switch (req.method) {
case "health": {
const health = await getHealthSnapshot();
healthVersion += 1;
respond(true, health, undefined);
break;
}
case "status": {
const status = await getStatusSummary();
respond(true, status, undefined);
break;
}
case "system-presence": {
const presence = listSystemPresence();
respond(true, presence, undefined);
break;
}
case "system-event": {
const text = String((req.params as { text?: unknown } | undefined)?.text ?? "").trim();
if (!text) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "text required"));
break;
}
enqueueSystemEvent(text);
presenceVersion += 1;
broadcast(
"presence",
{ presence: listSystemPresence() },
{
dropIfSlow: true,
stateVersion: { presence: presenceVersion, health: healthVersion },
},
);
respond(true, { ok: true }, undefined);
break;
}
case "set-heartbeats": {
respond(true, { ok: true }, undefined);
break;
}
case "send": {
const p = (req.params ?? {}) as Record<string, unknown>;
if (!validateSendParams(p)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid send params: ${formatValidationErrors(validateSendParams.errors)}`,
),
);
break;
}
const params = p as {
to: string;
message: string;
mediaUrl?: string;
provider?: string;
idempotencyKey: string;
};
const idem = params.idempotencyKey;
const cached = dedupe.get(`send:${idem}`);
if (cached) {
respond(cached.ok, cached.payload, cached.error);
break;
}
const to = params.to.trim();
const message = params.message.trim();
try {
const result = await sendMessageWhatsApp(to, message, {
mediaUrl: params.mediaUrl,
verbose: false,
});
const payload = {
runId: idem,
messageId: result.messageId,
toJid: result.toJid ?? `${to}@s.whatsapp.net`,
};
dedupe.set(`send:${idem}`, { ts: Date.now(), ok: true, payload });
respond(true, payload, undefined);
} catch (err) {
const error = errorShape(ErrorCodes.UNAVAILABLE, String(err));
dedupe.set(`send:${idem}`, { ts: Date.now(), ok: false, error });
respond(false, undefined, error);
}
break;
}
case "agent": {
const p = (req.params ?? {}) as Record<string, unknown>;
if (!validateAgentParams(p)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid agent params: ${formatValidationErrors(validateAgentParams.errors)}`,
),
);
break;
}
const params = p as {
message: string;
to?: string;
sessionId?: string;
thinking?: string;
deliver?: boolean;
idempotencyKey: string;
timeout?: number;
};
const idem = params.idempotencyKey;
const cached = dedupe.get(`agent:${idem}`);
if (cached) {
respond(cached.ok, cached.payload, cached.error);
break;
}
const message = params.message.trim();
const runId = params.sessionId || randomUUID();
const ackPayload = { runId, status: "accepted" as const };
dedupe.set(`agent:${idem}`, { ts: Date.now(), ok: true, payload: ackPayload });
respond(true, ackPayload, undefined); // ack quickly
try {
await agentCommand(
{
message,
to: params.to,
sessionId: params.sessionId,
thinking: params.thinking,
deliver: params.deliver,
timeout: params.timeout?.toString(),
},
defaultRuntime,
deps,
);
const payload = { runId, status: "ok" as const, summary: "completed" };
dedupe.set(`agent:${idem}`, { ts: Date.now(), ok: true, payload });
respond(true, payload, undefined);
} catch (err) {
const error = errorShape(ErrorCodes.UNAVAILABLE, String(err));
const payload = { runId, status: "error" as const, summary: String(err) };
dedupe.set(`agent:${idem}`, { ts: Date.now(), ok: false, payload, error });
respond(false, payload, error);
}
break;
}
default: {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, `unknown method: ${req.method}`));
break;
}
}
} catch (err) {
logError(`gateway: parse/handle error: ${String(err)}`);
// If still in handshake, close; otherwise respond error
if (!client) {
close();
}
}
});
});
defaultRuntime.log(
`gateway listening on ws://127.0.0.1:${port} (PID ${process.pid})`,
);
return {
close: async () => {
broadcast("shutdown", { reason: "gateway stopping", restartExpectedMs: null });
clearInterval(tickInterval);
clearInterval(dedupeCleanup);
if (agentUnsub) {
try {
agentUnsub();
} catch {
/* ignore */
}
}
for (const c of clients) {
try {
c.socket.close(1012, "service restart");
} catch {
/* ignore */
}
}
clients.clear();
await new Promise<void>((resolve) => wss.close(() => resolve()));
},
};
}