import http from "node:http"; import { randomUUID } from "node:crypto"; import { fileURLToPath } from "node:url"; import { WebSocketServer } from "ws"; const DEFAULT_SHELL_OPEN_TIMEOUT_MS = 15000; const TELEMETRY_INGEST_ERROR_MESSAGE = "Telemetry ingestion failed"; function parseJsonBody(req) { return new Promise((resolve, reject) => { let raw = ""; req.on("data", (chunk) => { raw += chunk.toString("utf8"); }); req.on("end", () => { try { resolve(raw === "" ? {} : JSON.parse(raw)); } catch (error) { reject(error); } }); req.on("error", reject); }); } function jsonResponse(res, statusCode, body) { res.writeHead(statusCode, { "content-type": "application/json" }); res.end(JSON.stringify(body)); } function trimTrailingSlash(value) { return String(value || "").replace(/\/+$/, ""); } async function parseJsonResponse(response) { const text = await response.text(); if (text === "") { return {}; } try { return JSON.parse(text); } catch { return { error: text }; } } function resolveManagerUrl(options = {}) { return trimTrailingSlash(options.managerUrl || process.env.EDGE_MANAGER_URL || process.env.EDGE_PUBLIC_API_URL || ""); } function resolveAuthMode(options = {}) { if (options.authMode) { return options.authMode; } if (process.env.EDGE_AUTH_MODE) { return process.env.EDGE_AUTH_MODE; } return "strict"; } function resolveSharedSecret(options = {}) { return String(options.sharedSecret ?? process.env.EDGE_BROKER_SHARED_SECRET ?? "").trim(); } function requireSharedSecret(req, res, sharedSecret) { if (sharedSecret === "") { jsonResponse(res, 503, { ok: false, error: "Edge broker shared secret is not configured", shared_secret_required: true, }); return false; } if (req.headers["x-edge-broker-secret"] !== sharedSecret) { jsonResponse(res, 403, { ok: false, error: "Forbidden", shared_secret_required: true, }); return false; } return true; } function parseScopes(value) { if (!Array.isArray(value)) { return []; } return Array.from( new Set( value .map((scope) => String(scope || "").trim().toLowerCase()) .filter(Boolean) ) ); } function eventScopes(message) { switch (message?.type) { case "gateway.telemetry": case "presence.changed": return ["overview", "statistics"]; case "task.updated": return ["tasks", "overview"]; case "log.append": return ["logs"]; case "stats.updated": return ["statistics"]; default: return []; } } function sessionAllowsScopes(sessionRecord, scopes) { const subscriptions = sessionRecord.subscriptions || new Set(); if (subscriptions.has("*")) { return true; } if (!scopes || scopes.length === 0) { return true; } return scopes.some((scope) => subscriptions.has(scope)); } function sendJson(ws, payload) { if (!ws || ws.readyState !== 1) { return false; } ws.send(JSON.stringify(payload)); return true; } function currentTimestamp() { return new Date().toISOString(); } function normalizeErrorMessage(error, fallback = "Connection step failed") { if (error instanceof Error && error.message) { return error.message; } const message = String(error || "").trim(); return message || fallback; } function rejectUpgrade(socket, statusCode, errorCode, message, details = {}) { const statusText = { 400: "Bad Request", 401: "Unauthorized", 403: "Forbidden", 404: "Not Found", 500: "Internal Server Error", 503: "Service Unavailable", }[statusCode] || "WebSocket Upgrade Rejected"; const body = JSON.stringify({ ok: false, error_code: errorCode, message, details, }); socket.end( [ `HTTP/1.1 ${statusCode} ${statusText}`, "content-type: application/json; charset=utf-8", `content-length: ${Buffer.byteLength(body)}`, "connection: close", "", body, ].join("\r\n") ); } export function createBrokerServer(options = {}) { const sharedSecret = resolveSharedSecret(options); const managerUrl = resolveManagerUrl(options); const authMode = resolveAuthMode(options); const commandTimeoutMs = options.commandTimeoutMs ?? 10000; const shellOpenTimeoutMs = options.shellOpenTimeoutMs ?? DEFAULT_SHELL_OPEN_TIMEOUT_MS; const agents = new Map(); const pendingCommands = new Map(); const browserShellSessions = new Map(); const browserStreamSessions = new Map(); const gatewayStreamSessions = new Map(); const inflightGatewaySyncs = new Map(); const managerRequest = async (path, body = {}, method = "POST") => { if (!managerUrl) { throw new Error("Edge manager URL is not configured"); } const response = await fetch(`${managerUrl}${path}`, { method, headers: { "content-type": "application/json", ...(sharedSecret ? { "x-edge-broker-secret": sharedSecret } : {}), }, body: method === "GET" ? undefined : JSON.stringify(body), }); const json = await parseJsonResponse(response); if (!response.ok) { const error = new Error(json?.data?.message || json?.message || json?.error || `HTTP ${response.status}`); error.status = response.status; error.code = json?.data?.error_code || json?.error_code || null; error.details = json?.data?.diagnostics || json?.diagnostics || null; throw error; } return json?.data ?? json; }; const validateAgent = options.validateAgent || (authMode === "stub" ? async ({ gatewayId }) => ({ id: gatewayId, gateway_id: gatewayId, label: `Gateway ${gatewayId}` }) : async ({ gatewayId, token }) => managerRequest(`/edge-agent/internal/gateways/${gatewayId}/validate`, { token })); const validateShellSession = options.validateShellSession || (authMode === "stub" ? async ({ token }) => ({ id: token, gateway_id: 1, reason: "stub", cols: 120, rows: 32 }) : async ({ token }) => managerRequest("/edge-agent/internal/shell-sessions/validate", { token })); const markShellSessionOpened = options.markShellSessionOpened || (authMode === "stub" ? async () => ({}) : async (token, connectionId) => managerRequest("/edge-agent/internal/shell-sessions/opened", { token, connection_id: connectionId, })); const closeShellSession = options.closeShellSession || (authMode === "stub" ? async () => ({}) : async (_id, token, transcript, reason, details = {}) => managerRequest("/edge-agent/internal/shell-sessions/close", { token, transcript, reason, ...details, })); const validateBrowserStream = options.validateBrowserStream || (authMode === "stub" ? async ({ token }) => ({ id: token, gateway_id: 1, scopes: ["overview", "tasks", "logs", "statistics"], }) : async ({ token }) => managerRequest("/edge-agent/internal/browser-streams/validate", { token })); const reportGatewayPresence = options.reportGatewayPresence || (authMode === "stub" ? async () => ({}) : async (gatewayId, { status, connectionId, reason = null, metadata = {} } = {}) => managerRequest(`/edge-agent/internal/gateways/${gatewayId}/presence`, { status, connection_id: connectionId, reason, metadata, })); const requestGatewayBacklog = options.requestGatewayBacklog || (authMode === "stub" ? async () => ({ gateway: {}, dispatch: [] }) : async (gatewayId, payload = {}) => managerRequest(`/edge-agent/internal/gateways/${gatewayId}/backlog`, payload)); const ingestTelemetry = options.ingestTelemetry || (authMode === "stub" ? async (_gatewayId, payload = {}) => payload : async (gatewayId, payload = {}) => managerRequest(`/edge-agent/internal/gateways/${gatewayId}/telemetry`, payload)); const ingestTaskEvent = options.ingestTaskEvent || (authMode === "stub" ? async (_gatewayId, _operationId, payload = {}) => payload : async (gatewayId, operationId, payload = {}) => managerRequest(`/edge-agent/internal/gateways/${gatewayId}/operations/${operationId}/events`, payload)); const ingestTaskResult = options.ingestTaskResult || (authMode === "stub" ? async (_gatewayId, _operationId, payload = {}) => payload : async (gatewayId, operationId, payload = {}) => managerRequest(`/edge-agent/internal/gateways/${gatewayId}/operations/${operationId}/complete`, payload)); const ingestLogEntry = options.ingestLogEntry || (authMode === "stub" ? async (_gatewayId, payload = {}) => payload : async (gatewayId, payload = {}) => managerRequest(`/edge-agent/internal/gateways/${gatewayId}/logs`, payload)); const ingestMachineSignal = options.ingestMachineSignal || (authMode === "stub" ? async (_gatewayId, payload = {}) => payload : async (gatewayId, payload = {}) => managerRequest(`/edge-agent/internal/gateways/${gatewayId}/selfserve/machine-signal`, payload)); const broadcastGatewayEvent = (gatewayId, message) => { const sessionIds = gatewayStreamSessions.get(String(gatewayId)); if (!sessionIds || sessionIds.size === 0) { return; } const allowedScopes = eventScopes(message); for (const sessionId of sessionIds.values()) { const sessionRecord = browserStreamSessions.get(String(sessionId)); if (!sessionRecord) { continue; } if (!sessionAllowsScopes(sessionRecord, allowedScopes)) { continue; } sendJson(sessionRecord.ws, message); } }; const closeBrowserShellSession = async (sessionRecord, reason, details = {}) => { try { await closeShellSession( sessionRecord.session.id, sessionRecord.ws.sessionToken, sessionRecord.transcript, reason, { message: sessionRecord.closedMessage || null, code: sessionRecord.closedCode ?? null, stage: sessionRecord.closedStage || null, broker_connection_id: sessionRecord.agentConnectionId || null, details: sessionRecord.closedDetails || {}, ...details, } ); } catch { // Preserve socket teardown even when the manager callback is unavailable. } }; const clearShellOpenTimer = (sessionRecord) => { if (sessionRecord?.openTimer) { clearTimeout(sessionRecord.openTimer); sessionRecord.openTimer = null; } }; const closeBrowserShellSocket = (sessionRecord, reason, message = null, code = 1000, details = {}) => { sessionRecord.closedReason = reason; sessionRecord.closedMessage = message || null; sessionRecord.closedCode = code; sessionRecord.closedDetails = details && typeof details === "object" ? details : {}; sessionRecord.closedStage = String(sessionRecord.closedDetails.stage || (sessionRecord.opened ? "shell_active" : "shell_open")); clearShellOpenTimer(sessionRecord); if (sessionRecord.ws.readyState >= 2) { return; } sendJson(sessionRecord.ws, { type: "closed", reason, code, ...(message ? { message } : {}), ...(Object.keys(sessionRecord.closedDetails).length ? { details: sessionRecord.closedDetails } : {}), }); sessionRecord.ws.close(code, reason); }; const markGatewayShellSessionsClosed = (gatewayId, reason) => { for (const sessionRecord of browserShellSessions.values()) { if (String(sessionRecord.session.gateway_id) !== String(gatewayId)) { continue; } closeBrowserShellSocket(sessionRecord, reason, "Gateway agent disconnected from the broker.", 1011, { stage: "agent_disconnect", gateway_id: gatewayId, shell_session_id: sessionRecord.session.id, }); } }; const registerGatewayStreamSession = (sessionRecord) => { const gatewayId = String(sessionRecord.session.gateway_id); if (!gatewayStreamSessions.has(gatewayId)) { gatewayStreamSessions.set(gatewayId, new Set()); } gatewayStreamSessions.get(gatewayId).add(String(sessionRecord.session.id)); browserStreamSessions.set(String(sessionRecord.session.id), sessionRecord); }; const removeGatewayStreamSession = (sessionRecord) => { browserStreamSessions.delete(String(sessionRecord.session.id)); const gatewayId = String(sessionRecord.session.gateway_id); const sessionIds = gatewayStreamSessions.get(gatewayId); if (!sessionIds) { return; } sessionIds.delete(String(sessionRecord.session.id)); if (sessionIds.size === 0) { gatewayStreamSessions.delete(gatewayId); } }; const syncGatewayBacklog = async (gatewayId, explicitAgent = null) => { const normalizedGatewayId = String(gatewayId); const agent = explicitAgent || agents.get(normalizedGatewayId); if (!agent || agent.readyState !== 1) { return { queued: false }; } if (inflightGatewaySyncs.has(normalizedGatewayId)) { return inflightGatewaySyncs.get(normalizedGatewayId); } const syncPromise = (async () => { const backlog = await requestGatewayBacklog(normalizedGatewayId, { agent_instance_id: agent.agentInstanceId || null, }); const dispatch = Array.isArray(backlog?.dispatch) ? backlog.dispatch : []; for (const instruction of dispatch) { sendJson(agent, instruction); } return { queued: dispatch.length > 0, dispatch, }; })().finally(() => { inflightGatewaySyncs.delete(normalizedGatewayId); }); inflightGatewaySyncs.set(normalizedGatewayId, syncPromise); return syncPromise; }; const sendAgentWelcome = (ws) => sendJson(ws, { type: "WELCOME", target: "edge-agent", gatewayId: String(ws.gatewayId || ""), connectionId: ws.connectionId || null, agentInstanceId: ws.agentInstanceId || null, serverTime: currentTimestamp(), broker: { authMode, }, }); const sendAgentConnectionProgress = (ws, stage, status, message, details = {}) => sendJson(ws, { type: "CONNECTION_PROGRESS", gatewayId: String(ws.gatewayId || ""), connectionId: ws.connectionId || null, stage, status, message, ...details, serverTime: currentTimestamp(), }); const sendAgentConnectionError = (ws, stage, error, details = {}) => sendJson(ws, { type: "CONNECTION_ERROR", gatewayId: String(ws.gatewayId || ""), connectionId: ws.connectionId || null, stage, status: "failed", message: normalizeErrorMessage(error), error: normalizeErrorMessage(error), ...details, serverTime: currentTimestamp(), }); const server = http.createServer(async (req, res) => { try { const url = new URL(req.url, "http://localhost"); if (req.method === "GET" && url.pathname === "/api/health") { jsonResponse(res, 200, { ok: true, service: "edge-broker", auth_mode: authMode, manager_url_configured: Boolean(managerUrl), shared_secret_configured: Boolean(sharedSecret), agents_connected: agents.size, }); return; } if (req.method === "POST" && url.pathname === "/api/diagnostics/shared-secret") { if (!requireSharedSecret(req, res, sharedSecret)) { return; } jsonResponse(res, 200, { ok: true, shared_secret_required: true, }); return; } if (req.method === "POST" && /^\/api\/gateways\/\d+\/commands$/.test(url.pathname)) { if (!requireSharedSecret(req, res, sharedSecret)) { return; } const gatewayId = url.pathname.split("/")[3]; const agent = agents.get(String(gatewayId)); if (!agent || agent.readyState !== 1) { jsonResponse(res, 503, { error: "Gateway agent is offline" }); return; } const body = await parseJsonBody(req); const commandId = randomUUID(); const promise = new Promise((resolve, reject) => { const timeout = setTimeout(() => { pendingCommands.delete(commandId); reject(new Error("Agent command timed out")); }, commandTimeoutMs); pendingCommands.set(commandId, { resolve, reject, timeout, }); }); sendJson(agent, { type: "COMMAND", commandId, commandType: body.commandType, payload: body.payload || {}, jobId: body.jobId ?? null, }); try { const result = await promise; jsonResponse(res, 200, result); } catch (error) { jsonResponse(res, 504, { ok: false, error: error instanceof Error ? error.message : String(error) }); } return; } if (req.method === "POST" && /^\/api\/gateways\/\d+\/sync$/.test(url.pathname)) { if (!requireSharedSecret(req, res, sharedSecret)) { return; } const gatewayId = url.pathname.split("/")[3]; const result = await syncGatewayBacklog(gatewayId); jsonResponse(res, 200, { ok: true, ...result }); return; } jsonResponse(res, 404, { error: "Not found" }); } catch (error) { jsonResponse(res, 500, { error: error instanceof Error ? error.message : String(error) }); } }); const wss = new WebSocketServer({ noServer: true }); server.on("upgrade", async (req, socket, head) => { const url = new URL(req.url, "http://localhost"); try { if (url.pathname === "/ws/agent") { const gatewayId = String(url.searchParams.get("gatewayId") || ""); const token = String(url.searchParams.get("token") || ""); const agentInstanceId = String(url.searchParams.get("agentInstanceId") || ""); if (gatewayId === "" || token === "") { socket.destroy(); return; } let gatewayInfo; try { gatewayInfo = await validateAgent({ gatewayId, token, headers: req.headers }); } catch (error) { const status = Number(error?.status) === 403 ? 403 : Number(error?.status) === 401 ? 401 : 503; rejectUpgrade(socket, status, error?.code || "agent_validation_failed", "Gateway agent could not be validated.", { stage: "agent_validate", }); return; } wss.handleUpgrade(req, socket, head, (ws) => { const existing = agents.get(gatewayId); if (existing && existing.readyState < 2) { existing.close(); } ws.gatewayId = gatewayId; ws.gatewayInfo = gatewayInfo; ws.agentInstanceId = agentInstanceId || null; ws.connectionId = randomUUID(); agents.set(gatewayId, ws); wss.emit("connection", ws, req); sendAgentWelcome(ws); sendAgentConnectionProgress(ws, "presence", "started", "Reporting broker presence to the edge manager."); reportGatewayPresence(gatewayId, { status: "connected", connectionId: ws.connectionId, metadata: { remote_address: req.socket.remoteAddress || null, agent_instance_id: ws.agentInstanceId, }, }) .then(() => { sendAgentConnectionProgress(ws, "presence", "succeeded", "Broker presence was reported."); }) .catch((error) => { sendAgentConnectionError(ws, "presence", error); }); broadcastGatewayEvent(gatewayId, { type: "presence.changed", gatewayId, status: "connected", connectionId: ws.connectionId, }); sendAgentConnectionProgress(ws, "backlog", "started", "Synchronizing queued gateway work."); syncGatewayBacklog(gatewayId, ws) .then((result) => { sendAgentConnectionProgress(ws, "backlog", "succeeded", "Queued gateway work was synchronized.", { queued: Boolean(result?.queued), dispatchCount: Array.isArray(result?.dispatch) ? result.dispatch.length : 0, }); }) .catch((error) => { sendAgentConnectionError(ws, "backlog", error); }); }); return; } if (url.pathname === "/ws/browser-shell") { const token = String(url.searchParams.get("token") || ""); if (token === "") { rejectUpgrade(socket, 400, "shell_session_token_missing", "Missing shell session token."); return; } let session; try { session = await validateShellSession({ token, headers: req.headers }); } catch (error) { rejectUpgrade(socket, Number(error?.status) === 403 ? 403 : 401, error?.code || "shell_session_invalid", "Shell session could not be validated.", { stage: "shell_session_validate", }); return; } wss.handleUpgrade(req, socket, head, (ws) => { ws.sessionToken = token; ws.sessionInfo = session; const sessionRecord = { ws, session, transcript: "", closedReason: null, closedMessage: null, closedCode: null, closedDetails: {}, closedStage: null, opened: false, openTimer: null, agentConnectionId: null, }; browserShellSessions.set(String(session.id), sessionRecord); wss.emit("connection", ws, req); const agent = agents.get(String(session.gateway_id)); if (agent && agent.readyState === 1) { sessionRecord.agentConnectionId = agent.connectionId || null; sendJson(agent, { type: "OPEN_ROOT_SHELL", payload: { sessionId: String(session.id), reason: session.reason, cols: session.cols ?? session.metadata?.cols ?? null, rows: session.rows ?? session.metadata?.rows ?? null, cwd: session.cwd ?? session.metadata?.cwd ?? null, shellCommand: session.shell_command ?? session.metadata?.shell_command ?? null, shellArgs: session.shell_args ?? session.metadata?.shell_args ?? [], }, }); sessionRecord.openTimer = setTimeout(() => { closeBrowserShellSocket( sessionRecord, "shell_open_timeout", "Gateway agent did not confirm that the shell opened before the broker timeout.", 1011, { stage: "shell_open", timeout_ms: shellOpenTimeoutMs, gateway_id: session.gateway_id, shell_session_id: session.id, } ); }, Math.max(250, shellOpenTimeoutMs)); sessionRecord.openTimer.unref?.(); } else { closeBrowserShellSocket( sessionRecord, "agent_offline", "Gateway agent is not connected to the broker.", 1011, { stage: "agent_lookup", gateway_id: session.gateway_id, shell_session_id: session.id, } ); } }); return; } if (url.pathname === "/ws/browser-gateway-stream") { const token = String(url.searchParams.get("token") || ""); if (token === "") { socket.destroy(); return; } const session = await validateBrowserStream({ token, headers: req.headers }); wss.handleUpgrade(req, socket, head, (ws) => { ws.sessionToken = token; ws.streamSessionInfo = session; const sessionRecord = { ws, session, subscriptions: new Set(parseScopes(session.scopes || ["overview", "tasks", "logs", "statistics"])), }; registerGatewayStreamSession(sessionRecord); sendJson(ws, { type: "gateway.stream.ready", gatewayId: String(session.gateway_id), subscriptions: Array.from(sessionRecord.subscriptions.values()), connected: Boolean(agents.get(String(session.gateway_id))?.readyState === 1), }); wss.emit("connection", ws, req); }); return; } } catch (error) { rejectUpgrade(socket, 500, "websocket_upgrade_failed", "WebSocket upgrade failed."); return; } socket.destroy(); }); wss.on("connection", (ws) => { ws.on("message", async (raw) => { let message; try { message = JSON.parse(raw.toString()); } catch { return; } try { if (ws.gatewayId) { if (message.type === "COMMAND_RESULT") { const pending = pendingCommands.get(message.commandId); if (!pending) { return; } clearTimeout(pending.timeout); pendingCommands.delete(message.commandId); pending.resolve({ ok: Boolean(message.ok), payload: message.payload, error: message.error, }); return; } if (message.type === "TELEMETRY") { const payload = { ...(message.payload || {}), broker_connection_id: ws.connectionId || null, broker_agent_instance_id: ws.agentInstanceId || null, }; let ingested = null; let ingestError = null; try { ingested = await ingestTelemetry(String(ws.gatewayId), payload); } catch { ingestError = TELEMETRY_INGEST_ERROR_MESSAGE; } const fallbackStatistics = { system_metrics: payload?.metadata?.system_metrics || {}, container_health: payload?.metadata?.container_health || {}, }; broadcastGatewayEvent(String(ws.gatewayId), { type: "gateway.telemetry", gatewayId: String(ws.gatewayId), telemetry: payload, gateway: ingested?.gateway || ingested || null, error: ingestError, }); broadcastGatewayEvent(String(ws.gatewayId), { type: "stats.updated", gatewayId: String(ws.gatewayId), statistics: ingested?.statistics || ingested || fallbackStatistics, error: ingestError, }); return; } if (message.type === "TASK_EVENT") { const operationId = Number(message.operationId ?? message.payload?.operation_id ?? 0); if (!Number.isFinite(operationId) || operationId <= 0) { return; } const operation = await ingestTaskEvent(String(ws.gatewayId), operationId, message.payload || {}); broadcastGatewayEvent(String(ws.gatewayId), { type: "task.updated", gatewayId: String(ws.gatewayId), operationId, operation, }); return; } if (message.type === "TASK_RESULT") { const operationId = Number(message.operationId ?? message.payload?.operation_id ?? 0); if (!Number.isFinite(operationId) || operationId <= 0) { return; } const operation = await ingestTaskResult(String(ws.gatewayId), operationId, message.payload || {}); broadcastGatewayEvent(String(ws.gatewayId), { type: "task.updated", gatewayId: String(ws.gatewayId), operationId, operation, }); await syncGatewayBacklog(String(ws.gatewayId), ws).catch(() => {}); return; } if (message.type === "LOG_FRAME") { const logEntry = await ingestLogEntry(String(ws.gatewayId), message.payload || {}); broadcastGatewayEvent(String(ws.gatewayId), { type: "log.append", gatewayId: String(ws.gatewayId), entry: logEntry, }); return; } if (message.type === "MACHINE_SIGNAL") { await ingestMachineSignal(String(ws.gatewayId), message.payload || {}); return; } if (["SHELL_OUTPUT", "SHELL_OPENED", "SHELL_EXIT"].includes(message.type)) { const sessionRecord = browserShellSessions.get(String(message.sessionId)); if (!sessionRecord) { return; } if (message.type === "SHELL_OUTPUT") { sessionRecord.transcript += String(message.data || ""); sendJson(sessionRecord.ws, { type: "output", data: String(message.data || "") }); return; } if (message.type === "SHELL_OPENED") { sessionRecord.opened = true; sessionRecord.agentConnectionId = ws.connectionId || sessionRecord.agentConnectionId || null; clearShellOpenTimer(sessionRecord); await markShellSessionOpened(sessionRecord.ws.sessionToken, ws.connectionId || null).catch(() => {}); sendJson(sessionRecord.ws, { type: "opened" }); return; } if (message.type === "SHELL_EXIT") { const reason = String(message.reason || "agent_exit"); const parsedExitCode = Number(message.code); const exitCode = Number.isFinite(parsedExitCode) ? parsedExitCode : 0; const closeMessage = message.message ? String(message.message) : null; clearShellOpenTimer(sessionRecord); sendJson(sessionRecord.ws, { type: "closed", reason, code: exitCode, ...(closeMessage ? { message: closeMessage } : {}), }); await closeBrowserShellSession(sessionRecord, reason, { message: closeMessage, code: exitCode, stage: sessionRecord.opened ? "shell_active" : "shell_start", broker_connection_id: ws.connectionId || null, }); browserShellSessions.delete(String(message.sessionId)); if (sessionRecord.ws.readyState < 2) { sessionRecord.ws.close(1000, reason); } } } return; } if (ws.sessionInfo) { const sessionId = String(ws.sessionInfo.id); const agent = agents.get(String(ws.sessionInfo.gateway_id)); if (!agent || agent.readyState !== 1) { return; } if (message.type === "input") { sendJson(agent, { type: "SHELL_INPUT", payload: { sessionId, data: String(message.data || ""), }, }); return; } if (message.type === "resize") { sendJson(agent, { type: "RESIZE_ROOT_SHELL", payload: { sessionId, cols: Number(message.cols || 0), rows: Number(message.rows || 0), }, }); return; } if (message.type === "close") { sendJson(agent, { type: "CLOSE_ROOT_SHELL", payload: { sessionId }, }); } return; } if (ws.streamSessionInfo) { const sessionRecord = browserStreamSessions.get(String(ws.streamSessionInfo.id)); if (!sessionRecord) { return; } if (message.type === "SUBSCRIBE") { for (const scope of parseScopes(message.scopes || message.subscriptions || [])) { sessionRecord.subscriptions.add(scope); } sendJson(sessionRecord.ws, { type: "subscribed", subscriptions: Array.from(sessionRecord.subscriptions.values()), }); return; } if (message.type === "UNSUBSCRIBE") { for (const scope of parseScopes(message.scopes || message.subscriptions || [])) { sessionRecord.subscriptions.delete(scope); } sendJson(sessionRecord.ws, { type: "unsubscribed", subscriptions: Array.from(sessionRecord.subscriptions.values()), }); return; } if (message.type === "PING") { sendJson(sessionRecord.ws, { type: "PONG" }); } } } catch { // Ignore stale gateway/session delivery errors without killing the broker process. } }); ws.on("close", async (code, buffer) => { const closeReason = buffer?.toString?.("utf8") || null; if (ws.gatewayId) { if (agents.get(String(ws.gatewayId)) === ws) { agents.delete(String(ws.gatewayId)); } markGatewayShellSessionsClosed(String(ws.gatewayId), "agent_disconnected"); broadcastGatewayEvent(String(ws.gatewayId), { type: "presence.changed", gatewayId: String(ws.gatewayId), status: "disconnected", reason: closeReason || "agent_disconnected", }); reportGatewayPresence(String(ws.gatewayId), { status: "disconnected", connectionId: ws.connectionId || null, reason: closeReason || "agent_disconnected", metadata: { agent_instance_id: ws.agentInstanceId || null, }, }).catch(() => {}); return; } if (ws.sessionInfo) { const sessionId = String(ws.sessionInfo.id); const agent = agents.get(String(ws.sessionInfo.gateway_id)); if (agent && agent.readyState === 1) { sendJson(agent, { type: "CLOSE_ROOT_SHELL", payload: { sessionId }, }); } const sessionRecord = browserShellSessions.get(sessionId); if (sessionRecord) { await closeBrowserShellSession(sessionRecord, sessionRecord.closedReason || "browser_closed", { code, stage: sessionRecord.closedStage || "browser_socket_close", message: sessionRecord.closedMessage || null, }); browserShellSessions.delete(sessionId); } return; } if (ws.streamSessionInfo) { const sessionRecord = browserStreamSessions.get(String(ws.streamSessionInfo.id)); if (sessionRecord) { removeGatewayStreamSession(sessionRecord); } } }); }); return { server, listen(port = Number(process.env.PORT || 4300)) { return new Promise((resolve) => { server.listen(port, () => resolve(server.address())); }); }, close() { return new Promise((resolve, reject) => { for (const agent of agents.values()) { agent.terminate(); } for (const session of browserShellSessions.values()) { session.ws.terminate(); } for (const session of browserStreamSessions.values()) { session.ws.terminate(); } for (const pending of pendingCommands.values()) { clearTimeout(pending.timeout); pending.reject(new Error("Broker shutting down")); } pendingCommands.clear(); wss.close(() => { server.close((error) => { if (error) { reject(error); return; } resolve(); }); }); }); }, state: { agents, browserShellSessions, browserStreamSessions, gatewayStreamSessions, pendingCommands, managerUrl, authMode, }, }; } async function runBrokerFromCli() { const broker = createBrokerServer(); let shuttingDown = false; const shutdown = async (signal) => { if (shuttingDown) { return; } shuttingDown = true; try { await broker.close(); process.exit(0); } catch (error) { console.error(`Failed to shut down broker after ${signal}:`, error); process.exit(1); } }; process.on("SIGINT", () => { void shutdown("SIGINT"); }); process.on("SIGTERM", () => { void shutdown("SIGTERM"); }); const requestedPort = Number(process.env.PORT || 4300); const address = await broker.listen(Number.isFinite(requestedPort) ? requestedPort : 4300); const normalizedPort = typeof address === "object" && address !== null && "port" in address ? address.port : requestedPort; console.log(`TruckWash edge broker listening on ${normalizedPort}`); } if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { runBrokerFromCli().catch((error) => { console.error("TruckWash edge broker failed to start:", error); process.exit(1); }); }