@beignet/core
Version:
Core framework primitives for Beignet
456 lines (411 loc) • 11.1 kB
text/typescript
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 },
};
}