@beignet/core
Version:
Core framework primitives for Beignet
278 lines • 9.59 kB
JavaScript
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