115 lines
4.7 KiB
TypeScript
115 lines
4.7 KiB
TypeScript
// The one place that talks HTTP. Relative paths on purpose: the browser client is served from the
|
|
// SAME ORIGIN as the platform, which is a fact and not a setting — the platform has no CORS layer
|
|
// at all, so a cross-origin client is inoperable as a class. A dev server proxies `/v0` onto its
|
|
// own origin (see vite.config.ts).
|
|
|
|
import type { Page, Problem } from './contract';
|
|
|
|
/** The version prefix, in ONE place: the upload speaks the same surface and must not carry a copy. */
|
|
export const basePath = '/v0';
|
|
|
|
// CSRF on the cookie path. What protects is the PRESENCE of the header; the value is arbitrary and
|
|
// carries no token semantics. Required on same-origin requests too — it is not a CORS mechanism.
|
|
const clientHeader = 'X-TM-Client';
|
|
const clientHeaderValue = 'web';
|
|
|
|
const safeMethods = new Set(['GET', 'HEAD']);
|
|
|
|
/** A failed call, with the product-level problem when the platform sent one. */
|
|
export class ApiError extends Error {
|
|
readonly status: number;
|
|
readonly problem: Problem | null;
|
|
|
|
constructor(status: number, problem: Problem | null, message: string) {
|
|
super(message);
|
|
this.name = 'ApiError';
|
|
this.status = status;
|
|
this.problem = problem;
|
|
}
|
|
}
|
|
|
|
interface Options {
|
|
method?: string;
|
|
body?: unknown;
|
|
query?: Record<string, string | number | undefined>;
|
|
}
|
|
|
|
export async function request<T>(path: string, options: Options = {}): Promise<T> {
|
|
const method = options.method ?? 'GET';
|
|
const headers: Record<string, string> = { Accept: 'application/json' };
|
|
if (!safeMethods.has(method)) headers[clientHeader] = clientHeaderValue;
|
|
if (options.body !== undefined) headers['Content-Type'] = 'application/json';
|
|
|
|
const query = new URLSearchParams();
|
|
for (const [name, value] of Object.entries(options.query ?? {})) {
|
|
if (value !== undefined) query.set(name, String(value));
|
|
}
|
|
const url = `${basePath}${path}${query.size > 0 ? `?${query.toString()}` : ''}`;
|
|
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(url, {
|
|
method,
|
|
headers,
|
|
// Explicit although it is the default: the session is a cookie, and a silent change of the
|
|
// default would log everyone out with no error to point at.
|
|
credentials: 'same-origin',
|
|
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
|
});
|
|
} catch {
|
|
// The platform was not reached at all — a different thing from a refusal, and the screen says
|
|
// so differently.
|
|
throw new ApiError(0, null, 'Server unreachable');
|
|
}
|
|
|
|
if (!response.ok)
|
|
throw new ApiError(response.status, await problemOf(response), 'Request refused');
|
|
return (await response.json()) as T;
|
|
}
|
|
|
|
// Errors arrive as RFC 9457 problem+json, and `detail` is already a product phrase — the engine's
|
|
// own detail strings never cross the boundary. Anything else is read as "no problem body".
|
|
async function problemOf(response: Response): Promise<Problem | null> {
|
|
if (!response.headers.get('Content-Type')?.includes('problem+json')) return null;
|
|
try {
|
|
return (await response.json()) as Problem;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Following the cursor lives here once rather than in every read: a server MAY page any collection,
|
|
// and a client that read the head and called it the whole thing would do so silently.
|
|
//
|
|
// The cap is not a page-size policy but a stop against a server that keeps handing out a cursor.
|
|
const maxPages = 50;
|
|
|
|
export async function requestAll<Row, Body extends Page>(
|
|
path: string,
|
|
rowsOf: (body: Body) => Row[],
|
|
query: Options['query'] = {},
|
|
): Promise<{ page: Page; rows: Row[] }> {
|
|
const rows: Row[] = [];
|
|
let cursor: string | undefined;
|
|
let page: Page | undefined;
|
|
|
|
for (let fetched = 0; fetched < maxPages; fetched++) {
|
|
const body = await request<Body>(path, { query: { ...query, cursor } });
|
|
rows.push(...rowsOf(body));
|
|
// The revision of a torn list is its OLDEST page, never its newest. Pages are read one after
|
|
// another and the book's revision moves between them (contract, `NextCursor`); stamped with the
|
|
// last page, a list whose head predates a frame already applied would pass the read guard and
|
|
// overwrite what that frame wrote.
|
|
page = {
|
|
revision: page === undefined ? body.revision : Math.min(page.revision, body.revision),
|
|
next_cursor: body.next_cursor,
|
|
};
|
|
// Anything that is not a cursor ends the walk. The field is required by the contract, so an
|
|
// absent one means a non-conformant server — and treating that as "one more page" re-requests
|
|
// the identical page until the cap, which reports a server defect as a client hang.
|
|
if (typeof body.next_cursor !== 'string' || body.next_cursor === '') return { page, rows };
|
|
cursor = body.next_cursor;
|
|
}
|
|
|
|
throw new ApiError(0, null, `The list did not end within ${String(maxPages)} pages: ${path}`);
|
|
}
|