Docs / Middleware

Middleware

before / after / wrap hooks, composed on the hopak() server.

Three hooks for the request pipeline. Typed functions, not a Koa-style (ctx, next) chain — no next() to forget.

type Before = (ctx: RequestContext) =>
  Promise<Response | void> | Response | void;

type After = (
  ctx: RequestContext,
  result: { response?: Response; error?: unknown },
) => Promise<void> | void;

type Wrap = (
  ctx: RequestContext,
  run: () => Promise<Response>,
) => Promise<Response>;

Where to register

Two scopes — global (every request) and per-route:

// main.ts — global
import { hopak, requestId, requestLog } from '@hopak/core';

await hopak()
  .before(requestId())
  .after(requestLog())
  .wrap(async (_ctx, run) => run())  // rarely needed
  .listen();
// app/routes/api/posts.ts — per-route
export const POST = defineRoute({
  before: [requireAuth()],
  after: [audit],
  handler: async (ctx) => { /* … */ },
});

crud.* helpers take the same options as a second argument — see CRUD → Gate a CRUD verb.

Execution order

For one request:

global.before[]  →  wrap[]  →  route.before[]  →  handler
                                (throw or return Response short-circuits)
route.after[]    →  global.after[]

Wraps nest — the outer-most runs first on entry, last on exit (like onion layers).

hopak().before/.after/.wrap is frozen after listen()

Registering middleware after the server starts throws:

const app = hopak();
await app.listen();
app.before(requestId());
// Error: hopak().before(): cannot register middleware
//        after listen() — add it before starting the server.

This protects against half-applied middleware on live requests.

Every request passes through

Changed in 1.0

Global middleware sees every request — including static files, 404s, and 405s. Access logs count misses, and a global auth or rate-limit guard fronts the whole surface, not just matched routes.

Built-in: requestId() + requestLog()

See Recipe 23 for the full walkthrough. One command enables both: hopak use request-log.

Built-in: rateLimit()

Added in 1.0

Fixed-window, in-process rate limiter. Over-limit requests get 429 with a Retry-After header:

import { hopak, rateLimit } from '@hopak/core';

// global: 100 requests per minute per IP (the defaults)
await hopak().before(rateLimit()).listen();
// per route, custom key
export const POST = defineRoute({
  before: [rateLimit({ max: 10, windowMs: 60_000, keyFor: (ctx) => ctx.user?.id ?? ctx.ip ?? '' })],
  handler: async (ctx) => { /* … */ },
});

State lives in the process — behind a load balancer each instance counts separately; put the limit at the edge when you need a global budget.

EMPTY_MIDDLEWARE

Exported sentinel for { before: [], after: [], wrap: [] }. Useful if you compose your own Middleware object and want an explicit empty default.