Share the one HTTP envelope and the one byte formatter

Both stores carried their own ~28-line request<T>, and formatBytes existed
twice – the second copy had lost the "B" branch, so a small sprite would have
read as 0 KB had its caller ever passed one under a kilobyte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-10 07:14:23 +02:00
1 parent 787a19d525
commit b13b130da7
11 files changed
+80 -78

No files matched your search

+13 -29
View File
@@ -1,5 +1,5 @@
import type { Project } from '../model/Project';
import { statusMessage } from './http';
import { apiRequest } from './http';
import {
type LoadedProject,
type ProjectStore,
@@ -8,7 +8,6 @@ import {
} from './ProjectStore';
import { deserializeProject, serializeProject } from './serialize';
/** Routing goes through the `r` query parameter because the site's nginx config rejects PATH_INFO. */
export class HttpProjectStore implements ProjectStore {
constructor(private readonly endpoint: string) {}
@@ -46,34 +45,19 @@ export class HttpProjectStore implements ProjectStore {
await this.request<void>('DELETE', `projects/${encodeURIComponent(id)}`);
}
/** 409 becomes a `StaleRevisionError`, every other failure an `Error` with the server's message. */
private async request<T>(method: string, route: string, payload?: unknown): Promise<T> {
const url = `${this.endpoint}?r=${encodeURIComponent(route)}`;
const response = await fetch(url, {
return apiRequest<T>(
this.endpoint,
method,
headers: payload !== undefined ? { 'Content-Type': 'application/json' } : {},
body: payload !== undefined ? JSON.stringify(payload) : undefined,
});
if (response.status === 204) return undefined as T;
const text = await response.text();
let data: unknown;
try {
data = text ? JSON.parse(text) : null;
} catch {
// nginx rejects an oversized body itself and answers with an HTML page, so a failure is
// explained by its status rather than as "invalid JSON".
if (response.ok) throw new Error(`API ${method} ${route}: invalid JSON (${response.status})`);
data = null;
}
if (response.status === 409) {
const rev = (data as { revision?: number } | null)?.revision ?? -1;
throw new StaleRevisionError(rev);
}
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;
route,
{
headers: payload !== undefined ? { 'Content-Type': 'application/json' } : {},
body: payload !== undefined ? JSON.stringify(payload) : undefined,
},
(status, data) =>
status === 409
? new StaleRevisionError((data as { revision?: number } | null)?.revision ?? -1)
: undefined,
);
}
}
+2 -22
View File
@@ -1,7 +1,6 @@
import { statusMessage } from './http';
import { apiRequest } from './http';
import type { StoredTexture, TextureStore, UploadedTexture } from './TextureStore';
/** Routing goes through the `r` query parameter because nginx does not pass PATH_INFO to PHP. */
export class HttpTextureStore implements TextureStore {
constructor(private readonly endpoint: string) {}
@@ -22,28 +21,9 @@ export class HttpTextureStore implements TextureStore {
route: string,
payload?: { bytes: Blob | ArrayBuffer; type: string },
): Promise<T> {
const url = `${this.endpoint}?r=${encodeURIComponent(route)}`;
const response = await fetch(url, {
method,
return apiRequest<T>(this.endpoint, method, route, {
headers: payload ? { 'Content-Type': payload.type } : {},
body: payload?.bytes,
});
if (response.status === 204) return undefined as T;
const text = await response.text();
let data: unknown;
try {
data = text ? JSON.parse(text) : null;
} catch {
// An upload is the request nginx refuses before PHP sees it, answering with an HTML page,
// so a failure is explained from the status instead.
if (response.ok) throw new Error(`API ${method} ${route}: invalid JSON (${response.status})`);
data = null;
}
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;
}
}
+39
View File
@@ -17,3 +17,42 @@ const STATUS_MESSAGES: Record<number, string> = {
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.
*
* `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'> = {},
mapError?: (status: number, data: unknown) => Error | undefined,
): Promise<T> {
const response = await fetch(`${endpoint}?r=${encodeURIComponent(route)}`, { method, ...init });
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;
}