feat(gateway): add bridge RPC chat history and push

This commit is contained in:
Peter Steinberger
2025-12-14 01:54:44 +00:00
parent dd7be2bfd8
commit dccdc950bf
4 changed files with 688 additions and 5 deletions

View File

@@ -153,6 +153,72 @@ describe("node bridge server", () => {
await server.close();
});
it("handles req/res RPC after authentication", async () => {
let lastRequest: { nodeId?: string; id?: string; method?: string } | null =
null;
const server = await startNodeBridgeServer({
host: "127.0.0.1",
port: 0,
pairingBaseDir: baseDir,
onRequest: async (nodeId, req) => {
lastRequest = { nodeId, id: req.id, method: req.method };
return { ok: true, payloadJSON: JSON.stringify({ ok: true }) };
},
});
const socket = net.connect({ host: "127.0.0.1", port: server.port });
const readLine = createLineReader(socket);
sendLine(socket, {
type: "pair-request",
nodeId: "n3-rpc",
platform: "ios",
});
// Approve the pending request from the gateway side.
let reqId: string | undefined;
for (let i = 0; i < 40; i += 1) {
const list = await listNodePairing(baseDir);
const req = list.pending.find((p) => p.nodeId === "n3-rpc");
if (req) {
reqId = req.requestId;
break;
}
await new Promise((r) => setTimeout(r, 25));
}
expect(reqId).toBeTruthy();
if (!reqId) throw new Error("expected a pending requestId");
await approveNodePairing(reqId, baseDir);
const line1 = JSON.parse(await readLine()) as { type: string };
expect(line1.type).toBe("pair-ok");
const line2 = JSON.parse(await readLine()) as { type: string };
expect(line2.type).toBe("hello-ok");
sendLine(socket, { type: "req", id: "r1", method: "health" });
const res = JSON.parse(await readLine()) as {
type: string;
id?: string;
ok?: boolean;
payloadJSON?: string | null;
error?: unknown;
};
expect(res.type).toBe("res");
expect(res.id).toBe("r1");
expect(res.ok).toBe(true);
expect(res.payloadJSON).toBe(JSON.stringify({ ok: true }));
expect(res.error).toBeUndefined();
expect(lastRequest).toEqual({
nodeId: "n3-rpc",
id: "r1",
method: "health",
});
socket.destroy();
await server.close();
});
it("passes node metadata to onAuthenticated and onDisconnected", async () => {
let lastAuthed: {
nodeId?: string;

View File

@@ -34,6 +34,21 @@ type BridgeEventFrame = {
payloadJSON?: string | null;
};
type BridgeRPCRequestFrame = {
type: "req";
id: string;
method: string;
paramsJSON?: string | null;
};
type BridgeRPCResponseFrame = {
type: "res";
id: string;
ok: boolean;
payloadJSON?: string | null;
error?: { code: string; message: string; details?: unknown } | null;
};
type BridgePingFrame = { type: "ping"; id: string };
type BridgePongFrame = { type: "pong"; id: string };
@@ -60,6 +75,8 @@ type AnyBridgeFrame =
| BridgeHelloFrame
| BridgePairRequestFrame
| BridgeEventFrame
| BridgeRPCRequestFrame
| BridgeRPCResponseFrame
| BridgePingFrame
| BridgePongFrame
| BridgeInvokeRequestFrame
@@ -78,6 +95,11 @@ export type NodeBridgeServer = {
paramsJSON?: string | null;
timeoutMs?: number;
}) => Promise<BridgeInvokeResponseFrame>;
sendEvent: (opts: {
nodeId: string;
event: string;
payloadJSON?: string | null;
}) => void;
listConnected: () => NodeBridgeClientInfo[];
};
@@ -94,6 +116,13 @@ export type NodeBridgeServerOpts = {
port: number; // 0 = ephemeral
pairingBaseDir?: string;
onEvent?: (nodeId: string, evt: BridgeEventFrame) => Promise<void> | void;
onRequest?: (
nodeId: string,
req: BridgeRPCRequestFrame,
) => Promise<
| { ok: true; payloadJSON?: string | null }
| { ok: false; error: { code: string; message: string; details?: unknown } }
>;
onAuthenticated?: (node: NodeBridgeClientInfo) => Promise<void> | void;
onDisconnected?: (node: NodeBridgeClientInfo) => Promise<void> | void;
onPairRequested?: (
@@ -124,6 +153,7 @@ export async function startNodeBridgeServer(
invoke: async () => {
throw new Error("bridge disabled in tests");
},
sendEvent: () => {},
listConnected: () => [],
};
}
@@ -333,6 +363,71 @@ export async function startNodeBridgeServer(
await opts.onEvent?.(nodeId, evt);
};
const handleRequest = async (req: BridgeRPCRequestFrame) => {
if (!isAuthenticated || !nodeId) {
send({
type: "res",
id: String(req.id ?? ""),
ok: false,
error: { code: "UNAUTHORIZED", message: "not authenticated" },
} satisfies BridgeRPCResponseFrame);
return;
}
if (!opts.onRequest) {
send({
type: "res",
id: String(req.id ?? ""),
ok: false,
error: { code: "UNAVAILABLE", message: "RPC not supported" },
} satisfies BridgeRPCResponseFrame);
return;
}
const id = String(req.id ?? "");
const method = String(req.method ?? "");
if (!id || !method) {
send({
type: "res",
id: id || "invalid",
ok: false,
error: { code: "INVALID_REQUEST", message: "id and method required" },
} satisfies BridgeRPCResponseFrame);
return;
}
try {
const result = await opts.onRequest(nodeId, {
type: "req",
id,
method,
paramsJSON: req.paramsJSON ?? null,
});
if (result.ok) {
send({
type: "res",
id,
ok: true,
payloadJSON: result.payloadJSON ?? null,
} satisfies BridgeRPCResponseFrame);
} else {
send({
type: "res",
id,
ok: false,
error: result.error,
} satisfies BridgeRPCResponseFrame);
}
} catch (err) {
send({
type: "res",
id,
ok: false,
error: { code: "UNAVAILABLE", message: String(err) },
} satisfies BridgeRPCResponseFrame);
}
};
socket.on("data", (chunk) => {
buffer += chunk.toString("utf8");
while (true) {
@@ -364,6 +459,9 @@ export async function startNodeBridgeServer(
case "event":
await handleEvent(frame as BridgeEventFrame);
break;
case "req":
await handleRequest(frame as BridgeRPCRequestFrame);
break;
case "ping": {
if (!isAuthenticated) {
sendError("UNAUTHORIZED", "not authenticated");
@@ -395,6 +493,10 @@ export async function startNodeBridgeServer(
sendError("INVALID_REQUEST", "invoke not allowed from node");
break;
}
case "res":
// Direction is node -> gateway only.
sendError("INVALID_REQUEST", "res not allowed from node");
break;
case "pong":
// ignore
break;
@@ -443,6 +545,24 @@ export async function startNodeBridgeServer(
);
},
listConnected: () => [...connections.values()].map((c) => c.nodeInfo),
sendEvent: ({ nodeId, event, payloadJSON }) => {
const normalizedNodeId = String(nodeId ?? "").trim();
const normalizedEvent = String(event ?? "").trim();
if (!normalizedNodeId || !normalizedEvent) return;
const conn = connections.get(normalizedNodeId);
if (!conn) return;
try {
conn.socket.write(
encodeLine({
type: "event",
event: normalizedEvent,
payloadJSON: payloadJSON ?? null,
} satisfies BridgeEventFrame),
);
} catch {
// ignore
}
},
invoke: async ({ nodeId, command, paramsJSON, timeoutMs }) => {
const normalizedNodeId = String(nodeId ?? "").trim();
const normalizedCommand = String(command ?? "").trim();