InternalsDXMiddleware

Middleware Without Mystery: Hooks, Ordering, and Response Transformation

The DaloyJS request lifecycle, end to end: onRequest → beforeHandle → handler → afterHandle → onSend → onResponse, plus onError on the error path. Where each hook fires, what it can change, how scopes compose (global → group → route), and what to put in which slot — with real short-circuit, header-stamping, and logging recipes.

Devlin DuldulaoFullstack cloud engineer13 min read

Hi, Devlin. Ten years of fullstack, currently in Norway, currently re-reading my own "why is this middleware running twice" Slack threads from 2018, 2021, and 2024 — every framework, same question, same shrug. So here is the post I wish someone had pinned in the channel: every DaloyJS lifecycle hook, in order, with a one-sentence rule for what belongs in each one.

The good news: there are six hooks. The better news: they fire in the order they appear in the code, in the order you registered them, in three nested scopes (global → group → route). No adapter shim, no "extends" chain, no hidden re-entry. You read the file, you know what happens.

The whole API in one screen

@daloyjs/core · types.ts
ts
// @daloyjs/core — the entire Hooks interface, no surprises.
export interface Hooks {
  onRequest?:    (req: Request)                        => void | Promise<void>;
  beforeHandle?: (ctx: BaseContext)                    => void | Response | Promise<void | Response>;
  afterHandle?:  (ctx: BaseContext, result: unknown)   => void | unknown | Promise<void | unknown>;
  onSend?:       (res: Response, ctx?: BaseContext)    => void | Response | Promise<void | Response>;
  onResponse?:   (res: Response)                       => void | Promise<void>;
  onError?:      (err: unknown, ctx?: BaseContext)     => void | Response | Promise<void | Response>;
}
//
// Successful request order:
//   onRequest → beforeHandle → handler → afterHandle → onSend → onResponse
// Error path:
//   onRequest → (anywhere it throws) → onError → onSend → onResponse
six hooks · two phases · zero hidden ones

The lifecycle, drawn

docs/lifecycle.txt
bash
time ─────────────────────────────────────────────────────────────────▶

   ┌──────────┐   ┌──────────────┐   ┌─────────┐   ┌─────────────┐
   │onRequest beforeHandle handler afterHandle
   └──────────┘   └──────────────┘   └─────────┘   └─────────────┘
 may return may return
 a Response (short-circuit)    ▼ a new value

   raw Request                                              ┌────────┐
                  ┌────────────┐   ┌────────────┐ socket
   onSend onResponse │→ closes
                  └────────────┘   └────────────┘  └────────┘
 may                fire-and-forget
 replace             observer

        └──[ anywhere throws ]──▶  onError  ─▶  Response  ─▶  onSend  ─▶  onResponse
hot path top · error path bottom · happens in one tick

Each hook, with the one-line rule

1onRequest(req: Request) => void | Promise<void>

Fires before any context is built. You see the raw Request. Use this for things that need the untouched body or headers — TLS hints, conditional decode of the raw byte stream. Almost everything else belongs in beforeHandle.

can return → nothing (observer over the raw Request)

2beforeHandle(ctx) => void | Response

The single most important hook. Authentication, authorization, rate limiting, maintenance gates, feature flags. Return a Response here and the handler never runs. Throw an HttpError here and the framework turns it into RFC 9457 problem+json for you.

can return → a Response to short-circuit the handler

3handler(ctx) => { status; body; headers? }

Your code. Nothing else lives here. If you find yourself writing "middleware-ish" logic at the top of a handler, that's a sign it wants to be a hook.

can return → the result, always

4afterHandle(ctx, result) => void | unknown

Transform the handler's return value before the framework serializes and validates it. Use sparingly. Reach for it when the transformation spans many routes — global PII redaction, envelope wrapping. For a single endpoint, just shape the body in the handler.

can return → a transformed result

5onSend(res: Response, ctx?) => void | Response

Fires after the Response is built — on every response (success, error, OPTIONS preflight). This is the right place to stamp universal headers: X-Request-Id, Server-Timing, security headers. Mutate res.headers in place, or return a brand-new Response to replace it entirely.

can return → a replacement Response (or mutate headers in place)

6onResponse(res: Response) => void | Promise<void>

