learning_ai_common_plat/packages/platform-client/src/index.ts

157 lines
5.1 KiB
TypeScript

/**
* 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<Session[]>('/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<boolean>;
/** 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<T = unknown>(path: string, headers?: Record<string, string>): Promise<T>;
post<T = unknown>(path: string, body?: unknown, headers?: Record<string, string>): Promise<T>;
put<T = unknown>(path: string, body?: unknown, headers?: Record<string, string>): Promise<T>;
del<T = unknown>(path: string, headers?: Record<string, string>): Promise<T>;
request<T = unknown>(
method: string,
path: string,
body?: unknown,
headers?: Record<string, string>
): Promise<T>;
}
// ── 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<T>(
method: string,
path: string,
body?: unknown,
extraHeaders?: Record<string, string>,
isRetry = false
): Promise<T> {
const url = `${baseUrl}${path}`;
const headers: Record<string, string> = {
'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<T>(method, path, body, extraHeaders, true);
}
}
if (!res.ok) {
throw new ApiError(
res.status,
json,
(json as Record<string, string>).message ?? `HTTP ${res.status}`
);
}
return json as T;
} finally {
clearTimeout(timer);
}
}
return {
get: <T = unknown>(path: string, headers?: Record<string, string>) =>
doRequest<T>('GET', path, undefined, headers),
post: <T = unknown>(path: string, body?: unknown, headers?: Record<string, string>) =>
doRequest<T>('POST', path, body, headers),
put: <T = unknown>(path: string, body?: unknown, headers?: Record<string, string>) =>
doRequest<T>('PUT', path, body, headers),
del: <T = unknown>(path: string, headers?: Record<string, string>) =>
doRequest<T>('DELETE', path, undefined, headers),
request: <T = unknown>(
method: string,
path: string,
body?: unknown,
headers?: Record<string, string>
) => doRequest<T>(method, path, body, headers),
};
}