import { WebSocketServer, WebSocket } from "ws"; import jwt from "jsonwebtoken"; import { JWT_SECRET } from "../middleware/auth.js"; let wss = null; let heartbeatTimer = null; const clients = new Set(); const HEARTBEAT_MS = 30_000; function parseAuthFromToken(token) { try { if (!token) return null; const payload = jwt.verify(token, JWT_SECRET); return { userId: payload.userId, username: payload.username, team: payload.team, }; } catch { return null; } } function sendRaw(client, text) { if (client.ws.readyState !== WebSocket.OPEN) return; client.ws.send(text); } function toWireMessage(type, payload = {}) { return JSON.stringify({ type, timestamp: Date.now(), ...payload, }); } function cleanupClient(client) { clients.delete(client); } function parseClientMessage(data) { try { const text = typeof data === "string" ? data : data.toString("utf8"); return JSON.parse(text); } catch { return null; } } function startHeartbeat() { if (heartbeatTimer) return; heartbeatTimer = setInterval(() => { for (const client of clients) { if (!client.isAlive) { try { client.ws.terminate(); } catch { // ignore } cleanupClient(client); continue; } client.isAlive = false; try { client.ws.ping(); } catch { cleanupClient(client); } } }, HEARTBEAT_MS); } export function initWebSocketHub(httpServer) { if (wss) return wss; wss = new WebSocketServer({ server: httpServer, path: "/ws", }); wss.on("connection", (ws) => { const client = { ws, auth: null, isAlive: true, }; clients.add(client); ws.on("pong", () => { client.isAlive = true; }); ws.on("close", () => { cleanupClient(client); }); ws.on("error", () => { cleanupClient(client); }); ws.on("message", (data, isBinary) => { if (isBinary) return; const message = parseClientMessage(data); if (!message || message.type !== "auth") return; client.auth = parseAuthFromToken(message.token); sendRaw( client, toWireMessage("auth-state", { authenticated: Boolean(client.auth), team: client.auth?.team ?? null, }) ); }); sendRaw( client, toWireMessage("welcome", { authenticated: false, team: null, }) ); }); startHeartbeat(); return wss; } export function broadcast(type, payload = {}) { const text = toWireMessage(type, payload); for (const client of clients) { sendRaw(client, text); } } export function broadcastToTeam(team, type, payload = {}) { const text = toWireMessage(type, payload); for (const client of clients) { if (client.auth?.team !== team) continue; sendRaw(client, text); } } export function getConnectedClientCount() { return clients.size; }