Learn / The ZudoJS core
LESSON 52 OF 84

The ZudoJS core Core

Middleware, CORS, security headers and graceful shutdown

Wrap every Task API route in middleware. Learn how a middleware pipeline runs, then add security headers, a CORS allow-list, rate limits with @zudojs/security, and a graceful shutdown that lets requests in progress finish.

  • 50 min to read and try
  • You need: Routes, requests and responses
  • You build: A Task API that sends safe headers, allows only its own web app, limits how fast clients can write, and shuts down without cutting off requests
Test yourself

What middleware is

Some work belongs to every request, not to one route: adding security headers, checking where a request comes from, counting requests per client, measuring time. Copying that code into every route would be repetitive and easy to forget.

Middleware is a function that runs around the route. It receives a context (with context.request and context.response) and a function called next. Calling next() runs everything after it: the next middleware, and at the end the route. A middleware can:

  • do work before next(), such as checking the request,
  • do work after next(), such as adding a header to the response,
  • or not call next() at all and answer by itself. That is called short-circuiting.

An HttpMiddlewarePipeline holds middleware in order. You can run it without a server, with a request made by createRequestContext, which also makes middleware easy to test:

order.tsNode.js only
import { createRequestContext, createResponseContext, HttpMiddlewarePipeline } from "@zudojs/http";
import type { HttpMiddleware } from "@zudojs/http";

function trace(name: string): HttpMiddleware {
  return async (context, next) => {
    console.log(`${name}: before`);
    const response = await next();
    console.log(`${name}: after`);
    return response;
  };
}

const pipeline = new HttpMiddlewarePipeline();
pipeline.use(trace("timing"));
pipeline.use(trace("security"), { priority: -10 });
pipeline.use(async (context) => {
  console.log("route: builds the response");
  return context.response.json({ ok: true });
});

const request = createRequestContext({ method: "GET", url: "/tasks" });
const response = await pipeline.execute(request, createResponseContext());
console.log(response.status, response.body);
Output of npx tsx order.ts
security: before
timing: before
route: builds the response
timing: after
security: after
200 {"ok":true}

The calls nest like layers of an onion: security starts first and finishes last. It was registered second but runs first because its priority is lower. Lower numbers run earlier, the default is 0, and equal priorities keep the order you added them in. The last function never calls next; it plays the role of the route.

Writing your own middleware

Two small middleware for the Task API. The first puts an x-response-time header on every response: work after next(). The second is a maintenance switch that short-circuits: while it is on, no request reaches a route:

own.tsNode.js only
import { createRequestContext, createResponseContext, HttpMiddlewarePipeline } from "@zudojs/http";
import type { HttpMiddleware } from "@zudojs/http";

const responseTime: HttpMiddleware = async (context, next) => {
  const started = performance.now();
  const response = await next();
  return response.setHeader("x-response-time", `${Math.round(performance.now() - started)}ms`);
};

let maintenance = false;
const maintenanceMode: HttpMiddleware = async (context, next) => {
  if (maintenance) {
    return context.response
      .setStatus(503)
      .setHeader("retry-after", "120")
      .json({ error: "The Task API is under maintenance" });
  }
  return next();
};

const pipeline = new HttpMiddlewarePipeline();
pipeline.use(responseTime, { priority: -100 });
pipeline.use(maintenanceMode);
pipeline.use(async (context) => context.response.json([{ id: 1, title: "Buy milk" }]));

for (const state of [false, true]) {
  maintenance = state;
  const response = await pipeline.execute(createRequestContext({ method: "GET", url: "/tasks" }), createResponseContext());
  console.log(response.status, response.headers["retry-after"], response.body);
  console.log("has x-response-time:", "x-response-time" in response.headers);
}
Output of npx tsx own.ts
200 undefined [{"id":1,"title":"Buy milk"}]
has x-response-time: true
503 120 {"error":"The Task API is under maintenance"}
has x-response-time: true

With maintenance on, the route never ran, yet the response still got its x-response-time header. responseTime has the lowest priority, so it wraps everything, including middleware that answers early. That is the rule for ordering: middleware that must see every response goes first.

One thing responseTime does not handle: if the route throws an error, await next() throws that same error, and the code after it never runs. The error travels out through every middleware to whoever called the pipeline. The ZudoJS error system writes a middleware that catches it and turns it into a response.

To run a pipeline for real requests, its last step dispatches the router, and the server hands every request to the pipeline:

