api-client.ts
1 import { fetchWithTimeout, isAbortError, isTimeoutError } from '@/lib/fetch-timeout' 2 import { safeStorageGet, safeStorageSet, safeStorageRemove } from './safe-storage' 3 import { sleep, hmrSingleton } from '@/lib/shared-utils' 4 5 const ACCESS_KEY_STORAGE = 'sc_access_key' 6 const DEFAULT_API_TIMEOUT_MS = 12_000 7 const DEFAULT_GET_RETRIES = 2 8 const RETRY_DELAY_BASE_MS = 300 9 const inflightGetRequests = hmrSingleton('apiClient_inflightGetRequests', () => new Map<string, Promise<unknown>>()) 10 11 export function getStoredAccessKey(): string { 12 return safeStorageGet(ACCESS_KEY_STORAGE) || '' 13 } 14 15 export function setStoredAccessKey(key: string) { 16 safeStorageSet(ACCESS_KEY_STORAGE, key) 17 } 18 19 export function clearStoredAccessKey() { 20 safeStorageRemove(ACCESS_KEY_STORAGE) 21 } 22 23 function buildInflightGetKey(path: string, key: string): string { 24 return `${key}::${path}` 25 } 26 27 export async function api<T = unknown>( 28 method: string, 29 path: string, 30 body?: unknown, 31 options?: { timeoutMs?: number; retries?: number }, 32 ): Promise<T> { 33 const key = getStoredAccessKey() 34 const timeoutMs = Math.max(1_000, Math.trunc(options?.timeoutMs ?? DEFAULT_API_TIMEOUT_MS)) 35 const upperMethod = method.toUpperCase() 36 const retries = Math.max(0, Math.trunc(options?.retries ?? (upperMethod === 'GET' ? DEFAULT_GET_RETRIES : 0))) 37 38 const requestInit: RequestInit = { 39 method: upperMethod, 40 headers: { 41 'Content-Type': 'application/json', 42 ...(key ? { 'X-Access-Key': key } : {}), 43 }, 44 } 45 if (body) requestInit.body = JSON.stringify(body) 46 47 const runRequest = async (): Promise<T> => { 48 for (let attempt = 0; attempt <= retries; attempt++) { 49 try { 50 const r = await fetchWithTimeout('/api' + path, requestInit, timeoutMs) 51 52 if (r.status === 401) { 53 // Clear stored key on auth failure, redirect to login 54 clearStoredAccessKey() 55 if (typeof window !== 'undefined') { 56 window.dispatchEvent(new Event('sc_auth_required')) 57 } 58 throw new Error('Unauthorized — invalid access key') 59 } 60 61 const ct = r.headers.get('content-type') || '' 62 63 if (!r.ok) { 64 if (ct.includes('json')) { 65 const payload = await r.json().catch(() => null) as { error?: unknown; message?: unknown } | null 66 const msg = 67 (typeof payload?.error === 'string' && payload.error.trim()) 68 || (typeof payload?.message === 'string' && payload.message.trim()) 69 || `Request failed (${r.status})` 70 throw new Error(msg) 71 } 72 const text = (await r.text().catch(() => '')).trim() 73 throw new Error(text || `Request failed (${r.status})`) 74 } 75 76 if (ct.includes('json')) return r.json() as Promise<T> 77 return r.text() as unknown as T 78 } catch (err) { 79 const isLastAttempt = attempt >= retries 80 const retryable = 81 isAbortError(err) 82 || isTimeoutError(err) 83 || (err instanceof TypeError && !String(err.message || '').includes('Unauthorized')) 84 if (isLastAttempt || !retryable) throw err 85 await sleep(RETRY_DELAY_BASE_MS * (attempt + 1)) 86 } 87 } 88 throw new Error('Request failed') 89 } 90 91 if (upperMethod !== 'GET') { 92 return runRequest() 93 } 94 95 const inflightKey = buildInflightGetKey(path, key) 96 const existing = inflightGetRequests.get(inflightKey) 97 if (existing) return existing as Promise<T> 98 99 const requestPromise = runRequest().finally(() => { 100 if (inflightGetRequests.get(inflightKey) === requestPromise) { 101 inflightGetRequests.delete(inflightKey) 102 } 103 }) 104 inflightGetRequests.set(inflightKey, requestPromise) 105 return requestPromise 106 }