mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
174 lines
4.3 KiB
TypeScript
174 lines
4.3 KiB
TypeScript
const BASE = '/api';
|
|
const CSRF_HEADER = 'X-Nyx-CSRF';
|
|
let csrfTokenPromise: Promise<string> | null = null;
|
|
|
|
export class ApiError extends Error {
|
|
/**
|
|
* Stable machine-readable code (matches backend `ApiError`'s `code` field).
|
|
* Falls back to a synthetic value when the response was not structured,
|
|
* `network` for fetch failures, `http_<status>` for plain-text responses.
|
|
*/
|
|
public code: string;
|
|
public detail?: unknown;
|
|
|
|
constructor(
|
|
status: number,
|
|
message: string,
|
|
code?: string,
|
|
detail?: unknown,
|
|
) {
|
|
super(message);
|
|
this.name = 'ApiError';
|
|
this.status = status;
|
|
this.code = code ?? `http_${status}`;
|
|
this.detail = detail;
|
|
}
|
|
|
|
public status: number;
|
|
|
|
/** True when the failure was a network/abort, not an HTTP response. */
|
|
isNetwork(): boolean {
|
|
return this.status === 0;
|
|
}
|
|
}
|
|
|
|
/** Build an ApiError from a non-OK Response, parsing a JSON error body if present. */
|
|
async function errorFromResponse(res: Response): Promise<ApiError> {
|
|
const text = await res.text().catch(() => '');
|
|
if (text) {
|
|
try {
|
|
const parsed = JSON.parse(text) as {
|
|
error?: unknown;
|
|
code?: unknown;
|
|
detail?: unknown;
|
|
};
|
|
const msg =
|
|
typeof parsed.error === 'string' && parsed.error.length > 0
|
|
? parsed.error
|
|
: res.statusText || `HTTP ${res.status}`;
|
|
const code = typeof parsed.code === 'string' ? parsed.code : undefined;
|
|
return new ApiError(res.status, msg, code, parsed.detail);
|
|
} catch {
|
|
// Plain-text body, use as-is.
|
|
return new ApiError(res.status, text);
|
|
}
|
|
}
|
|
return new ApiError(res.status, res.statusText || `HTTP ${res.status}`);
|
|
}
|
|
|
|
async function getCsrfToken(): Promise<string> {
|
|
if (!csrfTokenPromise) {
|
|
csrfTokenPromise = fetch(`${BASE}/session`)
|
|
.then(async (res) => {
|
|
if (!res.ok) {
|
|
throw await errorFromResponse(res);
|
|
}
|
|
|
|
const text = await res.text();
|
|
const payload = text
|
|
? (JSON.parse(text) as { csrf_token?: unknown })
|
|
: {};
|
|
if (
|
|
typeof payload.csrf_token !== 'string' ||
|
|
payload.csrf_token.length === 0
|
|
) {
|
|
throw new ApiError(500, 'Missing CSRF token', 'missing_csrf_token');
|
|
}
|
|
|
|
return payload.csrf_token;
|
|
})
|
|
.catch((error) => {
|
|
csrfTokenPromise = null;
|
|
throw error;
|
|
});
|
|
}
|
|
|
|
return csrfTokenPromise;
|
|
}
|
|
|
|
function isMutatingMethod(method?: string): boolean {
|
|
const upper = (method || 'GET').toUpperCase();
|
|
return (
|
|
upper === 'POST' ||
|
|
upper === 'PUT' ||
|
|
upper === 'PATCH' ||
|
|
upper === 'DELETE'
|
|
);
|
|
}
|
|
|
|
async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
|
|
const { headers: rawHeaders, ...rest } = opts;
|
|
const url = `${BASE}${path}`;
|
|
const headers: Record<string, string> = {
|
|
...(rawHeaders as Record<string, string>),
|
|
};
|
|
if (isMutatingMethod(rest.method)) {
|
|
headers[CSRF_HEADER] = await getCsrfToken();
|
|
}
|
|
if (opts.body) {
|
|
headers['Content-Type'] = 'application/json';
|
|
}
|
|
let res: Response;
|
|
try {
|
|
res = await fetch(url, {
|
|
...rest,
|
|
headers,
|
|
});
|
|
} catch (err) {
|
|
if (err instanceof DOMException && err.name === 'AbortError') {
|
|
throw err;
|
|
}
|
|
const message =
|
|
err instanceof Error ? err.message : 'Network request failed';
|
|
throw new ApiError(0, message, 'network');
|
|
}
|
|
|
|
if (!res.ok) {
|
|
throw await errorFromResponse(res);
|
|
}
|
|
|
|
// Handle empty responses
|
|
const text = await res.text();
|
|
if (!text) return undefined as T;
|
|
return JSON.parse(text) as T;
|
|
}
|
|
|
|
export function apiGet<T>(path: string, signal?: AbortSignal): Promise<T> {
|
|
return request<T>(path, { signal });
|
|
}
|
|
|
|
export function apiPost<T>(
|
|
path: string,
|
|
body?: unknown,
|
|
signal?: AbortSignal,
|
|
): Promise<T> {
|
|
return request<T>(path, {
|
|
method: 'POST',
|
|
body: body != null ? JSON.stringify(body) : undefined,
|
|
signal,
|
|
});
|
|
}
|
|
|
|
export function apiPut<T>(
|
|
path: string,
|
|
body?: unknown,
|
|
signal?: AbortSignal,
|
|
): Promise<T> {
|
|
return request<T>(path, {
|
|
method: 'PUT',
|
|
body: body != null ? JSON.stringify(body) : undefined,
|
|
signal,
|
|
});
|
|
}
|
|
|
|
export function apiDelete<T>(
|
|
path: string,
|
|
body?: unknown,
|
|
signal?: AbortSignal,
|
|
): Promise<T> {
|
|
return request<T>(path, {
|
|
method: 'DELETE',
|
|
body: body != null ? JSON.stringify(body) : undefined,
|
|
signal,
|
|
});
|
|
}
|