/** * vapor-chamber — HTTP client * * Adapted and improved from useFetch (2026-02-05A). * TypeScript rewrite aligned with vapor-chamber conventions and CDCC thresholds. * * Improvements over the original: * - Full TypeScript types (no `any` casts) * - CDCC-compliant function sizes * - `AbortSignal.any` with manual fallback for older environments * - Jitter on exponential backoff (avoids thundering herd) * - `X-RateLimit-Reset` header as Retry-After fallback * - 419 CSRF refresh coalesces concurrent requests (no duplicate refreshes) * - `session-expired` CustomEvent + configurable callback * - `TimeoutError` distinct from `AbortError` (user abort vs timeout) */ // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export type HttpConfig = { /** Request timeout in ms. Default: 10_000 */ timeout?: number; /** Max retry attempts on 5xx/429/408. Default: 0 */ retry?: number; /** External abort signal (e.g. from component unmount) */ signal?: AbortSignal; /** Read CSRF token from DOM and attach as header. Default: false */ csrf?: boolean; /** * URL to fetch when a CSRF-expiry response (HTTP 419) occurs, to obtain a * fresh token. The default targets the Laravel Sanctum SPA convention * because it's the most common backend issuing 419 — override for other * frameworks, or set to '' to disable the auto-refresh entirely (the lib * will then only re-read the token from the DOM on retry). * Default: '/sanctum/csrf-cookie'. */ csrfCookieUrl?: string; /** Additional headers merged into every request */ headers?: Record; /** Called when a 401 session-expired response is received */ onSessionExpired?: (status: number) => void; /** * Stamps a thrown error's `.silent` so a caller-provided global error * handler can skip it — for fire-and-forget requests (best-effort * telemetry, background prefetch) that shouldn't surface UI noise. * Default: false. */ silent?: boolean; }; export type HttpResponse = { data: T; status: number; headers: Record; ok: boolean; /** True when this response was served from a stale (past-fresh) cache entry. */ stale?: boolean; /** Present on a stale hit: resolves with the fresh response once the background revalidation lands. */ revalidation?: Promise>; /** True when this is a retained cache entry served in place of a transient failure (`cache.serveStaleOnError`). */ servedOnError?: boolean; /** The transient error `servedOnError` masked — surfaced alongside the stale data, never silently dropped. */ error?: unknown; }; export type HttpError = Error & { name: 'HttpError' | 'TimeoutError' | 'AbortError'; response?: HttpResponse; status?: number; /** Machine-readable error code from response body (e.g. `'CART_ITEM_LIMIT_EXCEEDED'`). */ code?: string; /** Set when the request's `silent: true` config opts the caller out of a global error handler/toast. */ silent?: boolean; }; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const RETRY_STATUS = [408, 429, 500, 502, 503, 504]; const RETRY_AFTER_STATUS = [429, 503]; const SESSION_EXPIRED_STATUS = [401]; // 419 is CSRF expiry, not session expiry const MAX_RETRY_AFTER_MS = 30_000; const CSRF_TTL_MS = 300_000; // 5 min const DEFAULT_CSRF_COOKIE_URL = '/sanctum/csrf-cookie'; // --------------------------------------------------------------------------- // CSRF — multi-source with TTL cache // --------------------------------------------------------------------------- type CsrfResult = { token: string; headerName: string }; type CsrfCacheEntry = CsrfResult & { expiresAt: number }; let _csrfCache: CsrfCacheEntry | null = null; /** Read CSRF token from DOM: meta tag → cookie → hidden input. TTL-cached for 5 min. */ export function readCsrfToken(): CsrfResult | null { const now = Date.now(); if (_csrfCache && now < _csrfCache.expiresAt) { return { token: _csrfCache.token, headerName: _csrfCache.headerName }; } if (typeof document === 'undefined') return null; const result = readCsrfFromDom(); if (result) _csrfCache = { ...result, expiresAt: now + CSRF_TTL_MS }; return result; } function readCsrfFromDom(): CsrfResult | null { const q = typeof document.querySelector === 'function' ? (sel: string) => document.querySelector(sel) : null; // 1. Meta tag — ``. Common in // server-rendered frameworks (Laravel Blade, Rails, others). if (q) { const meta = q('meta[name="csrf-token"]') as HTMLMetaElement | null; if (meta?.content) return { token: meta.content, headerName: 'X-CSRF-TOKEN' }; } // 2. Cookie — read cookie name from `` or default // to `XSRF-TOKEN` (the de-facto SPA convention shared across frameworks). const cookieNameMeta = q?.('meta[name="xsrf-cookie"]') as HTMLMetaElement | null; const cookieName = cookieNameMeta?.content || 'XSRF-TOKEN'; const escaped = cookieName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const cookieMatch = document.cookie?.match(new RegExp(`(?:^|;\\s*)${escaped}=([^;]+)`)); if (cookieMatch) return { token: decodeURIComponent(cookieMatch[1]), headerName: 'X-XSRF-TOKEN' }; // 3. Hidden input — ``. Emitted by Laravel's `@csrf` // Blade directive, also appears in Rails forms and other stacks. if (q) { const input = q('input[name="_token"]') as HTMLInputElement | null; if (input?.value) return { token: input.value, headerName: 'X-CSRF-TOKEN' }; } return null; } /** Invalidate the CSRF token cache (e.g. after logout). */ export function invalidateCsrfCache(): void { _csrfCache = null; } let _csrfRefreshPromise: Promise | null = null; function refreshCsrfOnce(cookieUrl: string): Promise { // Coalesce: concurrent 419s share the single in-flight refresh promise — // waiters resolve/reject the instant it settles, no polling. if (_csrfRefreshPromise) return _csrfRefreshPromise; _csrfRefreshPromise = (async () => { try { // Fetch the CSRF cookie endpoint so the backend issues a fresh // XSRF-TOKEN cookie (Laravel Sanctum's `/sanctum/csrf-cookie` is the // most common shape; other frameworks expose equivalent endpoints). // Only fetch if cookieUrl is a non-empty string. if (typeof cookieUrl === 'string' && cookieUrl.length > 0) { try { await fetch(cookieUrl, { method: 'GET', credentials: 'same-origin' }); } catch { /* ignore network errors */ } } invalidateCsrfCache(); const freshToken = readCsrfToken(); // re-read DOM / cookie after fetch if (!freshToken) { throw new Error('[vapor-chamber] CSRF refresh failed: no token found in DOM after refresh'); } } finally { _csrfRefreshPromise = null; } })(); return _csrfRefreshPromise; } // --------------------------------------------------------------------------- // Retry timing // --------------------------------------------------------------------------- function parseRetryAfter(header: string | null): number | null { if (!header) return null; const seconds = Number(header); if (!Number.isNaN(seconds)) { const ms = seconds * 1000; return ms <= MAX_RETRY_AFTER_MS ? ms : null; } const date = Date.parse(header); if (!Number.isNaN(date)) { const ms = date - Date.now(); return ms > 0 && ms <= MAX_RETRY_AFTER_MS ? ms : null; } return null; } /** Exponential backoff with ±200ms jitter to avoid thundering herd. */ function backoffMs(attempt: number): number { return Math.min(1000 * 2 ** attempt, 30_000) + Math.random() * 200; } // --------------------------------------------------------------------------- // AbortSignal utilities // --------------------------------------------------------------------------- type CombinedSignal = { signal: AbortSignal; detach: () => void }; function combineSignals(a: AbortSignal, b: AbortSignal): CombinedSignal { // AbortSignal.any listeners are platform-managed (GC-safe) — nothing to detach. if (typeof AbortSignal.any === 'function') { return { signal: AbortSignal.any([a, b]), detach: () => {} }; } // Fallback for environments without AbortSignal.any. Callers MUST detach() // when the request settles — the user signal is typically component-lifetime, // so listeners left behind accrete once per request until unmount. const ctrl = new AbortController(); const abort = () => ctrl.abort(); a.addEventListener('abort', abort, { once: true }); b.addEventListener('abort', abort, { once: true }); return { signal: ctrl.signal, detach: () => { a.removeEventListener('abort', abort); b.removeEventListener('abort', abort); }, }; } function sleepMs(ms: number, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { const onAbort = () => { clearTimeout(id); reject(new DOMException('Aborted', 'AbortError')); }; // Detach on normal resolve too — the signal outlives this sleep, and the // listener would otherwise pin the timer closure per retry sleep. const id = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve(); }, ms); signal?.addEventListener('abort', onAbort, { once: true }); }); } // --------------------------------------------------------------------------- // Error constructors // --------------------------------------------------------------------------- function httpError(message: string, response: HttpResponse): HttpError { const err = new Error(message) as HttpError; err.name = 'HttpError'; err.response = response; err.status = response.status; const code = (response.data as any)?.code; if (code != null) err.code = String(code); return err; } function timeoutError(action: string, timeoutMs: number): HttpError { const err = new Error(`"${action}" timed out after ${timeoutMs}ms`) as HttpError; err.name = 'TimeoutError'; return err; } // --------------------------------------------------------------------------- // Session expiry // --------------------------------------------------------------------------- function handleSessionExpiry(status: number, url: string, onSessionExpired?: (s: number) => void): void { onSessionExpired?.(status); if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent('session-expired', { detail: { status, url } })); } } // --------------------------------------------------------------------------- // Core: postCommand // // Sends a single POST request with retry, CSRF, timeout and session detection. // Used by createHttpBridge — not intended as a general-purpose HTTP client. // --------------------------------------------------------------------------- async function doFetch(url: string, serialized: string, headers: Record, signal: AbortSignal): Promise> { const raw = await fetch(url, { method: 'POST', headers, body: serialized, credentials: 'same-origin', signal }); const resHeaders = raw.headers ? Object.fromEntries(raw.headers.entries()) : {}; let data: T = null as T; try { data = await raw.json() as T; } catch { /* non-JSON */ } return { data, status: raw.status, headers: resHeaders, ok: raw.ok }; } export async function postCommand( url: string, body: unknown, config: HttpConfig = {}, ): Promise> { const { timeout = 10_000, retry = 0, signal: userSignal, csrf = false, csrfCookieUrl = DEFAULT_CSRF_COOKIE_URL, headers: extra = {}, onSessionExpired, silent = false } = config; const headers: Record = { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', ...extra, }; if (csrf) { const token = readCsrfToken(); if (token) headers[token.headerName] = token.token; } const serialized = JSON.stringify(body); let csrfRetried = false; for (let attempt = 0; attempt <= retry; attempt++) { if (userSignal?.aborted) throw new DOMException('Aborted', 'AbortError'); const timeoutCtrl = new AbortController(); const timeoutId = setTimeout(() => timeoutCtrl.abort(), timeout); const combined = userSignal ? combineSignals(userSignal, timeoutCtrl.signal) : null; const signal = combined ? combined.signal : timeoutCtrl.signal; try { const res = await doFetch(url, serialized, headers, signal); clearTimeout(timeoutId); combined?.detach(); if (!res.ok) { if (SESSION_EXPIRED_STATUS.includes(res.status)) handleSessionExpiry(res.status, url, onSessionExpired); // 419: CSRF expired — fetch fresh cookie, refresh once, doesn't count against retry budget if (res.status === 419 && !csrfRetried) { csrfRetried = true; await refreshCsrfOnce(csrfCookieUrl); const fresh = readCsrfToken(); if (fresh) headers[fresh.headerName] = fresh.token; attempt--; continue; } // NOTE: unlike clientRequest, a 419 that survives the refresh retry is // NOT escalated to session expiry here — postCommand's documented // contract (whitepaper §5.7, pinned by tests) is that 419 never fires // onSessionExpired; the bridge surfaces it as an HttpError instead. // Retry on retryable status codes if (RETRY_STATUS.includes(res.status) && attempt < retry) { const retryAfter = res.headers['retry-after'] ?? res.headers['x-ratelimit-reset'] ?? null; const wait = RETRY_AFTER_STATUS.includes(res.status) ? (parseRetryAfter(retryAfter) ?? backoffMs(attempt)) : backoffMs(attempt); await sleepMs(wait, userSignal); continue; } const failed = httpError(`HTTP ${res.status}`, res); if (silent) failed.silent = true; throw failed; } return res; } catch (e) { clearTimeout(timeoutId); combined?.detach(); const err = e as HttpError; if (err.name === 'AbortError' && userSignal?.aborted) throw err; // A timeout-triggered abort is a transient failure, not a user cancel — // it must compete for the same retry budget as a 5xx/429/408 response // instead of always throwing on the first attempt regardless of `retry`. const failure = err.name === 'AbortError' ? timeoutError(url, timeout) : err; if (attempt >= retry) { if (silent) failure.silent = true; throw failure; } await sleepMs(backoffMs(attempt), userSignal); } } throw new Error('unreachable'); } // --------------------------------------------------------------------------- // Multi-method HTTP client — createHttpClient // // For new code, prefer createHttpClient(). postCommand is retained for // backward compatibility and is used by createHttpBridge. // --------------------------------------------------------------------------- export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; export type ResponseType = 'json' | 'blob' | 'text'; export type HttpRequestConfig = HttpConfig & { /** HTTP method. Default: 'GET' */ method?: HttpMethod; /** Request body (auto-serialized if object, passthrough for FormData) */ data?: unknown; /** Query parameters — supports arrays and nested objects */ params?: Record; /** Base URL prepended to relative paths */ baseURL?: string; /** Response parsing mode. Default: 'json' */ responseType?: ResponseType; /** * Enable LRU caching for GET. `true` = default TTL, no stale window. * `{ ttl }` sets the fresh-window duration. `{ staleTtl }` opts into * stale-while-revalidate: a hit within `ttl + staleTtl` past `ttl` is * served instantly with `{ stale: true, revalidation: Promise }` attached, * while a background fetch refreshes the entry. `serveStaleOnError` is a * separate opt-in: a transient failure (timeout/network/5xx) with ANY * retained entry (even past its stale window) resolves to * `{ stale: true, servedOnError: true, error }` instead of rejecting. */ cache?: boolean | { ttl?: number; staleTtl?: number; serveStaleOnError?: boolean }; /** Enable request deduplication for GET. Default: true */ dedupe?: boolean; /** @internal marks a CSRF-retried request */ _csrfRetried?: boolean; }; export type SafeResult = { data: T | null; error: { message: string; code?: string; [key: string]: unknown } | null; status: number; }; export type DownloadResult = { data: Blob; status: number; filename: string; }; export type Interceptor = { onFulfilled?: (value: T) => T | void; onRejected?: (error: unknown) => void; }; export type InterceptorManager = { use(onFulfilled?: (value: T) => T | void, onRejected?: (error: unknown) => void): number; eject(id: number): void; }; export type HttpClient = { get(url: string, config?: HttpRequestConfig): Promise>; post(url: string, data?: unknown, config?: HttpRequestConfig): Promise>; put(url: string, data?: unknown, config?: HttpRequestConfig): Promise>; patch(url: string, data?: unknown, config?: HttpRequestConfig): Promise>; delete(url: string, config?: HttpRequestConfig): Promise>; request(url: string, config?: HttpRequestConfig): Promise>; download(url: string, filename?: string, config?: HttpRequestConfig): Promise; safe: { get(url: string, config?: HttpRequestConfig): Promise>; post(url: string, data?: unknown, config?: HttpRequestConfig): Promise>; put(url: string, data?: unknown, config?: HttpRequestConfig): Promise>; patch(url: string, data?: unknown, config?: HttpRequestConfig): Promise>; delete(url: string, config?: HttpRequestConfig): Promise>; }; interceptors: { request: InterceptorManager; response: InterceptorManager; }; create(defaults?: Partial): HttpClient; clearCache(): void; invalidateCache(pattern: string | RegExp): void; }; // --------------------------------------------------------------------------- // Interceptor manager // --------------------------------------------------------------------------- type InterceptorEntry = Interceptor | null; function createInterceptorManager(): InterceptorManager & { forEach(fn: (h: Interceptor) => void): void } { const handlers: InterceptorEntry[] = []; return { use(onFulfilled, onRejected) { handlers.push({ onFulfilled, onRejected }); return handlers.length - 1; }, eject(id) { if (handlers[id]) handlers[id] = null; }, forEach(fn) { for (const h of handlers) { if (h) fn(h); } }, }; } // --------------------------------------------------------------------------- // Imports from internal helpers // --------------------------------------------------------------------------- import { getCached, getCachedAny, setCache, clearAllCache, invalidateCacheByPattern, getInflight, setInflight, CACHE_DEFAULT_TTL } from './http-cache'; import { classifyError } from './http-errors'; import { buildFullUrl } from './http-query'; // --------------------------------------------------------------------------- // Constants for multi-method client // --------------------------------------------------------------------------- const IDEMPOTENT_METHODS: HttpMethod[] = ['GET']; const MUTATION_METHODS: HttpMethod[] = ['POST', 'PUT', 'PATCH', 'DELETE']; const DEFAULT_GET_RETRY = 2; const DEFAULT_MUTATION_RETRY = 0; const DEFAULT_CLIENT_TIMEOUT = 30_000; // --------------------------------------------------------------------------- // Internal: generic fetch with retry, CSRF, timeout (multi-method) // --------------------------------------------------------------------------- async function doClientFetch( fullUrl: string, method: HttpMethod, headers: Record, body: string | FormData | undefined, responseType: ResponseType, signal: AbortSignal, ): Promise> { const init: RequestInit = { method, headers, credentials: 'same-origin', signal }; if (body !== undefined) init.body = body; const raw = await fetch(fullUrl, init); const resHeaders = raw.headers ? Object.fromEntries(raw.headers.entries()) : {}; let data: any = null; if (responseType === 'blob') { data = await raw.blob(); } else if (responseType === 'text') { data = await raw.text(); } else { // json (default) — graceful fallback for non-JSON responses const contentType = raw.headers.get('content-type') || ''; if (contentType.includes('application/json')) { const text = await raw.text(); data = text ? JSON.parse(text) : null; } else { data = await raw.text(); } } return { data: data as T, status: raw.status, headers: resHeaders, ok: raw.ok }; } async function clientRequest( fullUrl: string, method: HttpMethod, headersObj: Record, body: string | FormData | undefined, responseType: ResponseType, maxRetries: number, timeout: number, userSignal: AbortSignal | undefined, csrf: boolean, csrfCookieUrl: string, onSessionExpired?: (status: number) => void, ): Promise> { let csrfRetried = false; // Attach CSRF for mutation methods if (csrf && MUTATION_METHODS.includes(method)) { const token = readCsrfToken(); if (token) headersObj[token.headerName] = token.token; } for (let attempt = 0; attempt <= maxRetries; attempt++) { if (userSignal?.aborted) throw new DOMException('Aborted', 'AbortError'); const timeoutCtrl = new AbortController(); const timeoutId = setTimeout(() => timeoutCtrl.abort(), timeout); const combined = userSignal ? combineSignals(userSignal, timeoutCtrl.signal) : null; const signal = combined ? combined.signal : timeoutCtrl.signal; try { const res = await doClientFetch(fullUrl, method, headersObj, body, responseType, signal); clearTimeout(timeoutId); combined?.detach(); if (!res.ok) { if (SESSION_EXPIRED_STATUS.includes(res.status)) handleSessionExpiry(res.status, fullUrl, onSessionExpired); // 419 CSRF refresh — once, doesn't count against retry budget if (res.status === 419 && !csrfRetried) { csrfRetried = true; await refreshCsrfOnce(csrfCookieUrl); const fresh = readCsrfToken(); if (fresh) headersObj[fresh.headerName] = fresh.token; attempt--; continue; } // CSRF expiry that survived the refresh retry is effectively a dead // session too. 401 already notified above via SESSION_EXPIRED_STATUS. if (res.status === 419 && csrfRetried) { handleSessionExpiry(res.status, fullUrl, onSessionExpired); } if (RETRY_STATUS.includes(res.status) && attempt < maxRetries) { const retryAfter = res.headers['retry-after'] ?? res.headers['x-ratelimit-reset'] ?? null; const wait = RETRY_AFTER_STATUS.includes(res.status) ? (parseRetryAfter(retryAfter) ?? backoffMs(attempt)) : backoffMs(attempt); await sleepMs(wait, userSignal); continue; } throw httpError(`HTTP ${res.status}`, res); } return res; } catch (e) { clearTimeout(timeoutId); combined?.detach(); const err = e as Error; if (err.name === 'AbortError' && userSignal?.aborted) throw err; // Same rule as postCommand: a timeout must retry like any other // transient failure instead of always throwing on the first attempt. const failure = err.name === 'AbortError' ? timeoutError(fullUrl, timeout) : err; if (attempt >= maxRetries) throw failure; await sleepMs(backoffMs(attempt), userSignal); } } throw new Error('unreachable'); } // --------------------------------------------------------------------------- // createHttpClient // --------------------------------------------------------------------------- /** * createHttpClient — multi-method HTTP client with interceptors, caching, * deduplication, safe mode, and file download. * * Aligned with useFetch (2026-02-05A) patterns. Framework-agnostic — no Vue imports. * * @example * const http = createHttpClient({ baseURL: '/api', csrf: true }); * * // All methods * const users = await http.get('/users', { params: { page: 1 } }); * await http.post('/cart', { itemId: 1, qty: 2 }); * await http.put('/cart/1', { qty: 5 }); * await http.delete('/cart/1'); * * // Safe mode — never throws * const result = await http.safe.post('/login', credentials); * if (result.error) console.log(result.error.message); * * // File download * await http.download('/export/csv', 'products.csv'); * * // Interceptors * http.interceptors.request.use((config) => { config.headers = { ...config.headers, 'X-Custom': '1' }; return config; }); * * // Create scoped instance * const adminHttp = http.create({ baseURL: '/admin/api', headers: { 'X-Admin': 'true' } }); */ export function createHttpClient(instanceDefaults: Partial = {}): HttpClient { const requestInterceptors = createInterceptorManager(); const responseInterceptors = createInterceptorManager(); async function request(url: string, options: HttpRequestConfig = {}): Promise> { // Merge instance defaults with per-call options let config: HttpRequestConfig = { ...instanceDefaults, ...options, headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest', ...instanceDefaults.headers, ...options.headers, }, }; // Run request interceptors requestInterceptors.forEach(({ onFulfilled, onRejected }) => { try { if (onFulfilled) config = onFulfilled(config) || config; } catch (e) { if (onRejected) onRejected(e); } }); const method: HttpMethod = (config.method ?? 'GET') as HttpMethod; const timeout = config.timeout ?? DEFAULT_CLIENT_TIMEOUT; const isIdempotent = IDEMPOTENT_METHODS.includes(method); const maxRetries = config.retry ?? (isIdempotent ? DEFAULT_GET_RETRY : DEFAULT_MUTATION_RETRY); const responseType: ResponseType = config.responseType ?? 'json'; const csrf = config.csrf ?? false; const csrfCookieUrl = config.csrfCookieUrl ?? DEFAULT_CSRF_COOKIE_URL; const dedupe = config.dedupe ?? true; const fullUrl = buildFullUrl(url, config.baseURL, config.params); // responseType is part of both keys — a concurrent get(url) (json) and // get(url, { responseType: 'blob' }) must not collapse to one request or // one cache slot, or the loser receives the wrong data type. const dedupeKey = `${method}:${responseType}:${fullUrl}`; const cacheKey = `${responseType}:${fullUrl}`; // Request deduplication for GET if (isIdempotent && dedupe) { const inflight = getInflight(dedupeKey); if (inflight) return inflight as Promise>; } // LRU cache for GET. A fresh hit short-circuits; a stale hit (within // cache.staleTtl) is captured and served below with a background // revalidation attached — the stale-while-revalidate path. const cacheEnabled = config.cache && isIdempotent; const cacheCfg = typeof config.cache === 'object' ? config.cache : {}; let staleResponse: HttpResponse | null = null; if (cacheEnabled) { const cached = getCached(cacheKey); if (cached && !cached.stale) return cached.data as HttpResponse; if (cached) staleResponse = cached.data as HttpResponse; } // Build headers and body const headersObj: Record = { ...config.headers } as Record; let body: string | FormData | undefined; const rawData = config.data; if (rawData !== undefined && rawData !== null) { if (rawData instanceof FormData) { body = rawData; delete headersObj['Content-Type']; // let browser set boundary } else if (typeof rawData === 'object') { body = JSON.stringify(rawData); headersObj['Content-Type'] = 'application/json'; } else { body = String(rawData); } } // Execute request const fetchPromise = clientRequest( fullUrl, method, headersObj, body, responseType, maxRetries, timeout, config.signal, csrf, csrfCookieUrl, config.onSessionExpired, ).then((res) => { // Run response interceptors let response = res as HttpResponse; responseInterceptors.forEach(({ onFulfilled }) => { if (onFulfilled) response = onFulfilled(response) || response; }); // Cache successful GET responses if (cacheEnabled && response.ok) { setCache(cacheKey, response, cacheCfg.ttl ?? CACHE_DEFAULT_TTL, cacheCfg.staleTtl ?? 0); } return response as HttpResponse; }).catch((err) => { // Run response error interceptors responseInterceptors.forEach(({ onRejected }) => { if (onRejected) onRejected(err); }); if (config.silent) (err as HttpError).silent = true; throw err; }); // Track in-flight GET for deduplication if (isIdempotent && dedupe) { setInflight(dedupeKey, fetchPromise); } // Stale-while-revalidate: serve the stale response now; the fetch above // finishes in the background and setCache()s on success. `revalidation` // lets a caller push the fresh data into its own state when it lands. if (staleResponse) { fetchPromise.catch(() => {}); // background failure must not surface as an unhandled rejection return { ...staleResponse, stale: true, revalidation: fetchPromise } as HttpResponse; } // cache.serveStaleOnError (opt-in): a transient failure (timeout/network/ // 5xx per classifyError) with ANY retained entry for this URL — even one // past its stale window — resolves to { stale, servedOnError, error } // instead of rejecting. Business errors (4xx) and user aborts always // surface; deduped followers share this promise's outcome. if (cacheEnabled && cacheCfg.serveStaleOnError) { return fetchPromise.catch((error) => { const aborted = (error as HttpError)?.name === 'AbortError'; if (!aborted && classifyError(error).transient) { const retained = getCachedAny(cacheKey); if (retained) { return { ...(retained.data as HttpResponse), stale: true, servedOnError: true, error }; } } throw error; }); } return fetchPromise; } // Safe mode wrapper async function safeRequest(method: HttpMethod, url: string, data?: unknown, config: HttpRequestConfig = {}): Promise> { try { const reqConfig: HttpRequestConfig = { ...config, method }; if (data !== undefined) reqConfig.data = data; const response = await request(url, reqConfig); return { data: response.data, error: null, status: response.status }; } catch (err) { const e = err as HttpError; const errorData = e.response?.data as any; return { data: null, error: errorData && typeof errorData === 'object' ? errorData : { message: e.message, code: e.code }, status: e.status ?? e.response?.status ?? 0, }; } } // Download helper async function download(url: string, filename?: string, config: HttpRequestConfig = {}): Promise { const response = await request(url, { ...config, method: config.method ?? 'GET', responseType: 'blob' }); let downloadFilename = filename; if (!downloadFilename) { const disposition = response.headers['content-disposition']; if (disposition) { const match = disposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/); if (match) downloadFilename = match[1].replace(/['"]/g, ''); } } downloadFilename = downloadFilename || 'download'; // Trigger browser download (guarded for SSR) if (typeof document !== 'undefined' && response.data instanceof Blob) { const link = document.createElement('a'); link.href = URL.createObjectURL(response.data); link.download = downloadFilename; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(link.href); } return { data: response.data, status: response.status, filename: downloadFilename }; } return { get: (url: string, config?: HttpRequestConfig) => request(url, { ...config, method: 'GET' }), post: (url: string, data?: unknown, config?: HttpRequestConfig) => request(url, { ...config, method: 'POST', data }), put: (url: string, data?: unknown, config?: HttpRequestConfig) => request(url, { ...config, method: 'PUT', data }), patch: (url: string, data?: unknown, config?: HttpRequestConfig) => request(url, { ...config, method: 'PATCH', data }), delete: (url: string, config?: HttpRequestConfig) => request(url, { ...config, method: 'DELETE' }), request, download, safe: { get: (url: string, config?: HttpRequestConfig) => safeRequest('GET', url, undefined, config), post: (url: string, data?: unknown, config?: HttpRequestConfig) => safeRequest('POST', url, data, config), put: (url: string, data?: unknown, config?: HttpRequestConfig) => safeRequest('PUT', url, data, config), patch: (url: string, data?: unknown, config?: HttpRequestConfig) => safeRequest('PATCH', url, data, config), delete: (url: string, config?: HttpRequestConfig) => safeRequest('DELETE', url, undefined, config), }, interceptors: { request: requestInterceptors, response: responseInterceptors, }, create: (newDefaults) => createHttpClient({ ...instanceDefaults, ...newDefaults, headers: { ...instanceDefaults.headers, ...newDefaults?.headers }, }), clearCache: clearAllCache, invalidateCache: invalidateCacheByPattern, }; }