Files
source-gamepanel/apps/web/src/lib/api.ts
T
hibna c1adb94abb Format the repository with Prettier
CI has been running `prettier --check` against a tree that was never
formatted, so the check reported 63 files and failed every run. Nothing
here is a behaviour change: `pnpm lint` and the four typecheck builds
pass exactly as before.

conduit-bringup-artifacts is added to .prettierignore instead. Those
files are captured bring-up reports, not maintained sources; reflowing
them would only churn a record of what happened.
2026-08-02 21:08:12 +03:00

138 lines
3.4 KiB
TypeScript

const RAW_API_BASE = (
(import.meta.env.VITE_API_URL as string | undefined) ??
(import.meta.env.VITE_API_BASE_URL as string | undefined) ??
'/api'
).trim();
const API_BASE = (RAW_API_BASE || '/api').replace(/\/+$/, '');
interface RequestOptions extends RequestInit {
params?: Record<string, string>;
}
function toRequestBody(body: unknown): BodyInit | undefined {
if (body === undefined || body === null) return undefined;
if (
body instanceof FormData ||
body instanceof Blob ||
body instanceof URLSearchParams ||
body instanceof ArrayBuffer
) {
return body;
}
return JSON.stringify(body);
}
class ApiError extends Error {
constructor(
public status: number,
public data: unknown,
) {
super(`API Error ${status}`);
this.name = 'ApiError';
}
}
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { params, ...fetchOptions } = options;
let url = `${API_BASE}${path}`;
if (params) {
const searchParams = new URLSearchParams(params);
url += `?${searchParams.toString()}`;
}
const token = localStorage.getItem('access_token');
const headers: Record<string, string> = {
...(fetchOptions.headers as Record<string, string>),
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
if (fetchOptions.body && typeof fetchOptions.body === 'string') {
headers['Content-Type'] = 'application/json';
}
const res = await fetch(url, {
...fetchOptions,
credentials: fetchOptions.credentials ?? 'include',
headers,
});
const shouldHandle401WithRefresh =
res.status === 401 &&
path !== '/auth/login' &&
path !== '/auth/register' &&
path !== '/auth/refresh';
if (shouldHandle401WithRefresh) {
// Try refresh
const refreshed = await refreshToken();
if (refreshed) {
headers['Authorization'] = `Bearer ${localStorage.getItem('access_token')}`;
const retry = await fetch(url, {
...fetchOptions,
credentials: fetchOptions.credentials ?? 'include',
headers,
});
if (!retry.ok) throw new ApiError(retry.status, await retry.json().catch(() => null));
if (retry.status === 204) return undefined as T;
return retry.json();
}
localStorage.removeItem('access_token');
window.location.href = '/login';
throw new ApiError(401, null);
}
if (!res.ok) {
throw new ApiError(res.status, await res.json().catch(() => null));
}
if (res.status === 204) return undefined as T;
return res.json();
}
async function refreshToken(): Promise<boolean> {
try {
const res = await fetch(`${API_BASE}/auth/refresh`, {
method: 'POST',
credentials: 'include',
});
if (!res.ok) return false;
const data = await res.json();
localStorage.setItem('access_token', data.accessToken);
return true;
} catch {
return false;
}
}
export const api = {
get: <T>(path: string, params?: Record<string, string>) => request<T>(path, { params }),
post: <T>(path: string, body?: unknown) =>
request<T>(path, {
method: 'POST',
body: toRequestBody(body),
}),
put: <T>(path: string, body?: unknown) =>
request<T>(path, {
method: 'PUT',
body: toRequestBody(body),
}),
patch: <T>(path: string, body?: unknown) =>
request<T>(path, {
method: 'PATCH',
body: toRequestBody(body),
}),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
};
export { ApiError };