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 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:

  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. Server beforeHandle hooks run before scoped route hooks.
  5. Route hooks run and add route-specific context. After every hook, the server re-attaches the gate so policies always see the current identity.
  6. The route runs: the bound use case or the full handle implementation.
  7. beforeSend can shape the final response.
  8. afterSend observes completion.
  9. onCaughtError observes caught failures.
  10. mapUnhandledError maps 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.