felt/frontend/src/lib/api.ts
Mikkel Georgsen 47e1f19edd feat(01-10): SvelteKit frontend scaffold with Catppuccin theme and clients
- SvelteKit SPA with adapter-static, prerender, SSR disabled
- Catppuccin Mocha/Latte theme CSS with semantic color tokens
- WebSocket client with auto-reconnect and exponential backoff
- HTTP API client with JWT auth and 401 handling
- Auth state store with localStorage persistence (Svelte 5 runes)
- Tournament state store handling all WS message types (Svelte 5 runes)
- PIN login page with numpad, 48px touch targets
- Updated Makefile frontend target for real SvelteKit build

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 03:54:29 +01:00

124 lines
3 KiB
TypeScript

/**
* HTTP API client for the Felt backend.
*
* Auto-detects base URL from current host, attaches JWT from auth store,
* handles 401 responses by clearing auth state and redirecting to login.
*/
import { auth } from '$lib/stores/auth.svelte';
import { goto } from '$app/navigation';
/** Typed API error with status code and message. */
export class ApiError extends Error {
constructor(
public readonly status: number,
public readonly statusText: string,
public readonly body: unknown
) {
const msg = typeof body === 'object' && body !== null && 'error' in body
? (body as { error: string }).error
: statusText;
super(msg);
this.name = 'ApiError';
}
}
/** Base URL for API requests — auto-detected from current host. */
function getBaseUrl(): string {
return `${window.location.origin}/api/v1`;
}
/** Build headers with JWT auth and content type. */
function buildHeaders(hasBody: boolean): HeadersInit {
const headers: Record<string, string> = {
'Accept': 'application/json'
};
if (hasBody) {
headers['Content-Type'] = 'application/json';
}
const token = auth.token;
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return headers;
}
/** Handle API response — parse JSON, handle errors. */
async function handleResponse<T>(response: Response): Promise<T> {
if (response.status === 401) {
// Token expired or invalid — clear auth and redirect to login
auth.logout();
await goto('/login');
throw new ApiError(401, 'Unauthorized', { error: 'Session expired' });
}
if (!response.ok) {
let body: unknown;
try {
body = await response.json();
} catch {
body = { error: response.statusText };
}
throw new ApiError(response.status, response.statusText, body);
}
// Handle 204 No Content
if (response.status === 204) {
return undefined as T;
}
return response.json() as Promise<T>;
}
/** Perform an API request. */
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const url = `${getBaseUrl()}${path}`;
const init: RequestInit = {
method,
headers: buildHeaders(body !== undefined),
credentials: 'same-origin'
};
if (body !== undefined) {
init.body = JSON.stringify(body);
}
const response = await fetch(url, init);
return handleResponse<T>(response);
}
/**
* HTTP API client.
*
* All methods auto-attach JWT from auth store and handle 401 responses.
*/
export const api = {
/** GET request. */
get<T>(path: string): Promise<T> {
return request<T>('GET', path);
},
/** POST request with JSON body. */
post<T>(path: string, body?: unknown): Promise<T> {
return request<T>('POST', path, body);
},
/** PUT request with JSON body. */
put<T>(path: string, body?: unknown): Promise<T> {
return request<T>('PUT', path, body);
},
/** PATCH request with JSON body. */
patch<T>(path: string, body?: unknown): Promise<T> {
return request<T>('PATCH', path, body);
},
/** DELETE request. */
delete<T>(path: string): Promise<T> {
return request<T>('DELETE', path);
}
};