Repository Public
1// One place for every call to the API: same-origin, cookies, the CSRF header, and errors with a stable code.2export class ApiError extends Error {3 constructor(public status: number, public code: string, message: string, public data: Record<string, any> = {}) { super(message); }4}5export interface CallOptions { headers?: Record<string, string>; signal?: AbortSignal }67async function parse<T>(res: Response): Promise<T> {8 const isJson = (res.headers.get('content-type') ?? '').includes('json');9 const body = isJson ? await res.json().catch(() => null) : null;10 if (!res.ok) throw new ApiError(res.status, body?.error ?? 'http_error', body?.message ?? `HTTP ${res.status}`, body ?? {});11 return body as T;12}13export async function api<T = unknown>(method: string, path: string, body?: unknown, o: CallOptions = {}): Promise<T> {14 const raw = body instanceof Blob || body instanceof ArrayBuffer || ArrayBuffer.isView(body);15 const res = await fetch(`/api/v1${path}`, {16 method, credentials: 'same-origin', signal: o.signal,17 headers: { ...(method !== 'GET' ? { 'X-SPD-CSRF': '1' } : {}), ...(raw ? { 'Content-Type': 'application/octet-stream' } : body !== undefined ? { 'Content-Type': 'application/json' } : {}), ...o.headers },18 body: raw ? (body as BodyInit) : body !== undefined ? JSON.stringify(body) : method !== 'GET' ? '{}' : undefined,19 }).catch((e: Error) => { if (e.name === 'AbortError') throw e; throw new ApiError(0, 'network', 'network error'); });20 if (res.status === 401 && !path.startsWith('/public/') && !path.startsWith('/auth/')) window.dispatchEvent(new CustomEvent('spd:unauthenticated', { detail: await res.clone().json().catch(() => ({})) }));21 return parse<T>(res);22}23export const get = <T = unknown>(path: string, o?: CallOptions) => api<T>('GET', path, undefined, o);24export const post = <T = unknown>(path: string, body?: unknown, o?: CallOptions) => api<T>('POST', path, body, o);25export const patch = <T = unknown>(path: string, body?: unknown, o?: CallOptions) => api<T>('PATCH', path, body, o);26export const del = <T = unknown>(path: string, o?: CallOptions) => api<T>('DELETE', path, undefined, o);27/** Query string from an object, skipping empty values. */28export const qs = (o: Record<string, string | number | boolean | undefined | null>) => {29 const p = new URLSearchParams();30 for (const [k, v] of Object.entries(o)) if (v !== undefined && v !== null && v !== '' && v !== false) p.set(k, String(v));31 const s = p.toString();32 return s ? `?${s}` : '';33};34export const sha256Hex = async (data: BufferSource): Promise<string> => [...new Uint8Array(await crypto.subtle.digest('SHA-256', data))].map((b) => b.toString(16).padStart(2, '0')).join('');35