feat(gateway): add bridge RPC chat history and push
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user