@beignet/core
Version:
Core framework primitives for Beignet
817 lines • 38.5 kB
JavaScript
import { httpErrors, isAppError, toErrorResponseBody, } from "../errors/index.js";
import { IdempotencyConflictError, IdempotencyInProgressError, } from "../idempotency/index.js";
import { runWithMemoScope, } from "../memo/index.js";
import { AuthUnauthorizedError, EntitlementRequiredError, GateAuthorizationError, TenantRequiredError, } from "../ports/index.js";
import { createProviderInstrumentation, resolveProviderInstrumentationPort, } from "../providers/index.js";
import { parseTraceparent, resolveTracingPort, runWithTracing, } from "../tracing/index.js";
import { getResponseFinalizerHook, } from "./internal-hooks.js";
import { readContextActor, readContextTenant, setActiveRequestIdentity, } from "./request-context.js";
import { prepareRequestInputs, requestBodyLimit, requestHeadersToRecord, } from "./request-preparation.js";
import { defaultErrorResponse, errorResponse, finalizeResponse, isHttpResponseLike, isWebResponse, mergeNativeResponseHeaders, normalizeHttpResponse, normalizeResponse, ResponseContractViolationError, responseForHooks, responseOwnerFor, toContractViolationResponse, withContractLifecycleHeaders, withFrameworkErrorOwnerHeader, } from "./response-finalization.js";
import { compilePath, decodeMatchedParams, PathDecodeError, } from "./route-matching.js";
import { InvalidRequestUrlError } from "./trusted-proxy-internal.js";
import { UseCaseRouteInputValidationError } from "./use-case-route.js";
function withoutHeadResponseBody(response, requestMethod) {
if (requestMethod.toUpperCase() !== "HEAD")
return response;
if (isWebResponse(response)) {
if (response.body === null)
return response;
void response.body.cancel().catch(() => { });
return new Response(null, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
const { body: _body, ...headResponse } = response;
return headResponse;
}
function copyTrustedRequestInfo(requestInfo) {
if (!requestInfo)
return undefined;
return {
...requestInfo,
url: new URL(requestInfo.url),
};
}
/**
* Build the per-request execution pipeline once.
*
* The returned executor takes the contract, compiled pattern, and user handler
* per invocation so fallback responses (404/405) can reuse a single pipeline
* across requests instead of rebuilding it per unmatched request.
*/
export function createRequestExecutor(options, finalPorts, contextRuntime, hooks, routeHooks = [], optionsOverrides) {
const warnedNativeReplacementHooks = new WeakSet();
const maxRequestBodyBytes = requestBodyLimit(options.requestBody);
const executeRequest = async (target, req, preMatchedParams) => {
const { contract, handler: userHandler } = target;
let requestInfo;
let baseCtx;
let pathValue;
let queryValue;
let headersValue;
let bodyValue;
const startedAt = Date.now();
// Stage timings accumulate because retries re-enter the send phase and
// beforeHandle spans the route-hook and server-hook loops.
const stages = {
onRequestMs: 0,
parseMs: 0,
contextMs: 0,
beforeHandleMs: 0,
handlerMs: 0,
sendMs: 0,
};
const timeStage = async (stage, run) => {
const stageStartedAt = performance.now();
try {
return await run();
}
finally {
stages[stage] += performance.now() - stageStartedAt;
}
};
const resolveErrorResult = async (error, ctx, path, query, headers, body, resultOptions) => {
let currentError = error;
const notifyCaughtError = async (caught) => {
const args = {
err: caught,
req,
requestInfo: copyTrustedRequestInfo(requestInfo),
ctx,
contract,
path,
query,
headers,
body,
};
for (const hook of hooks) {
if (!hook.onCaughtError)
continue;
try {
await hook.onCaughtError(args);
}
catch {
// Observers must not change response behavior.
}
}
if (options.onCaughtError) {
try {
await options.onCaughtError(args);
}
catch {
// Observers must not change response behavior.
}
}
};
await notifyCaughtError(currentError);
if (currentError instanceof ResponseContractViolationError) {
return {
ctx,
response: toContractViolationResponse(currentError),
error: currentError,
owner: "framework",
};
}
if (currentError instanceof UseCaseRouteInputValidationError) {
return {
ctx,
response: errorResponse(500, currentError.code, currentError.message, {
contractName: currentError.contractName,
useCaseName: currentError.useCaseName,
location: "useCaseInput",
}),
error: currentError,
owner: "framework",
};
}
if (currentError instanceof InvalidRequestUrlError) {
return {
ctx,
response: errorResponse(400, "INVALID_REQUEST_URL", "Malformed request URL"),
error: currentError,
owner: "framework",
};
}
if (isAppError(currentError)) {
return {
ctx,
response: {
status: currentError.status,
...(currentError.headers
? { headers: { ...currentError.headers } }
: {}),
body: toErrorResponseBody(currentError),
},
error: currentError,
owner: resultOptions?.owner ?? "route",
};
}
if (currentError instanceof AuthUnauthorizedError) {
return {
ctx,
response: errorResponse(401, currentError.code, currentError.message),
error: currentError,
owner: "framework",
};
}
if (currentError instanceof TenantRequiredError) {
return {
ctx,
response: errorResponse(currentError.status, currentError.code, currentError.message),
error: currentError,
owner: "framework",
};
}
if (currentError instanceof IdempotencyConflictError) {
return {
ctx,
response: errorResponse(httpErrors.IdempotencyConflict.status, httpErrors.IdempotencyConflict.code, currentError.message, {
namespace: currentError.namespace,
key: currentError.key,
}),
error: currentError,
owner: "framework",
};
}
if (currentError instanceof IdempotencyInProgressError) {
return {
ctx,
response: errorResponse(httpErrors.IdempotencyInProgress.status, httpErrors.IdempotencyInProgress.code, currentError.message, {
namespace: currentError.namespace,
key: currentError.key,
}),
error: currentError,
owner: "framework",
};
}
if (currentError instanceof GateAuthorizationError) {
return {
ctx,
response: errorResponse(currentError.status, currentError.code, currentError.message, currentError.details),
error: currentError,
owner: "framework",
};
}
if (currentError instanceof EntitlementRequiredError) {
return {
ctx,
response: errorResponse(currentError.status, currentError.code, currentError.message, currentError.details),
error: currentError,
owner: "framework",
};
}
for (const hook of hooks) {
if (!hook.mapUnhandledError)
continue;
try {
const handled = await hook.mapUnhandledError({
err: currentError,
req,
requestInfo: copyTrustedRequestInfo(requestInfo),
ctx,
contract,
path,
query,
headers,
body,
});
if (handled) {
const response = normalizeHttpResponse(handled);
return {
ctx,
response,
error: currentError,
owner: responseOwnerFor(response, "framework"),
};
}
}
catch (hookError) {
currentError = hookError;
await notifyCaughtError(currentError);
}
}
if (options.mapUnhandledError) {
try {
const handled = await options.mapUnhandledError({
err: currentError,
req,
requestInfo: copyTrustedRequestInfo(requestInfo),
ctx,
contract,
path,
query,
headers,
body,
});
if (handled) {
const response = normalizeHttpResponse(handled);
return {
ctx,
response,
error: currentError,
owner: responseOwnerFor(response, "framework"),
};
}
}
catch (hookError) {
currentError = hookError;
await notifyCaughtError(currentError);
}
}
return {
ctx,
response: defaultErrorResponse(currentError, ctx),
error: currentError,
owner: "framework",
};
};
try {
const resolvedRequestInfo = contextRuntime.resolveRequestInfo(req);
requestInfo = resolvedRequestInfo;
const url = new URL(req.url);
let matchedParams;
if (preMatchedParams) {
matchedParams = preMatchedParams;
}
else {
const compiled = target.compiled;
const match = compiled ? compiled.pattern.exec(url.pathname) : null;
const contractMethod = contract.method.toUpperCase();
const requestMethod = req.method.toUpperCase();
const methodMatches = contractMethod === requestMethod ||
(contractMethod === "GET" && requestMethod === "HEAD");
if (!compiled || !match || !methodMatches) {
return errorResponse(404, "NOT_FOUND", "Not found");
}
try {
matchedParams = decodeMatchedParams(compiled.keys, match);
}
catch (error) {
if (error instanceof PathDecodeError) {
return errorResponse(400, "INVALID_PATH", "Malformed URL path");
}
throw error;
}
}
const rawHeaders = requestHeadersToRecord(req.headers);
const runNativeBeforeSend = async (initialResult, nativeResponse) => {
const originalView = responseForHooks(nativeResponse);
const originalHeaders = originalView.headers ?? {};
let transformed = originalView;
for (const hook of hooks) {
if (!hook.beforeSend)
continue;
const nextResponse = await hook.beforeSend({
req,
requestInfo: copyTrustedRequestInfo(resolvedRequestInfo),
ctx: initialResult.ctx,
contract,
path: pathValue,
query: queryValue,
headers: headersValue,
body: bodyValue,
response: transformed,
error: initialResult.error,
native: true,
});
if (nextResponse) {
if ((nextResponse.status !== nativeResponse.status ||
nextResponse.body !== undefined) &&
!warnedNativeReplacementHooks.has(hook) &&
process.env.NODE_ENV !== "production") {
warnedNativeReplacementHooks.add(hook);
console.warn(`[beignet] beforeSend hook "${hook.name ?? "(anonymous)"}" returned a replacement status or body for a native Response on ${contract.method} ${contract.path}. Native responses are headers-only in beforeSend; status and body changes are ignored.`);
}
transformed = {
status: nativeResponse.status,
headers: nextResponse.headers,
};
}
}
return mergeNativeResponseHeaders(nativeResponse, originalHeaders, transformed.headers ?? {});
};
const applyTransformHooks = async (initialResult, allowRetry) => {
try {
if (isWebResponse(initialResult.response)) {
return {
...initialResult,
response: await runNativeBeforeSend(initialResult, initialResult.response),
};
}
let transformed = normalizeResponse(initialResult.response);
for (const hook of hooks) {
if (!hook.beforeSend)
continue;
const nextResponse = await hook.beforeSend({
req,
requestInfo: copyTrustedRequestInfo(resolvedRequestInfo),
ctx: initialResult.ctx,
contract,
path: pathValue,
query: queryValue,
headers: headersValue,
body: bodyValue,
response: transformed,
error: initialResult.error,
});
if (nextResponse) {
transformed = normalizeResponse(nextResponse);
}
}
return {
...initialResult,
response: transformed,
};
}
catch (error) {
const mapped = await resolveErrorResult(error, initialResult.ctx, pathValue, queryValue, headersValue, bodyValue, { owner: "framework" });
if (!allowRetry) {
return mapped;
}
return applyTransformHooks(mapped, false);
}
};
const applyResponseFinalizerHooks = async (initialResult, allowRetry, responseValidation) => {
const response = normalizeHttpResponse(initialResult.response);
const native = isWebResponse(response);
const owner = responseOwnerFor(response, initialResult.owner);
try {
for (const hook of hooks) {
const finalizer = getResponseFinalizerHook(hook);
if (!finalizer)
continue;
await finalizer({
req,
ctx: initialResult.ctx,
contract,
path: pathValue,
query: queryValue,
headers: headersValue,
body: bodyValue,
response: responseForHooks(response),
error: initialResult.error,
native: native ? true : undefined,
owner,
responseValidation,
});
}
return {
...initialResult,
response,
};
}
catch (error) {
const mapped = await resolveErrorResult(error, initialResult.ctx, pathValue, queryValue, headersValue, bodyValue, { owner: "framework" });
if (!allowRetry) {
return mapped;
}
const transformed = await applyTransformHooks(mapped, true);
return applyResponseFinalizerHooks(transformed, false, "not-applicable");
}
};
let result;
const onRequestStartedAt = performance.now();
for (const hook of hooks) {
if (!hook.onRequest)
continue;
try {
const hookResult = await hook.onRequest({
req,
requestInfo: copyTrustedRequestInfo(resolvedRequestInfo),
ports: finalPorts,
contract,
params: matchedParams,
});
if (hookResult) {
const response = normalizeHttpResponse(hookResult);
result = {
response,
owner: responseOwnerFor(response, "framework"),
};
break;
}
}
catch (error) {
result = await resolveErrorResult(error, undefined, undefined, undefined, undefined, undefined, { owner: "framework" });
break;
}
}
stages.onRequestMs = performance.now() - onRequestStartedAt;
if (!result) {
if (optionsOverrides?.skipRoutePreparation) {
let createdCtx;
try {
createdCtx = await timeStage("contextMs", () => contextRuntime.createRequestContext(req, contract, resolvedRequestInfo));
baseCtx = createdCtx;
}
catch (error) {
result = await resolveErrorResult(error, undefined, undefined, undefined, undefined, undefined, { owner: "framework" });
}
if (!result) {
try {
result = {
ctx: createdCtx,
response: normalizeHttpResponse(await timeStage("handlerMs", () => userHandler({
req,
ctx: createdCtx,
contract,
path: {},
query: {},
headers: rawHeaders,
body: undefined,
}))),
owner: "framework",
};
}
catch (error) {
result = await resolveErrorResult(error, createdCtx, undefined, undefined, undefined, undefined, { owner: "framework" });
}
}
}
else {
const parseStartedAt = performance.now();
let path = undefined;
let query = undefined;
let headers = undefined;
let body = undefined;
const prepared = await prepareRequestInputs({
contract,
req,
url,
rawHeaders,
matchedParams,
maxRequestBodyBytes,
rawRoute: optionsOverrides?.rawRoute,
});
if (!prepared.ok) {
result = {
response: prepared.response,
owner: "framework",
};
}
else {
path = prepared.inputs.path;
query = prepared.inputs.query;
headers = prepared.inputs.headers;
body = prepared.inputs.body;
}
stages.parseMs = performance.now() - parseStartedAt;
if (!result) {
pathValue = path;
queryValue = query;
headersValue = headers;
bodyValue = body;
let createdCtx;
try {
createdCtx = await timeStage("contextMs", () => contextRuntime.createRequestContext(req, contract, resolvedRequestInfo));
baseCtx = createdCtx;
}
catch (error) {
result = await resolveErrorResult(error, undefined, pathValue, queryValue, headersValue, bodyValue, { owner: "framework" });
}
if (!result) {
const baseArgs = {
req,
ctx: createdCtx,
contract,
path: path,
query: query,
headers: headers,
body: body,
};
let currentCtx = createdCtx;
const beforeHandleStartedAt = performance.now();
for (const hook of routeHooks) {
try {
const additions = await hook.resolve({
req,
ctx: currentCtx,
contract,
path,
query,
headers,
body,
});
if (additions && typeof additions === "object") {
currentCtx = contextRuntime.finalizeContext({
...currentCtx,
...additions,
});
}
}
catch (error) {
result = await resolveErrorResult(error, currentCtx, pathValue, queryValue, headersValue, bodyValue, { owner: "framework" });
break;
}
}
if (!result) {
for (const hook of hooks) {
if (!hook.beforeHandle)
continue;
try {
const hookResult = await hook.beforeHandle({
req,
requestInfo: copyTrustedRequestInfo(resolvedRequestInfo),
ctx: currentCtx,
contract,
path,
query,
headers,
body,
});
if (isWebResponse(hookResult)) {
result = {
ctx: currentCtx,
response: hookResult,
owner: "transport",
};
break;
}
if (isHttpResponseLike(hookResult)) {
result = {
ctx: currentCtx,
response: normalizeResponse(hookResult),
owner: "framework",
};
break;
}
if (hookResult?.ctx !== undefined) {
currentCtx = contextRuntime.finalizeContext(hookResult.ctx);
}
if (hookResult?.response) {
const response = normalizeHttpResponse(hookResult.response);
result = {
ctx: currentCtx,
response,
owner: responseOwnerFor(response, "framework"),
};
break;
}
}
catch (error) {
result = await resolveErrorResult(error, currentCtx, pathValue, queryValue, headersValue, bodyValue, { owner: "framework" });
break;
}
}
}
stages.beforeHandleMs = performance.now() - beforeHandleStartedAt;
if (!result) {
// Hooks may have elevated the actor or resolved a tenant.
// Refresh the ambient request context so record-time
// consumers such as createAmbientAuditLog see the finalized
// identity.
setActiveRequestIdentity({
actor: readContextActor(currentCtx),
tenant: readContextTenant(currentCtx),
});
try {
result = {
ctx: currentCtx,
response: normalizeHttpResponse(await timeStage("handlerMs", () => userHandler({ ...baseArgs, ctx: currentCtx }))),
};
}
catch (error) {
result = await resolveErrorResult(error, currentCtx, pathValue, queryValue, headersValue, bodyValue);
}
}
}
}
}
}
const sendStartedAt = performance.now();
result = await applyTransformHooks(result, true);
let finalResponse = normalizeHttpResponse(result.response);
let finalError = result.error;
let finalOwner = responseOwnerFor(finalResponse, result.owner);
let responseValidation = "not-applicable";
if (finalOwner === "route" && !isWebResponse(finalResponse)) {
const validateContract = options.validateResponses ?? true;
try {
finalResponse = await finalizeResponse(contract, finalResponse, target.responseValidationExemptStatus, { validateContract });
result = {
...result,
response: finalResponse,
};
responseValidation = validateContract ? "validated" : "disabled";
}
catch (error) {
if (error instanceof ResponseContractViolationError) {
result = {
ctx: result.ctx,
response: toContractViolationResponse(error),
error,
owner: "framework",
};
}
else {
result = await resolveErrorResult(error, result.ctx, pathValue, queryValue, headersValue, bodyValue, { owner: "framework" });
}
finalResponse = normalizeHttpResponse(result.response);
finalError = result.error;
finalOwner = responseOwnerFor(finalResponse, result.owner);
result = await applyTransformHooks(result, true);
finalResponse = normalizeHttpResponse(result.response);
finalError = result.error;
finalOwner = responseOwnerFor(finalResponse, result.owner);
responseValidation = "not-applicable";
}
}
result = await applyResponseFinalizerHooks(result, true, responseValidation);
finalResponse = normalizeHttpResponse(result.response);
finalError = result.error;
finalOwner = responseOwnerFor(finalResponse, result.owner);
if (!isWebResponse(finalResponse)) {
finalResponse = withFrameworkErrorOwnerHeader(finalResponse, finalOwner);
}
finalResponse = withContractLifecycleHeaders(finalResponse, contract);
finalResponse = withoutHeadResponseBody(finalResponse, req.method);
stages.sendMs = performance.now() - sendStartedAt;
const durationMs = Date.now() - startedAt;
const stageTimings = roundStageTimings(stages);
for (const hook of hooks) {
if (!hook.afterSend)
continue;
try {
await hook.afterSend({
req,
requestInfo: copyTrustedRequestInfo(resolvedRequestInfo),
ctx: result.ctx,
contract,
path: pathValue,
query: queryValue,
headers: headersValue,
body: bodyValue,
response: responseForHooks(finalResponse),
error: finalError,
durationMs,
stages: stageTimings,
});
}
catch {
// Ignore after-response hook failures; they should never change the response.
}
}
return finalResponse;
}
catch (error) {
const result = await resolveErrorResult(error, baseCtx, pathValue, queryValue, headersValue, bodyValue, {
owner: "framework",
});
const response = withoutHeadResponseBody(withContractLifecycleHeaders(normalizeHttpResponse(result.response), contract), req.method);
if (isWebResponse(response)) {
return response;
}
return withFrameworkErrorOwnerHeader(response, responseOwnerFor(response, result.owner));
}
};
return async (target, req, preMatchedParams) => {
const tracing = resolveTracingPort(finalPorts);
const instrumentationOptions = options.instrumentation === false ? undefined : options.instrumentation;
const pathname = (() => {
try {
return new URL(req.url).pathname;
}
catch {
return req.url;
}
})();
const ignored = (instrumentationOptions?.ignorePaths ?? ["/api/devtools"]).some((prefix) => {
const normalized = prefix.replace(/\/+$/, "");
return pathname === normalized || pathname.startsWith(`${normalized}/`);
});
if (!tracing || ignored) {
return executeRequest(target, req, preMatchedParams);
}
const traceContextHeader = instrumentationOptions?.traceContextHeader ?? "traceparent";
const active = tracing.current();
const parsedTraceparent = traceContextHeader === false
? undefined
: parseTraceparent(req.headers.get(traceContextHeader));
const parent = active
? undefined
: !parsedTraceparent
? undefined
: {
traceparent: parsedTraceparent.traceparent,
tracestate: req.headers.get("tracestate") ?? undefined,
};
const traceAttributes = {
"beignet.contract.name": target.contract.name,
"http.request.method": req.method.toUpperCase(),
"http.route": target.contract.path,
};
return await runWithTracing(tracing, {
name: `beignet.request ${target.contract.name}`,
type: "request",
kind: active ? "internal" : "server",
parent,
attributes: traceAttributes,
metricAttributes: traceAttributes,
}, async (span) => {
const response = await executeRequest(target, req, preMatchedParams);
span?.setAttribute("http.response.status_code", response.status);
if (response.status >= 500)
span?.setStatus("error");
return response;
});
};
}
/**
* Build a memo instrumentation recorder from the app ports, or undefined when
* no instrumentation sink is wired.
*
* Resolved per execution rather than at route-build time because providers
* contribute `ports.instrumentation`/`ports.devtools` during setup, after
* routes are registered.
*/
export function createMemoScopeRecorder(ports) {
const target = ports;
if (!resolveProviderInstrumentationPort(target))
return undefined;
const instrumentation = createProviderInstrumentation(target, {
providerName: "memo",
watcher: "memo",
});
return (event) => {
instrumentation.custom({
name: `memo.${event.kind}`,
label: event.kind === "hit" ? "Memo hit" : "Memo fill",
summary: `Memo ${event.kind} for ${event.memo}`,
details: {
memo: event.memo,
key: event.key,
...(event.durationMs !== undefined
? { durationMs: event.durationMs }
: {}),
...(event.failed ? { failed: true } : {}),
},
});
};
}
export function buildHandler(options, finalPorts, contextRuntime, contract, userHandler, hooks, routeHooks = [], optionsOverrides, responseValidationExemptStatus) {
const execute = createRequestExecutor(options, finalPorts, contextRuntime, hooks, routeHooks, optionsOverrides);
const executionTarget = {
contract,
compiled: compilePath(contract.path),
handler: userHandler,
responseValidationExemptStatus,
};
// Every HTTP execution runs inside a fresh memo scope so createMemo(...)
// wrappers dedupe lookups for exactly one request.
return (req, preMatchedParams) => runWithMemoScope({ record: createMemoScopeRecorder(finalPorts) }, () => execute(executionTarget, req, preMatchedParams));
}
function roundStageTimings(stages) {
const round = (value) => Math.round(value * 100) / 100;
return {
onRequestMs: round(stages.onRequestMs),
parseMs: round(stages.parseMs),
contextMs: round(stages.contextMs),
beforeHandleMs: round(stages.beforeHandleMs),
handlerMs: round(stages.handlerMs),
sendMs: round(stages.sendMs),
};
}
//# sourceMappingURL=request-executor.js.map