UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

278 lines 9.59 kB
import { decodeQueryTransport, getContractHeaderSchemas, methodSupportsRequestBody, QueryTransportError, } from "../contracts/index.js"; import { parseStandardSchema, SchemaValidationError, } from "./providers/index.js"; import { errorResponse } from "./response-finalization.js"; const DEFAULT_REQUEST_BODY_MAX_BYTES = 1024 * 1024; function cancelRequestBody(source, reason) { try { void source.cancel(reason).catch(() => { }); } catch { // Cancellation is best-effort and must not replace the 413 response. } } class RequestBodyTooLargeError extends Error { maxBytes; actualBytes; constructor(maxBytes, actualBytes) { super("Request body exceeds the configured size limit."); this.name = "RequestBodyTooLargeError"; this.maxBytes = maxBytes; this.actualBytes = actualBytes; } } class MalformedJsonBodyError extends Error { parseError; constructor(parseError) { super("Request body contains malformed JSON."); this.name = "MalformedJsonBodyError"; this.parseError = parseError; } } function contractDiagnostics(contract) { return { contract: contract.name, method: contract.method, path: contract.path, }; } function requestValidationDetails(contract, location, error, additionalDetails) { 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, status, code, message, location, error, additionalDetails) { return errorResponse(status, code, message, requestValidationDetails(contract, location, error, additionalDetails)); } function missingJsonContentTypeHint(req, body) { 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) { const record = {}; headers.forEach((value, key) => { record[key.toLowerCase()] = value; }); return record; } async function parseHeaderSchemas(schemas, rawHeaders) { let parsedHeaders = rawHeaders; for (const schema of schemas) { const parsed = await parseStandardSchema(schema, rawHeaders); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { parsedHeaders = { ...parsedHeaders, ...parsed, }; } else { parsedHeaders = parsed; } } return parsedHeaders; } export function requestBodyLimit(options) { 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, maxBytes) { 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, maxBytes) { 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, maxBytes) { const method = req.method.toUpperCase(); 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) { 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 = 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 = 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 = 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; // 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 }, }; } //# sourceMappingURL=request-preparation.js.map