/** * Core fetch wrapper - reusable across modules */ export interface FetchWrapperOptions { /** Additional headers merged into every request */ headers?: Record; /** Override default Content-Type (default: application/json) */ contentType?: string; /** Override default Accept type (default: application/json) */ accept?: string; /** Called before every request */ beforeRequest?: () => Promise; /** Called after every successful non-stream response */ afterResponse?: (data: any) => void; } export interface RequestCallOptions { /** When set, the raw Response is forwarded to this handler instead of being JSON-parsed */ onStream?: (response: Response) => Promise; /** Per-call header overrides — highest priority, wins over factory-level headers */ headers?: Record; } /** Error thrown for non-2xx responses, carrying the HTTP status code */ export class FetchError extends Error { constructor(message: string, public readonly status: number) { super(message); this.name = 'FetchError'; } } function getCsrfToken(): string | null { if (typeof document === 'undefined') return null; const cookie = document.cookie.split('; ').find(r => r.startsWith('X-CSRF-TOKEN=')); return cookie ? (cookie.split('=')[1] ?? null) : null; } export function createFetchWrapper(options: FetchWrapperOptions = {}) { async function request( method: string, url: string, body?: object, callOptions?: RequestCallOptions ): Promise { if (options.beforeRequest) { await options.beforeRequest(); } // Header priority: defaults < factory options.headers < callOptions.headers const headers: Record = { 'Content-Type': options.contentType ?? 'application/json', 'Accept': options.accept ?? 'application/json', ...options.headers, ...callOptions?.headers, }; const csrf = getCsrfToken(); if (csrf) headers['X-CSRF-TOKEN'] = csrf; const requestOptions: RequestInit = { method, headers, credentials: 'include', body: body ? JSON.stringify(body) : undefined, }; const response = await fetch(url, requestOptions); if (!response.ok) { const text = await response.text(); let data = null; try { data = text ? JSON.parse(text) : null; } catch { /* non-JSON error body */ } throw new FetchError(data?.message || response.statusText, response.status); } if (callOptions?.onStream) { return callOptions.onStream(response); } const text = await response.text(); const data = text ? JSON.parse(text) : null; options.afterResponse?.(data); return data; } return { get: (url: string, callOptions?: RequestCallOptions) => request('GET', url, undefined, callOptions), post: (url: string, body?: object, callOptions?: RequestCallOptions) => request('POST', url, body, callOptions), put: (url: string, body?: object, callOptions?: RequestCallOptions) => request('PUT', url, body, callOptions), patch: (url: string, body?: object, callOptions?: RequestCallOptions) => request('PATCH', url, body, callOptions), delete: (url: string, callOptions?: RequestCallOptions) => request('DELETE', url, undefined, callOptions), }; }