import type { ApiError } from '~/types'; const API_BASE = '/api/v1'; /** * Centralized HTTP client for all API communication. * * Every request includes the `X-Requested-With: XMLHttpRequest` header as a * lightweight CSRF mitigation: the backend rejects requests that lack this * header, which browsers won't add on cross-origin form submissions. * * Credentials are sent with `same-origin` policy so the session cookie is * attached automatically without exposing it to third-party origins. * * On a 401 response the client redirects the browser to `/login`, ensuring * expired sessions are handled uniformly across the application. */ class ApiClient { /** * Execute an HTTP request and return the parsed JSON response. * * Handles JSON serialization, FormData pass-through, 401 redirects, * and 204 (no-content) responses. */ private async request( method: string, path: string, options?: { body?: unknown; headers?: Record; signal?: AbortSignal; }, ): Promise { const url = `${API_BASE}${path}`; const headers: Record = { 'X-Requested-With': 'XMLHttpRequest', ...options?.headers, }; if (options?.body && !(options.body instanceof FormData)) { headers['Content-Type'] = 'application/json'; } const response = await fetch(url, { method, headers, body: options?.body instanceof FormData ? options.body : options?.body ? JSON.stringify(options.body) : undefined, credentials: 'same-origin', signal: options?.signal, }); if (!response.ok) { if (response.status === 401) { window.location.href = '/login'; throw { status: 401, message: 'Session expired' } satisfies ApiError; } const errorBody = await response .json() .catch(() => ({ error: 'Unknown error' })); const apiError: ApiError = { status: response.status, message: errorBody.message || errorBody.error || `HTTP ${response.status}`, field_errors: errorBody.field_errors, }; throw apiError; } if (response.status === 204) { return undefined as T; } return response.json(); } /** Send a GET request. Accepts an optional `AbortSignal` for cancellation. */ get(path: string, signal?: AbortSignal): Promise { return this.request('GET', path, { signal }); } /** Send a POST request with an optional JSON or FormData body. */ post(path: string, body?: unknown): Promise { return this.request('POST', path, { body }); } /** Send a PUT request with an optional JSON body. */ put(path: string, body?: unknown): Promise { return this.request('PUT', path, { body }); } /** Send a DELETE request. */ delete(path: string): Promise { return this.request('DELETE', path); } } /** Singleton API client instance used by all API modules. */ export const api = new ApiClient();