Fire-and-forget observer. By design, it cannot change the response — the bytes are already on the wire. This is your logging, metrics, and audit-event slot. Safe to put slow stuff here (within reason); it won't block the client.

can return → nothing (the response already left)

!onError(err, ctx?) => void | Response

Runs on the error path before serialization. Log once, translate library-foreign errors into your own HttpError subclasses, then fall through to the default problem+json serializer (which already does production redaction — see the RFC 9457 errors post).

can return → a Response to take over rendering

Three scopes, composed

Each kind of hook can be registered at three levels. They compose pipeline-style: global runs first, then group, then route. Same hook kind at the same level composes in registration order. That's it. There is no "priority" field.

src/app.ts
ts
// Three scopes. Composed in this order. No magic.
const app = new App({
  hooks: { /* (1) GLOBAL — runs first, every request, every route */ },
});

app.use({  /* (2) GROUP  — runs after global, on routes registered AFTER this */ });

app.route({
  method: "GET",
  path: "/admin",
  handler: ...,
  hooks: { /* (3) ROUTE — runs last, only on THIS route */ },
});

// Each hook *kind* (onRequest, beforeHandle, etc.) composes pipeline-style:
// global onSend, then every group onSend in registration order, then route onSend.
// First one to return a new Response wins the next stage's input.
global → group (app.use) → route (route.hooks)

Watch the order in your terminal

If you don't internalize one other thing from this post, internalize this snippet. It's the fastest way to lock the ordering into your fingers:

scripts/lifecycle-demo.ts
ts
// Watch the order. The console output is the easiest way to internalize it.
const app = new App({
  hooks: {
    onRequest:    () => console.log("[1] global  onRequest"),
    beforeHandle: () => console.log("[2] global  beforeHandle"),
    afterHandle:  () => console.log("[6] global  afterHandle"),
    onSend:       () => console.log("[8] global  onSend"),
    onResponse:   () => console.log("[10] global onResponse"),
  },
});

app.use({
  beforeHandle: () => console.log("[3] group   beforeHandle"),
  afterHandle:  () => console.log("[7] group   afterHandle"),
  onSend:       () => console.log("[9] group   onSend"),
});

app.route({
  method: "GET",
  path: "/x",
  handler: async () => {
    console.log("[5] handler runs");
    return { status: 200, body: { ok: true } };
  },
  hooks: {
    beforeHandle: () => console.log("[4] route   beforeHandle"),
  },
});

// $ curl http://localhost:3000/x
// [1] global  onRequest
// [2] global  beforeHandle
// [3] group   beforeHandle
// [4] route   beforeHandle
// [5] handler runs
// [6] global  afterHandle
// [7] group   afterHandle
// [8] global  onSend
// [9] group   onSend
// [10] global onResponse
run it · read the numbers · keep it in muscle memory

Recipe 1 — Short-circuit with beforeHandle

The maintenance-window pattern, written once, applied globally, with an escape hatch for the liveness probe so your orchestrator doesn't kill the pod while you're fixing things:

src/middleware/maintenance.ts
ts
// src/middleware/maintenance.ts — short-circuit BEFORE the handler runs.
import type { Hooks } from "@daloyjs/core";

export function maintenanceMode(opts: { enabled: () => boolean }): Hooks {
  return {
    beforeHandle(ctx) {
      if (!opts.enabled()) return;            // ← void: continue the pipeline
      if (ctx.route?.path === "/healthz") return; // ← let liveness through
      return new Response(
        JSON.stringify({
          type:   "https://daloyjs.dev/errors/service-unavailable",
          title:  "Maintenance in progress",
          status: 503,
          detail: "Back in roughly 5 minutes. Thank you for your patience.",
        }),
        {
          status: 503,
          headers: {
            "content-type": "application/problem+json",
            "retry-after": "300",
          },
        },
      );
    },
  };
}

// Globally:
app.use(maintenanceMode({ enabled: () => process.env.MAINTENANCE === "1" }));
return a Response → handler never runs → RFC 9457 503 + Retry-After
src/middleware/require-role.ts
ts
// src/middleware/require-role.ts — a per-route auth gate.
// beforeHandle is where authentication and authorization live, because
// short-circuiting here means the handler never runs, never queries the DB,
// never consumes the body, never burns its rate-limit slot.

