121 lines
4.2 KiB
TypeScript
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'] } },
|
|
};
|