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>
400 lines
16 KiB
TypeScript
400 lines
16 KiB
TypeScript
// 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 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<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();
|