import type { Server as HttpServer } from 'node:http'; import { WebSocket, WebSocketServer, type VerifyClientCallbackSync } from 'ws'; import { BASE_PATH } from '../../../../shared/basePath.js'; import { handleChatConnection } from '@/modules/websocket/services/chat-websocket.service.js'; import { verifyWebSocketClient } from '@/modules/websocket/services/websocket-auth.service.js'; import { handlePluginWsProxy } from '@/modules/websocket/services/plugin-websocket-proxy.service.js'; import { handleShellConnection } from '@/modules/websocket/services/shell-websocket.service.js'; import { handleDesktopNotificationsConnection } from '@/modules/notifications/index.js'; import type { AuthenticatedWebSocketRequest } from '@/shared/types.js'; type WebSocketServerDependencies = { verifyClient: Parameters[1]; chat: Parameters[2]; shell: Parameters[1]; getPluginPort: Parameters[2]; }; /** * Used by this module's websocket gateway to keep active transports alive and * close half-open connections so their route-specific clients can reconnect. */ export function attachWebSocketHeartbeat( ws: WebSocket, intervalMs = 30_000, scheduler = { setInterval, clearInterval, }, ): () => void { let isAlive = true; let stopped = false; const markAlive = () => { isAlive = true; }; const stopHeartbeat = () => { if (stopped) { return; } stopped = true; scheduler.clearInterval(heartbeat); ws.off('pong', markAlive); ws.off('close', stopHeartbeat); ws.off('error', stopHeartbeat); }; ws.on('pong', markAlive); ws.on('close', stopHeartbeat); ws.on('error', stopHeartbeat); const heartbeat = scheduler.setInterval(() => { if (ws.readyState !== WebSocket.OPEN) { return; } // A socket that did not answer the previous ping is half-open from the // server's perspective. Terminating it emits close and lets clients resume. if (!isAlive) { stopHeartbeat(); ws.terminate(); return; } isAlive = false; try { ws.ping(); } catch { stopHeartbeat(); ws.terminate(); } }, intervalMs); return stopHeartbeat; } /** * Creates and wires the server-wide websocket gateway used for chat, shell, and * plugin proxy routes. Exported through the websocket module for server startup. */ export function createWebSocketServer( server: HttpServer, dependencies: WebSocketServerDependencies ): WebSocketServer { const wss = new WebSocketServer({ server, verifyClient: (( info: Parameters>[0] ) => verifyWebSocketClient(info, dependencies.verifyClient)), }); wss.on('connection', (ws, request) => { attachWebSocketHeartbeat(ws); const incomingRequest = request as AuthenticatedWebSocketRequest; const url = incomingRequest.url ?? '/'; let pathname = new URL(url, 'http://localhost').pathname; // The app is served under BASE_PATH (e.g. /cc), so browser clients connect // to prefixed paths. Bare paths stay accepted for direct consumers. if (BASE_PATH && (pathname === BASE_PATH || pathname.startsWith(`${BASE_PATH}/`))) { pathname = pathname.slice(BASE_PATH.length) || '/'; } if (pathname === '/shell') { handleShellConnection(ws, dependencies.shell); return; } if (pathname === '/ws') { handleChatConnection(ws, incomingRequest, dependencies.chat); return; } if (pathname === '/desktop-notifications') { handleDesktopNotificationsConnection(ws, incomingRequest); return; } if (pathname.startsWith('/plugin-ws/')) { handlePluginWsProxy(ws, pathname, dependencies.getPluginPort); return; } console.log('[WARN] Unknown WebSocket path:', pathname); ws.close(); }); return wss; }