157 lines
5.1 KiB
TypeScript
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),
|
|
};
|
|
}
|