UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

817 lines 38.5 kB
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