wiring.ts (part)Node.js only
pipeline.use(async (context) => (await router.dispatch(context.request)).response);

const server = createHttpServer({
  adapter: createNodeHttpAdapter({ host: "127.0.0.1", port: 3000 }),
  handler: (request) => pipeline.execute(request, createResponseContext()),
});

Security headers

Browsers understand a set of response headers that switch on extra protection: do not let other sites put this page in a frame, do not guess content types, and so on. createSecurityMiddleware() sends a safe set on every response. So the next examples can use a real server, this helper serves a pipeline and stops it again:

serve.tsNode.js only
import { createHttpServer, createNodeHttpAdapter, createResponseContext } from "@zudojs/http";
import type { HttpMiddlewarePipeline } from "@zudojs/http";

export async function withServer(pipeline: HttpMiddlewarePipeline, run: (base: string) => Promise<void>): Promise<void> {
  const server = createHttpServer({
    adapter: createNodeHttpAdapter({ host: "127.0.0.1", port: 0 }),
    handler: (request) => pipeline.execute(request, createResponseContext()),
  });
  await server.start();
  try {
    await run(`http://127.0.0.1:${server.address?.port}`);
  } finally {
    await server.stop();
  }
}
headers.tsNode.js only
import { createSecurityMiddleware, HttpMiddlewarePipeline } from "@zudojs/http";
import { withServer } from "./serve.js";

const pipeline = new HttpMiddlewarePipeline();
pipeline.use(createSecurityMiddleware());
pipeline.use(async (context) => context.response.json([{ id: 1, title: "Buy milk" }]));

await withServer(pipeline, async (base) => {
  const response = await fetch(`${base}/tasks`);
  for (const name of [
    "x-content-type-options",
    "x-frame-options",
    "referrer-policy",
    "content-security-policy",
    "strict-transport-security",
  ]) {
    console.log(`${name}: ${response.headers.get(name)}`);
  }
});
Output of npx tsx headers.ts
x-content-type-options: nosniff
x-frame-options: DENY
referrer-policy: strict-origin-when-cross-origin
content-security-policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'
strict-transport-security: max-age=31536000; includeSubDomains; preload

What they do, in short:

  • X-Content-Type-Options: nosniff: the browser must trust the Content-Type you send and not guess, so a JSON answer can never be run as a script.
  • X-Frame-Options: DENY and CSP's frame-ancestors 'none': no other site may show your pages inside a frame. This stops clickjacking, where an invisible frame tricks the user into clicking your buttons.
  • Referrer-Policy: other sites do not see your full URLs, which may contain ids.
  • Content-Security-Policy (CSP): the list of places a page may load scripts, styles and images from.
  • Strict-Transport-Security (HSTS): "only ever talk to me over HTTPS" for a year.

The middleware also sends a few more (Permissions-Policy and the cross-origin headers). You get all of them with one line, and you never have to remember them.

HSTS AND LOCALHOST

Browsers remember HSTS, but they only accept it from an HTTPS answer: over plain http:// they ignore it. If a development server on localhost ever answers over HTTPS (with a local certificate, or behind a local proxy) and sends HSTS, browsers then refuse plain http://localhost for a year, on every port. For such a server, use createSecurityMiddleware({ strictTransportSecurity: "max-age=0" }), which tells browsers to forget it, and keep the default in production behind HTTPS. Security for every public API goes deeper into CSP and HSTS.

CORS: which websites may call you

Imagine the Task API at api.tasks.example.com and its web app at tasks.example.com. When JavaScript on one website calls another address, the browser first checks whether that address allows it. That rule is CORS (Cross-Origin Resource Sharing). An origin is a scheme, host and port, like https://tasks.example.com. The browser sends it in the Origin header, and your answer must say that this origin is allowed.

For some requests (for example a POST with JSON) the browser first sends a separate OPTIONS request, called a preflight, to ask for permission. createCorsMiddleware handles both. Give it an allow-list: the exact origins you trust.

cors.tsNode.js only
import { createCorsMiddleware, HttpMiddlewarePipeline } from "@zudojs/http";
import { withServer } from "./serve.js";

const pipeline = new HttpMiddlewarePipeline();
pipeline.use(createCorsMiddleware({ allowOrigin: ["https://tasks.example.com"], credentials: true }));
pipeline.use(async (context) => context.response.json([{ id: 1, title: "Buy milk" }]));

async function call(label: string, base: string, init: RequestInit): Promise<void> {
  const response = await fetch(`${base}/tasks`, init);
  console.log(label, response.status, "allow-origin:", response.headers.get("access-control-allow-origin"));
}

