/** * @file WebSocket functionalities for real-time communication with clients, including connection management, heartbeat for detecting dead connections, and broadcasting messages to all connected clients. * @author Nguyễn Ngọc Trí Vĩ */ const { WebSocketServer } = require("ws"); const { isHostAllowed, isWebSocketAuthorized } = require("./lib/security"); let wss = null; function initWebSocket(server) { // Express middleware doesn't run on WS upgrades, so enforce the same Host // allowlist (anti DNS-rebinding) and optional token here (GHSA-gr74-4xfh-6jw9). wss = new WebSocketServer({ server, path: "/ws", maxPayload: 64 * 1024, verifyClient(info, done) { if (!isHostAllowed(info.req.headers.host)) return done(false, 403, "host not allowed"); if (!isWebSocketAuthorized(info.req)) return done(false, 401, "unauthorized"); return done(true); }, }); wss.on("connection", (ws) => { ws.isAlive = true; ws.on("pong", () => { ws.isAlive = true; }); ws.on("error", (err) => { // Log but don't crash — client disconnects are normal if (err.code !== "ECONNRESET") { console.warn("[WS] client error:", err.code || err.message); } }); }); // Heartbeat every 30s to detect dead connections const interval = setInterval(() => { if (!wss) { clearInterval(interval); return; } wss.clients.forEach((ws) => { if (!ws.isAlive) { ws.terminate(); return; } ws.isAlive = false; ws.ping(); }); }, 30000); interval.unref(); wss.on("close", () => { clearInterval(interval); }); return wss; } let ptyWss = null; const PTY_PATH_RE = /^\/ws-pty\/(ccam-lane-\d+)$/; /** * Second WebSocket server, dedicated to the terminal-run PTY transport * (`/ws-pty/:runId`). Kept separate from the `/ws` JSON-broadcast path so * raw binary PTY frames never have to coexist with the typed * `{type, data, timestamp}` envelope the rest of the app relies on. Reuses * the exact same auth guard as `/ws`. * * `runId` is a path SEGMENT, not a fixed string, so this can't use the `ws` * library's `{server, path}` shorthand (that option only matches an exact * string). Instead this server is created with `noServer: true` and the * upgrade is handled manually — the same `server.on("upgrade", ...)` pattern * `ws` itself uses internally, just filtered to `/ws-pty/*` first so `/ws`'s * own upgrade handling (already registered by `initWebSocket`) is untouched. */ function initPtyWebSocket(server) { const { attach } = require("./lib/pty-attach"); ptyWss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 }); ptyWss.on("connection", (ws, runId) => { try { attach(ws, runId, { cols: 80, rows: 24 }); } catch (err) { try { ws.close(1008, err.message); } catch { /* ignore */ } } }); server.on("upgrade", (req, socket, head) => { const url = new URL(req.url, "http://localhost"); const match = url.pathname.match(PTY_PATH_RE); if (!match) return; // not ours — the `/ws` WebSocketServer (registered // by initWebSocket, also attached to this same http.Server) handles its // own path independently and ignores upgrades it doesn't match too. if (!isHostAllowed(req.headers.host)) { socket.destroy(); return; } if (!isWebSocketAuthorized(req)) { socket.destroy(); return; } ptyWss.handleUpgrade(req, socket, head, (ws) => { ptyWss.emit("connection", ws, match[1]); }); }); return ptyWss; } function broadcast(type, data) { if (!wss) return; const message = JSON.stringify({ type, data, timestamp: new Date().toISOString() }); wss.clients.forEach((client) => { if (client.readyState === 1) { try { client.send(message); } catch { // Client closed between readyState check and send — safe to ignore } } }); } function getConnectionCount() { if (!wss) return 0; let count = 0; wss.clients.forEach((client) => { if (client.readyState === 1) count++; }); return count; } /** * Tear down the WebSocket server for a graceful shutdown. Open WS clients keep * their underlying TCP sockets alive, which prevents http.Server#close() from * ever completing — under `node --watch` that turns every restart into a * multi-second "waiting for graceful termination" stall. Terminating the * clients first lets the HTTP server drain and close promptly. */ function closeWebSocket() { if (wss) { wss.clients.forEach((client) => { try { client.terminate(); } catch { /* already gone */ } }); try { wss.close(); } catch { /* ignore */ } wss = null; } if (ptyWss) { ptyWss.clients.forEach((client) => { try { client.terminate(); } catch { /* already gone */ } }); try { ptyWss.close(); } catch { /* ignore */ } ptyWss = null; } } module.exports = { initWebSocket, initPtyWebSocket, broadcast, getConnectionCount, closeWebSocket };