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:

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:

StageMeasures
onRequestMsonRequest hooks, including early rate limits and CORS preflight
parseMsQuery/path/header/body parsing and contract validation
contextMsThe context.request factory, including gate attachment
beforeHandleMsRoute hooks plus beforeHandle hooks (auth resolution, user-scoped rate limits)
handlerMsThe route handler or bound use case
sendMsbeforeSend 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:

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:

OwnerSourceValidation
Route-ownedBound use case output, handler { status, body }, or route-owned AppErrorValidated against contract.responses
Framework-ownedParsing, validation, hooks, unmatched routes, global error handling, contract violationsUses Beignet's standard error envelope
Transport-ownedNative Response returned by handler or hookBypasses 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:

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.