await withServer(pipeline, async (base) => {
  await call("our app      ", base, { headers: { origin: "https://tasks.example.com" } });
  await call("other site   ", base, { headers: { origin: "https://evil.example" } });
  await call("preflight    ", base, {
    method: "OPTIONS",
    headers: { origin: "https://tasks.example.com", "access-control-request-method": "POST" },
  });
});
Output of npx tsx cors.ts
our app       200 allow-origin: https://tasks.example.com
other site    200 allow-origin: null
preflight     204 allow-origin: https://tasks.example.com

Our own app got access-control-allow-origin back with its origin, so its browser lets the page read the answer. The preflight was answered with 204 by the middleware itself; the route never ran.

Look closely at the other site: it got status 200 and the data was sent. Only the missing header makes the browser hide the answer from that site's JavaScript. CORS protects users' browsers, not your server. curl, a script or another server ignores CORS completely. It never replaces authentication or permissions.

credentials: true lets the browser send cookies along. Combined with "every origin" ("*"), any website could make requests with your users' cookies. The package refuses that combination at startup:

cors-wildcard.tsNode.js only
import { createCorsMiddleware } from "@zudojs/http";

try {
  createCorsMiddleware({ allowOrigin: "*", credentials: true });
} catch (error) {
  console.log((error as Error).name);
  console.log((error as Error).message);
}
Output of npx tsx cors-wildcard.ts
ConfigurationError
CORS: credentials cannot be combined with a wildcard origin ("*"). Enumerate the allowed origins, or supply a function or RegExp.

Keep the allow-list in configuration, not in the code, so development can allow http://localhost:5173 and production only your real domain. The next lesson does exactly that.

Rate limiting

A public API must limit how many requests one client can send. Without a limit, one script can flood the Task API with junk tasks or guess passwords all day. A rate limiter counts requests per client within a time window and answers 429 Too Many Requests when the client goes over.

The limiter lives in @zudojs/security. The Task API already lists that package in its package.json, because the generated security headers come from it too. In another project, run npm install @zudojs/security. createRateLimiter counts per key, usually the client's IP address:

limiter.tsNode.js only
import { createRateLimiter } from "@zudojs/security";

const limiter = createRateLimiter({ windowMs: 60_000, max: 3 });

for (let i = 1; i <= 4; i++) {
  const decision = limiter.check({ ip: "203.0.113.7" });
  console.log(`request ${i}: allowed=${decision.allowed} remaining=${decision.remaining}`);
}
console.log("another client:", limiter.check({ ip: "198.51.100.2" }).allowed);
limiter.destroy();
Output of npx tsx limiter.ts
request 1: allowed=true remaining=2
request 2: allowed=true remaining=1
request 3: allowed=true remaining=0
request 4: allowed=false remaining=0
another client: true

The fourth request inside one minute was refused, but a different client was not affected. The window slides: each check forgets requests older than windowMs, so a client cannot send three requests at 11:59:59 and three more at 12:00:00. destroy() stops the limiter's clean-up timer when you no longer need it.

In a pipeline, createRateLimitMiddleware wraps a limiter. Give it options to create one, or pass { limiter } to share one you made. Creating tasks costs more than reading them, so the Task API gets a gentle limit for every request and a stricter one for writes:

rate-limit.tsNode.js only
import { createRateLimitMiddleware, HttpMiddlewarePipeline } from "@zudojs/http";
import type { HttpMiddleware } from "@zudojs/http";
import { createRateLimiter } from "@zudojs/security";
import { withServer } from "./serve.js";

const writes = createRateLimitMiddleware({ limiter: createRateLimiter({ windowMs: 60_000, max: 2 }) });
const limitWrites: HttpMiddleware = (context, next) =>
  context.request.method === "GET" ? next() : writes(context, next);

const pipeline = new HttpMiddlewarePipeline();
pipeline.use(createRateLimitMiddleware({ windowMs: 60_000, max: 100 }));
pipeline.use(limitWrites);
pipeline.use(async (context) => context.response.setStatus(context.request.method === "POST" ? 201 : 200).json({ ok: true }));

await withServer(pipeline, async (base) => {
  for (const method of ["POST", "POST", "POST", "GET"]) {
    const response = await fetch(`${base}/tasks`, { method });
    console.log(method, response.status, "retry-after:", response.headers.get("retry-after"), await response.text());
  }
});
Output of npx tsx rate-limit.ts
POST 201 retry-after: null {"ok":true}
POST 201 retry-after: null {"ok":true}
POST 429 retry-after: 60 {"error":{"code":"RATE_LIMIT_EXCEEDED","message":"Too many requests"}}
GET 200 retry-after: null {"ok":true}

