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.
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:
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);
npx tsx order.tssecurity: 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:
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);
}
npx tsx own.ts200 undefined [{"id":1,"title":"Buy milk"}]
has x-response-time: true
503 120 {"error":"The Task API is under maintenance"}
has x-response-time: trueWith 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:
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:
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();
}
}
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)}`);
}
});
npx tsx headers.tsx-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 theContent-Typeyou send and not guess, so a JSON answer can never be run as a script.X-Frame-Options: DENYand CSP'sframe-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 plainhttp:// 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.
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" },
});
});
npx tsx cors.tsour 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:
import { createCorsMiddleware } from "@zudojs/http";
try {
createCorsMiddleware({ allowOrigin: "*", credentials: true });
} catch (error) {
console.log((error as Error).name);
console.log((error as Error).message);
}
npx tsx cors-wildcard.tsConfigurationError
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:
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();
npx tsx limiter.tsrequest 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:
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());
}
});
npx tsx rate-limit.tsPOST 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 examplecreateNodeHttpAdapter({ trustProxy: 1 }); only then does it believe theX-Forwarded-Forheader. 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:
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);
}
npx tsx job-pipeline.tsstart 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):
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]);
npx tsx graceful.tsreport: started
server: stopping
new request refused: ECONNREFUSED
report: finished
client got 200 {"tasks":3}
server: stoppedThe 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:
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 fromsrc/utils/http.ts. It is first, so every answer gets the headers, even a 429 or a CORS preflight. It usesgenerateSecurityHeaders()from@zudojs/security, the same kind of set ascreateSecurityMiddleware(), with a stricter Content-Security-Policy and a two-year HSTS. It only adds a header the response has not set itself, because the/docspage sends its own CSP to load its scripts.createCorsMiddlewaretakes its allow-list from theCORS_ORIGINSsetting. Empty means no website at all, the safe default.createRateLimitMiddlewaregives each client 300 requests per minute, fromRATE_LIMIT_MAXandRATE_LIMIT_WINDOW_MS. CORS comes before it, so a preflight does not use up the client's allowance.dispatchis last: it hands the request to the router.
Here is the helper, so you can see there is no magic in it:
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:
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:
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:
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
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;
};
}
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"]);
}
npx tsx src/check-middleware.tsPOST 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_000tocreateHttpServer, so requests in progress get 20 seconds, well inside the 30 seconds many platforms wait afterSIGTERM. - In
src/app.ts, adddisposeContainerOnStop: trueto the options ofcreateRuntime, next tohandleSignals: false.runtime.stop()then disposes the container after every module has stopped. - In the signal loop, change
process.once(signal, …)toprocess.on(signal, …). Undernpm run dev, one Ctrl + C reaches the server twice: once from the terminal and once passed on bytsx watch. Withonce, the second signal finds no listener, and Node.js ends the process in the middle of the shutdown. Withon, theif (stopping) return;line the CLI wrote ignores it.
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:
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:
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:
^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
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"]);
npx tsx request-id.tstrue 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 anX-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
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"));
}
});
npx tsx cors-two.tshttps://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. Lowerpriorityruns first and wraps the rest. createSecurityMiddleware()sends a safe set of security headers. Usemax-age=0for 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.createRateLimiterfrom@zudojs/securitycounts requests per client in a sliding window;createRateLimitMiddlewareanswers 429 withRetry-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.