import { createApiClient, type RequestOptions } from '@sport/api-client'; import { getServerEnv } from './env'; /** * Two clients, because the two runtimes have different needs: * * - `getServerApi()` runs inside React Server Components and route handlers. * In Docker it reaches the API container over the internal network, skipping * the public hostname and TLS entirely. * - `browserApi` runs in the browser, hits the public API and carries the * access token. * * Both are the same typed client from @sport/api-client. No component anywhere * calls `fetch` against the API directly. */ export function getServerApi() { return createApiClient({ baseUrl: getServerEnv().API_INTERNAL_URL, // Server-side reads are anonymous. Authenticated server fetches will pass // the token explicitly per request once auth lands. }); } /** * Browser client. * * `baseUrl: ''` means same-origin: requests go through this app's own host and * are proxied to the API (Next rewrite in development, Nginx in production). * That is what makes the refresh cookie first-party — see ADR-0015 and the * `rewrites()` comment in next.config.ts. * * Pointing this at the API host directly would work for anonymous catalog reads * and then break the moment customer sign-in lands in M8, which is precisely * the kind of latent inconsistency worth removing now. */ /** * In-memory access token. * * Module scope rather than React state, for the same reasons as the admin's: * the API client reads it from inside a `fetch` callback and it must survive * re-renders. It is never written to localStorage or a readable cookie — * those outlive the tab and are readable by any script, which is exactly what * an XSS payload goes looking for. The httpOnly refresh cookie is what * survives a reload. */ let accessToken: string | null = null; let onSessionLost: (() => void) | null = null; /** * The in-flight refresh, if any. * * Without it, every request that 401s at the same moment starts its own * rotation — and because rotation invalidates the previous token, the second * one is treated as *token reuse* and revokes the whole family. A shopper with * two tabs open would be signed out for it. One shared promise, one rotation. */ let refreshInFlight: Promise | null = null; export const customerTokenStore = { get: (): string | null => accessToken, set: (token: string | null): void => { accessToken = token; }, onLost: (handler: (() => void) | null): void => { onSessionLost = handler; }, }; export const browserApi = createApiClient({ baseUrl: '', getAccessToken: () => customerTokenStore.get(), /** * Transparent re-auth: on a 401, rotate once and retry. * * Storefront-specific consequence: a shopper whose access token expires * mid-checkout must not be bounced to a login form. If the rotation itself * fails they were genuinely signed out, and the provider reacts. */ onUnauthorized: () => { refreshInFlight ??= (async () => { try { const refreshed = await browserApi.auth.refresh(); customerTokenStore.set(refreshed.accessToken); return true; } catch { customerTokenStore.set(null); onSessionLost?.(); return false; } finally { // Cleared in a microtask so every caller awaiting this rotation sees // the same result before a new one can start. queueMicrotask(() => { refreshInFlight = null; }); } })(); return refreshInFlight; }, }); /** * Caching policy for catalog reads, in one place. * * Tags let a future admin write invalidate exactly what changed via * `revalidateTag` instead of waiting out a TTL. The durations are the * *frontend's* policy and are deliberately independent of the API's own Redis * TTLs — two layers, two decisions. */ export const CATALOG_CACHE: Record< 'navigation' | 'listing' | 'product' | 'collections', RequestOptions > = { navigation: { next: { revalidate: 900, tags: ['navigation'] } }, listing: { next: { revalidate: 60, tags: ['products'] } }, product: { next: { revalidate: 300, tags: ['products'] } }, collections: { next: { revalidate: 300, tags: ['collections'] } }, };