/** * static/js/events.js * Server-Sent Events client – connects to /api/events and dispatches * updates into the central state store. * Uses full item payloads embedded in events to avoid extra re-fetch round-trips. */ import { state } from './state.js'; import { api, getStoredToken } from './api.js'; let _es = null; let _reconnectTimer = null; let _reconnecting = false; const RECONNECT_DELAY_MS = 3000; export function connectSSE() { if (_es) return; _connect(); } function _connect() { const token = getStoredToken(); const url = token ? `/api/events?access_token=${encodeURIComponent(token)}` : '/api/events'; _es = new EventSource(url); _es.onopen = () => { console.debug('[SSE] connected'); if (_reconnecting) { _reconnecting = false; state.set('sseReconnecting', false); // Full refresh after reconnect to catch anything we missed _fullRefresh(); } state.set('serverOnline', true); }; _es.onmessage = (e) => { try { const event = JSON.parse(e.data); _handleEvent(event); } catch (err) { console.warn('[SSE] parse error', err); } }; _es.onerror = () => { console.warn('[SSE] connection lost – reconnecting in', RECONNECT_DELAY_MS, 'ms'); _reconnecting = true; state.set('sseReconnecting', true); _es.close(); _es = null; clearTimeout(_reconnectTimer); _reconnectTimer = setTimeout(_connect, RECONNECT_DELAY_MS); }; } async function _fullRefresh() { try { const [instructions, status, config] = await Promise.all([ api.listInstructions('all'), api.status(), api.getConfig(), ]); state.set('instructions', instructions.items); state.set('status', status); state.set('config', config); } catch (e) { console.error('[SSE] full refresh failed', e); } } function _applyInstructionPatch(item) { const current = state.get('instructions') || []; const idx = current.findIndex(i => i.id === item.id); if (idx === -1) { // New item – append and re-sort by position const next = [...current, item].sort((a, b) => a.position - b.position); state.set('instructions', next); } else { // Replace in-place, preserving order const next = [...current]; next[idx] = item; state.set('instructions', next.sort((a, b) => a.position - b.position)); } } function _handleEvent(event) { switch (event.type) { case 'instruction.created': case 'instruction.updated': case 'instruction.consumed': { if (event.data?.item) { _applyInstructionPatch(event.data.item); } else { // Fallback: full refresh api.listInstructions('all').then(d => state.set('instructions', d.items)).catch(console.error); } break; } case 'instruction.deleted': { const id = event.data?.id; if (id) { const next = (state.get('instructions') || []).filter(i => i.id !== id); state.set('instructions', next); } break; } case 'history.cleared': { const next = (state.get('instructions') || []).filter(i => i.status !== 'consumed'); state.set('instructions', next); break; } case 'status.changed': { api.status().then(s => state.set('status', s)).catch(console.error); break; } case 'config.updated': { api.getConfig().then(c => state.set('config', c)).catch(console.error); break; } default: console.debug('[SSE] unknown event type', event.type); } }