import { writable } from 'svelte/store' import { sseUrl } from '$lib/config' export interface OikosEvent { id: number ts: string type: string entity_id?: string | null severity: 'info' | 'warning' | 'critical' source: string data?: unknown correlation_id?: string | null } const MAX_BUFFERED = 200 export const liveEvents = writable([]) let source: EventSource | null = null let subscriberCount = 0 let retry: ReturnType | null = null let backoff = 0 /** True while the stream is live. Every live surface is only as fresh as this. */ export const eventsConnected = writable(true) const RETRY_BASE_MS = 1000 const RETRY_MAX_MS = 30000 async function connect() { if (source) return // The browser's EventSource sends Last-event-ID automatically on reconnect, // so a reconnect replays whatever was missed rather than leaving a hole. // sseUrl is async so the OIDC access token is refreshed if expired. const es = new EventSource(await sseUrl('/api/v1/events/stream')) source = es es.onopen = () => { backoff = 0 eventsConnected.set(true) } es.onmessage = (ev) => { try { const parsed: OikosEvent = JSON.parse(ev.data) liveEvents.update((events) => [parsed, ...events].slice(0, MAX_BUFFERED)) } catch { // skip malformed } } // EventSource only auto-reconnects from a *transient* failure. Once it // reaches CLOSED — which is what an HTTP error on (re)connect produces, e.g. // the API restarting during a deploy — it stays closed and never retries. // Leaving that to the browser meant a single blip silently froze every live // surface in the app: health, signals and executions all just stopped // updating, with nothing on screen to say so. That is precisely the // stale-UI failure this whole change set exists to remove. es.onerror = () => { if (es.readyState !== EventSource.CLOSED) return // transient; browser retries eventsConnected.set(false) if (source === es) source = null es.close() if (subscriberCount === 0 || retry) return backoff = backoff ? Math.min(backoff * 2, RETRY_MAX_MS) : RETRY_BASE_MS retry = setTimeout(() => { retry = null if (subscriberCount > 0) connect() }, backoff) } } function disconnect() { if (retry) { clearTimeout(retry) retry = null } backoff = 0 source?.close() source = null } // Reference-counted: the stream stays open as long as at least one page subscribes. export function subscribeEvents(): () => void { subscriberCount++ if (subscriberCount === 1) connect() return () => { subscriberCount-- if (subscriberCount === 0) disconnect() } }