/** * Versioned, bounded codecs shared by HTTP bodies, loader streams, and WebSocket frames. Plain JSON * remains the zero-configuration fast path; rich wire is explicit and preserves non-JSON values. */ const DEFAULT_MAX_BYTES = 16 * 1024 * 1024 const TOKEN = /^[a-z][a-z0-9_.-]{0,63}$/ export interface TransportDecodeOptions { readonly maxBytes?: number } export interface TransportCodec { readonly id: string readonly version: number readonly mediaType: string encode(value: unknown): string decode(text: string): unknown } export class TransportCodecError extends TypeError { constructor(message: string, options?: ErrorOptions) { super(message, options) this.name = "TransportCodecError" } } /** * Run a decode step and normalize whatever it throws into {@link TransportCodecError}. * * Decoding is the one place this module touches attacker-controlled bytes, and the primitives it * delegates to raise their own native types - `JSON.parse` throws `SyntaxError`, a BYO codec throws * whatever it likes. Every other failure here is a `TransportCodecError`, so letting those escape * would mean a malformed payload - the single most likely hostile input - is the one case that slips * past a caller catching the documented error type. The original is kept as `cause`, so the parse * failure stays diagnosable; a codec already speaking the contract is re-thrown untouched. */ function decodeOrThrow(decode: () => T, message: string): T { try { return decode() } catch (cause) { if (cause instanceof TransportCodecError) throw cause throw new TransportCodecError(message, { cause }) } } function maxBytesOf(options: TransportDecodeOptions): number { const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) throw new RangeError("transport maxBytes must be a non-negative safe integer") return maxBytes } const BYTE_ENCODER = new TextEncoder() function byteLength(text: string): number { return BYTE_ENCODER.encode(text).byteLength } /** * Enforce the transport byte cap on already-read text - the same limit (and the same error) the * streaming reader applies during a bounded read. Exported for callers that read a body through a * native, non-streaming path (the typed client's in-process branch) and still owe the cap contract. */ export function assertTransportTextBounded(text: string, options: TransportDecodeOptions): void { const maxBytes = maxBytesOf(options) // Fast accept: UTF-8 emits at most 3 bytes per UTF-16 code unit, so `length * 3` bounds the // encoded size from above - a body comfortably under the cap (the overwhelmingly common case // against the 16 MB default) never pays a full re-encode just to prove it fits. Only a string // within 3x of the cap needs the exact count. if (text.length * 3 <= maxBytes) return if (byteLength(text) > maxBytes) { throw new TransportCodecError("transport payload exceeds maxBytes") } } const assertBounded = assertTransportTextBounded export const plainJsonCodec: TransportCodec = Object.freeze({ id: "json", version: 1, mediaType: "application/json", encode(value: unknown): string { const encoded = JSON.stringify(value) if (encoded === undefined) throw new TransportCodecError("value is not JSON serializable") return encoded }, decode(text: string): unknown { return JSON.parse(text) }, }) export interface TransportCodecRegistry { readonly fallback: TransportCodec forContentType(contentType: string | null): TransportCodec negotiate(accept: string | null): TransportCodec byIdentity(id: string, version: number): TransportCodec } function canonicalMediaType(value: string): string { return value .split(";") .map((part) => part.trim().toLowerCase()) .filter(Boolean) .join(";") } export function createTransportCodecRegistry( codecs: readonly TransportCodec[], fallback: TransportCodec = plainJsonCodec, ): TransportCodecRegistry { if (codecs.length === 0) throw new TypeError("transport codec registry cannot be empty") const byMedia = new Map() const byId = new Map() for (const codec of codecs) { if (!TOKEN.test(codec.id)) throw new TypeError("transport codec id is invalid") if (!Number.isSafeInteger(codec.version) || codec.version < 1) throw new TypeError("transport codec version must be a positive safe integer") if (typeof codec.encode !== "function" || typeof codec.decode !== "function") throw new TypeError("transport codec must provide encode and decode") const media = canonicalMediaType(codec.mediaType) const identity = `${codec.id}@${codec.version}` if (byMedia.has(media) || byId.has(identity)) throw new TypeError("duplicate transport codec registration") byMedia.set(media, codec) byId.set(identity, codec) } if (!byId.has(`${fallback.id}@${fallback.version}`)) throw new TypeError("transport fallback must be registered") const jsonCodec = byId.get("json@1") const forContentType = (contentType: string | null): TransportCodec => { if (contentType === null || contentType.trim() === "") return fallback // Direct probe before canonicalizing: a server-emitted content type is already lowercase with // no stray spacing (nifra's own always is), so the common case skips the split/trim/join walk. // The charset-suffixed JSON string is what `Response.json` (and nifra's respond path) emits on // every JSON response - it resolves to the same `json@1` the parameter-stripping walk below // would reach, just without paying the walk. const exact = byMedia.get(contentType) if (exact !== undefined) return exact if (contentType === "application/json;charset=utf-8" && jsonCodec !== undefined) return jsonCodec const canonical = canonicalMediaType(contentType) const direct = byMedia.get(canonical) if (direct !== undefined) return direct if (canonical.split(";")[0] === "application/json" && jsonCodec !== undefined) return jsonCodec throw new TransportCodecError(`unsupported transport content type: ${contentType}`) } return Object.freeze({ fallback, forContentType, negotiate(accept: string | null) { if (accept === null || accept.trim() === "" || accept.trim() === "*/*") return fallback const candidates = accept .split(",") .map((candidate: string) => { const quality = /;\s*q=([01](?:\.\d+)?)\s*$/iu.exec(candidate) const q = quality === null ? 1 : Number(quality[1]) return { media: quality === null ? candidate.trim() : candidate.slice(0, quality.index).trim(), q, } }) .filter( (candidate: { readonly media: string; readonly q: number }) => Number.isFinite(candidate.q) && candidate.q > 0 && candidate.q <= 1, ) .sort( ( a: { readonly media: string; readonly q: number }, b: { readonly media: string; readonly q: number }, ) => b.q - a.q, ) for (const candidate of candidates) { try { return forContentType(candidate.media) } catch { // Try the next advertised representation. } } throw new TransportCodecError("no acceptable transport codec") }, byIdentity(id: string, version: number) { const codec = byId.get(`${id}@${version}`) if (codec === undefined) throw new TransportCodecError(`unsupported transport codec: ${id}@${version}`) return codec }, }) } export const defaultTransportCodecs: TransportCodecRegistry = createTransportCodecRegistry([ plainJsonCodec, ]) export function encodeTransportResponse( value: unknown, codec: TransportCodec = plainJsonCodec, init: ResponseInit = {}, ): Response { const headers = new Headers(init.headers as ConstructorParameters[0]) headers.set("content-type", codec.mediaType) headers.set("vary", appendVary(headers.get("vary"), "accept")) return new Response(codec.encode(value), { ...init, headers }) } function appendVary(current: string | null, name: string): string { if (current === null || current.trim() === "") return name const values = current.split(",").map((value) => value.trim().toLowerCase()) return values.includes(name.toLowerCase()) ? current : `${current}, ${name}` } /** * Read a response body into memory, refusing to exceed `maxBytes`. * * Bounded while STREAMING rather than after the fact: the `content-length` shortcut only helps when a * sender declares one, so the loop also counts as it goes and cancels the reader the moment the total * passes the limit. A cap that buffers first and complains afterwards has already spent the memory it * was meant to protect. * * Exported so the client can bound a plain-text body with the same reader it bounds JSON with, rather * than growing a second copy of this loop that could drift from it. */ export async function readBoundedBytes( response: Response, options: TransportDecodeOptions, ): Promise { const maxBytes = maxBytesOf(options) const declared = response.headers.get("content-length") if (declared !== null && /^\d+$/u.test(declared) && Number(declared) > maxBytes) throw new TransportCodecError("transport payload exceeds maxBytes") if (response.body === null) return new Uint8Array(0) const reader = response.body.getReader() const chunks: Uint8Array[] = [] let total = 0 try { for (;;) { const { done, value } = await reader.read() if (done) break total += value.byteLength if (!Number.isSafeInteger(total) || total > maxBytes) { await reader.cancel() throw new TransportCodecError("transport payload exceeds maxBytes") } chunks.push(value) } } finally { reader.releaseLock() } const bytes = new Uint8Array(total) let offset = 0 for (const chunk of chunks) { bytes.set(chunk, offset) offset += chunk.byteLength } return bytes } // Shared decoder: `TextDecoder.decode` on a whole buffer is stateless, so one instance serves every // call (constructing one costs ~115ns - it was previously built per response). const FATAL_UTF8 = new TextDecoder("utf-8", { fatal: true }) async function readBoundedText( response: Response, options: TransportDecodeOptions, ): Promise { const bytes = await readBoundedBytes(response, options) return decodeOrThrow(() => FATAL_UTF8.decode(bytes), "transport payload is not valid UTF-8") } /** * Decode an ALREADY-READ transport body. The counterpart to {@link decodeTransportResponse} for a * caller that obtained the text through its own bounded read - notably the typed client's * in-process path, where the response body is same-process memory that was fully resident before * the read, so the native `Response.text()` is safe and the streaming byte-cap loop would protect * nothing while costing ~23x. The byte cap is still enforced here (identical error), just after * the read instead of during it. */ export function decodeTransportText( text: string, contentType: string | null, registry: TransportCodecRegistry = defaultTransportCodecs, options: TransportDecodeOptions = {}, ): unknown { assertBounded(text, options) if (text === "") return undefined const codec = registry.forContentType(contentType) return decodeOrThrow( () => codec.decode(text), `malformed transport body for codec ${codec.id}@${codec.version}`, ) } export async function decodeTransportResponse( response: Response, registry: TransportCodecRegistry = defaultTransportCodecs, options: TransportDecodeOptions = {}, ): Promise { const codec = registry.forContentType(response.headers.get("content-type")) const text = await readBoundedText(response, options) if (text === "") return undefined return decodeOrThrow( () => codec.decode(text), `malformed transport body for codec ${codec.id}@${codec.version}`, ) } interface TransportFrame { readonly codec: string readonly version: number readonly payload: string } export function encodeTransportFrame( value: unknown, codec: TransportCodec = plainJsonCodec, ): string { return JSON.stringify({ codec: codec.id, version: codec.version, payload: codec.encode(value) }) } export function decodeTransportFrame( frame: string, registry: TransportCodecRegistry = defaultTransportCodecs, options: TransportDecodeOptions = {}, ): unknown { assertBounded(frame, options) const envelope = decodeOrThrow( () => JSON.parse(frame), "malformed transport frame", ) as Partial if ( envelope === null || typeof envelope !== "object" || typeof envelope.codec !== "string" || !Number.isSafeInteger(envelope.version) || typeof envelope.payload !== "string" ) { throw new TransportCodecError("malformed transport frame") } const payload = envelope.payload assertBounded(payload, options) const codec = registry.byIdentity(envelope.codec, envelope.version as number) return decodeOrThrow( () => codec.decode(payload), `malformed transport frame payload for codec ${codec.id}@${codec.version}`, ) }