'use client'; import { createContext, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from 'react'; import type { BgpStatsEvent, Front, GlobalPressure, Incident, InternalStatus, InternalStatusEvent, LevelId, ProbeStats, Region, RegionalUpdate, ServiceDegradationEvent, Snapshot, Ticker, } from './types'; /** * One EventSource per page. State is kept in a tiny external store split into slices; components subscribe with * `useLive(selector)` so an SSE message re-renders only the islands whose slice changed (spec §51). * Nothing here ever invents motion: every state change corresponds to a received event. */ export interface CountryLite { cc: string; pressure: number; level: LevelId; delta_1h: number; } export interface LiveState { global: GlobalPressure | null; ticker: Ticker | null; regions: Region[] | null; countries: CountryLite[] | null; fronts: Front[] | null; incidents: Incident[] | null; bgp: BgpStatsEvent | null; probeStats: ProbeStats | null; internalStatus: InternalStatus; internalReason: string | null; serviceDegradations: ServiceDegradationEvent[]; /** ms timestamp of the last *data* event received (not pings). */ lastEventAt: number | null; /** ISO ts carried by the last engine update — the "frozen since" reference when degraded. */ lastEngineTs: string | null; connection: 'idle' | 'connecting' | 'open' | 'reconnecting'; /** How many real updates arrived (used to gate number animations: never animate the SSR value). */ updates: number; } export interface LiveInitial { global?: GlobalPressure | null; ticker?: Ticker | null; regions?: Region[] | null; fronts?: Front[] | null; incidents?: Incident[] | null; bgp?: BgpStatsEvent | null; } type Listener = () => void; class LiveStore { state: LiveState; private listeners = new Set(); constructor(initial: LiveInitial) { const g = initial.global ?? null; this.state = { global: g, ticker: initial.ticker ?? null, regions: initial.regions ?? null, countries: null, fronts: initial.fronts ?? null, incidents: initial.incidents ?? null, bgp: initial.bgp ?? null, probeStats: null, internalStatus: g?.internal_status ?? initial.ticker?.internal_status ?? 'ok', internalReason: null, serviceDegradations: [], lastEventAt: null, lastEngineTs: g?.ts ?? null, connection: 'idle', updates: 0, }; } subscribe = (l: Listener) => { this.listeners.add(l); return () => { this.listeners.delete(l); }; }; get = () => this.state; set(patch: Partial, isData = true) { this.state = { ...this.state, ...patch, ...(isData ? { lastEventAt: Date.now(), updates: this.state.updates + 1 } : {}) }; for (const l of this.listeners) l(); } } const Ctx = createContext(null); function mergeIncident(list: Incident[] | null, inc: Incident): Incident[] { const rest = (list ?? []).filter((i) => i.event_id !== inc.event_id); const next = inc.status === 'resolved' ? rest : [inc, ...rest]; return next.sort((a, b) => b.current_pressure - a.current_pressure); } export function LiveProvider({ initial, children, enabled = true }: { initial: LiveInitial; children: ReactNode; enabled?: boolean }) { const [store] = useState(() => new LiveStore(initial)); useEffect(() => { if (process.env.NODE_ENV !== 'production') (window as unknown as { __ipLive?: LiveStore }).__ipLive = store; }, [store]); useEffect(() => { if (!enabled || typeof window === 'undefined' || typeof EventSource === 'undefined') return; let es: EventSource | null = null; let attempt = 0; let timer: ReturnType | null = null; let closed = false; const parse = (e: MessageEvent): T | null => { try { return JSON.parse(e.data) as T; } catch { return null; } }; const open = () => { if (closed) return; store.set({ connection: attempt === 0 ? 'connecting' : 'reconnecting' }, false); es = new EventSource('/api/v1/live'); es.onopen = () => { attempt = 0; store.set({ connection: 'open' }, false); }; es.onerror = () => { // EventSource retries on its own while CONNECTING; when the browser gives up (CLOSED) we back off and recreate. if (es && es.readyState === EventSource.CLOSED) { es.close(); es = null; attempt++; const delay = Math.min(30_000, 1000 * 2 ** Math.min(attempt, 5)) + Math.random() * 500; store.set({ connection: 'reconnecting' }, false); timer = setTimeout(open, delay); } else { store.set({ connection: 'reconnecting' }, false); } }; es.addEventListener('snapshot', (e) => { const s = parse(e as MessageEvent); if (!s) return; store.set({ global: s.global, ticker: s.ticker, regions: s.regions, fronts: s.fronts, incidents: s.incidents, internalStatus: s.global?.internal_status ?? s.ticker?.internal_status ?? store.state.internalStatus, lastEngineTs: s.global?.ts ?? store.state.lastEngineTs, }); }); es.addEventListener('global_pressure_update', (e) => { const g = parse(e as MessageEvent); if (!g) return; store.set({ global: g, internalStatus: g.internal_status, lastEngineTs: g.ts, internalReason: g.internal_status === 'ok' ? null : store.state.internalReason }); }); es.addEventListener('regional_pressure_update', (e) => { const u = parse(e as MessageEvent); if (!u) return; store.set({ regions: u.regions, countries: u.countries }); }); es.addEventListener('ticker', (e) => { const t = parse(e as MessageEvent); if (!t) return; store.set({ ticker: t, internalStatus: t.internal_status ?? store.state.internalStatus }); }); es.addEventListener('bgp_stats', (e) => { const b = parse(e as MessageEvent); if (b) store.set({ bgp: b }); }); es.addEventListener('probe_stats', (e) => { const p = parse(e as MessageEvent); if (p) store.set({ probeStats: p }); }); es.addEventListener('front_update', (e) => { const f = parse<{ fronts: Front[] }>(e as MessageEvent); if (f?.fronts) store.set({ fronts: f.fronts }); }); const onIncident = (e: Event) => { const inc = parse(e as MessageEvent); if (inc) store.set({ incidents: mergeIncident(store.state.incidents, inc) }); }; es.addEventListener('incident_created', onIncident); es.addEventListener('incident_updated', onIncident); es.addEventListener('service_degradation', (e) => { const d = parse(e as MessageEvent); if (d) store.set({ serviceDegradations: [d, ...store.state.serviceDegradations.filter((x) => x.slug !== d.slug)].slice(0, 12) }); }); es.addEventListener('internal_status', (e) => { const s = parse(e as MessageEvent); if (s) store.set({ internalStatus: s.internal_status, internalReason: s.reason ?? null }, false); }); }; open(); return () => { closed = true; if (timer) clearTimeout(timer); es?.close(); }; }, [store, enabled]); return {children}; } const EMPTY = new LiveStore({}); function shallowEqual(a: unknown, b: unknown): boolean { if (Object.is(a, b)) return true; if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false; const ka = Object.keys(a as object); const kb = Object.keys(b as object); if (ka.length !== kb.length) return false; for (const k of ka) if (!Object.is((a as Record)[k], (b as Record)[k])) return false; return true; } /** * Subscribe to a slice. Selectors may return fresh objects: the result is cached per store state and * shallow-compared so useSyncExternalStore sees a stable snapshot (no re-render unless the slice changed). */ export function useLive(selector: (s: LiveState) => T): T { const store = useContext(Ctx) ?? EMPTY; const cache = useRef<{ state: LiveState; result: T } | null>(null); const read = () => { const state = store.get(); const c = cache.current; if (c && c.state === state) return c.result; const result = selector(state); if (c && shallowEqual(c.result, result)) { cache.current = { state, result: c.result }; return c.result; } cache.current = { state, result }; return result; }; return useSyncExternalStore(store.subscribe, read, read); } /** True when the instrument itself is unhealthy: the number must be shown dimmed and never read as an Internet event. */ export function useDegraded(): { degraded: boolean; status: InternalStatus; reason: string | null; frozenSince: string | null } { return useLive((s) => ({ degraded: s.internalStatus !== 'ok' || Boolean(s.global?.stale), status: s.internalStatus, reason: s.internalReason, frozenSince: s.lastEngineTs, })); } /** A clock that ticks every `ms` for relative labels ("3 s ago"). The text updates, the data does not. */ export function useNow(ms = 1000): number { const [now, setNow] = useState(() => Date.now()); useEffect(() => { const id = setInterval(() => setNow(Date.now()), ms); return () => clearInterval(id); }, [ms]); return now; } /** Stable helper for components that want the initial (SSR) value merged with the live slice. */ export function useLiveOr(selector: (s: LiveState) => T | null | undefined, fallback: T): T { const v = useLive(selector); return useMemo(() => v ?? fallback, [v, fallback]); }