import { get } from 'svelte/store'; import { authStore } from '$lib/stores/auth'; const BASE = '/api/v1'; export class ApiError extends Error { constructor( public readonly status: number, public readonly code: string, message: string, public readonly details?: Array<{ field?: string; message?: string }>, ) { super(message); this.name = 'ApiError'; } } // Deduplicates concurrent 401 refresh attempts into a single in-flight request. let refreshPromise: Promise | null = null; async function refreshTokens(): Promise { const { refreshToken } = get(authStore); if (!refreshToken) { authStore.set({ accessToken: null, refreshToken: null, user: null }); throw new ApiError(401, 'unauthorized', 'Session expired'); } const res = await fetch(`${BASE}/auth/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refresh_token: refreshToken }), }); if (!res.ok) { authStore.set({ accessToken: null, refreshToken: null, user: null }); throw new ApiError(401, 'unauthorized', 'Session expired'); } const data = await res.json(); authStore.update((s) => ({ ...s, accessToken: data.access_token ?? null, refreshToken: data.refresh_token ?? null, })); } function buildHeaders(init: RequestInit | undefined, accessToken: string | null): HeadersInit { const isFormData = init?.body instanceof FormData; const base: Record = isFormData ? {} : { 'Content-Type': 'application/json' }; if (accessToken) base['Authorization'] = `Bearer ${accessToken}`; return { ...base, ...(init?.headers as Record | undefined) }; } async function request(path: string, init?: RequestInit): Promise { let res = await fetch(BASE + path, { ...init, headers: buildHeaders(init, get(authStore).accessToken), }); if (res.status === 401) { if (!refreshPromise) { refreshPromise = refreshTokens().finally(() => { refreshPromise = null; }); } try { await refreshPromise; } catch { throw new ApiError(401, 'unauthorized', 'Session expired'); } res = await fetch(BASE + path, { ...init, headers: buildHeaders(init, get(authStore).accessToken), }); } if (!res.ok) { let body: { code?: string; message?: string; details?: Array<{ field?: string; message?: string }> } = {}; try { body = await res.json(); } catch { // ignore parse failure } throw new ApiError(res.status, body.code ?? 'error', body.message ?? res.statusText, body.details); } if (res.status === 204) return undefined as T; return res.json(); } /** Upload with XHR so we can track progress via onProgress(0–100). */ export function uploadWithProgress( path: string, formData: FormData, onProgress: (pct: number) => void, ): Promise { return new Promise((resolve, reject) => { const token = get(authStore).accessToken; const xhr = new XMLHttpRequest(); xhr.open('POST', BASE + path); if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`); xhr.upload.onprogress = (e) => { if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100)); }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { try { resolve(JSON.parse(xhr.responseText) as T); } catch { resolve(undefined as T); } } else { let body: { code?: string; message?: string } = {}; try { body = JSON.parse(xhr.responseText); } catch { /* ignore */ } reject(new ApiError(xhr.status, body.code ?? 'error', body.message ?? xhr.statusText)); } }; xhr.onerror = () => reject(new ApiError(0, 'network_error', 'Network error')); xhr.send(formData); }); } export const api = { get: (path: string) => request(path), post: (path: string, body?: unknown) => request(path, { method: 'POST', body: JSON.stringify(body) }), patch: (path: string, body?: unknown) => request(path, { method: 'PATCH', body: JSON.stringify(body) }), put: (path: string, body?: unknown) => request(path, { method: 'PUT', body: JSON.stringify(body) }), delete: (path: string) => request(path, { method: 'DELETE' }), upload: (path: string, formData: FormData) => request(path, { method: 'POST', body: formData }), };