The third POST got 429, with a JSON body (sent as application/json) and a Retry-After header: the number of seconds to wait. Reading still works, because GET requests only count against the gentle limit.

  • The client is identified by request.remoteAddress. Behind a proxy or load balancer, every request seems to come from the proxy, so all users would share one allowance. Tell the adapter how many proxies you run, for example createNodeHttpAdapter({ trustProxy: 1 }); only then does it believe the X-Forwarded-For header. Never trust that header without it: any client can send it and pick its own address.
  • These counts live in the memory of one process. With several copies of the app, each counts separately; Security for every public API covers that case and login limits.

Middleware outside HTTP: @zudojs/middleware

The same idea helps with work that is not a request, such as a background job that sends task reminders. @zudojs/middleware is a general pipeline for any kind of context. It is already installed with the other ZudoJS packages. Each middleware is given with a name, and the result tells you whether the run succeeded:

job-pipeline.tsNode.js only
import { createPipeline, timeoutMiddleware } from "@zudojs/middleware";
import type { Middleware } from "@zudojs/middleware";

interface Job {
  readonly name: string;
}

const announce: Middleware<Job, string> = async (job, next) => {
  console.log(`start ${job.name}`);
  const result = await next();
  console.log(`done  ${job.name}`);
  return result;
};

const run = createPipeline<Job, string>(
  [{ name: "announce", handler: announce }, timeoutMiddleware(1_000)],
  async (job) => `sent reminders for ${job.name}`,
);

const outcome = await run({ name: "due-today" });
if (outcome.success) {
  console.log(outcome.result, outcome.executedMiddleware);
}
Output of npx tsx job-pipeline.ts
start due-today
done  due-today
sent reminders for due-today [ 'announce', 'timeout' ]

For HTTP, stay with HttpMiddlewarePipeline from @zudojs/http: it knows about requests and responses. The queue lesson uses job pipelines like this one.

Graceful shutdown of the server

In the runtime lesson you saw why src/server.ts handles SIGTERM itself: it stops the HTTP server before the runtime. Here is what server.stop() does. It closes the door to new connections at once, but waits for requests that are already running, up to gracefulShutdownTimeout (30 seconds by default):

graceful.tsNode.js only
import { createHttpServer, createNodeHttpAdapter, createResponseContext, createRouter } from "@zudojs/http";

let reportStarted!: () => void;
const started = new Promise<void>((resolve) => (reportStarted = resolve));

const router = createRouter();
router.get("/report", async () => {
  reportStarted();
  console.log("report: started");
  await new Promise((resolve) => setTimeout(resolve, 300));
  console.log("report: finished");
  return createResponseContext().json({ tasks: 3 });
});

const server = createHttpServer({
  adapter: createNodeHttpAdapter({ host: "127.0.0.1", port: 0 }),
  handler: async (request) => (await router.dispatch(request)).response,
  gracefulShutdownTimeout: 10_000,
});
server.on("onStopping", () => console.log("server: stopping"));
server.on("onStopped", () => console.log("server: stopped"));
await server.start();
const base = `http://127.0.0.1:${server.address?.port}`;

const slow = fetch(`${base}/report`).then(async (r) => console.log("client got", r.status, await r.text()));
await started;
const stopping = server.stop();

try {
  await fetch(`${base}/report`);
} catch (error) {
  console.log("new request refused:", ((error as Error).cause as { code?: string }).code);
}
await Promise.all([slow, stopping]);
Output of npx tsx graceful.ts
report: started
server: stopping
new request refused: ECONNREFUSED
report: finished
client got 200 {"tasks":3}
server: stopped

The report that was already running finished and its client got a full 200, while a new request was refused. Only then did the server report stopped. After that, runtime.stop() can safely close the store, because no request is using it any more.

The full order in src/server.ts is therefore: stop the HTTP server, stop the runtime, dispose the container, exit with 0. If anything fails, log it and exit with 1. Keep gracefulShutdownTimeout below the time your platform waits after SIGTERM before it kills the process (often 30 seconds), so your shutdown finishes first.

Put it in the Task API

Open src/server.ts and find the pipeline. The CLI already built most of this lesson into it:

