Some checks are pending
CI / validate (push) Waiting to run
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>
111 lines
3.5 KiB
TypeScript
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;
|
|
}
|