feat(gateway): add node.invoke for iOS canvas
This commit is contained in:
@@ -253,4 +253,87 @@ describe("node bridge server", () => {
|
||||
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("supports invoke roundtrip to a connected node", async () => {
|
||||
const server = await startNodeBridgeServer({
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
pairingBaseDir: baseDir,
|
||||
});
|
||||
|
||||
const socket = net.connect({ host: "127.0.0.1", port: server.port });
|
||||
const readLine = createLineReader(socket);
|
||||
sendLine(socket, { type: "pair-request", nodeId: "n5", 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 === "n5");
|
||||
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 pairOk = JSON.parse(await readLine()) as {
|
||||
type: string;
|
||||
token?: string;
|
||||
};
|
||||
expect(pairOk.type).toBe("pair-ok");
|
||||
expect(typeof pairOk.token).toBe("string");
|
||||
if (!pairOk.token) throw new Error("expected pair-ok token");
|
||||
const token = pairOk.token;
|
||||
|
||||
const helloOk = JSON.parse(await readLine()) as { type: string };
|
||||
expect(helloOk.type).toBe("hello-ok");
|
||||
|
||||
const responder = (async () => {
|
||||
while (true) {
|
||||
const frame = JSON.parse(await readLine()) as {
|
||||
type: string;
|
||||
id?: string;
|
||||
command?: string;
|
||||
};
|
||||
if (frame.type !== "invoke") continue;
|
||||
sendLine(socket, {
|
||||
type: "invoke-res",
|
||||
id: frame.id,
|
||||
ok: true,
|
||||
payloadJSON: JSON.stringify({ echo: frame.command }),
|
||||
});
|
||||
break;
|
||||
}
|
||||
})();
|
||||
|
||||
const res = await server.invoke({
|
||||
nodeId: "n5",
|
||||
command: "screen.eval",
|
||||
paramsJSON: JSON.stringify({ javaScript: "1+1" }),
|
||||
timeoutMs: 3000,
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
const payload = JSON.parse(String(res.payloadJSON ?? "null")) as {
|
||||
echo?: string;
|
||||
};
|
||||
expect(payload.echo).toBe("screen.eval");
|
||||
|
||||
await responder;
|
||||
socket.destroy();
|
||||
|
||||
// Ensure invoke works only for connected nodes (hello with token on a new socket).
|
||||
const socket2 = net.connect({ host: "127.0.0.1", port: server.port });
|
||||
const readLine2 = createLineReader(socket2);
|
||||
sendLine(socket2, { type: "hello", nodeId: "n5", token });
|
||||
const hello2 = JSON.parse(await readLine2()) as { type: string };
|
||||
expect(hello2.type).toBe("hello-ok");
|
||||
socket2.destroy();
|
||||
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
|
||||
@@ -36,6 +37,13 @@ type BridgeEventFrame = {
|
||||
type BridgePingFrame = { type: "ping"; id: string };
|
||||
type BridgePongFrame = { type: "pong"; id: string };
|
||||
|
||||
type BridgeInvokeRequestFrame = {
|
||||
type: "invoke";
|
||||
id: string;
|
||||
command: string;
|
||||
paramsJSON?: string | null;
|
||||
};
|
||||
|
||||
type BridgeInvokeResponseFrame = {
|
||||
type: "invoke-res";
|
||||
id: string;
|
||||
@@ -54,6 +62,7 @@ type AnyBridgeFrame =
|
||||
| BridgeEventFrame
|
||||
| BridgePingFrame
|
||||
| BridgePongFrame
|
||||
| BridgeInvokeRequestFrame
|
||||
| BridgeInvokeResponseFrame
|
||||
| BridgeHelloOkFrame
|
||||
| BridgePairOkFrame
|
||||
@@ -63,6 +72,13 @@ type AnyBridgeFrame =
|
||||
export type NodeBridgeServer = {
|
||||
port: number;
|
||||
close: () => Promise<void>;
|
||||
invoke: (opts: {
|
||||
nodeId: string;
|
||||
command: string;
|
||||
paramsJSON?: string | null;
|
||||
timeoutMs?: number;
|
||||
}) => Promise<BridgeInvokeResponseFrame>;
|
||||
listConnected: () => NodeBridgeClientInfo[];
|
||||
};
|
||||
|
||||
export type NodeBridgeClientInfo = {
|
||||
@@ -105,6 +121,10 @@ export async function startNodeBridgeServer(
|
||||
return {
|
||||
port: 0,
|
||||
close: async () => {},
|
||||
invoke: async () => {
|
||||
throw new Error("bridge disabled in tests");
|
||||
},
|
||||
listConnected: () => [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -113,7 +133,20 @@ export async function startNodeBridgeServer(
|
||||
? opts.serverName.trim()
|
||||
: os.hostname();
|
||||
|
||||
const connections = new Map<string, net.Socket>();
|
||||
type ConnectionState = {
|
||||
socket: net.Socket;
|
||||
nodeInfo: NodeBridgeClientInfo;
|
||||
invokeWaiters: Map<
|
||||
string,
|
||||
{
|
||||
resolve: (value: BridgeInvokeResponseFrame) => void;
|
||||
reject: (err: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
const connections = new Map<string, ConnectionState>();
|
||||
|
||||
const server = net.createServer((socket) => {
|
||||
socket.setNoDelay(true);
|
||||
@@ -127,17 +160,22 @@ export async function startNodeBridgeServer(
|
||||
{
|
||||
resolve: (value: BridgeInvokeResponseFrame) => void;
|
||||
reject: (err: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
>();
|
||||
|
||||
const abort = new AbortController();
|
||||
const stop = () => {
|
||||
if (!abort.signal.aborted) abort.abort();
|
||||
if (nodeId) connections.delete(nodeId);
|
||||
for (const [, waiter] of invokeWaiters) {
|
||||
clearTimeout(waiter.timer);
|
||||
waiter.reject(new Error("bridge connection closed"));
|
||||
}
|
||||
invokeWaiters.clear();
|
||||
if (nodeId) {
|
||||
const existing = connections.get(nodeId);
|
||||
if (existing?.socket === socket) connections.delete(nodeId);
|
||||
}
|
||||
};
|
||||
|
||||
const send = (frame: AnyBridgeFrame) => {
|
||||
@@ -182,7 +220,14 @@ export async function startNodeBridgeServer(
|
||||
}
|
||||
|
||||
isAuthenticated = true;
|
||||
connections.set(nodeId, socket);
|
||||
const existing = connections.get(nodeId);
|
||||
if (existing?.socket && existing.socket !== socket) {
|
||||
try {
|
||||
existing.socket.destroy();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
nodeInfo = {
|
||||
nodeId,
|
||||
displayName: verified.node.displayName ?? hello.displayName,
|
||||
@@ -190,6 +235,7 @@ export async function startNodeBridgeServer(
|
||||
version: verified.node.version ?? hello.version,
|
||||
remoteIp: remoteAddress,
|
||||
};
|
||||
connections.set(nodeId, { socket, nodeInfo, invokeWaiters });
|
||||
send({ type: "hello-ok", serverName } satisfies BridgeHelloOkFrame);
|
||||
await opts.onAuthenticated?.(nodeInfo);
|
||||
};
|
||||
@@ -258,7 +304,14 @@ export async function startNodeBridgeServer(
|
||||
}
|
||||
|
||||
isAuthenticated = true;
|
||||
connections.set(nodeId, socket);
|
||||
const existing = connections.get(nodeId);
|
||||
if (existing?.socket && existing.socket !== socket) {
|
||||
try {
|
||||
existing.socket.destroy();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
nodeInfo = {
|
||||
nodeId,
|
||||
displayName: req.displayName,
|
||||
@@ -266,6 +319,7 @@ export async function startNodeBridgeServer(
|
||||
version: req.version,
|
||||
remoteIp: remoteAddress,
|
||||
};
|
||||
connections.set(nodeId, { socket, nodeInfo, invokeWaiters });
|
||||
send({ type: "pair-ok", token: wait.token } satisfies BridgePairOkFrame);
|
||||
send({ type: "hello-ok", serverName } satisfies BridgeHelloOkFrame);
|
||||
await opts.onAuthenticated?.(nodeInfo);
|
||||
@@ -331,10 +385,16 @@ export async function startNodeBridgeServer(
|
||||
const waiter = invokeWaiters.get(res.id);
|
||||
if (waiter) {
|
||||
invokeWaiters.delete(res.id);
|
||||
clearTimeout(waiter.timer);
|
||||
waiter.resolve(res);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "invoke": {
|
||||
// Direction is gateway -> node only.
|
||||
sendError("INVALID_REQUEST", "invoke not allowed from node");
|
||||
break;
|
||||
}
|
||||
case "pong":
|
||||
// ignore
|
||||
break;
|
||||
@@ -372,7 +432,7 @@ export async function startNodeBridgeServer(
|
||||
close: async () => {
|
||||
for (const sock of connections.values()) {
|
||||
try {
|
||||
sock.destroy();
|
||||
sock.socket.destroy();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
@@ -382,5 +442,52 @@ export async function startNodeBridgeServer(
|
||||
server.close((err) => (err ? reject(err) : resolve())),
|
||||
);
|
||||
},
|
||||
listConnected: () => [...connections.values()].map((c) => c.nodeInfo),
|
||||
invoke: async ({ nodeId, command, paramsJSON, timeoutMs }) => {
|
||||
const normalizedNodeId = String(nodeId ?? "").trim();
|
||||
const normalizedCommand = String(command ?? "").trim();
|
||||
if (!normalizedNodeId) {
|
||||
throw new Error("INVALID_REQUEST: nodeId required");
|
||||
}
|
||||
if (!normalizedCommand) {
|
||||
throw new Error("INVALID_REQUEST: command required");
|
||||
}
|
||||
|
||||
const conn = connections.get(normalizedNodeId);
|
||||
if (!conn) {
|
||||
throw new Error(
|
||||
`UNAVAILABLE: node not connected (${normalizedNodeId})`,
|
||||
);
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const timeout = Number.isFinite(timeoutMs) ? Number(timeoutMs) : 15_000;
|
||||
|
||||
return await new Promise<BridgeInvokeResponseFrame>((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => {
|
||||
conn.invokeWaiters.delete(id);
|
||||
reject(new Error("UNAVAILABLE: invoke timeout"));
|
||||
},
|
||||
Math.max(0, timeout),
|
||||
);
|
||||
|
||||
conn.invokeWaiters.set(id, { resolve, reject, timer });
|
||||
try {
|
||||
conn.socket.write(
|
||||
encodeLine({
|
||||
type: "invoke",
|
||||
id,
|
||||
command: normalizedCommand,
|
||||
paramsJSON: paramsJSON ?? null,
|
||||
} satisfies BridgeInvokeRequestFrame),
|
||||
);
|
||||
} catch (err) {
|
||||
conn.invokeWaiters.delete(id);
|
||||
clearTimeout(timer);
|
||||
reject(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user