import { randomUUID } from "node:crypto"; import type { GatewayWsClient } from "./server/ws-types.js"; export type NodeSession = { nodeId: string; connId: string; client: GatewayWsClient; displayName?: string; platform?: string; version?: string; coreVersion?: string; uiVersion?: string; deviceFamily?: string; modelIdentifier?: string; remoteIp?: string; caps: string[]; commands: string[]; permissions?: Record; connectedAtMs: number; }; type PendingInvoke = { nodeId: string; command: string; resolve: (value: NodeInvokeResult) => void; reject: (err: Error) => void; timer: ReturnType; }; export type NodeInvokeResult = { ok: boolean; payload?: unknown; payloadJSON?: string | null; error?: { code?: string; message?: string } | null; }; export class NodeRegistry { private nodesById = new Map(); private nodesByConn = new Map(); private pendingInvokes = new Map(); register(client: GatewayWsClient, opts: { remoteIp?: string | undefined }) { const connect = client.connect; const nodeId = connect.device?.id ?? connect.client.id; const caps = Array.isArray(connect.caps) ? connect.caps : []; const commands = Array.isArray((connect as { commands?: string[] }).commands) ? ((connect as { commands?: string[] }).commands ?? []) : []; const permissions = typeof (connect as { permissions?: Record }).permissions === "object" ? ((connect as { permissions?: Record }).permissions ?? undefined) : undefined; const session: NodeSession = { nodeId, connId: client.connId, client, displayName: connect.client.displayName, platform: connect.client.platform, version: connect.client.version, coreVersion: (connect as { coreVersion?: string }).coreVersion, uiVersion: (connect as { uiVersion?: string }).uiVersion, deviceFamily: connect.client.deviceFamily, modelIdentifier: connect.client.modelIdentifier, remoteIp: opts.remoteIp, caps, commands, permissions, connectedAtMs: Date.now(), }; this.nodesById.set(nodeId, session); this.nodesByConn.set(client.connId, nodeId); return session; } unregister(connId: string): string | null { const nodeId = this.nodesByConn.get(connId); if (!nodeId) return null; this.nodesByConn.delete(connId); this.nodesById.delete(nodeId); for (const [id, pending] of this.pendingInvokes.entries()) { if (pending.nodeId !== nodeId) continue; clearTimeout(pending.timer); pending.reject(new Error(`node disconnected (${pending.command})`)); this.pendingInvokes.delete(id); } return nodeId; } listConnected(): NodeSession[] { return [...this.nodesById.values()]; } get(nodeId: string): NodeSession | undefined { return this.nodesById.get(nodeId); } async invoke(params: { nodeId: string; command: string; params?: unknown; timeoutMs?: number; idempotencyKey?: string; }): Promise { const node = this.nodesById.get(params.nodeId); if (!node) { return { ok: false, error: { code: "NOT_CONNECTED", message: "node not connected" }, }; } const requestId = randomUUID(); const payload = { id: requestId, nodeId: params.nodeId, command: params.command, paramsJSON: "params" in params && params.params !== undefined ? JSON.stringify(params.params) : null, timeoutMs: params.timeoutMs, idempotencyKey: params.idempotencyKey, }; const ok = this.sendEventToSession(node, "node.invoke.request", payload); if (!ok) { return { ok: false, error: { code: "UNAVAILABLE", message: "failed to send invoke to node" }, }; } const timeoutMs = typeof params.timeoutMs === "number" ? params.timeoutMs : 30_000; return await new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pendingInvokes.delete(requestId); resolve({ ok: false, error: { code: "TIMEOUT", message: "node invoke timed out" }, }); }, timeoutMs); this.pendingInvokes.set(requestId, { nodeId: params.nodeId, command: params.command, resolve, reject, timer, }); }); } handleInvokeResult(params: { id: string; nodeId: string; ok: boolean; payload?: unknown; payloadJSON?: string | null; error?: { code?: string; message?: string } | null; }): boolean { const pending = this.pendingInvokes.get(params.id); if (!pending) return false; if (pending.nodeId !== params.nodeId) return false; clearTimeout(pending.timer); this.pendingInvokes.delete(params.id); pending.resolve({ ok: params.ok, payload: params.payload, payloadJSON: params.payloadJSON ?? null, error: params.error ?? null, }); return true; } sendEvent(nodeId: string, event: string, payload?: unknown): boolean { const node = this.nodesById.get(nodeId); if (!node) return false; return this.sendEventToSession(node, event, payload); } private sendEventInternal(node: NodeSession, event: string, payload: unknown): boolean { try { node.client.socket.send( JSON.stringify({ type: "event", event, payload, }), ); return true; } catch { return false; } } private sendEventToSession(node: NodeSession, event: string, payload: unknown): boolean { return this.sendEventInternal(node, event, payload); } }