UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

456 lines (411 loc) 11.1 kB
import { decodeQueryTransport, getContractHeaderSchemas, type HttpContractConfig, methodSupportsRequestBody, QueryTransportError, type StandardSchema, } from "../contracts/index.js"; import type { HttpRequestLike, HttpResponseLike } from "./http.js"; import { parseStandardSchema, SchemaValidationError, } from "./providers/index.js"; import { errorResponse } from "./response-finalization.js"; /** * Request body limits enforced before contract body validation. */ export interface RequestBodyOptions { /** * Maximum request body size in bytes for JSON/text route bodies. * * @default 1048576 */ maxBytes?: number; } export type PreparedRequestInputs = { path: unknown; query: unknown; headers: unknown; body: unknown; rawHeaders: Record<string, string>; }; export type RequestPreparationResult = | { ok: true; inputs: PreparedRequestInputs } | { ok: false; response: HttpResponseLike }; type RequestValidationLocation = "query" | "path" | "headers" | "body"; const DEFAULT_REQUEST_BODY_MAX_BYTES = 1024 * 1024; function cancelRequestBody( source: { cancel(reason?: unknown): Promise<void> }, reason: unknown, ): void { try { void source.cancel(reason).catch(() => {}); } catch { // Cancellation is best-effort and must not replace the 413 response. } } class RequestBodyTooLargeError extends Error { readonly maxBytes: number; readonly actualBytes?: number; constructor(maxBytes: number, actualBytes?: number) { super("Request body exceeds the configured size limit."); this.name = "RequestBodyTooLargeError"; this.maxBytes = maxBytes; this.actualBytes = actualBytes; } } class MalformedJsonBodyError extends Error { readonly parseError: unknown; constructor(parseError: unknown) { super("Request body contains malformed JSON."); this.name = "MalformedJsonBodyError"; this.parseError = parseError; } } function contractDiagnostics(contract: HttpContractConfig) { return { contract: contract.name, method: contract.method, path: contract.path, }; } function requestValidationDetails( contract: HttpContractConfig, location: RequestValidationLocation, error?: unknown, additionalDetails?: Record<string, unknown>, ) { const details = { ...contractDiagnostics(contract), location, ...additionalDetails, }; if ( error instanceof SchemaValidationError || error instanceof QueryTransportError ) { return { ...details, issues: error.issues, }; } if (error instanceof Error) { return { ...details, message: error.message, }; } return details; } function requestValidationError( contract: HttpContractConfig, status: number, code: string, message: string, location: RequestValidationLocation, error?: unknown, additionalDetails?: Record<string, unknown>, ): HttpResponseLike { return errorResponse( status, code, message, requestValidationDetails(contract, location, error, additionalDetails), ); } function missingJsonContentTypeHint( req: HttpRequestLike, body: unknown, ): Record<string, unknown> | undefined { if (req.headers.get("content-type") || typeof body !== "string") { return undefined; } const trimmed = body.trim(); if (!(trimmed.startsWith("{") || trimmed.startsWith("["))) { return undefined; } try { const parsed = JSON.parse(trimmed); if (typeof parsed !== "object" || parsed === null) return undefined; } catch { return undefined; } return { hint: 'The request body looks like JSON. Set "Content-Type: application/json" to parse it as JSON.', }; } export function requestHeadersToRecord( headers: Headers, ): Record<string, string> { const record: Record<string, string> = {}; headers.forEach((value, key) => { record[key.toLowerCase()] = value; }); return record; } async function parseHeaderSchemas( schemas: readonly StandardSchema[], rawHeaders: Record<string, string>, ): Promise<Record<string, unknown>> { let parsedHeaders: Record<string, unknown> = rawHeaders; for (const schema of schemas) { const parsed = await parseStandardSchema(schema, rawHeaders); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { parsedHeaders = { ...parsedHeaders, ...(parsed as Record<string, unknown>), }; } else { parsedHeaders = parsed as Record<string, unknown>; } } return parsedHeaders; } export function requestBodyLimit( options: RequestBodyOptions | undefined, ): number { const maxBytes = options?.maxBytes ?? DEFAULT_REQUEST_BODY_MAX_BYTES; if (!Number.isFinite(maxBytes) || maxBytes <= 0) { throw new Error( "createServer requestBody.maxBytes must be a positive number.", ); } return maxBytes; } function assertContentLengthWithinLimit( headers: Headers, maxBytes: number, ): void { const contentLength = headers.get("content-length"); if (contentLength === null) return; const actualBytes = Number(contentLength); if (!Number.isFinite(actualBytes) || actualBytes < 0) return; if (actualBytes > maxBytes) { throw new RequestBodyTooLargeError(maxBytes, actualBytes); } } async function readLimitedRequestText( req: HttpRequestLike, maxBytes: number, ): Promise<string> { const body = req.raw?.body; try { assertContentLengthWithinLimit(req.headers, maxBytes); } catch (error) { if (body) { cancelRequestBody(body, error); } throw error; } if (!body) { const text = await req.text(); const actualBytes = new TextEncoder().encode(text).byteLength; if (actualBytes > maxBytes) { throw new RequestBodyTooLargeError(maxBytes, actualBytes); } return text; } const reader = body.getReader(); const decoder = new TextDecoder(); let received = 0; let text = ""; try { while (true) { const result = await reader.read(); if (result.done) break; received += result.value.byteLength; if (received > maxBytes) { const error = new RequestBodyTooLargeError(maxBytes, received); cancelRequestBody(reader, error); throw error; } text += decoder.decode(result.value, { stream: true }); } text += decoder.decode(); } finally { reader.releaseLock(); } return text; } async function parseBody( req: HttpRequestLike, maxBytes: number, ): Promise<unknown> { const method = req.method.toUpperCase() as HttpContractConfig["method"]; if (!methodSupportsRequestBody(method)) { return undefined; } const bodyReq = req.clone?.() ?? req; const contentType = req.headers.get("content-type") || ""; if (contentType.includes("application/json")) { const text = await readLimitedRequestText(bodyReq, maxBytes); if (text === "") return undefined; try { return JSON.parse(text); } catch (error) { throw new MalformedJsonBodyError(error); } } const text = await readLimitedRequestText(bodyReq, maxBytes); return text === "" ? undefined : text; } export async function prepareRequestInputs(args: { contract: HttpContractConfig; req: HttpRequestLike; url: URL; rawHeaders: Record<string, string>; matchedParams: Record<string, string>; maxRequestBodyBytes: number; rawRoute?: boolean; }): Promise<RequestPreparationResult> { const { contract, req, url, rawHeaders, matchedParams, maxRequestBodyBytes, rawRoute, } = args; const rawQuery = Object.fromEntries( [...new Set(url.searchParams.keys())].map((key) => { const values = url.searchParams.getAll(key); return [key, values.length === 1 ? values[0] : values]; }), ); let query: unknown = rawQuery; if (contract.query) { if (!contract.queryTransport) { throw new Error( `Contract "${contract.name}" declares a query schema without a query transport.`, ); } try { const decoded = decodeQueryTransport( contract.queryTransport, url.searchParams, ); query = await parseStandardSchema(contract.query, decoded); } catch (error) { return { ok: false, response: requestValidationError( contract, 422, "VALIDATION_ERROR", "Invalid query parameters", "query", error, ), }; } } let path: unknown = matchedParams; if (contract.pathParams) { try { path = await parseStandardSchema(contract.pathParams, matchedParams); } catch (error) { return { ok: false, response: requestValidationError( contract, 422, "VALIDATION_ERROR", "Invalid path parameters", "path", error, ), }; } } let headers: unknown = rawHeaders; const headerSchemas = getContractHeaderSchemas(contract.headers); if (headerSchemas.length > 0) { try { headers = await parseHeaderSchemas(headerSchemas, rawHeaders); } catch (error) { return { ok: false, response: requestValidationError( contract, 422, "VALIDATION_ERROR", "Invalid request headers", "headers", error, ), }; } } let body: unknown; // Raw routes own body consumption: the handler reads `req` itself, for // example to verify a webhook signature over the exact bytes. if (!rawRoute) { try { body = await parseBody(req, maxRequestBodyBytes); } catch (error) { if (error instanceof RequestBodyTooLargeError) { return { ok: false, response: requestValidationError( contract, 413, "PAYLOAD_TOO_LARGE", "Request body is too large", "body", error, ), }; } return { ok: false, response: requestValidationError( contract, 400, "INVALID_BODY", error instanceof MalformedJsonBodyError ? "Malformed JSON" : "Could not read request body", "body", error instanceof MalformedJsonBodyError ? error.parseError : error, ), }; } } if (contract.body) { try { body = await parseStandardSchema(contract.body, body); } catch (error) { if (body === undefined && error instanceof SchemaValidationError) { return { ok: false, response: requestValidationError( contract, 400, "MISSING_BODY", "Request body is required", "body", error, ), }; } return { ok: false, response: requestValidationError( contract, 422, "VALIDATION_ERROR", "Invalid request body", "body", error, missingJsonContentTypeHint(req, body), ), }; } } return { ok: true, inputs: { path, query, headers, body, rawHeaders }, }; }