export class ApiError extends Error { status: number; body: Record; constructor(status: number, body: Record) { const msg = (body.error as string) || `API error: ${status}`; super(msg); this.name = "ApiError"; this.status = status; this.body = body; } } async function handleResponse(res: Response, path: string): Promise { if (!res.ok) { let body: Record = {}; try { body = await res.json(); } catch { body = { error: `API error: ${res.status}` }; } // Redirect to login on 401 (except for auth endpoints) if (res.status === 401 && !path.startsWith("/api/auth/")) { const { default: router } = await import("@/router/index"); const currentPath = window.location.pathname; if (currentPath !== "/login" && currentPath !== "/register") { router.push({ name: "login", query: { redirect: currentPath } }); } } throw new ApiError(res.status, body); } // Handle 204 No Content if (res.status === 204) { return undefined as T; } return res.json() as Promise; } export async function apiGet(path: string): Promise { const res = await fetch(path); return handleResponse(res, path); } export async function apiPost(path: string, body: unknown): Promise { const res = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); return handleResponse(res, path); } export async function apiPut(path: string, body: unknown): Promise { const res = await fetch(path, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); return handleResponse(res, path); } export async function apiPatch(path: string, body: unknown): Promise { const res = await fetch(path, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); return handleResponse(res, path); } export async function apiDelete(path: string): Promise { const res = await fetch(path, { method: "DELETE" }); return handleResponse(res, path); } export interface SSEStreamHandle { close(): void; /** Resolves when the stream closes (normally or via error/abort). */ done: Promise; } export interface SSENamedEvent { id: number; event: string; data: Record; } export function apiSSEStream( path: string, onEvent: (event: SSENamedEvent) => void, options?: { lastEventId?: number; signal?: AbortSignal }, ): SSEStreamHandle { const controller = new AbortController(); const combinedSignal = options?.signal ? AbortSignal.any([controller.signal, options.signal]) : controller.signal; const headers: Record = {}; if (options?.lastEventId !== undefined) { headers["Last-Event-ID"] = String(options.lastEventId); } const done = (async () => { const res = await fetch(path, { headers, signal: combinedSignal }); if (!res.ok) { let body: Record = {}; try { body = await res.json(); } catch { body = { error: `API error: ${res.status}` }; } if (res.status === 401 && !path.startsWith("/api/auth/")) { const { default: router } = await import("@/router/index"); router.push({ name: "login" }); } throw new ApiError(res.status, body); } const reader = res.body?.getReader(); if (!reader) throw new Error("No response body"); const decoder = new TextDecoder(); let buffer = ""; // SSE field parsing state let currentId = -1; let currentEvent = "message"; let currentData = ""; function dispatch() { if (!currentData) return; let parsed: Record; try { parsed = JSON.parse(currentData); } catch { return; } onEvent({ id: currentId, event: currentEvent, data: parsed }); // Reset for next event currentEvent = "message"; currentData = ""; } function processLine(line: string) { if (line === "") { // Empty line = end of event dispatch(); return; } if (line.startsWith(":")) return; // comment / keepalive const colonIdx = line.indexOf(":"); let field: string; let value: string; if (colonIdx === -1) { field = line; value = ""; } else { field = line.slice(0, colonIdx); value = line.slice(colonIdx + 1); if (value.startsWith(" ")) value = value.slice(1); } switch (field) { case "id": currentId = parseInt(value, 10); break; case "event": currentEvent = value; break; case "data": currentData += value; break; } } while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() || ""; for (const line of lines) { processLine(line); } } // Process remaining buffer if (buffer) { processLine(buffer); } dispatch(); })().catch(() => { // Stream closed or aborted — handled by caller via reconnection }); return { close: () => controller.abort(), done }; } export async function apiStreamPost( path: string, body: unknown, onChunk: (data: Record) => void ): Promise { const res = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!res.ok) { let errBody: Record = {}; try { errBody = await res.json(); } catch { errBody = { error: `API error: ${res.status}` }; } if (res.status === 401 && !path.startsWith("/api/auth/")) { const { default: router } = await import("@/router/index"); router.push({ name: "login" }); } throw new ApiError(res.status, errBody); } const reader = res.body?.getReader(); if (!reader) throw new Error("No response body"); const decoder = new TextDecoder(); let buffer = ""; function processLines(text: string) { const lines = text.split("\n"); // Keep the last (possibly incomplete) line in the buffer buffer = lines.pop() || ""; for (const line of lines) { const trimmed = line.trim(); if (trimmed.startsWith("data: ")) { let data; try { data = JSON.parse(trimmed.slice(6)); } catch { continue; // Skip malformed JSON lines } onChunk(data); } } } try { while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); processLines(buffer); } } catch { // Stream may close with a network error after all data was sent. // Process whatever remains in the buffer before deciding to throw. } // Process any remaining buffer const remaining = buffer.trim(); if (remaining.startsWith("data: ")) { let data; try { data = JSON.parse(remaining.slice(6)); } catch { return; // Skip malformed JSON } onChunk(data); } }