Spaces:
Sleeping
Sleeping
| 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<string, boolean>; | |
| pathEnv?: string; | |
| connectedAtMs: number; | |
| }; | |
| type PendingInvoke = { | |
| nodeId: string; | |
| command: string; | |
| resolve: (value: NodeInvokeResult) => void; | |
| reject: (err: Error) => void; | |
| timer: ReturnType<typeof setTimeout>; | |
| }; | |
| export type NodeInvokeResult = { | |
| ok: boolean; | |
| payload?: unknown; | |
| payloadJSON?: string | null; | |
| error?: { code?: string; message?: string } | null; | |
| }; | |
| export class NodeRegistry { | |
| private nodesById = new Map<string, NodeSession>(); | |
| private nodesByConn = new Map<string, string>(); | |
| private pendingInvokes = new Map<string, PendingInvoke>(); | |
| 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<string, boolean> }).permissions === "object" | |
| ? ((connect as { permissions?: Record<string, boolean> }).permissions ?? undefined) | |
| : undefined; | |
| const pathEnv = | |
| typeof (connect as { pathEnv?: string }).pathEnv === "string" | |
| ? (connect as { pathEnv?: string }).pathEnv | |
| : 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, | |
| pathEnv, | |
| 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<NodeInvokeResult> { | |
| 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<NodeInvokeResult>((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); | |
| } | |
| } | |