Files
web_sport/apps/storefront/src/lib/api.ts
T
2026-08-13 23:20:23 +07:00

121 lines
4.2 KiB
TypeScript

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<boolean> | 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'] } },
};