/** * Low-level fetch helpers for the rpki_query_service HTTP API. * * All JSON endpoints share the envelope shape: * { "data": ..., "page": { "nextCursor": string | null, "limit": number } | null, * "meta": { "runId": string, "schemaVersion": number } } * Errors are `{ "error": string }` with a non-2xx status. */ export class ApiError extends Error { readonly status: number; constructor(status: number, message: string) { super(message); this.name = "ApiError"; this.status = status; } } export type QueryParams = Record< string, string | number | boolean | null | undefined >; /** Build a URL with a query string, skipping empty values. */ export function withQuery(path: string, params?: QueryParams): string { if (!params) return path; const search = new URLSearchParams(); for (const [key, value] of Object.entries(params)) { if (value === undefined || value === null || value === "") continue; search.set(key, String(value)); } const qs = search.toString(); return qs ? `${path}?${qs}` : path; } async function parseErrorBody(res: Response): Promise { try { const body: unknown = await res.json(); if ( body !== null && typeof body === "object" && "error" in body && typeof (body as { error: unknown }).error === "string" ) { return (body as { error: string }).error; } } catch { // fall through to status text } return `HTTP ${res.status} ${res.statusText}`.trim(); } /** Default timeout for every API request — hung streams must not spin forever. */ const REQUEST_TIMEOUT_MS = 30_000; /** Combine the caller's abort signal (if any) with the default timeout. */ function requestSignal(signal?: AbortSignal | null): AbortSignal { const signals = [signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)].filter( (s): s is AbortSignal => s != null, ); return AbortSignal.any(signals); } /** Fetch JSON from the API, throwing ApiError on non-2xx. */ export async function apiFetch(path: string, init?: RequestInit): Promise { let res: Response; try { res = await fetch(path, { ...init, signal: requestSignal(init?.signal) }); } catch (err) { throw new ApiError(0, err instanceof Error ? err.message : "network error"); } if (!res.ok) { throw new ApiError(res.status, await parseErrorBody(res)); } return (await res.json()) as Promise; } /** Fetch binary content (raw object bytes, export tarballs). */ export async function apiFetchBlob(path: string): Promise { let res: Response; try { res = await fetch(path, { signal: requestSignal() }); } catch (err) { throw new ApiError(0, err instanceof Error ? err.message : "network error"); } if (!res.ok) { throw new ApiError(res.status, await parseErrorBody(res)); } return res.blob(); } /** POST a JSON body, returning the parsed JSON response. */ export async function apiPost(path: string, body: unknown): Promise { return apiFetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body ?? {}), }); } /** Trigger a browser download for a blob. */ export function saveBlob(blob: Blob, filename: string): void { const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = filename; document.body.appendChild(anchor); anchor.click(); anchor.remove(); URL.revokeObjectURL(url); }