public/api.js
// A tiny API client: JSON in/out, the CSRF header on every state-changing
// request, and errors that carry the HTTP status (§67).
let csrfToken = "";
export const setCsrf = (t) => { csrfToken = t; };
export class ApiError extends Error {
constructor(status, message) {
super(message);
this.status = status;
}
}
export async function api(path, { method = "GET", body, signal } = {}) {
const res = await fetch(path, {
method,
signal,
credentials: "same-origin",
headers: {
...(body !== undefined && { "Content-Type": "application/json" }),
...(method !== "GET" && { "X-CSRF-Token": csrfToken }),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!res.ok) {
const { error = res.statusText } = await res.json().catch(() => ({}));
throw new ApiError(res.status, error);
}
return res;
}
// Server-Sent Events over fetch (EventSource can't POST or send headers).
// Yields one parsed event object per `data:` frame (§59).
export async function* readEvents(res) {
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buf = "";
for (;;) {
const { value, done } = await reader.read();
if (done) return;
buf += value;
let end;
while ((end = buf.indexOf("\n\n")) !== -1) {
const frame = buf.slice(0, end);
buf = buf.slice(end + 2);
const data = frame
.split("\n")
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).trimStart())
.join("\n");
if (data) yield JSON.parse(data);
}
}
}