src/server.ts (part)Node.js only
const pipeline = new HttpMiddlewarePipeline({
  middlewares: [
    securityHeaders(),
    createCorsMiddleware({ allowOrigin: config.corsOrigins }),
    createRateLimitMiddleware({ windowMs: config.rateLimit.windowMs, max: config.rateLimit.max }),
    // zudojs:server-middleware:start
    // zudojs:server-middleware:end
    dispatch,
  ],
});

A pipeline can take its middleware as a list, in order, instead of pipeline.use() calls. Read it from the top:

  • securityHeaders() comes from src/utils/http.ts. It is first, so every answer gets the headers, even a 429 or a CORS preflight. It uses generateSecurityHeaders() from @zudojs/security, the same kind of set as createSecurityMiddleware(), with a stricter Content-Security-Policy and a two-year HSTS. It only adds a header the response has not set itself, because the /docs page sends its own CSP to load its scripts.
  • createCorsMiddleware takes its allow-list from the CORS_ORIGINS setting. Empty means no website at all, the safe default.
  • createRateLimitMiddleware gives each client 300 requests per minute, from RATE_LIMIT_MAX and RATE_LIMIT_WINDOW_MS. CORS comes before it, so a preflight does not use up the client's allowance.
  • dispatch is last: it hands the request to the router.

Here is the helper, so you can see there is no magic in it:

src/utils/http.ts (part)Node.js only
export function securityHeaders(): HttpMiddleware {
  const defaults = Object.entries(generateSecurityHeaders());
  return async (_context, next) => {
    const response = (await next()).clone();
    const present = new Set(Object.keys(response.headers).map((name) => name.toLowerCase()));
    for (const [name, value] of defaults) {
      if (!present.has(name.toLowerCase())) response.setHeader(name, value);
    }
    return response;
  };
}

All work happens after next(), like responseTime earlier in this lesson. The // zudojs:server-middleware markers are where zudojs add puts the middleware of a feature it adds.

One thing from this lesson is missing: a stricter limit for writes. Creating tasks costs more than reading them. Put it in the empty src/middlewares/ folder. The file name follows the CLI's naming for middleware:

src/middlewares/write-limit.middleware.tsNode.js only
import { createRateLimitMiddleware } from "@zudojs/http";
import type { HttpMiddleware } from "@zudojs/http";

const READS = new Set(["GET", "HEAD", "OPTIONS"]);

/** A stricter rate limit for requests that change data. Reads pass straight through. */
export function writeLimitMiddleware(options: { readonly windowMs: number; readonly max: number }): HttpMiddleware {
  const limit = createRateLimitMiddleware(options);
  return (context, next) => (READS.has(context.request.method) ? next() : limit(context, next));
}

Export it from src/middlewares/index.ts:

src/middlewares/index.tsNode.js only
export { writeLimitMiddleware } from "./write-limit.middleware.js";

In src/server.ts, import it with import { writeLimitMiddleware } from "./middlewares/index.js"; and add it after the general rate limit, below the markers. The limit of 20 writes a minute is written in the code for one more lesson; Configuration moves it into a setting:

src/server.ts (part)Node.js only
createRateLimitMiddleware({ windowMs: config.rateLimit.windowMs, max: config.rateLimit.max }),
// zudojs:server-middleware:start
// zudojs:server-middleware:end
writeLimitMiddleware({ windowMs: 60_000, max: 20 }),
dispatch,

A check script builds the same order with a stand-in route and a limit of 2 writes, and runs it without a server:

Show src/utils/http.ts, which the script imports
src/utils/http.ts
import {
  HttpError,
  badRequest,
  createResponseContext,
  type HttpMiddleware,
  type HttpResponseContext,
  type HttpRouterContext,
} from "@zudojs/http";
import type { SchemaIssue } from "@zudojs/schema";
import { generateSecurityHeaders } from "@zudojs/security";

/** A JSON response with `status`. */
export function json(status: number, data: unknown): HttpResponseContext {
  return createResponseContext({ status }).json(data);
}

/** A response with no body (for example 204). */
export function empty(status: number): HttpResponseContext {
  return createResponseContext({ status });
}

/** 400 listing where the input failed validation, without echoing it back. */
export function validationFailed(issues: readonly SchemaIssue[]): HttpResponseContext {
  return json(400, {
    error: "Validation failed",
    issues: issues.map((issue) => ({
      path: issue.path.map(String).join("."),
      message: issue.message,
    })),
  });
}

