NGU-Web/src/lib/api.js

102 lines
3.6 KiB
JavaScript

/* ═══════════════════════════════════════════════════════════════
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),
});
}