190 lines
5.3 KiB
TypeScript
190 lines
5.3 KiB
TypeScript
/**
|
|
* §6.4–§6.5. The only boot-time JSON reader.
|
|
*
|
|
* `response.json()` and unbounded `response.text()` are prohibited: a hostile or
|
|
* misconfigured origin must not be able to amplify boot memory, and an HTML
|
|
* error page must not reach `JSON.parse` as if it were configuration.
|
|
*/
|
|
|
|
export type BootJsonOperation = "RUNTIME_CONFIG" | "RELEASE_MANIFEST";
|
|
|
|
export interface BootJsonPolicy {
|
|
readonly operation: BootJsonOperation;
|
|
readonly maximumBytes: number;
|
|
readonly totalDeadlineMs: 5_000;
|
|
}
|
|
|
|
export const BOOT_JSON_POLICIES = Object.freeze({
|
|
RUNTIME_CONFIG: Object.freeze({
|
|
operation: "RUNTIME_CONFIG" as const,
|
|
maximumBytes: 65_536,
|
|
totalDeadlineMs: 5_000 as const,
|
|
}),
|
|
RELEASE_MANIFEST: Object.freeze({
|
|
operation: "RELEASE_MANIFEST" as const,
|
|
maximumBytes: 1_048_576,
|
|
totalDeadlineMs: 5_000 as const,
|
|
}),
|
|
} satisfies Readonly<Record<BootJsonOperation, BootJsonPolicy>>);
|
|
|
|
export type BootLoadFailure =
|
|
| "FETCH_FAILED"
|
|
| "TIMEOUT"
|
|
| "HTTP_STATUS_INVALID"
|
|
| "CONTENT_TYPE_INVALID"
|
|
| "BODY_TOO_LARGE"
|
|
| "UTF8_INVALID"
|
|
| "JSON_INVALID"
|
|
| "SHAPE_INVALID"
|
|
| "SECRET_NAME_REJECTED"
|
|
| "SCHEMA_INVALID"
|
|
| "BUILD_MISMATCH"
|
|
| "RELEASE_MISMATCH"
|
|
| "ASSET_MISMATCH"
|
|
| "CONTRACT_SET_MISMATCH";
|
|
|
|
export type BootJsonOutcome =
|
|
| Readonly<{ ok: true; value: Readonly<Record<string, unknown>> }>
|
|
| Readonly<{ ok: false; failure: BootLoadFailure }>;
|
|
|
|
export type ReadBoundedBootJsonOptions = Readonly<{
|
|
fetcher?: typeof fetch;
|
|
signal?: AbortSignal;
|
|
}>;
|
|
|
|
function isJsonMediaType(headerValue: string | null): boolean {
|
|
if (!headerValue) return false;
|
|
const essence = headerValue.split(";", 1)[0]?.trim().toLowerCase() ?? "";
|
|
return essence === "application/json" || essence.endsWith("+json");
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
}
|
|
|
|
export async function readBoundedBootJson(
|
|
url: string,
|
|
policy: BootJsonPolicy,
|
|
options: ReadBoundedBootJsonOptions = {},
|
|
): Promise<BootJsonOutcome> {
|
|
if (options.signal?.aborted) return fail("FETCH_FAILED");
|
|
|
|
const fetcher = options.fetcher ?? fetch;
|
|
const controller = new AbortController();
|
|
let timedOut = false;
|
|
const timer = setTimeout(() => {
|
|
timedOut = true;
|
|
controller.abort();
|
|
}, policy.totalDeadlineMs);
|
|
const forwardAbort = () => controller.abort();
|
|
options.signal?.addEventListener("abort", forwardAbort, { once: true });
|
|
|
|
try {
|
|
let response: Response;
|
|
try {
|
|
response = await fetcher(url, {
|
|
method: "GET",
|
|
cache: "no-store",
|
|
credentials: "same-origin",
|
|
redirect: "error",
|
|
referrerPolicy: "no-referrer",
|
|
headers: { Accept: "application/json" },
|
|
signal: controller.signal,
|
|
});
|
|
} catch {
|
|
return fail(timedOut ? "TIMEOUT" : "FETCH_FAILED");
|
|
}
|
|
|
|
if (response.status !== 200 || response.redirected) {
|
|
await discard(response);
|
|
return fail("HTTP_STATUS_INVALID");
|
|
}
|
|
if (!isJsonMediaType(response.headers.get("content-type"))) {
|
|
await discard(response);
|
|
return fail("CONTENT_TYPE_INVALID");
|
|
}
|
|
|
|
const declared = Number(response.headers.get("content-length"));
|
|
if (Number.isFinite(declared) && declared > policy.maximumBytes) {
|
|
await discard(response);
|
|
return fail("BODY_TOO_LARGE");
|
|
}
|
|
|
|
const bytes = await readBoundedBytes(response, policy.maximumBytes, controller);
|
|
if (bytes === "TOO_LARGE") return fail("BODY_TOO_LARGE");
|
|
if (bytes === "STREAM_FAILED") return fail(timedOut ? "TIMEOUT" : "FETCH_FAILED");
|
|
|
|
let text: string;
|
|
try {
|
|
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
} catch {
|
|
return fail("UTF8_INVALID");
|
|
}
|
|
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(text);
|
|
} catch {
|
|
return fail("JSON_INVALID");
|
|
}
|
|
if (!isRecord(parsed)) return fail("SHAPE_INVALID");
|
|
|
|
return Object.freeze({ ok: true as const, value: parsed });
|
|
} finally {
|
|
clearTimeout(timer);
|
|
options.signal?.removeEventListener("abort", forwardAbort);
|
|
}
|
|
}
|
|
|
|
async function readBoundedBytes(
|
|
response: Response,
|
|
maximumBytes: number,
|
|
controller: AbortController,
|
|
): Promise<Uint8Array | "TOO_LARGE" | "STREAM_FAILED"> {
|
|
const body = response.body;
|
|
if (!body) {
|
|
// A body-less 200 cannot satisfy any boot document.
|
|
return new Uint8Array(0);
|
|
}
|
|
const reader = body.getReader();
|
|
const chunks: Uint8Array[] = [];
|
|
let total = 0;
|
|
try {
|
|
for (;;) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
if (!value) continue;
|
|
total += value.byteLength;
|
|
if (total > maximumBytes) {
|
|
await reader.cancel().catch(() => {});
|
|
controller.abort();
|
|
return "TOO_LARGE";
|
|
}
|
|
chunks.push(value);
|
|
}
|
|
} catch {
|
|
await reader.cancel().catch(() => {});
|
|
return "STREAM_FAILED";
|
|
}
|
|
|
|
const output = new Uint8Array(total);
|
|
let offset = 0;
|
|
for (const chunk of chunks) {
|
|
output.set(chunk, offset);
|
|
offset += chunk.byteLength;
|
|
}
|
|
return output;
|
|
}
|
|
|
|
async function discard(response: Response): Promise<void> {
|
|
try {
|
|
await response.body?.cancel();
|
|
} catch {
|
|
// Cancelling an already-settled body is not a boot failure.
|
|
}
|
|
}
|
|
|
|
function fail(failure: BootLoadFailure): BootJsonOutcome {
|
|
return Object.freeze({ ok: false as const, failure });
|
|
}
|