Hooks

Hooks are ordered lifecycle functions for infrastructure behavior around route handlers. Use them for auth, CORS, logging, tracing, rate limits, response shaping, and error mapping.

Use 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.

Hooks are configured on the server:

export const server = await createNextServer({
  ports,
  hooks: [loggingHooks, authHooks, rateLimitHooks],
  createContext: ({ ports }) => ({ ports }),
});

Common infrastructure concerns should use first-party hook helpers when they fit:

import {
  createAuthHooks,
  createCorsHooks,
  createLoggingHooks,
  createRateLimitHooks,
} from "@beignet/core/server";

const hooks = [
  createCorsHooks({ origins: "*" }),
  createLoggingHooks({ logger }),
  createAuthHooks<AppContext>({
    assign: ({ ctx, session }) => ({
      ...ctx,
      auth: session,
      user: session?.user ?? null,
    }),
  }),
  createRateLimitHooks<AppContext>(),
];

Lifecycle

Hooks run in this order:

  1. onRequest runs after route matching and before request parsing or createContext.
  2. Request path, query, headers, and body are parsed and validated.
  3. createContext runs.
  4. beforeHandle runs before the route handler.
  5. The route handler runs.
  6. beforeSend can shape the final response.
  7. afterSend observes completion.
  8. onCaughtError observes caught failures.
  9. 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",
        },
      };
    }
  },
};

beforeHandle

Use beforeHandle when you need validated input, parsed contract headers, context, ports, or route metadata.

const tenantHooks = {
  name: "tenant",
  beforeHandle: async ({ ctx, headers, contract }) => {
    if (contract.metadata?.tenant !== "required") {
      return;
    }

    const tenant = await ctx.ports.tenants.findById(headers["x-tenant-id"]);
    if (!tenant) {
      return {
        ctx,
        response: {
          status: 404,
          body: { code: "TENANT_NOT_FOUND", message: "Tenant not found" },
        },
      };
    }

    return {
      ctx: { ...ctx, tenant },
    };
  },
};

beforeHandle can return a new ctx, a short-circuit response, or both.

Metadata-driven hooks

Contracts can carry metadata for hooks.

export const createTodo = todos
  .post("/api/todos")
  .meta({
    auth: "required",
    rateLimit: { max: 10, windowSec: 60 },
  })
  .body(CreateTodoSchema)
  .responses({ 201: TodoSchema });
const rateLimitHooks = {
  name: "rate-limit",
  beforeHandle: async ({ ctx, contract }) => {
    const rule = contract.metadata?.rateLimit;
    if (!rule) return;

    const result = await ctx.ports.rateLimit.hit({
      key: `route:${contract.name}`,
      limit: rule.max,
      windowSec: rule.windowSec,
    });

    if (!result.allowed) {
      return {
        ctx,
        response: {
          status: 429,
          body: { code: "RATE_LIMITED", message: "Too many requests" },
        },
      };
    }
  },
};

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);
  },
};

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.

const errorHooks = {
  name: "errors",
  onCaughtError: ({ err }) => {
    console.error(err);
  },
  mapUnhandledError: ({ err }) => {
    return {
      status: 500,
      body: {
        code: "INTERNAL_SERVER_ERROR",
        message: "Internal server error",
      },
    };
  },
};

Hook short-circuit responses and mapUnhandledError responses are framework-owned, so they skip route response validation. Framework-owned Beignet error envelopes include x-beignet-error-owner: framework.