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>
66 lines
2.4 KiB
TypeScript
66 lines
2.4 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|