Hooks

Hooks run framework-level behavior around your route handlers. Beignet has two hook scopes:

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:

  1. onRequest runs after route matching and before request parsing or context creation.
  2. Request path, query, headers, and body are parsed and validated.
  3. The context.request factory runs and the server attaches ctx.gate when the blueprint declares one.
  4. 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.
  5. Server beforeHandle hooks run with the route-hook context, so metadata hooks such as user-scoped rate limits and actor-scoped idempotency see the finalized identity.
  6. The route runs: the bound use case or the full handle implementation.
  7. If a failure occurs, onCaughtError observes it and mapUnhandledError can map unknown or otherwise unhandled failures.
  8. beforeSend can shape the response. Route-owned replacements are validated against the contract before they are returned.
  9. afterSend observes 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.