102 lines
3.6 KiB
JavaScript
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),
|
|
});
|
|
}
|