/* ═══════════════════════════════════════════════════════════════ API CLIENT One place that knows how to talk to the server, so components never call fetch directly and swapping the transport later is a single-file change. Two things worth knowing about the design: The cache is a module-level Map of in-flight and settled promises. Two components asking for /events during the same render pass share one request, and a remount inside the TTL costs nothing. It resets on page load, which is the right lifetime for content that changes weekly. Every reader can pass a `fallback`. If the request fails, that value is used instead. This is what keeps the Figma preview working: pass the old static module as the fallback and the preview renders real content with no server in sight. ═══════════════════════════════════════════════════════════════ */ const BASE = import.meta.env?.VITE_API_BASE ?? "/api"; const DEFAULT_TTL = 60_000; const cache = new Map(); // path → { at, promise } export class ApiError extends Error { constructor(message, { status, fields } = {}) { super(message); this.name = "ApiError"; this.status = status; this.fields = fields; } } async function request(path, options = {}) { const response = await fetch(`${BASE}${path}`, { headers: { Accept: "application/json", ...options.headers }, ...options, }); if (response.status === 204) return null; const type = response.headers.get("content-type") ?? ""; if (!type.includes("application/json")) { // Usually the SPA fallback returning index.html for a URL the // API doesn't serve. Parsing it would throw something useless. throw new ApiError("Server did not return JSON.", { status: response.status, }); } const body = await response.json(); if (!response.ok) { throw new ApiError(body.error ?? "Request failed.", { status: response.status, fields: body.fields, }); } return body; } /* ── Reads ───────────────────────────────────────────────────── get("/events") → cached for 60s get("/events", { ttl: 0 }) → always fresh get("/events", { fallback }) → fallback on any failure ───────────────────────────────────────────────────────────── */ export function get(path, { ttl = DEFAULT_TTL, fallback } = {}) { const hit = cache.get(path); if (hit && Date.now() - hit.at < ttl) return hit.promise; const promise = request(path).catch((err) => { cache.delete(path); // a failure shouldn't be cached if (fallback !== undefined) { console.warn(`api: ${path} failed, using fallback`, err); return fallback; } throw err; }); cache.set(path, { at: Date.now(), promise }); return promise; } export function invalidate(path) { if (path) cache.delete(path); else cache.clear(); } /* ── Writes ──────────────────────────────────────────────────── */ export function post(path, data) { return request(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), }); }