// 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, ApiError } from './api'; import type { DeltaResponse } from './types'; type StreamTicketResponse = { ticket: string; server_time: string }; 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; /** * True once a stream has opened at least once this session. Distinguishes "first * connect" from "reconnect" now that `lastEventTime` is seeded at ticket-mint rather * than in `onopen` (see `connectSse`) — without it every boot would fire a pointless * zero-width delta. */ let streamEverOpened = false; /** * `Date.now()` of the last thing we actually received on the live stream. * * Keep-alives are deliberately NOT observable here: the backend sends them as SSE * COMMENTS (`KeepAlive::new().text("ping")` emits `:ping`), and the EventSource parser * discards comments without dispatching anything at all. There is no browser API that * exposes them. So liveness cannot be decided by a plain silence timer — it could not * tell a dead socket from a genuinely quiet half hour, and would churn reconnects for * every guest through every lull. The backstop poll below decides it on evidence * instead: the server had news that this stream never delivered. */ let lastStreamActivity = 0; /** * 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', 'comment-deleted', 'user-hidden', 'event-closed', 'event-opened', 'event-updated', 'export-progress', 'export-available', 'pin-reset', // A guest asked a host to reset their PIN — hosts refresh their pending-request badge. 'pin-reset-requested' ] 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 can't send an Authorization header, so we exchange the JWT for // a short-lived single-use ticket via POST /stream/ticket (Bearer auth) and // pass that on the URL. The JWT itself never appears in URLs / access logs. void (async () => { let ticket: string; let serverTime: string; try { const res = await api.post('/stream/ticket', {}); ticket = res.ticket; serverTime = res.server_time; } catch { // Failed to mint a ticket (auth lapse, network blip). Back off and retry // via the existing error path. scheduleReconnect(); return; } // Seed the reconnect cursor the moment we have a server clock — NOT in `onopen`, // which is where it used to live. `onopen` never fires behind a captive portal or // any proxy that buffers `text/event-stream`, so on exactly the networks where the // stream fails the cursor stayed `null` forever and the backstop poll below had no // `since` to fetch from. Still the server clock and never `new Date()`: a skewed // browser clock would shift the window and silently drop uploads. if (!lastEventTime) lastEventTime = serverTime; // Auth flow may have torn things down while we were awaiting the ticket — and the // phone may have gone to sleep during that round-trip. Opening a stream while // hidden is worse than not opening one: iOS reaps a backgrounded socket without // ever firing `onerror`, so `eventSource` stays non-null and the guard at the top // of this function then treats the corpse as a live connection for the rest of the // evening. The visibility handler reconnects us when the screen comes back. if (!getToken() || eventSource || (typeof document !== 'undefined' && document.hidden)) return; eventSource = new EventSource(`/api/v1/stream?ticket=${encodeURIComponent(ticket)}`); eventSource.onopen = () => { // Successful connection — reset the backoff counter. reconnectAttempt = 0; noteStreamActivity(); // A reconnect has a gap to close; the very first open of a session does not // (the cursor was just seeded from this ticket's server clock). The delta // advances `lastEventTime` from the SERVER clock it returns. if (streamEverOpened && lastEventTime) void deltaFetchAndFan(lastEventTime); streamEverOpened = true; }; for (const eventName of KNOWN_EVENTS) { eventSource.addEventListener(eventName, (e) => { noteStreamActivity(); dispatch(eventName, (e as MessageEvent).data); }); } // `resync` is emitted by the server when our broadcast subscription fell // behind and events were dropped. Rather than let those losses leave the feed // stale, fetch the gap since the last event we actually saw and fan it out as // a feed-delta (which reconciles new uploads AND deletions). Handled with its // own listener — not via `dispatch` — so reading `lastEventTime` as the gap // start isn't clobbered by dispatch bumping it to "now". eventSource.addEventListener('resync', () => { noteStreamActivity(); const since = lastEventTime; if (since) void deltaFetchAndFan(since); }); 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(); scheduleReconnect(); }; })(); } function scheduleReconnect(): void { reconnectAttempt++; const delay = Math.min(60_000, 1_000 * 2 ** (reconnectAttempt - 1)); // Jitter must SCALE WITH the backoff, not be a flat 500ms. Every client that dropped // together shares the same `reconnectAttempt`, so they compute an identical `delay` — // a fixed 500ms window spreads 100 phones over half a second no matter how long the // backoff grew, which is the thundering herd the backoff exists to prevent. Each // reconnect costs a ticket POST + stream GET + feed-delta fetch, so the herd lands on // the DB pool three times over. Scaling the jitter to the delay (floored at 1s so the // first, most synchronised retry is spread too) turns that into a smooth ramp. const jitter = Math.random() * Math.max(delay, 1_000); 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; } } function noteStreamActivity(): void { lastStreamActivity = Date.now(); } // ── Stream backstop ──────────────────────────────────────────────────────────────── // // EVERY feed update is triggered by an SSE event — there is no periodic refetch — so a // stream that stops delivering freezes a guest's gallery for the rest of the evening. // And a venue produces exactly the two failures that the reconnect path cannot see: // // • a captive portal or any proxy that buffers `text/event-stream` never forwards a // byte, so `onopen` may never fire and neither does `onerror`; // • a phone that roams between APs leaves a HALF-OPEN socket — the connection is gone // but `readyState` still reads OPEN, no error is raised, and `connectSse` early- // returns on its non-null `eventSource`, so nothing ever reconnects. // // Neither surfaces anything to react to, which is why the only honest backstop is to // ask the server. `/feed/delta` is idempotent and answers with an empty payload when // nothing changed, and one request per 60–120s is ~1/60th of the per-user feed limit — // cheap insurance against a guest staring at a frozen feed all night. The diashow uses // the same reconcile-on-a-timer for the same reason (`RECONCILE_INTERVAL_MS` there). const BACKSTOP_MIN_MS = 60_000; const BACKSTOP_MAX_MS = 120_000; let backstopTimer: ReturnType | null = null; let backstopEnabled = false; /** * Start the poll-based liveness/completeness backstop. Opt-in per page (the feed is the * consumer that needs it; the export page's SSE use is a status ping and the diashow * runs its own full reconcile), and idempotent. */ export function startStreamBackstop(): void { if (typeof document === 'undefined' || backstopEnabled) return; backstopEnabled = true; scheduleBackstop(); } export function stopStreamBackstop(): void { backstopEnabled = false; if (backstopTimer) { clearTimeout(backstopTimer); backstopTimer = null; } } function scheduleBackstop(): void { if (backstopTimer) clearTimeout(backstopTimer); // Jittered for the same reason the reconnect backoff is: ~100 phones that joined // within the same few minutes would otherwise poll in permanent lockstep. const delay = BACKSTOP_MIN_MS + Math.random() * (BACKSTOP_MAX_MS - BACKSTOP_MIN_MS); backstopTimer = setTimeout(() => void runBackstop(), delay); } async function runBackstop(): Promise { backstopTimer = null; try { // A hidden tab has no stream (the visibility handler closed it) and cannot show a // result anyway; the reopen path already fetches the gap. if (document.hidden || !getToken()) return; // No stream, or one the browser has admitted is closed, while we are visible: there // is no other way back, because `connectSse`'s `eventSource` guard cannot tell a // corpse from a live connection. if (!eventSource || eventSource.readyState === EventSource.CLOSED) { disconnectSse(); reconnectAttempt = 0; connectSse(); return; } const since = lastEventTime; if (!since) return; const activityBefore = lastStreamActivity; const carried = await deltaFetchAndFan(since); // The server had news that this stream never delivered. That is the evidence a // half-open socket cannot otherwise give us — reconnect, or every remaining update // tonight arrives at poll latency instead of instantly. if (carried && lastStreamActivity === activityBefore) { disconnectSse(); reconnectAttempt = 0; connectSse(); } } finally { if (backstopEnabled) scheduleBackstop(); } } export function getLastEventTime(): string | null { return lastEventTime; } export function setLastEventTime(time: string): void { lastEventTime = time; } function dispatch(eventType: string, data: string): void { // Advance the reconnect cursor from the SERVER timestamp carried in the payload (when // present — e.g. a new upload's `created_at`), never the browser clock. Events without // a timestamp (likes, lock toggles) leave the cursor where it is; the next delta // re-fetches from the last content timestamp we saw, which merges idempotently. const ts = extractCreatedAt(data); if (ts) lastEventTime = ts; const list = handlers.get(eventType); if (list) { for (const handler of list) { handler(data); } } } /** Pull an ISO `created_at` out of an event payload if it has one, else undefined. */ function extractCreatedAt(data: string): string | undefined { try { const parsed = JSON.parse(data); if (parsed && typeof parsed.created_at === 'string') return parsed.created_at; } catch { // non-JSON payload (e.g. a plain count) — no timestamp to extract } return undefined; } /** * 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. * * Resolves to whether the delta actually CARRIED something. `runBackstop` uses that as * its liveness signal: content the poll found but the stream never pushed means the * stream is dead in the way the browser will not report. */ async function deltaFetchAndFan(since: string, attempt = 0): Promise { try { const response = await api.get(`/feed/delta?since=${encodeURIComponent(since)}`); // Advance the cursor to the server clock this delta was computed at, so the next // reconnect resumes exactly where the server left off (no browser-clock skew). lastEventTime = response.server_time; dispatch('feed-delta', JSON.stringify(response)); return ( response.uploads.length > 0 || response.deleted_ids.length > 0 || response.hidden_user_ids.length > 0 ); } catch (e) { // A throttled delta (429) must NOT be silently dropped: live events keep advancing // `lastEventTime`, so the next reconnect would resume PAST this un-fetched gap and // lose it. Retry the SAME `since` with backoff so the gap [since, now] is still // covered regardless of how the live cursor moves in the meantime. Bounded, and only // reachable by a rapidly flapping EventSource hitting the per-user delta limit. if (e instanceof ApiError && e.status === 429 && attempt < MAX_DELTA_RETRIES) { // Jittered for the same reason `scheduleReconnect` is: the clients that hit this // 429 are the ones that just reconnected together after a venue-wide wifi blip, // so they share `attempt` and would otherwise retry in lockstep at exactly 2s, // 4s, 8s — re-tripping the same per-user limit in a synchronised wave. const base = DELTA_RETRY_BASE_MS * 2 ** attempt; setTimeout(() => void deltaFetchAndFan(since, attempt + 1), base + Math.random() * base); } // Other errors are non-fatal — the next live SSE event keeps the feed moving. return false; } } /** Bounded backoff for a rate-limited reconnect delta (see `deltaFetchAndFan`). */ const MAX_DELTA_RETRIES = 4; const DELTA_RETRY_BASE_MS = 2000; // Page Visibility API: close while hidden, reopen on focus. On reopen `connectSse`'s // `onopen` runs the delta fetch. function handleVisibilityChange() { if (document.hidden) { disconnectSse(); } else { // Tear down UNCONDITIONALLY before reconnecting rather than leaning on // `connectSse`'s `eventSource` guard. iOS can reap a backgrounded socket without // ever firing `onerror`, which leaves a non-null but permanently dead EventSource — // and the guard would then read that as "already connected" and never reconnect, // for the rest of the evening. Closing an already-closed EventSource is a no-op. disconnectSse(); // 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(); } } let visibilityBound = false; /** Idempotent: safe to call more than once; only the first registration sticks. */ function bindVisibility() { if (visibilityBound || typeof document === 'undefined') return; document.addEventListener('visibilitychange', handleVisibilityChange); visibilityBound = true; } /** Remove the visibility listener (e.g. on teardown / test cleanup). */ export function teardownVisibility() { if (!visibilityBound || typeof document === 'undefined') return; document.removeEventListener('visibilitychange', handleVisibilityChange); visibilityBound = false; } bindVisibility();