const API_BASE_URL = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:3001/api/v1'; export class ApiError extends Error { constructor( public status: number, message: string, ) { super(message); this.name = 'ApiError'; } } type RequestOptions = Omit & { body?: unknown; }; function getCsrfToken(): string | undefined { if (typeof document === 'undefined') return undefined; const match = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]*)/); return match?.[1] ? decodeURIComponent(match[1]) : undefined; } const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); // Endpoints for which a transparent refresh-and-retry on 401 must NOT run // (refresh itself, login, logout, exchange-token, etc.). const AUTH_ENDPOINTS = new Set([ '/auth/refresh', '/auth/login', '/auth/logout', '/auth/register', '/auth/exchange-token', '/auth/forgot-password', '/auth/reset-password', ]); /** * Coalesce concurrent refresh attempts: if ten requests race a 401 at the same * moment, we should fire `/auth/refresh` once, not ten times. */ let refreshInflight: Promise | null = null; async function tryRefresh(): Promise { if (refreshInflight) return refreshInflight; refreshInflight = (async () => { try { const csrfToken = getCsrfToken(); const res = await fetch(`${API_BASE_URL}/auth/refresh`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}), }, body: JSON.stringify({}), }); return res.ok; } catch { return false; } finally { // Allow a new refresh attempt after this one settles. setTimeout(() => { refreshInflight = null; }, 0); } })(); return refreshInflight; } async function doFetch(endpoint: string, options: RequestOptions): Promise { const { body, headers, ...rest } = options; const method = options.method?.toUpperCase() ?? 'GET'; const csrfHeaders: HeadersInit = {}; if (!SAFE_METHODS.has(method)) { const csrfToken = getCsrfToken(); if (csrfToken) { csrfHeaders['X-CSRF-Token'] = csrfToken; } } return fetch(`${API_BASE_URL}${endpoint}`, { ...rest, credentials: 'include', headers: { 'Content-Type': 'application/json', ...csrfHeaders, ...headers, }, body: body ? JSON.stringify(body) : undefined, }); } async function request(endpoint: string, options: RequestOptions = {}): Promise { let res = await doFetch(endpoint, options); // If the access_token expired silently, try one refresh+retry before giving // up. Skip for auth endpoints themselves to avoid loops. if (res.status === 401 && !AUTH_ENDPOINTS.has(endpoint)) { const refreshed = await tryRefresh(); if (refreshed) { res = await doFetch(endpoint, options); } } if (!res.ok) { const error = await res.json().catch(() => ({ message: res.statusText })); throw new ApiError(res.status, error.message || 'Request failed'); } return res.json(); } export const apiClient = { get: (endpoint: string, headers?: HeadersInit) => request(endpoint, { method: 'GET', headers }), post: (endpoint: string, body?: unknown, headers?: HeadersInit) => request(endpoint, { method: 'POST', body, headers }), patch: (endpoint: string, body?: unknown, headers?: HeadersInit) => request(endpoint, { method: 'PATCH', body, headers }), delete: (endpoint: string, headers?: HeadersInit) => request(endpoint, { method: 'DELETE', headers }), };