export function requireRole(role: string): Hooks {
  return {
    beforeHandle(ctx) {
      const user = ctx.state.session?.user;
      if (!user)               throw new UnauthorizedError("Sign in first");
      if (!user.roles?.includes(role))
                               throw new ForbiddenError(`Need role: ${role}`);
    },
  };
}

app.route({
  method: "DELETE",
  path: "/admin/users/:id",
  handler: deleteUser,
  hooks: { ...requireRole("admin") },   // ← scoped to THIS route only
});
throw HttpError → framework serializes problem+json for free

Recipe 2 — Stamp headers with onSend

Server-Timing on every response, including errors and preflights. Notice the pattern: stash the start time in ctx.state from beforeHandle, read it from onSend. Mutate the response headers in place — no need to return a new Response:

src/middleware/server-timing.ts
ts
// onSend — the right place to stamp response headers on EVERY response,
// including the ones produced by error paths and OPTIONS preflights.

export function stampServerTiming(): Hooks {
  return {
    beforeHandle(ctx) {
      ctx.state._startedAt = performance.now();
    },
    onSend(res, ctx) {
      // Mutate in place — no need to return a new Response.
      const elapsed = performance.now() - (ctx?.state._startedAt ?? 0);
      res.headers.set("server-timing", `app;dur=${elapsed.toFixed(1)}`);
      res.headers.set("x-request-id", ctx?.requestId ?? "unknown");
    },
  };
}

app.use(stampServerTiming());

// You can also REPLACE the response by returning a new one:
//   onSend(res) {
//     if (res.status !== 401) return;
//     return new Response(res.body, {
//       status: 401,
//       headers: { ...Object.fromEntries(res.headers), "www-authenticate": "Bearer" },
//     });
//   }
mutate res.headers in place · ctx.requestId on every response

Recipe 3 — Log with onResponse

Anything that you'd call observation belongs in onResponse. The framework guarantees you can't accidentally break the request from here, which is exactly the constraint you want around log lines that someone added at 3 a.m. on a Friday.

src/middleware/access-log.ts
ts
// onResponse — fire-and-forget observer. Cannot change anything.
// This is your logging/metrics/audit slot. By design it cannot accidentally
// break the response because the bytes are already in flight.

export function accessLog(): Hooks {
  return {
    onResponse(res) {
      // `res` is the SAME response the client received.
      logger.info(
        {
          status: res.status,
          contentType: res.headers.get("content-type"),
          bytes: res.headers.get("content-length"),
        },
        "http",
      );
    },
  };
}

// Same place is perfect for metrics:
//   metrics.histogram("http_response_status").observe(res.status);
//   metrics.counter("http_responses_total").inc({ status: res.status });
fire-and-forget · cannot mutate · cannot replace

Recipe 4 — Translate errors with onError

src/middleware/error-translation.ts
ts
// onError — runs on the error path before the response is serialized.
// Return a Response to take over rendering; return nothing to fall through to
// the framework's default RFC 9457 serialization (which is what you usually want).

app.use({
  onError(err, ctx) {
    // 1. Always log. The framework will not double-log.
    ctx?.log.error(
      {
        err,
        requestId: ctx.requestId,
        route: ctx.route?.operationId,
      },
      "unhandled",
    );

    // 2. Translate a specific upstream library error into a domain HttpError.
    if (err instanceof PaymentGatewayTimeout) {
      return new ServiceUnavailableError("Payments are slow right now").toResponse();
    }

    // 3. Anything else: fall through. The framework wraps unknown errors as
    //    InternalError and serializes them as problem+json with the right
    //    production redaction baked in.
  },
});
log once · translate vendor errors · fall through for everything else

Recipe 5 — Transform with afterHandle (carefully)

src/middleware/redact-email.ts
ts
// afterHandle — transform the handler's return value before serialization.
// Use sparingly. 95% of the time you should just shape the body in the handler.
// Real use case: redact PII from a generic search response across many routes.

