import { z } from 'zod' let csrfToken: string | null = null // Called once on app bootstrap. Fetches the CSRF token and sets the cookie. export async function bootstrapCSRF(): Promise { const res = await fetch('/api/v1/csrf', { credentials: 'include' }) if (!res.ok) return const data = await res.json() if (typeof data.token === 'string') { csrfToken = data.token } } export async function getCSRFToken(): Promise { if (csrfToken) return csrfToken await bootstrapCSRF() return csrfToken ?? '' } export class ApiError extends Error { readonly status: number constructor(status: number, message: string) { super(message) this.name = 'ApiError' this.status = status } } interface RequestOptions extends RequestInit { json?: unknown } async function request( path: string, schema: z.ZodType, options: RequestOptions = {}, ): Promise { const { json, ...rest } = options const headers: Record = {} if (json !== undefined) { headers['Content-Type'] = 'application/json' } const method = (rest.method ?? 'GET').toUpperCase() if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) { headers['X-CSRF-Token'] = await getCSRFToken() } const res = await fetch(path, { ...rest, credentials: 'include', headers: { ...headers, ...(rest.headers as Record | undefined) }, body: json !== undefined ? JSON.stringify(json) : rest.body, }) if (!res.ok) { let message = res.statusText try { const body = await res.json() if (typeof body.error === 'string') message = body.error } catch {} throw new ApiError(res.status, message) } if (res.status === 204) return schema.parse(null) const data = await res.json() return schema.parse(data) } export const api = { get: (path: string, schema: z.ZodType) => request(path, schema), post: (path: string, schema: z.ZodType, body: unknown) => request(path, schema, { method: 'POST', json: body }), put: (path: string, schema: z.ZodType, body: unknown) => request(path, schema, { method: 'PUT', json: body }), patch: (path: string, schema: z.ZodType, body: unknown) => request(path, schema, { method: 'PATCH', json: body }), delete: (path: string, schema: z.ZodType) => request(path, schema, { method: 'DELETE' }), }