/**
 * The request body parsed as JSON; `undefined` when there is none.
 * A body that is not sent as JSON is answered with 415, malformed JSON with 400.
 */
export function readJsonBody(ctx: HttpRouterContext): unknown {
  const body: unknown = ctx.request.body;
  if (body === undefined || body === null) return undefined;
  const text =
    body instanceof Uint8Array
      ? new TextDecoder().decode(body)
      : typeof body === "string"
        ? body
        : undefined;
  if (text === undefined) return body;
  if (text.trim() === "") return undefined;
  const type = ctx.request.getHeader("content-type") ?? "";
  if (!type.toLowerCase().startsWith("application/json")) {
    throw new HttpError(415, "Send JSON with Content-Type: application/json");
  }
  try {
    return JSON.parse(text) as unknown;
  } catch {
    throw badRequest("The request body is not valid JSON.");
  }
}

/** A task id from the path; anything but a positive whole number is answered with 400. */
export function parseId(raw: string | undefined): number {
  const id = Number(raw);
  if (!Number.isSafeInteger(id) || id < 1) {
    throw badRequest("The task id must be a positive whole number");
  }
  return id;
}

/**
 * The response for an error that carries an exposed 4xx status
 * (NotFoundError, badRequest(), ...). Anything else is left to the server,
 * which answers a generic 500 and never leaks the message.
 */
export function errorResponse(error: unknown): HttpResponseContext | undefined {
  if (typeof error !== "object" || error === null) return undefined;
  const candidate = error as {
    readonly statusCode?: unknown;
    readonly expose?: unknown;
    readonly message?: unknown;
    readonly code?: unknown;
  };
  const status = candidate.statusCode;
  if (typeof status !== "number" || status < 400 || status > 499) return undefined;
  if (candidate.expose !== true || typeof candidate.message !== "string") return undefined;
  return json(status, {
    error: candidate.message,
    ...(typeof candidate.code === "string" ? { code: candidate.code } : {}),
  });
}

/**
 * Adds the @zudojs/security default headers (CSP, HSTS, nosniff,
 * X-Frame-Options DENY, ...) to every response that does not set its own:
 * the /docs page, for instance, sends a CSP that allows its assets.
 */
export function securityHeaders(): HttpMiddleware {
  const defaults = Object.entries(generateSecurityHeaders());
  return async (_context, next) => {
    const response = (await next()).clone();
    const present = new Set(Object.keys(response.headers).map((name) => name.toLowerCase()));
    for (const [name, value] of defaults) {
      if (!present.has(name.toLowerCase())) response.setHeader(name, value);
    }
    return response;
  };
}
src/check-middleware.tsNode.js only
import { createCorsMiddleware, createRequestContext, createResponseContext, HttpMiddlewarePipeline } from "@zudojs/http";
import { writeLimitMiddleware } from "./middlewares/write-limit.middleware.js";
import { securityHeaders } from "./utils/http.js";

const pipeline = new HttpMiddlewarePipeline({
  middlewares: [
    securityHeaders(),
    createCorsMiddleware({ allowOrigin: ["http://localhost:5173"] }),
    writeLimitMiddleware({ windowMs: 60_000, max: 2 }),
    async (context) => context.response.setStatus(context.request.method === "POST" ? 201 : 200).json({ ok: true }),
  ],
});

for (const method of ["POST", "POST", "POST", "GET"]) {
  const request = createRequestContext({ method, url: "/tasks", headers: { origin: "http://localhost:5173" } });
  const { status, headers } = await pipeline.execute(request, createResponseContext());
  console.log(method, status, "retry-after:", headers["retry-after"], "cors:", headers["access-control-allow-origin"], "frame:", headers["x-frame-options"]);
}
Output of npx tsx src/check-middleware.ts
POST 201 retry-after: undefined cors: http://localhost:5173 frame: DENY
POST 201 retry-after: undefined cors: http://localhost:5173 frame: DENY
POST 429 retry-after: 60 cors: http://localhost:5173 frame: DENY
GET 200 retry-after: undefined cors: http://localhost:5173 frame: DENY

The third write got 429, reading still works, and even the 429 carries the security and CORS headers, because both come before the limit.

