wordeck/apps/web/src/lib/api/client.ts
Till JS fa5da8e937
Some checks are pending
CI / validate (push) Waiting to run
fix(api): API_BASE liest PUBLIC_WORDECK_API_URL statt PUBLIC_CARDS_API_URL
Rebrand-Rest aus dem Cards-Cutover: client.ts las noch den alten
PUBLIC_CARDS_API_URL, die Production-Compose exportiert aber
PUBLIC_WORDECK_API_URL. Folge: das Web-Bundle fiel auf den
Dev-Default localhost:3081 zurück und alle API-Calls liefen ins
Leere (Deck-Liste, Marketplace).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 13:25:56 +02:00

111 lines
3.5 KiB
TypeScript

/**
* Cards-API-Client. Dünner Fetch-Wrapper.
*
* Phase 10c: schickt `Authorization: Bearer <jwt>` aus der echten
* mana-auth-Session. Wenn kein JWT da ist (Stub-Modus für Tests),
* fällt er auf den `X-User-Id`-Header zurück.
*
* 401 → wir leeren die Session und werfen ApiError. Aufrufer kann
* darauf reagieren (z.B. Redirect auf `/`).
*/
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
import { env as publicEnv } from '$env/dynamic/public';
// `$env/dynamic/public` liest die env zur Runtime aus den ENV-Vars
// des Node-Servers (adapter-node). Im Browser kommt der Wert aus dem
// vom Server gerenderten Init-Snapshot, daher gleicher Pfad für SSR
// und Client. Falls nichts gesetzt → localhost:3081 (Dev-Default).
export const API_BASE = (() => {
const fromPublic = publicEnv.PUBLIC_WORDECK_API_URL;
if (fromPublic) return fromPublic;
if (typeof window !== 'undefined') return 'http://localhost:3081';
return process.env.WORDECK_API_URL ?? 'http://localhost:3081';
})();
export class ApiError extends Error {
constructor(
readonly status: number,
readonly body: unknown,
message?: string
) {
super(message ?? `wordeck-api ${status}`);
}
}
type RequestOptions = {
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
body?: unknown;
signal?: AbortSignal;
};
async function doFetch(path: string, opts: RequestOptions): Promise<Response> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (devUser.token) {
headers['Authorization'] = `Bearer ${devUser.token}`;
} else if (devUser.stubId) {
headers['X-User-Id'] = devUser.stubId;
}
return fetch(`${API_BASE}${path}`, {
method: opts.method ?? 'GET',
headers,
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
signal: opts.signal,
});
}
// Für Multipart-Uploads (z.B. Bild → Deck). Content-Type wird vom Browser
// automatisch mit Boundary gesetzt — kein manueller Header nötig.
export async function apiForm<T>(path: string, form: FormData): Promise<T> {
await devUser.ensureFreshToken();
const headers: Record<string, string> = {};
if (devUser.token) {
headers['Authorization'] = `Bearer ${devUser.token}`;
} else if (devUser.stubId) {
headers['X-User-Id'] = devUser.stubId;
}
const res = await fetch(`${API_BASE}${path}`, { method: 'POST', headers, body: form });
if (!res.ok) {
const text = await res.text();
let body: unknown = text;
try { body = JSON.parse(text); } catch { /* keep text */ }
throw new ApiError(res.status, body);
}
return (await res.json()) as T;
}
export async function api<T>(path: string, opts: RequestOptions = {}): Promise<T> {
// Proaktive Frische-Prüfung: wenn Token <60s gültig ist, refreshen
// wir, bevor der Request rausgeht. Coalesced über tryRefresh().
await devUser.ensureFreshToken();
let res = await doFetch(path, opts);
// Reaktive Refresh-Retry: trotz proaktiver Prüfung kann der Token
// zwischenzeitlich invalid werden (Server-Rotation, Clock-Skew).
// Genau ein Retry nach erfolgreichem Refresh, sonst klassisches 401.
if (res.status === 401 && devUser.token) {
const refreshed = await devUser.tryRefresh();
if (refreshed) {
res = await doFetch(path, opts);
}
if (res.status === 401) {
// Auch nach Refresh 401 → Session ist tot. Lokal leeren,
// Landing zeigt Re-Login.
devUser.clear();
}
}
if (!res.ok) {
const text = await res.text();
let body: unknown = text;
try { body = JSON.parse(text); } catch { /* keep text */ }
throw new ApiError(res.status, body);
}
return (await res.json()) as T;
}