/** * Core fetch wrapper - reusable across modules * Does not depend on stores to avoid bundling issues in library builds */ export interface FetchWrapperOptions { /** * Optional callback to handle logout on auth failure * If not provided, only logs error without redirecting */ onLogout?: () => void | Promise; /** * Enable automatic retry of failed requests after token refresh * @default true */ autoRetry?: boolean; } // Mutex to prevent multiple simultaneous refresh attempts class RefreshMutex { private promise: Promise | null = null; async acquire(): Promise { if (this.promise) { return this.promise; } this.promise = this.performRefresh(); const result = await this.promise; this.promise = null; return result; } private async performRefresh(): Promise { try { const response = await fetch('/security/refresh', { method: 'POST', credentials: 'include' }); return response.ok; } catch (error) { console.error('Token refresh failed:', error); return false; } } } const tokenRefreshMutex = new RefreshMutex(); export interface RequestCallOptions { /** * Override autoRetry for this specific request * @default true */ autoRetry?: boolean; /** * Skip calling onLogout callback on 401/403 errors * Useful for authentication endpoints where 401 means invalid credentials, not session expiry * @default false */ skipLogoutOnError?: boolean; } export function createFetchWrapper(options: FetchWrapperOptions = {}) { const { autoRetry: defaultAutoRetry = true } = options; return { get: request('GET', options, defaultAutoRetry), post: request('POST', options, defaultAutoRetry), put: request('PUT', options, defaultAutoRetry), delete: request('DELETE', options, defaultAutoRetry) }; } interface RequestOptions { method: string; headers: Record; body?: string; credentials: 'include'; } function request(method: string, options: FetchWrapperOptions, defaultAutoRetry: boolean) { return async (url: string, body?: object, callOptions?: RequestCallOptions): Promise => { const autoRetry = callOptions?.autoRetry ?? defaultAutoRetry; const requestOptions: RequestOptions = { method, headers: getHeaders(url), credentials: 'include' }; if (body) { requestOptions.headers['Content-Type'] = 'application/json'; requestOptions.body = JSON.stringify(body); } try { const response = await fetch(url, requestOptions); if (response.status === 401 && autoRetry) { // Try to refresh the token const refreshSuccess = await tokenRefreshMutex.acquire(); if (refreshSuccess) { // Retry the original request with the new token const retryResponse = await fetch(url, requestOptions); return handleResponse(retryResponse, options, callOptions?.skipLogoutOnError); } } return handleResponse(response, options, callOptions?.skipLogoutOnError); } catch (error) { console.error('API error:', error); throw error; } }; } function getHeaders(_url: string): Record { const headers: Record = {}; // Add CSRF token if available const csrfToken = getCsrfTokenFromCookie(); if (csrfToken) { headers['X-CSRF-TOKEN'] = csrfToken; } return headers; } function getCsrfTokenFromCookie(): string | null { if (typeof document === 'undefined') return null; const csrfCookie = document.cookie .split('; ') .find(row => row.startsWith('X-CSRF-TOKEN=')); return csrfCookie ? csrfCookie.split('=')[1] : null; } async function handleResponse(response: Response, options: FetchWrapperOptions, skipLogoutOnError?: boolean): Promise { const text = await response.text(); const data = text && JSON.parse(text); if (!response.ok) { if ([401, 403].includes(response.status) && !skipLogoutOnError) { // Call logout callback if provided if (options.onLogout) { await options.onLogout(); } else { console.error('Authentication failed. Please log in again.'); } } const error: string = (data && data.message) || response.statusText; throw new Error(error); } return data; }