Request lifecycle
A Beignet request moves through a predictable pipeline before your application code runs and before a response is sent. Read this page when you need to know what the framework guarantees around a request — matching, validation, correlation, and who owns each kind of response.
HTTP request
-> adapter request normalization
-> route matching
-> request correlation (request ID + trace context)
-> onRequest hooks
-> request parsing
-> contract request validation
-> context creation
-> route hooks
-> beforeHandle hooks
-> route handler or use case
-> beforeSend hooks (correlation response headers)
-> route-owned response validation
-> adapter response serialization
-> afterSend hooks (instrumentation events)This pipeline keeps business responses strict without forcing every route to declare infrastructure responses such as malformed JSON, auth failures, rate limits, unmatched routes, or unexpected failures.
Route matching
Beignet apps register routes centrally in server/index.ts with
defineRoutes. A catch-all app/api/[[...path]]/route.ts file exposes
createApiRoute(getServer), so the adapter can dispatch the incoming request
to the registered contract and handler.
Routes are matched by HTTP method and path. Static segments are more specific
than dynamic segments, so /posts/new wins over /posts/:slug regardless of
registration order. Dynamic parameter names do not affect matching —
registering both /items/:id and /items/:slug for the same method is
rejected at startup as ambiguous, along with the other
registration-time guarantees.
If the path matches one or more registered routes but the method does not,
Beignet returns a framework-owned 405 response with
code: "METHOD_NOT_ALLOWED" and an Allow header listing the registered
methods for that path. HEAD is not implicitly mapped to GET: a HEAD
request on a GET-only path gets a 405 with Allow: GET, so metadata-driven
hooks never run a handler for a method the contract did not declare. CORS
preflight onRequest hooks still short-circuit OPTIONS requests before the
405 is produced.
If no route matches the path at all, Beignet returns a framework-owned 404
error response.
Request validation
The runtime parses path params, query params, headers, and JSON body data from the request. It validates each declared schema before your handler runs.
Invalid request data never reaches the handler. It becomes a framework-owned error response with a standard error envelope.
Request instrumentation
The server owns request correlation. You can rely on these outcomes without writing any hooks:
- Every request gets a request ID and W3C trace context — taken from incoming
x-request-idandtraceparentheaders when present, generated otherwise — available to context factories asrequestIdandtrace. - By default the server writes
x-request-idandtraceparentresponse headers, including on streamed responses. requestanderrorevents are recorded to the resolved provider instrumentation port (see Writing a provider) after responses are sent; without an installed sink, headers are still written and events are a no-op.- Correlation is ambient for the request's duration, so instrumentation sinks can correlate events recorded anywhere in the request.
- When
ports.tracingis installed, the request runs inside an activebeignet.request <contract>span. Nested use cases and provider SDK spans inherit that OpenTelemetry context across asynchronous work.
Configure it with the instrumentation option on createServer(...):
const server = await createNextServer({
// ...
instrumentation: {
requestIdHeader: "x-request-id", // or false
traceContextHeader: "traceparent", // or false
ignorePaths: ["/api/devtools"],
redact: (event) => event,
shouldCapture: ({ response }) => response.status !== 404,
},
});Pass instrumentation: false to disable headers and event recording. Context
factories still receive requestId and trace arguments. Context values win
over server-computed correlation: when a factory sets its own requestId,
headers and recorded events use it.
Install @beignet/provider-tracing-opentelemetry to turn correlation into
production spans and metrics. The app still owns SDK registration, sampling,
export, and serverless flush behavior. See OpenTelemetry.
Per-stage timings
Every request records a per-stage timing breakdown alongside its total
duration. afterSend hooks receive it as stages, and the recorded
request event carries the same object, which the
devtools waterfall renders as sub-bars under the
request span:
| Stage | Measures |
|---|---|
onRequestMs | onRequest hooks, including early rate limits and CORS preflight |
parseMs | Query/path/header/body parsing and contract validation |
contextMs | The context.request factory, including gate attachment |
beforeHandleMs | Route hooks plus beforeHandle hooks (auth resolution, user-scoped rate limits) |
handlerMs | The route handler or bound use case |
sendMs | beforeSend hooks, response validation, and finalizers |
Stages that did not run report 0, and the stages do not sum exactly to
durationMs — routing and framework bookkeeping live in the gaps.
Context latency budgets
contextMs is the number to watch in serverless deployments with a remote
database. The context.request factory runs on every contract request
before any handler, so each sequential await inside it — a session lookup,
then a membership query — adds a full database round-trip to every request in
the app. Two sequential 120ms remote queries put a ~240ms floor under every
endpoint before business logic starts.
Budget context creation like the hot path it is:
- Run independent lookups concurrently with
Promise.allinstead of sequential awaits, and resolve dependent data (a role for the resolved tenant) inside the use cases that need it when most routes do not. - Cache what the auth provider lets you cache — for example Better Auth's
cookieCacheavoids a session query per request. - Wrap repository reads in
createMemoso a lookup the context factory already resolved is not paid again by policies and use cases in the same request. - Keep the factory to identity and correlation. Loading feature data in context makes every route pay for the heaviest route's needs.
- Watch the waterfall in development: a wide
contextbar on every request is this problem, and it shows up long before production traffic does.
Request-scoped memoization
createMemo from @beignet/core/memo deduplicates a lookup for the lifetime
of one request: the first call runs, and every later call with the same
arguments — from a policy, a use case, an event handler — returns the same
value, including the same in-flight promise, so concurrent calls share one
query. The cache dies with the request, so memoized reads can never serve
data staler than the request that fetched them: no TTL, no invalidation
policy, no cross-request state.
Wrap reads where the adapter is wired, in infra, so use cases and policies never know caching exists:
// infra/issues-repository.ts
import { createMemo } from "@beignet/core/memo";
const repository = createDrizzleIssuesRepository(db);
export const issues = {
...repository,
findById: createMemo(repository.findById, { name: "issues.findById" }),
update: async (id: string, patch: IssuePatch) => {
const updated = await repository.update(id, patch);
// A write makes the memoized read stale within this same request.
issues.findById.invalidate(id);
return updated;
},
};Memoize reads, not mutations, and pair every mutation that affects a
memoized read with invalidate(...) as above. Default cache keys use a
structural, type-tagged encoding of the arguments ("1" and 1 never
collide, object key order is irrelevant); arguments that cannot be encoded
deterministically — class instances, functions, circular values — throw a
MemoKeyError that names the memo, and a key option takes over:
createMemo(repository.search, {
name: "issues.search",
key: (query) => query.normalized,
});The server enters a memo scope around every HTTP request (contract routes and
raw routes) and around server.runServiceContext(...) executions, and
createTestApp requests each get their own scope. createServiceContext(...)
has no call boundary that could end a scope, so memoized functions call
straight through there — as they do in plain scripts, where runMemoScope
from @beignet/core/memo creates a scope explicitly.
When devtools is installed, every memoized call records a memo.hit or
memo.miss event (with fill duration) under the request, so duplicate
lookups are visible in the waterfall before and after you wrap them.
For caching that must survive across requests — the remote-database session
and role floor above — reach for the explicit tier instead: the auth
provider's cookie cache for sessions, and ports.cache.remember with keys
that change when the data changes (a per-tenant version segment, for
example) for everything else. Cross-request caching trades staleness for
latency and needs an invalidation story; request-scoped memoization never
does, which is why it is the tier the framework applies automatically.
Context and hooks
The server's context.request factory builds the per-request context used by
handlers, hooks, and use cases from the framework-neutral request, final
ports, the matched contract, and the server-resolved requestId and trace
values. See Server for the context blueprint and service
contexts.
When the context blueprint declares a gate, the server attaches ctx.gate
itself and re-attaches it after hooks enrich the context, so authorization
always evaluates the current identity.
Hooks run around the handler for application-level behavior: authentication at
the HTTP boundary, CORS, logging and tracing, rate limiting, response shaping,
and error mapping. Hooks can short-circuit the request; short-circuit
responses are framework-owned unless the hook returns a native Response.
See Hooks for the lifecycle order and hook kinds.
Route execution
After validation, the route runs. Binder routes ({ contract, useCase }) map
the validated request parts to the use case input, run the use case, and
return its output with the contract's declared success status:
{
contract: getProject,
useCase: getProjectUseCase,
}When the route shares schemas by reference with its use case, Beignet skips the redundant second validation pass. See Server for the binder's input mapping and one-schema-one-parse rules.
Full handle routes receive the validated parts directly and own the
response:
{
contract: getProject,
handle: async ({ req, ctx, path, query, headers, body }) => ({
status: 200,
body: await getProjectUseCase.run({ ctx, input: { id: path.id } }),
}),
}Response ownership
Beignet separates responses into three groups:
| Owner | Source | Validation |
|---|---|---|
| Route-owned | Bound use case output, handler { status, body }, or route-owned AppError | Validated against contract.responses |
| Framework-owned | Parsing, validation, hooks, unmatched routes, global error handling, contract violations | Uses Beignet's standard error envelope |
| Transport-owned | Native Response returned by handler or hook | Bypasses JSON response validation |
Route-owned responses are business outcomes: responses returned by the
handler, and AppError instances thrown by the handler or bound use case.
When the route declares response schemas, an undeclared status or a
non-matching body becomes a framework-owned 500 contract-violation response;
an empty contract.responses applies no response validation. The
validateResponses server option and its production posture live in
Server.
Framework-owned responses skip route response validation and use Beignet's standard error envelope when applicable:
- Request parsing and request validation errors
- Unmatched route 404s and method-mismatch 405s
- Hook short-circuit responses from
onRequestorbeforeHandle mapUnhandledErrorresponses- Internal contract-violation 500s
Framework-owned Beignet error envelopes include
x-beignet-error-owner: framework so generated clients can distinguish
framework errors from route-owned error responses that share the same status
code.
Transport-owned responses are native Response objects returned from handlers
or hooks. They bypass JSON response validation, and beforeSend sees a
headers-only view where only header changes apply; afterSend still observes
their status and headers. Use them for non-JSON payloads, redirects,
streaming, and Server-Sent Events.
This keeps the contract strict for business responses while letting infrastructure concerns such as auth, CORS, rate limits, malformed requests, and unexpected failures stay outside each route's response union.
Client behavior
Typed clients use the same contracts. A non-2xx route-owned response is parsed
against the declared error response schema. Framework-owned errors use
Beignet's standard { code, message, details?, requestId? } envelope when the
response includes Beignet's ownership header.
Use call() when failures should throw ContractError. Use safeCall() when
explicit result branching is clearer. See Client for
narrowing and handling patterns.