The shutdown code at the end of src/server.ts already follows the order above: let integrations close long-lived connections (drainIntegrations), stop the HTTP server, stop the runtime, exit with 0, or log and exit with 1 on failure. Three small changes finish it:

  • Pass gracefulShutdownTimeout: 20_000 to createHttpServer, so requests in progress get 20 seconds, well inside the 30 seconds many platforms wait after SIGTERM.
  • In src/app.ts, add disposeContainerOnStop: true to the options of createRuntime, next to handleSignals: false. runtime.stop() then disposes the container after every module has stopped.
  • In the signal loop, change process.once(signal, …) to process.on(signal, …). Under npm run dev, one Ctrl + C reaches the server twice: once from the terminal and once passed on by tsx watch. With once, the second signal finds no listener, and Node.js ends the process in the middle of the shutdown. With on, the if (stopping) return; line the CLI wrote ignores it.
src/server.ts (part)Node.js only
const server = createHttpServer({
  adapter: createNodeHttpAdapter({ server: httpServer, host: config.host, port: config.port }),
  handler: (request: HttpRequestContext) => pipeline.execute(request, createResponseContext()),
  gracefulShutdownTimeout: 20_000,
});

await server.start();
console.log(`Listening on http://${config.host}:${server.address?.port ?? config.port}`);

let stopping = false;
for (const signal of ["SIGINT", "SIGTERM"] as const) {
  process.on(signal, () => {
    if (stopping) return;
    stopping = true;
    void drainIntegrations(integrations)
      .then(() => server.stop())
      .then(() => runtime.stop())
      .then(() => {
        process.exit(0);
      })
      .catch((error: unknown) => {
        console.error(error);
        process.exit(1);
      });
  });
}

Now try the real thing. To let one web app in, start the server with CORS_ORIGINS set for that one command:

Terminal on your computer
npx tsx src/check-middleware.ts
POST 201 retry-after: undefined cors: http://localhost:5173 frame: DENY
POST 201 retry-after: undefined cors: http://localhost:5173 frame: DENY
POST 429 retry-after: 60 cors: http://localhost:5173 frame: DENY
GET 200 retry-after: undefined cors: http://localhost:5173 frame: DENY
CORS_ORIGINS=http://localhost:5173 npm run dev

Listening on http://0.0.0.0:3000

In a second terminal, read a task as that web app would, then create 21 tasks in a row. seq 1 20 counts from 1 to 20, and -w "%{http_code} " makes curl print only the status:

Second terminal
curl -i http://localhost:3000/tasks/1 -H "origin: http://localhost:5173"
HTTP/1.1 200 OK
content-type: application/json
access-control-allow-origin: http://localhost:5173
vary: Origin
x-content-type-options: nosniff
x-frame-options: DENY

content-length: 113
Date: Wed, 23 Sep 2026 19:51:45 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"id":1,"title":"Read the runtime lesson","done":true,"priority":"normal","createdAt":"2026-09-23T09:00:00.000Z"}
for i in $(seq 1 20); do curl -s -o /dev/null -w "%{http_code} " -X POST http://localhost:3000/tasks -H "content-type: application/json" -d "{\"title\":\"Task number $i\"}"; done; echo
201 201 201 201 201 201 201 201 201 201 201 201 201 201 201 201 201 201 201 201
curl -i -X POST http://localhost:3000/tasks -H "content-type: application/json" -d '{"title":"One too many"}'
HTTP/1.1 429 Too Many Requests
retry-after: 60
x-ratelimit-remaining: 0
x-ratelimit-limit: 20
x-ratelimit-reset: 1790193166
content-type: application/json; charset=utf-8
x-content-type-options: nosniff
x-frame-options: DENY


{"error":{"code":"RATE_LIMIT_EXCEEDED","message":"Too many requests"}}

Your own app's origin got the CORS headers, the 21st write got 429 with Retry-After, and curl http://localhost:3000/tasks still answers 200. x-ratelimit-reset is the time the window ends, in seconds since 1970. The loop is for macOS, Linux and Git Bash; on Windows PowerShell, run the curl.exe command 21 times with the arrow keys instead. Now press Ctrl + C in the server's terminal:

Terminal on your computer
^C2026-09-23T19:51:45.445Z [INFO] [task-api] Initiating graceful shutdown. timeoutMs=30000
2026-09-23T19:51:45.445Z [INFO] [task-api] All modules stopped. modules=["tasks","store","integrations"] durationMs=0
2026-09-23T19:51:45.446Z [INFO] [task-api] store closed
2026-09-23T19:51:45.446Z [INFO] [task-api] All modules destroyed. durationMs=0
2026-09-23T19:51:45.446Z [INFO] [task-api] Graceful shutdown complete.
2026-09-23T19:51:45.447Z [INFO] [task-api] Runtime stopped. runtimeId=rt_e7dcf43c4f0445f1b53b77293d27fd2e

