Files
vacuum-wall/webui/static/hoover/websocket.js
T

135 lines
3.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 } from './component.js';
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 (_) {}
};
}
/**
* 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.
*/
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 || '*');
}
for (const s of _wsSubs.values()) {
if (s.unsubscribed) continue;
if (s.topic === '*') {
s.loadFn(s.state);
} else if (topics.some(t => t === s.topic || t === '*')) {
s.loadFn(s.state);
}
}
}
/**
* Subscribe a component to WS topics.
*
* Called by component.js on mount. Returns an unsubscribe function
* called by component.js on unmount.
*
* @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 entry = { componentFn, topic, loadFn, state, unsubscribed: false };
_wsSubs.set(componentFn, entry);
return () => {
entry.unsubscribed = true;
_wsSubs.delete(componentFn);
};
}
/** 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; _wsSubs.delete(handler + ':' + t); });
}
return () => fns.forEach(f => f());
}