// Thin EventSource wrapper with a per-event-type registration pattern. // // Subscribers register via `onSseEvent(type, handler)` and receive the raw payload // string. The list of event types we know how to relay lives in `KNOWN_EVENTS` so // adding one new is one constant entry — keeps the file friendly to extension. // // Lifecycle: // - Connection survives backgrounding via the visibility listener at the bottom of // this file (closes on hidden, reopens on visible). // - On reopen we fire a `feed-delta` synthetic event with the gap since last seen // to whoever subscribes. The feed page is the typical consumer; it merges the // delta into its in-memory list. import { getToken } from './auth'; import { api } from './api'; import type { DeltaResponse } from './types'; type EventHandler = (data: string) => void; let eventSource: EventSource | null = null; let lastEventTime: string | null = null; const handlers: Map = new Map(); /** Consecutive reconnect attempts since last successful onopen. Reset on success. */ let reconnectAttempt = 0; let reconnectTimer: ReturnType | null = null; /** * SSE event names emitted by the backend. Add new ones here as `state.sse_tx.send` * call sites grow — every entry becomes a relay registration below. */ const KNOWN_EVENTS = [ 'new-upload', 'upload-processed', 'upload-error', 'upload-deleted', 'like-update', 'new-comment', 'event-closed', 'event-opened', 'event-updated', 'export-progress', 'export-available', 'pin-reset' ] as const; /** * Synthetic event types — not emitted by the server, dispatched locally to fan out * cross-cutting state changes (e.g. delta-fetch results after a reconnect). */ export type SyntheticEvent = 'feed-delta'; export function onSseEvent(eventType: string, handler: EventHandler): () => void { if (!handlers.has(eventType)) { handlers.set(eventType, []); } handlers.get(eventType)!.push(handler); return () => { const list = handlers.get(eventType); if (list) { const idx = list.indexOf(handler); if (idx >= 0) list.splice(idx, 1); } }; } export function connectSse(): void { const token = getToken(); if (!token || eventSource) return; // EventSource doesn't support custom headers, so pass token as query param. eventSource = new EventSource(`/api/v1/stream?token=${encodeURIComponent(token)}`); eventSource.onopen = () => { // Successful connection — reset the backoff counter. reconnectAttempt = 0; // If we have a previous timestamp this is a reconnect — fetch the gap. const since = lastEventTime; if (since) { void deltaFetchAndFan(since); } lastEventTime = new Date().toISOString(); }; for (const eventName of KNOWN_EVENTS) { eventSource.addEventListener(eventName, (e) => dispatch(eventName, (e as MessageEvent).data) ); } eventSource.onerror = () => { // EventSource auto-reconnects but the connection state can stay broken; close // and try again ourselves with exponential backoff capped at 60s. Prevents // retry storms (and lets the backend recover quietly) when the server is down // for a while or when 100+ guests reconnect simultaneously after an outage. disconnectSse(); reconnectAttempt++; const delay = Math.min(60_000, 1_000 * 2 ** (reconnectAttempt - 1)); const jitter = Math.random() * 500; if (reconnectTimer) clearTimeout(reconnectTimer); reconnectTimer = setTimeout(connectSse, delay + jitter); }; } export function disconnectSse(): void { if (eventSource) { eventSource.close(); eventSource = null; } if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } } export function getLastEventTime(): string | null { return lastEventTime; } export function setLastEventTime(time: string): void { lastEventTime = time; } function dispatch(eventType: string, data: string): void { lastEventTime = new Date().toISOString(); const list = handlers.get(eventType); if (list) { for (const handler of list) { handler(data); } } } /** * Fetch all feed activity since `since` and fan it out as a synthetic `feed-delta` * event. Subscribers (typically the feed page) merge the result into their * in-memory list. Swallows errors — a failed delta is non-fatal; the next live * SSE event will keep the feed moving. */ async function deltaFetchAndFan(since: string): Promise { try { const response = await api.get( `/feed/delta?since=${encodeURIComponent(since)}` ); dispatch('feed-delta', JSON.stringify(response)); } catch { // non-fatal } } // Page Visibility API: close while hidden, reopen on focus. On reopen `connectSse`'s // `onopen` runs the delta fetch. if (typeof document !== 'undefined') { document.addEventListener('visibilitychange', () => { if (document.hidden) { disconnectSse(); } else { // User-initiated reconnect — clear backoff so we don't wait out a long // retry delay that was scheduled from a prior background error. reconnectAttempt = 0; connectSse(); } }); }