export function redactEmail(): Hooks {
  return {
    afterHandle(ctx, result) {
      if (!ctx.route?.path.startsWith("/v1/search")) return; // narrow scope
      // result is whatever the handler returned: { status, body, headers? }.
      // Return a new value, or undefined to leave it alone.
      const shaped = result as { status: number; body: Array<{ email?: string }> };
      return {
        ...shaped,
        body: shaped.body.map((row) =>
          row.email ? { ...row, email: row.email.replace(/(?<=.).(?=[^@]*?@)/g, "*") } : row,
        ),
      };
    },
  };
}
cross-cutting transform · narrow scope by route path

Plugins: hooks + routes, encapsulated

For anything you'd ship as a unit — a metrics endpoint plus its onResponse observer, a sessions store plus its beforeHandle reader — use app.register(). Inside the plugin you get a child App whose hooks and routes are scoped to the mount point. The plugin can ship its own prefix, its own hooks, and its own auth defaults without leaking into the parent app:

src/plugins/observability.ts
ts
// src/plugins/observability.ts — encapsulated plugin.
// Routes/hooks registered inside `register` are scoped to the child app.
import type { App, Hooks } from "@daloyjs/core";

export const observability = {
  name: "observability",
  register(child: App) {
    child.use(stampServerTiming());
    child.use(accessLog());

    // You can mount routes too — they get the prefix from the parent's call.
    child.route({
      method: "GET",
      path: "/metrics",
      operationId: "metrics",
      responses: { 200: { description: "Prometheus exposition" } },
      handler: async () => ({
        status: 200,
        body: await metrics.render(),
        headers: { "content-type": "text/plain; version=0.0.4" },
      }),
    });
  },
};

// Mount it like this:
app.register(observability, {
  prefix: "/_ops",            // every route inside lives under /_ops
  hooks: { /* extra hooks just for this plugin's scope */ },
  auth: false,                // turn off global auth inside the plugin
});
encapsulated plugin · prefix-scoped · own hooks

The cheat sheet

NOTES.md
bash
# What goes where — print this out, tape it to your monitor.

onRequest stuff that needs the raw Request (TLS termination metadata,
                 conditional request decoding). No context yet. Cannot decide.

beforeHandle AUTH. AUTHZ. RATE LIMITING. Anything that should prevent
                 the handler from running. THIS is where short-circuiting lives.

handler your code. Nothing else.

afterHandle shape transformations that span MANY routes (PII redaction,
                 envelope wrapping). 95% of the time, just shape it in the handler.

onSend response HEADERS for every response (success + error + OPTIONS).
                 Server-Timing, X-Request-Id, Strict-Transport-Security, CSP,

onResponse FIRE-AND-FORGET observers. Logging. Metrics. Audit events.
                 Cannot change the response. This is by design.

onError translate framework-foreign errors into HttpError subclasses,
                 log once, fall through to the default problem+json serializer.
tape this to your monitor · seriously

The three patterns I keep deleting in code review

ANTIPATTERNS.md
bash
# Three patterns that look smart but bite you in production:

# 1) Mutating ctx.state in onResponse. Too late! The response is already gone.
#    Put state changes in beforeHandle / afterHandle. Put OBSERVATION in onResponse.

# 2) Doing auth in afterHandle. The handler already ran (and probably hit the DB,
#    and probably consumed the rate-limit budget). Auth belongs in beforeHandle.

# 3) Catching errors in beforeHandle to "swallow" them. The framework already
#    does graceful error → problem+json conversion. Trust it. If you need to
#    REWRITE the error, do it in onError. If you need to PREVENT it, do it in
#    beforeHandle with a short-circuit Response or a thrown HttpError.
bookmark · re-read every six months

Wrapping up

Middleware in DaloyJS is one interface, Hooks, with six method slots. Three scopes, composed in a fixed order. One side-channel for errors. That is the whole machine. Once the mental model clicks, you stop asking where does this go and start asking which scope — which is a far more interesting question, and one whose answer you can usually argue about in a Slack thread without anyone getting hurt.

For the surrounding pieces: RFC 9457 errors is the contract onError serializes into; sessions is the most common beforeHandle consumer you'll write; and the bookstore tutorial shows the whole pipeline running against a real route table.

— Devlin

Devlin Duldulao

Ten years of fullstack, currently writing TypeScript from a desk in Norway. Has explained "why is my middleware running twice" enough times to make a poster of it.