Files
feuerwerk/src/core/store/http.ts
T
DeveloperandClaude Opus 5 99c5d2ad78 Uploads carry the name the file came with (#83)
An upload sends its file name next to the raw bytes, and the server keeps
it in a sidecar next to the content-addressed file. Both pickers caption
every tile and row with it, and an asset made there is named after the
file instead of "Texture" / "Font". Last upload of the same content wins.

The API's own set_error_handler ignores error_reporting(), so @-silenced
warnings still throw: a missing sidecar took the whole listing down with a
500. Existence is checked instead of suppressed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:32:45 +02:00

66 lines
2.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* A failed response does not always come from our API: nginx caps request bodies at 1 MB and
* answers a larger one with its own HTML page, so there is no `{error}` object to read.
*/
const STATUS_MESSAGES: Record<number, string> = {
400: 'Bad request',
403: 'Forbidden',
404: 'Not found',
413: 'Request too large',
500: 'Server error',
502: 'Server unavailable',
503: 'Server unavailable',
504: 'Server timed out',
};
export function statusMessage(status: number, statusText: string): string {
return STATUS_MESSAGES[status] ?? (statusText || `HTTP ${status}`);
}
/**
* One call against the PHP API. Routing goes through the `r` query parameter because the site's
* nginx config does not pass PATH_INFO to PHP; `query` adds further parameters beside it, which is
* how an upload sends its file name while the body stays the raw bytes. An empty value is dropped
* rather than sent as `&name=`.
*
* `mapError` lets a store turn a particular status into its own error type before the generic
* one is thrown.
*/
export async function apiRequest<T>(
endpoint: string,
method: string,
route: string,
init: Pick<RequestInit, 'headers' | 'body'> & { query?: Record<string, string> } = {},
mapError?: (status: number, data: unknown) => Error | undefined,
): Promise<T> {
const { query, ...request } = init;
let url = `${endpoint}?r=${encodeURIComponent(route)}`;
for (const [key, value] of Object.entries(query ?? {})) {
if (value !== '') url += `&${key}=${encodeURIComponent(value)}`;
}
const response = await fetch(url, { method, ...request });
if (response.status === 204) return undefined as T;
const text = await response.text();
let data: unknown;
try {
data = text ? JSON.parse(text) : null;
} catch {
// A body nginx refused is an HTML page, so a failure is explained by its status rather than
// as "invalid JSON" – which would only be true of a *successful* response.
if (response.ok) throw new Error(`API ${method} ${route}: invalid JSON (${response.status})`);
data = null;
}
const mapped = mapError?.(response.status, data);
if (mapped) throw mapped;
if (!response.ok) {
const message =
(data as { error?: string } | null)?.error ?? statusMessage(response.status, response.statusText);
throw new Error(`API ${method} ${route}: ${message} (${response.status})`);
}
return data as T;
}