Hooks
Hooks run framework-level behavior around your route handlers. Beignet has two hook scopes:
- Server hooks wrap every request for protocol and lifecycle behavior such as security headers, CSRF, CORS, logging, tracing, response shaping, and error observation.
- Route hooks run only where they are attached and add route-specific context for authentication, tenancy, feature gates, idempotency, or audit scope.
Use route hooks for HTTP boundary authentication and infrastructure checks:
parse the session, reject routes that require a signed-in request, or enrich
ctx. Keep business authorization in use cases or app-owned policy functions
so the same ownership, role, tenant, or resource-state rule runs outside HTTP
too.
For the full auth story, read Authentication and Authorization. For production observability and traffic protection, read Deployment, Logging, Error reporting, and Rate limiting.
Server hooks are configured on the server:
export const getServer = createNextServerLoader(() =>
createNextServer({
ports,
hooks: [loggingHooks, corsHooks, devtoolsHooks],
context: appContextBlueprint,
}),
);Common global infrastructure concerns should use first-party server hook helpers when they fit:
import {
createCorsHooks,
createCsrfHooks,
createErrorReportingHooks,
createLoggingHooks,
createSecurityHeadersHooks,
} from "@beignet/core/server";
import type { AppContext } from "@/app-context";
const hooks = [
createSecurityHeadersHooks<AppContext>(),
createCorsHooks({ origins: "*" }),
createCsrfHooks<AppContext>(),
createErrorReportingHooks<AppContext>(),
createLoggingHooks({ logger }),
];origins: "*" is only for non-credentialed browser requests. When cookies or
authorization headers are allowed cross-origin, pass an explicit origin list
with credentials: true; Beignet rejects credentialed wildcard CORS during hook
setup.
createSecurityHeadersHooks(...) adds the framework's browser response-header
baseline without guessing your CSP or HSTS policy. createCsrfHooks(...)
protects unsafe methods with same-origin checks by default and can be configured
with allowMissingOrigin: false plus double-submit cookie tokens for
cookie-authenticated browser-only APIs. Provider webhooks and auth callbacks can
be skipped with app-owned skip rules when another verifier owns the route.
When the app sits behind a trusted edge, pass trustedProxy: {} so CSRF origin
checks compare against the external forwarded host and protocol.
Route hooks live beside route groups:
import { createAuthHooks, defineRouteGroup } from "@beignet/core/server";
import type { AppContext } from "@/app-context";
const auth = createAuthHooks<AppContext>()({
resolve: ({ ctx }) => {
return ctx.auth ? { user: ctx.auth.user } : null;
},
});
export const postRoutes = defineRouteGroup<AppContext>()({
name: "posts",
hooks: [auth.optional()],
routes: [
{
contract: createPost,
hooks: [auth.required()],
useCase: createPostUseCase,
},
],
});createAuthHooks<AppContext>() binds the app context; the inner call infers
the added fields from resolve. When credentials live in request headers,
declare a headers schema on the auth hooks so resolve receives typed
header values; see Authentication for the header-based and
session-based variants.
Binder routes pair hooks with use cases directly, without extra typing
helpers: auth.required() guards the HTTP boundary, and the use case enforces
the business rule from its own context.
Lifecycle
Hooks run in this order:
onRequestruns after route matching and before request parsing or context creation.- Request path, query, headers, and body are parsed and validated.
- The
context.requestfactory runs and the server attachesctx.gatewhen the blueprint declares one. - Route hooks run and add route-specific context such as auth or tenancy. After every hook, the server re-attaches the gate so policies always see the current identity.
- Server
beforeHandlehooks run with the route-hook context, so metadata hooks such as user-scoped rate limits and actor-scoped idempotency see the finalized identity. - The route runs: the bound use case or the full
handleimplementation. - If a failure occurs,
onCaughtErrorobserves it andmapUnhandledErrorcan map unknown or otherwise unhandled failures. beforeSendcan shape the response. Route-owned replacements are validated against the contract before they are returned.afterSendobserves completion.
onRequest
Use onRequest for raw request concerns that do not need parsed input or context.
const corsHooks = {
name: "cors",
onRequest: ({ req }) => {
if (req.method === "OPTIONS") {
return {
status: 204,
headers: {
"access-control-allow-origin": "*",
"access-control-allow-methods": "GET,POST,PATCH,DELETE,OPTIONS",
},
};
}
},
};Route hooks
Use route hooks when a policy or context addition belongs to one feature, route
group, or route. Route hooks add fields to ctx; they should throw framework or
application errors for denials instead of returning HTTP responses directly.
Authentication hooks come from createAuthHooks(...). Other route hooks are
plain RouteHook object literals:
import { GateAuthorizationError } from "@beignet/core/ports";
import type { RouteHook } from "@beignet/core/server";
export const requireTenant: RouteHook<AppContext, { tenant: Tenant }> = {
name: "tenant.required",
resolve: async ({ ctx }) => {
const tenant = await ctx.ports.tenants.resolveTenant(ctx);
if (!tenant) {
throw new GateAuthorizationError("Tenant is required");
}
return { tenant };
},
};Group hooks apply to every route in the group. Use the curried
defineRouteGroup<AppContext>()({ ... }) form for all feature route groups,
including groups whose hooks add fields to ctx; route hooks append after
group hooks.
export const billingRoutes = defineRouteGroup<AppContext>()({
name: "billing",
hooks: [auth.required(), requireTenant],
routes: [{ contract: listInvoices, useCase: listInvoicesUseCase }],
});Full handle routes that read hook-added context fields should be wrapped in
defineRoute<AppContext>()(...) so ctx is enriched at compile time:
import { defineRoute } from "@beignet/core/server";
const route = defineRoute<AppContext>();
route({
contract: createPost,
hooks: [auth.required()],
handle: async ({ ctx, body }) => {
ctx.user.id; // typed because the hook is attached through defineRoute
return { status: 201, body: await createPostUseCase.run({ ctx, input: body }) };
},
});beforeHandle
Use server beforeHandle hooks when the behavior is global and should run for
every route, such as response-wide infrastructure checks. Request
instrumentation does not need a hook: the server owns request IDs, trace
context, correlation headers, and request/error events through the
instrumentation option on createServer(...). See
request lifecycle.
Server beforeHandle can return a new ctx, a short-circuit response, or
both. Prefer route hooks for route-specific policy because they are visible in
the feature route group.
Metadata-driven hooks
Contracts can carry metadata for docs, OpenAPI, clients, and app conventions. Metadata is not enforcement by itself: a built-in server hook must read it.
export const createTodo = todos
.post("/api/todos")
.meta({
auth: "required",
rateLimit: { max: 10, windowSec: 60 },
idempotency: { required: true, ttlSec: 60 * 60 * 24 },
})
.body(CreateTodoSchema)
.responses({ 201: TodoSchema });createRateLimitHooks(...) enforces meta.rateLimit and
createIdempotencyHooks(...) enforces meta.idempotency:
hooks: [
createRateLimitHooks<AppContext>(),
createIdempotencyHooks<AppContext>(),
],The bare createRateLimitHooks<AppContext>() call covers global and user
scoped limits. Contracts that declare rateLimit: { scope: "ip" } require an
explicit trustedProxy.clientIp, ipSource, or earlyKey option —
createServer(...) fails at startup otherwise instead of silently sharing one
ip:unknown bucket:
createRateLimitHooks<AppContext>({
trustedProxy: { clientIp: "x-forwarded-for-last" },
});See Rate limiting and Idempotency for the
metadata shapes and error semantics. For metadata without a built-in hook, such
as auth, prefer explicit route hooks for runtime enforcement:
hooks: [auth.required()]beforeSend and afterSend
Use beforeSend to add headers, shape framework-owned responses, or make
last-mile route response changes that still match the contract. Use afterSend
for logging, metrics, and tracing.
const loggingHooks = {
name: "logging",
beforeSend: ({ response }) => ({
...response,
headers: {
...response.headers,
"x-beignet": "1",
},
}),
afterSend: ({ req, response, durationMs }) => {
console.info(req.method, response.status, durationMs);
},
};Native responses and hooks
When a route returns a native web Response, beforeSend still runs with native: true and a headers-only view ({ status, headers }). The body is not readable, and returned body or status changes are ignored with a one-time dev warning; header changes are merged onto the native Response without buffering the stream. This is how CORS, x-request-id, and traceparent headers reach streamed responses. Streamed responses are also not idempotency-replayable; see Idempotency.
Error handling
Hook-thrown errors, handler-thrown unknown errors, and handler-thrown AppError instances are passed to onCaughtError observers. AppError instances are auto-mapped before mapUnhandledError; mapUnhandledError only maps unknown or otherwise unhandled failures. See Errors for observing and mapping them.
Hook short-circuit responses and mapUnhandledError responses are
framework-owned, so they skip route response validation. See
Request lifecycle for the response
ownership taxonomy.