/ src / lib / app / api-client.ts
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  }