120 lines
3.2 KiB
JavaScript
120 lines
3.2 KiB
JavaScript
/**
|
||
* 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 } 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() {
|
||
_es = new EventSource('/api/events');
|
||
|
||
_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');
|
||
state.set('serverOnline', false);
|
||
_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 '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);
|
||
}
|
||
}
|
||
|