/** * Hoover — websocket.js * * WebSocket connection manager with auto-reconnect. WS messages are routed * to model-based refresh and direct onMessage handlers. * Page-level subscribe/unsubscribe is replaced by the model layer. */ import { refreshByTopic } from './model.js?v=7'; let _wsConn = null; let _wsReconnectMs = 0; /** Direct onMessage handlers: { topics, handler, unsubscribed }[] */ const _directHandlers = []; /** * Build the WebSocket URL. Supports an override via `window.__WS_URL__` * (useful for proxy setups). Falls back to port 9091 when the current * origin has no port (nginx fronting the WS on a different port). */ function _wsUrl() { if (window.__WS_URL__) return window.__WS_URL__; const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; return proto + '//' + location.host + '/ws'; } /** Attempt a WebSocket connection. */ function _wsConnect() { if (_wsConn && _wsConn.readyState <= 1) return; _wsConn = new WebSocket(_wsUrl()); _wsConn.onopen = () => { _wsReconnectMs = 0; }; _wsConn.onclose = () => { _wsReconnectMs = Math.min(_wsReconnectMs * 2 + 1000, 15000); setTimeout(_wsConnect, _wsReconnectMs); }; _wsConn.onerror = () => { _wsConn.close(); }; _wsConn.onmessage = (ev) => { try { const msg = typeof ev.data === 'string' ? JSON.parse(ev.data) : ev.data; handleMessage(msg); } catch (_) {} }; } /** * Route an incoming WS message to model refresh and direct handlers. * * Expected message shapes: * { type: 'versions', updated: ['firewall', 'dnsmasq', …] } * { type: 'notify', topic: 'firewall' } * { type: 'status', topic: 'firewall', … } */ function handleMessage(msg) { const topics = []; if (msg.type === 'versions' || msg.type === 'refresh') { topics.push(...(msg.updated || msg.topics || [])); } else if (msg.type === 'notify') { topics.push(msg.topic); } else if (msg.type === 'status') { topics.push(msg.topic || '*'); } // Refresh models for each topic for (const topic of topics) { refreshByTopic(topic); } // Notify direct onMessage handlers for (const h of _directHandlers) { if (h.unsubscribed) continue; if (topics.some(t => h.topics.includes(t) || h.topics.includes('*'))) { try { h.handler(msg); } catch (_) {} } } } /** * Public subscribe API for direct one-off usage (e.g. from page code). * Handler receives the raw parsed message when a matching topic arrives. * @param {string|string[]} topics * @param {function} handler * @returns {function} unsubscribe */ export function onMessage(topics, handler) { const tArray = Array.isArray(topics) ? topics : [topics]; const entry = { topics: tArray, handler, unsubscribed: false }; _directHandlers.push(entry); return () => { entry.unsubscribed = true; const idx = _directHandlers.indexOf(entry); if (idx !== -1) _directHandlers.splice(idx, 1); }; } /** Start the WebSocket connection. */ export function connect() { _wsConnect(); }