/** * Hoover — websocket.js * * WebSocket connection manager with auto-reconnect, subscribe/unsubscribe * per component per topic, and version-track messages. * * The _wsSubs Map stores entries keyed by renderer function so that * auto-refresh messages from the backend can trigger page reloads. */ import { setSubscribeFn, isComponentStateMounted, getComponentEntry } from './component.js?v=6'; const _wsSubs = new Map(); let _wsConn = null; let _wsReconnectMs = 0; /** * 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 (_) {} }; } /** Per-state debounce timer (shared across all subscriptions for that state). */ const _wsDebounceTimers = new Map(); /** * Fire the debounced load for a component state. * * Only one load fires per state regardless of how many subscriptions * matched. Passes the mount entry so refactorLoad can toggle * loading / refreshing flags correctly. */ function debouncedLoad(state, entry) { if (!isComponentStateMounted(state)) return; // Abort any in-flight load for this component if (entry && entry.loadAbort) entry.loadAbort.abort(); const ac = new AbortController(); const firstSub = [..._wsSubs.values()] .find(s => !s.unsubscribed && s.state === state); if (firstSub) { firstSub.loadFn(state, ac, entry); } } /** * Debounce helper: coalesces all matching subscriptions for the same * component state into a single reload, keyed by state object. */ function scheduleReload(state) { if (_wsDebounceTimers.has(state)) { clearTimeout(_wsDebounceTimers.get(state)); } _wsDebounceTimers.set(state, setTimeout(() => { _wsDebounceTimers.delete(state); const entry = getComponentEntry(state); debouncedLoad(state, entry); }, 300)); } /** * Route an incoming WS message to subscribed components. * * Expected message shapes: * { type: 'versions', updated: ['firewall', 'dnsmasq', …] } * { type: 'notify', topic: 'firewall' } * { type: 'status', topic: 'firewall', … } * * Components subscribed to wildcard ('*') match every topic. * * Uses per-component-state debouncing (300ms) to prevent a burst of WS * messages or multiple matching topics from triggering overlapping * loads. All subscriptions that share the same state object are * coalesced into a single debounced reload. */ 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 || '*'); } // Track which states have already been scheduled to avoid // double-scheduling when multiple subscriptions of the same // component match the same message. const scheduled = new Set(); for (const s of _wsSubs.values()) { if (s.unsubscribed || !isComponentStateMounted(s.state)) continue; const matched = s.topic === '*' || topics.some(t => t === s.topic || t === '*'); if (!matched) continue; if (scheduled.has(s.state)) continue; scheduled.add(s.state); scheduleReload(s.state); } } /** * Subscribe a component to WS topics. * * Called by component.js on mount. Returns an unsubscribe function * called by component.js on unmount. * * Key is `componentFn + ':' + topic` so a component can subscribe to * multiple topics without overwriting previous subscriptions. * * @param {function} componentFn – The page renderer function (used as map key) * @param {string} topic – Topic to listen for ('*' = all) * @param {function} loadFn – Function to call when topic updates * @param {object} state – Reactive state passed to loadFn * @returns {function} unsubscribe */ function subscribe(componentFn, topic, loadFn, state) { const key = componentFn + ':' + topic; const entry = { componentFn, topic, loadFn, state, unsubscribed: false }; _wsSubs.set(key, entry); return () => { entry.unsubscribed = true; // Clear per-state debounce timer if this was the last active // subscription for that state const remaining = [..._wsSubs.values()] .some(s => !s.unsubscribed && s.state === entry.state); if (!remaining && _wsDebounceTimers.has(entry.state)) { clearTimeout(_wsDebounceTimers.get(entry.state)); _wsDebounceTimers.delete(entry.state); } _wsSubs.delete(key); }; } /** Register the subscribe function with component.js and kick off connection. */ setSubscribeFn(subscribe); /** Start the WebSocket connection. */ export function connect() { _wsConnect(); } /** * Public subscribe API for direct one-off usage (e.g. from page code). * @param {string|string[]} topics * @param {function} handler * @returns {function} unsubscribe */ export function onMessage(topics, handler) { const tArray = Array.isArray(topics) ? topics : [topics]; const fns = []; for (const t of tArray) { const entry = { componentFn: handler, topic: t, loadFn: handler, state: {}, unsubscribed: false }; _wsSubs.set(handler + ':' + t, entry); fns.push(() => { entry.unsubscribed = true; if (_wsDebounceTimers.has(entry.state)) { clearTimeout(_wsDebounceTimers.get(entry.state)); _wsDebounceTimers.delete(entry.state); } _wsSubs.delete(handler + ':' + t); }); } return () => fns.forEach(f => f()); }