/** * Shared HTTP request parsing and input assembly for AoT and dynamic paths. * * Both compilers must call these helpers so responses stay byte-identical. */ /** Which request parts a route handler needs. */ export interface ContextInference { readonly body: boolean; readonly query: boolean; readonly params: boolean; readonly headers: boolean; readonly cookie: boolean; } /** Mutable inference accumulator (sucrose merges with OR). */ export type MutableInference = { -readonly [K in keyof ContextInference]: boolean; }; /** Parts extracted from a request before contract validation. */ export interface InputParts { readonly params?: Readonly>; readonly query?: Readonly>; readonly headers?: Readonly>; readonly cookie?: Readonly>; readonly body?: unknown; } /** All context slots enabled — used by the dynamic fallback. */ export const FULL_INFERENCE: ContextInference = { body: true, query: true, params: true, headers: true, cookie: true, }; /** Empty inference seed. */ export function emptyInference(): MutableInference { return { body: false, query: false, params: false, headers: false, cookie: false, }; } /** * Merge inference flags with logical OR. * * @param into - Accumulator * @param next - Flags to merge */ export function mergeInference(into: MutableInference, next: ContextInference): MutableInference { into.body = into.body || next.body; into.query = into.query || next.query; into.params = into.params || next.params; into.headers = into.headers || next.headers; into.cookie = into.cookie || next.cookie; return into; } /** * Extract `:param` names from an HTTP path pattern. * * @param path - Route path (e.g. `/notes/:id`) */ export function pathParamNames(path: string): string[] { const names: string[] = []; for (const segment of path.split("/")) { if (segment.startsWith(":") && segment.length > 1) { names.push(segment.slice(1)); } } return names; } /** Default request body cap (1 MiB). */ export const DEFAULT_MAX_BODY_BYTES = 1_048_576; /** A body the route refused before the flow ran. */ export class HttpBodyRejected extends Error { /** Builtin failure code. */ readonly code: "PayloadTooLarge" | "UnsupportedMediaType" | "InvalidQuery"; /** `InvalidQuery` reason, when set. */ readonly reason?: string; /** * @param code - Builtin failure code * @param reason - Optional InvalidQuery reason */ constructor(code: "PayloadTooLarge" | "UnsupportedMediaType" | "InvalidQuery", reason?: string) { super(code); this.name = "HttpBodyRejected"; this.code = code; if (reason !== undefined) this.reason = reason; } } /** Per-route body policy. */ export interface ParseBodyOptions { /** Byte cap. Default {@link DEFAULT_MAX_BODY_BYTES}. */ readonly maxBytes?: number; /** Default `required`. `any` keeps today's parse-or-raw-string behavior. */ readonly jsonContentType?: "required" | "any"; } function mediaType(request: Request): string { const raw = request.headers.get("content-type"); if (!raw) return ""; return (raw.split(";")[0] ?? "").trim().toLowerCase(); } function parseJson(text: string): unknown { const value = JSON.parse(text) as unknown; // Own `__proto__` keys are rare. Skip the clone unless the text can name one, // including a `\u` escape of that key. if (!text.includes("__proto__") && !text.includes("\\u")) return value; return dropProto(value); } function dropProto(value: unknown): unknown { if (Array.isArray(value)) return value.map((item) => dropProto(item)); if (value !== null && typeof value === "object") { const out: Record = {}; for (const [key, item] of Object.entries(value as Record)) { if (key === "__proto__") continue; out[key] = dropProto(item); } return out; } return value; } const textDecoder = new TextDecoder(); async function readDeclared(request: Request, max: number): Promise { const text = await request.text(); if (byteLength(text) > max) throw new HttpBodyRejected("PayloadTooLarge"); return text; } function byteLength(text: string): number { for (let i = 0; i < text.length; i++) { if (text.charCodeAt(i) > 0x7f) return new TextEncoder().encode(text).byteLength; } return text.length; } function decode(bytes: Uint8Array): string { return textDecoder.decode(bytes); } /** * Read a body with no usable Content-Length. * * At the default cap, `Bun.serve` already stops the socket at the same size, * so this uses the native buffered read and then checks the real length. * A tighter per-route cap still counts chunks and cancels the stream. */ async function readLimited(request: Request, max: number): Promise { if (max >= DEFAULT_MAX_BODY_BYTES) { const text = await request.text(); if (byteLength(text) > max) throw new HttpBodyRejected("PayloadTooLarge"); return text; } const body = request.body; if (!body) { const text = await request.text(); if (byteLength(text) > max) throw new HttpBodyRejected("PayloadTooLarge"); return text; } const reader = body.getReader(); const first = await reader.read(); if (first.done) return ""; if (first.value.byteLength > max) { await reader.cancel(); throw new HttpBodyRejected("PayloadTooLarge"); } const second = await reader.read(); if (second.done) return first.value.byteLength === 0 ? "" : decode(first.value); const chunks: Uint8Array[] = [first.value, second.value]; let total = first.value.byteLength + second.value.byteLength; if (total > max) { await reader.cancel(); throw new HttpBodyRejected("PayloadTooLarge"); } for (;;) { const step = await reader.read(); if (step.done) break; total += step.value.byteLength; if (total > max) { await reader.cancel(); throw new HttpBodyRejected("PayloadTooLarge"); } chunks.push(step.value); } const bytes = new Uint8Array(total); let offset = 0; for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } return decode(bytes); } /** * Parse a request body. * * Empty bodies are `undefined`. JSON content types must parse. A JSON-shaped * body sent as text, form, or with no content type is 415 unless the route * opts out. `__proto__` keys are dropped. * * @param request - Incoming request * @param options - Cap and content-type policy */ export async function parseBody(request: Request, options?: ParseBodyOptions): Promise { const max = options?.maxBytes ?? DEFAULT_MAX_BODY_BYTES; const declared = request.headers.get("content-length"); if (declared !== null && declared !== "" && Number(declared) > max) { throw new HttpBodyRejected("PayloadTooLarge"); } // A declared length inside the cap is buffered by the platform. Check the // real byte length afterwards so an understated Content-Length is still 413. // A missing length uses the native buffer at the default cap (the serve // socket already stops there) and a cancelling read for a tighter cap. const text = declared !== null && declared !== "" && Number(declared) <= max ? await readDeclared(request, max) : await readLimited(request, max); if (text.length === 0) return undefined; const mt = mediaType(request); const jsonType = mt === "application/json" || (mt.endsWith("+json") && mt.length > "+json".length); if (jsonType) { try { return parseJson(text); } catch { throw new HttpBodyRejected("InvalidQuery", "malformed_body"); } } if (options?.jsonContentType === "any") { try { return parseJson(text); } catch { return text; } } const loose = mt === "" || mt === "text/plain" || mt === "application/x-www-form-urlencoded" || mt === "multipart/form-data"; const trimmed = text.trim(); if (loose && (trimmed.startsWith("{") || trimmed.startsWith("["))) { try { JSON.parse(trimmed); throw new HttpBodyRejected("UnsupportedMediaType"); } catch (err) { if (err instanceof HttpBodyRejected) throw err; } } return text; } /** * Parse the query string into a flat string map. * * @param request - Incoming request */ export function parseQuery(request: Request): Record { const url = new URL(request.url); const out: Record = {}; for (const [key, value] of url.searchParams) { out[key] = value; } return out; } /** * Lower-cased header map (first value wins). * * @param request - Incoming request */ export function parseHeaders(request: Request): Record { const out: Record = {}; request.headers.forEach((value, key) => { out[key.toLowerCase()] = value; }); return out; } /** * Parse `Cookie` header into a name → value map. * * @param request - Incoming request */ export function parseCookie(request: Request): Record { const raw = request.headers.get("cookie"); if (!raw) return {}; const out: Record = {}; for (const part of raw.split(";")) { const idx = part.indexOf("="); if (idx === -1) continue; const key = part.slice(0, idx).trim(); const value = part.slice(idx + 1).trim(); if (key) out[key] = value; } return out; } /** * Assemble handler input from extracted parts. * * Order: params → query → body fields (object merge) → headers/cookie bags. * Matches the historical `oke().fetch` merge so AoT and dynamic agree. * * @param parts - Extracted request parts */ export function assembleInput(parts: InputParts): unknown { const out: Record = {}; if (parts.params !== undefined) { Object.assign(out, parts.params); } if (parts.query !== undefined) { Object.assign(out, parts.query); } if (parts.body !== undefined) { if (typeof parts.body === "object" && parts.body !== null && !Array.isArray(parts.body)) { const body = parts.body as Record; for (const [key, value] of Object.entries(body)) { if (key === "__proto__") continue; out[key] = value; } } else { out.body = parts.body; } } if (parts.headers !== undefined) { out.headers = parts.headers; } if (parts.cookie !== undefined) { out.cookie = parts.cookie; } if (Object.keys(out).length === 0) { return parts.body !== undefined ? parts.body : undefined; } return out; } /** * Extract request parts according to inference flags. * * @param request - Incoming request * @param params - Router path params * @param inference - Which slots to populate */ export async function extractParts( request: Request, params: Readonly>, inference: ContextInference, options?: { readonly body?: ParseBodyOptions }, ): Promise { const parts: { params?: Record; query?: Record; headers?: Record; cookie?: Record; body?: unknown; } = {}; if (inference.params) parts.params = { ...params }; if (inference.query) parts.query = parseQuery(request); if (inference.headers) parts.headers = parseHeaders(request); if (inference.cookie) parts.cookie = parseCookie(request); if (inference.body) parts.body = await parseBody(request, options?.body); return parts; }