The HTTP server stopped first (it prints nothing), then the runtime stopped the modules, and the container was disposed last. timeoutMs=30000 is the runtime's own shutdownTimeout for the modules; the 20 seconds you set are for the requests.

Practice

TRY IT YOURSELF

A request id on every response

Write a middleware that puts an x-request-id header on every response, using context.request.id: the unique id @zudojs/http gives every request. Test it with two requests made by createRequestContext, and check that they get different ids.

Show a solution
request-id.tsNode.js only
import { createRequestContext, createResponseContext, HttpMiddlewarePipeline } from "@zudojs/http";
import type { HttpMiddleware } from "@zudojs/http";

const requestId: HttpMiddleware = async (context, next) => {
  const response = await next();
  return response.setHeader("x-request-id", context.request.id);
};

const pipeline = new HttpMiddlewarePipeline();
pipeline.use(requestId, { priority: -100 });
pipeline.use(async (context) => context.response.json({ ok: true }));

const first = createRequestContext({ method: "GET", url: "/tasks" });
const second = createRequestContext({ method: "GET", url: "/tasks" });
const a = await pipeline.execute(first, createResponseContext());
const b = await pipeline.execute(second, createResponseContext());

console.log(a.headers["x-request-id"] === first.id, b.headers["x-request-id"] === second.id);
console.log("different ids:", first.id !== second.id);
console.log(a.headers["x-request-id"]);
Output of npx tsx request-id.ts
true true
different ids: true
9b1ccbf4-c024-4eab-873c-dd73f8f8da38

The id is a random UUID, different on every run. When a user reports an error, they can send you the id from the response, and you can find that exact request in your logs. The logging lesson builds on this.

NOTE

When a real request arrives with an X-Request-Id header of 1 to 128 letters, digits, dots, dashes, underscores or colons, request.id reuses it. That way an id set by a proxy or another service in front of yours follows the request through your logs. The adapter refuses a malformed one with 400. Treat the id as a label, never as proof of anything, because any client can choose it. createNodeHttpAdapter({ trustRequestId: false }) always generates a new one.

TRY IT YOURSELF

Allow a second origin

The Task API also gets a mobile web app at https://m.tasks.example.com. Change the CORS example so it is allowed too, and show that https://tasks.example.com.evil.example, which merely starts with an allowed origin, is still refused.

Show a solution
cors-two.tsNode.js only
import { createCorsMiddleware, HttpMiddlewarePipeline } from "@zudojs/http";
import { withServer } from "./serve.js";

const pipeline = new HttpMiddlewarePipeline();
pipeline.use(createCorsMiddleware({ allowOrigin: ["https://tasks.example.com", "https://m.tasks.example.com"] }));
pipeline.use(async (context) => context.response.json([]));

await withServer(pipeline, async (base) => {
  for (const origin of ["https://m.tasks.example.com", "https://tasks.example.com.evil.example"]) {
    const response = await fetch(`${base}/tasks`, { headers: { origin } });
    console.log(origin, "->", response.headers.get("access-control-allow-origin"));
  }
});
Output of npx tsx cors-two.ts
https://m.tasks.example.com -> https://m.tasks.example.com
https://tasks.example.com.evil.example -> null

The allow-list compares whole origins exactly. Never build your own check with startsWith or a loose regular expression: that is exactly the mistake the second origin exploits.

Recap

  • Middleware runs around the route: work before next(), work after it, or answer early without calling it. Lower priority runs first and wraps the rest.
  • createSecurityMiddleware() sends a safe set of security headers. Use max-age=0 for HSTS on a development server that answers over HTTPS.
  • createCorsMiddleware({ allowOrigin: [...] }) allows only listed origins. CORS protects browsers, not your server, and "*" with credentials is refused.
  • createRateLimiter from @zudojs/security counts requests per client in a sliding window; createRateLimitMiddleware answers 429 with Retry-After.
  • server.stop() refuses new connections and waits for requests in progress. Stop the server, then the runtime, then dispose the container.

The CLI already reads the allowed origins, the general rate limit and the port from settings; only your write limit is still written in the code. The next lesson explains how that configuration works, moves the write limit into it, and makes production stricter than development.

Test yourself

Five questions, picked at random from this lesson's question bank. Some ask you to choose an answer, some to predict what code prints, and some to write code and run it in the terminal. Get 4 of 5 right to pass. If you don't, read the explanations and try again: you get 5 different questions.