import { getToken, clearAuth } from './auth'; const BASE = '/api/v1'; export class ApiError extends Error { status: number; code: string; constructor(status: number, code: string, message: string) { super(message); this.status = status; this.code = code; } } const TIMEOUT_MS = 20_000; /** Pages that ARE the recovery flow — redirecting from them would loop. */ const AUTH_ROUTES = ['/join', '/recover']; /** * Send a guest whose session died back to the join screen. * * Deliberately uses `window.location` rather than SvelteKit's `goto`: `toast-store` already * imports `ApiError` from this module, so pulling a store or `$app/navigation` in here would * create an import cycle. A full document load is also the more correct behaviour after a * session loss — it resets every module-level store, which is exactly what we want, and the * queued upload blobs live in IndexedDB so they survive it. */ function redirectToJoin(): void { if (typeof window === 'undefined') return; const path = window.location.pathname; if (AUTH_ROUTES.some((r) => path === r || path.startsWith(`${r}/`))) return; window.location.assign('/join'); } async function request(method: string, path: string, body?: unknown): Promise { const headers: Record = {}; const token = getToken(); if (token) { headers['Authorization'] = `Bearer ${token}`; } if (body !== undefined) { headers['Content-Type'] = 'application/json'; } // Abort hung requests so a dead connection surfaces as a friendly error // instead of a spinner that never resolves. // // The timer must stay armed until the BODY has been read, not just the headers. // `fetch` resolves as soon as the response head arrives, so clearing it in a `finally` // around the fetch left `res.text()` below completely uncovered — and no longer // abortable, since the controller had already been disarmed. An upstream that sends // headers and then stalls the body (the shape of a half-dead proxy, or of the pool // saturation this same release adds shedding for) hung that call forever. const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); let res: Response; try { res = await fetch(`${BASE}${path}`, { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined, signal: controller.signal }); } catch (e) { clearTimeout(timer); if (e instanceof DOMException && e.name === 'AbortError') { throw new ApiError(0, 'timeout', 'Zeitüberschreitung – bitte erneut versuchen.'); } throw new ApiError(0, 'network', 'Netzwerkfehler – bitte Verbindung prüfen.'); } if (res.status === 204) { // Must clear on this path too, or every no-content request (logout, delete, like) // leaks a live 20 s timer. clearTimeout(timer); return undefined as T; } // A 5xx behind a proxy (or a crash page) can return HTML, not JSON — parsing // it directly would throw an opaque SyntaxError. Read text, parse defensively. let raw: string; try { raw = await res.text(); } catch (e) { if (e instanceof DOMException && e.name === 'AbortError') { throw new ApiError(0, 'timeout', 'Zeitüberschreitung – bitte erneut versuchen.'); } throw new ApiError(0, 'network', 'Netzwerkfehler – bitte Verbindung prüfen.'); } finally { clearTimeout(timer); } let data: { error?: string; message?: string } | unknown = null; if (raw) { try { data = JSON.parse(raw); } catch { data = null; } } if (!res.ok) { // An expired/invalid token (401) clears the dead session. Banned users are // NOT logged out — they keep read access by design (USER_JOURNEYS §10) and // simply get a 403 "gesperrt" toast on writes. if (res.status === 401) { clearAuth(); // Clearing auth alone leaves the guest stranded: the bottom nav and FAB are // gated on `isAuthenticated` so they simply vanish, route guards only run in // onMount (which does not re-run), and a standalone PWA has no URL bar — so // there is no way back to /join. Real triggers mid-event are a host PIN reset // (which revokes that guest's sessions) and a redeployed JWT_SECRET. // Queued upload blobs survive in IndexedDB and are picked up again after // re-joining — via the `onSetAuth` hook in upload-queue.ts, NOT the boot-time // call in +layout.svelte: this redirect lands on /join with no token, so the // layout's `if (getToken())` skips it, and the subsequent recover navigates // with `goto()`, which never re-runs `onMount`. redirectToJoin(); } const d = (data ?? {}) as { error?: string; message?: string }; throw new ApiError( res.status, d.error ?? 'unknown', d.message ?? `Serverfehler (${res.status}).` ); } return data as T; } export const api = { get: (path: string) => request('GET', path), post: (path: string, body?: unknown) => request('POST', path, body), patch: (path: string, body?: unknown) => request('PATCH', path, body), delete: (path: string) => request('DELETE', path) };