// Live updates: one multiplexed WebSocket with auto-reconnect and a 15 s // polling fallback signal (ยง2.2). Subscribers register per channel. const subs = new Map(); // channel -> Set let socket = null; let attempts = 0; let fallbackPolling = false; const pollListeners = new Set(); export function subscribe(channel, fn) { if (!subs.has(channel)) subs.set(channel, new Set()); subs.get(channel).add(fn); ensureSocket(); return () => subs.get(channel).delete(fn); } // onPollFallback registers a callback invoked every 15 s while the socket is // down, so pages can refresh their data without WS. export function onPollFallback(fn) { pollListeners.add(fn); } export function send(channel, event, data) { if (socket && socket.readyState === WebSocket.OPEN) { socket.send(JSON.stringify({ channel, event, data })); } } let pollTimer = null; function startPolling() { if (fallbackPolling) return; fallbackPolling = true; pollTimer = setInterval(() => pollListeners.forEach((fn) => fn()), 15000); } function stopPolling() { fallbackPolling = false; if (pollTimer) clearInterval(pollTimer); } function ensureSocket() { if (socket && socket.readyState <= WebSocket.OPEN) return; const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; socket = new WebSocket(`${proto}//${window.location.host}/ws`); socket.addEventListener('open', () => { attempts = 0; stopPolling(); }); socket.addEventListener('message', (e) => { try { const msg = JSON.parse(e.data); (subs.get(msg.channel) || []).forEach((fn) => fn(msg.event, msg.data)); } catch (err) { /* ignore malformed frames */ } }); socket.addEventListener('close', () => { startPolling(); attempts += 1; const delay = Math.min(30000, 1000 * 2 ** attempts); setTimeout(ensureSocket, delay); }); socket.addEventListener('error', () => socket.close()); }