Files
EventSnap/frontend/src/lib/sse.ts
Fabian Hamm (Privat) 51e55b1ace fix(feed): survive a bad network, and let the lightbox actually browse
Four things a guest on congested venue wifi would have hit, and one they would have
hit immediately.

A FAILED FEED LOAD CLAIMED THE GALLERY WAS EMPTY. `loadFeed` caught, toasted for five
seconds and left `uploads` empty, so the page fell through to "Noch keine Fotos. Tippe
auf den Kamera-Button unten!" — the most likely first impression at the party, and a
lie. There is now a distinct error state with "Erneut laden". Refreshes suppressed the
toast entirely, so pull-to-refresh and the "Neue Beiträge" pill failed in total
silence; they now report, and the pill survives its own failure instead of clearing
before the request.

THE FILTER-EMPTY STATE WAS DEAD CODE. With filtering server-side `displayUploads` is a
plain alias of `uploads`, so the grid's "Keine Treffer für die gewählten Filter." plus
its reset button sat behind an identical earlier branch and could never render — a
guest tapping a chip with no matches was told to go take a photo.

SSE COULD FREEZE THE FEED FOR THE WHOLE EVENING. Nothing in the feed ever refetched on
a timer; every update path was triggered exclusively by a stream event. Behind a proxy
that buffers `text/event-stream` `onopen` never fires, so the guest saw only the photos
that were on screen when they arrived; and a socket left half-open by an AP roam is
worse, because `connectSse` early-returns on a non-null EventSource and nothing ever
reconnects. A pure silence timer is not implementable — the backend sends keep-alives
as SSE comments, which the EventSource parser discards without dispatching — so
liveness is established on evidence instead: a jittered 60-120s `/feed/delta` backstop
that reconnects when a poll returns content the stream never delivered. The ticket
round-trip also seeds the delta cursor before the EventSource is created, so the
backstop has a `since` even if `onopen` never fires.

THE PILL COLLAPSED A DEEPLY-SCROLLED FEED to 20 items and dumped the guest at an
arbitrary scroll position — the exact yank the pill exists to avoid. It merges now.

The refresh debounce was 800ms + jitter, which during a burst is roughly one feed query
per client every two seconds; at 100 guests that approaches the 60/min per-user limit,
and the resulting 429s were swallowed by a bare `catch {}`, so the feed would simply
stop updating with no signal. Now 8s + jitter, coalescing, and skipped entirely while
the page is hidden.

Not one `<img>` in the app had an `onerror`. `pickMediaUrl` falls back to the original
whenever preview and thumbnail are null — i.e. for everything still compressing, which
during a burst is the top of the feed — so a 404 there rendered an empty grey box with
`alt=""`, not even a message. Each now retries once, then shows the placeholder.

The lightbox had no swipe, no prev/next and no arrow keys, so browsing 300 photos meant
closing and reopening the modal for every one — while FEATURES.md and USER_JOURNEYS
both claimed swipe shipped. It now has chevrons (44px, German aria-labels, hidden at
the ends), arrow keys, and horizontal swipe, with focus handed to the surviving control
so a disappearing chevron can't drop focus to `<body>`. Comment deletion was a ~14px
`✕` four pixels from the text that deleted permanently on one tap, while deleting a
POST two components away goes through a ConfirmSheet; it now matches.

`feed-filter.ts` and its test are deleted — with the server filtering, they were dead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 18:36:05 +02:00

400 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<string, EventHandler[]> = new Map();
/** Consecutive reconnect attempts since last successful onopen. Reset on success. */
let reconnectAttempt = 0;
let reconnectTimer: ReturnType<typeof setTimeout> | 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<StreamTicketResponse>('/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 60120s 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<typeof setTimeout> | 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<void> {
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<boolean> {
try {
const response = await api.get<DeltaResponse>(`/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();