/** * Browser/React Native-safe typed fetch wrapper for platform-service. * * Client-side counterpart to @bytelyst/api-client (which is server-side). * Uses bearer tokens from storage (not httpOnly cookies). * Includes auto-retry on 401 via token refresh. * * @example * ```ts * import { createPlatformClient } from '@bytelyst/platform-client'; * * const api = createPlatformClient({ * baseUrl: 'http://localhost:4003/api', * productId: 'nomgap', * getAccessToken: () => authClient.getAccessToken(), * refreshAccessToken: () => authClient.refreshAccessToken(), * }); * * const sessions = await api.get('/fasting-sessions'); * await api.post('/fasting-sessions', { protocol: '16:8' }); * ``` */ // ── Types ──────────────────────────────────────────────────── export interface PlatformClientConfig { /** Platform-service base URL (e.g. "http://localhost:4003/api"). */ baseUrl: string; /** Product identifier sent as x-product-id header. */ productId: string; /** Function that returns the current access token, or null. */ getAccessToken: () => string | null; /** Optional function to refresh the access token. Returns true on success. */ refreshAccessToken?: () => Promise; /** Request timeout in milliseconds. Default: 15000. */ timeoutMs?: number; } export class ApiError extends Error { constructor( public readonly status: number, public readonly body: unknown, message?: string ) { super(message ?? `API error ${status}`); this.name = 'ApiError'; } } export interface PlatformClient { get(path: string, headers?: Record): Promise; post(path: string, body?: unknown, headers?: Record): Promise; put(path: string, body?: unknown, headers?: Record): Promise; del(path: string, headers?: Record): Promise; request( method: string, path: string, body?: unknown, headers?: Record ): Promise; } // ── UUID helper ────────────────────────────────────────────── function uuid(): string { if (typeof globalThis.crypto?.randomUUID === 'function') { return globalThis.crypto.randomUUID(); } return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { const r = (Math.random() * 16) | 0; return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16); }); } // ── Factory ────────────────────────────────────────────────── export function createPlatformClient(config: PlatformClientConfig): PlatformClient { const { baseUrl, productId, getAccessToken, refreshAccessToken, timeoutMs = 15_000 } = config; async function doRequest( method: string, path: string, body?: unknown, extraHeaders?: Record, isRetry = false ): Promise { const url = `${baseUrl}${path}`; const headers: Record = { 'Content-Type': 'application/json', 'x-product-id': productId, 'x-request-id': uuid(), ...extraHeaders, }; const token = getAccessToken(); if (token) headers['Authorization'] = `Bearer ${token}`; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const res = await globalThis.fetch(url, { method, headers, body: body != null ? JSON.stringify(body) : undefined, signal: controller.signal, }); if (res.status === 204) return undefined as T; const json = await res.json().catch(() => ({})); // Auto-refresh on 401 (once) if (res.status === 401 && !isRetry && !path.startsWith('/auth/') && refreshAccessToken) { clearTimeout(timer); const refreshed = await refreshAccessToken(); if (refreshed) { return doRequest(method, path, body, extraHeaders, true); } } if (!res.ok) { throw new ApiError( res.status, json, (json as Record).message ?? `HTTP ${res.status}` ); } return json as T; } finally { clearTimeout(timer); } } return { get: (path: string, headers?: Record) => doRequest('GET', path, undefined, headers), post: (path: string, body?: unknown, headers?: Record) => doRequest('POST', path, body, headers), put: (path: string, body?: unknown, headers?: Record) => doRequest('PUT', path, body, headers), del: (path: string, headers?: Record) => doRequest('DELETE', path, undefined, headers), request: ( method: string, path: string, body?: unknown, headers?: Record ) => doRequest(method, path, body, headers), }; }