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 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 Logging and Rate limiting.
Server hooks are configured on the server:
export const server = await createNextServer({
ports,
hooks: [loggingHooks, corsHooks, devtoolsHooks],
context: appContextBlueprint,
});Common global infrastructure concerns should use first-party server hook helpers when they fit:
import {
createCorsHooks,
createLoggingHooks,
} from "@beignet/core/server";
const hooks = [
createCorsHooks({ origins: "*" }),
createLoggingHooks({ logger }),
];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. - Server
beforeHandlehooks run before scoped route hooks. - Route hooks run and add route-specific context. After every hook, the server re-attaches the gate so policies always see the current identity.
- The route runs: the bound use case or the full
handleimplementation. beforeSendcan shape the final response.afterSendobserves completion.onCaughtErrorobserves caught failures.mapUnhandledErrormaps unknown or otherwise unhandled failures.
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 when group hooks add fields to
ctx; route hooks append after group hooks. Ordinary route groups that do not
add context should use the direct defineRouteGroup<AppContext>({ ... })
form:
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>(),
],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 or shape framework-owned responses. 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.