Docs
/
Packages
/
@zudojs/middleware
v1.1.0
@zudojs/middleware
Composable middleware pipeline for the Zudojs framework. Provides middleware composition, chaining, priority ordering, execution tracking with timing, error handling, context propagation, built-in middleware for logging, errors, timeouts, and rate limiting, and the guard-response contract that lets a middleware refuse a request with a real status code.
MIDDLEWARE
PIPELINE
COMPOSITION
TIMING
INSTALLATION
// npm
npm install @zudojs/middleware
// pnpm
pnpm add @zudojs/middleware
// yarn
yarn add @zudojs/middleware
Peer Dependencies: @zudojs/middleware depends on @zudojs/errors (at 1.3.0). Uses the BaseError class for structured error handling in pipeline execution.
WHAT IT DOES
@zudojs/middleware is the foundation of cross-cutting concern handling across the Zudojs framework. It provides:
- → A composable middleware pipeline where middleware functions chain via
next()
- → Type-safe middleware functions generic over context and result types
- → Named middleware with priority — filter and sort by name, enabled flag, and numeric priority
- → Middleware composition via the
compose() function combining middleware and a handler
- → Execution tracking with timing — pipeline reports which middleware ran and total duration
- → Error handling — structured error classes for timeouts and next() misuse
- → Context propagation — context object flows through every middleware in the chain
- → Built-in middleware for logging, error handling, timeout enforcement, and rate limiting
- → Pipeline options — configurable max middleware count and stop-on-error behavior
- → Guard responses —
createGuardResponse() lets a middleware refuse a request with a real 401/403 that an HTTP adapter sends as-is
Core Principle: Middleware wraps the request/response cycle with cross-cutting concerns. Each middleware calls next() to delegate to the next middleware or the final handler. This pattern is used by @zudojs/messaging, @zudojs/events, @zudojs/http, and other packages.
WHERE IT SITS
APPLICATION LAYER (CQRS, Events, Modules, HTTP Handlers)
MESSAGING LAYER (@zudojs/messaging, @zudojs/events)
@zudojs/middleware (PIPELINE & COMPOSITION)
@zudojs/errors (FOUNDATION LEAF PACKAGE)
@zudojs/middleware sits below the messaging and event layers. It provides the generic pipeline infrastructure that higher-level packages use for their own middleware chains. It depends only on @zudojs/errors.
DEPENDENCIES
| Package |
Version |
Purpose |
| @zudojs/errors |
1.3.0 |
All middleware error classes (MiddlewareError, MiddlewareTimeoutError, MiddlewareNextCalledMultipleTimesError, MiddlewareLimitExceededError, MiddlewareDepthExceededError, MiddlewareRateLimitError, MiddlewareAbortedError) are the @zudojs/errors classes, re-exported |
Internal dependencies: Packages depend on each other with workspace:*, always — including on main. They are never hand-pinned to an exact version. At publish time pnpm rewrites each workspace:* to the exact version of that package in the same release, so a published tarball carries real ranges. Releases go out through publish-all.sh, which runs pnpm -r publish — it rewrites the ranges and publishes in dependency order. Plain npm publish does not understand the workspace: protocol and would ship a literal workspace:* to the registry.
CORE TYPES
Type: Middleware
A middleware function that processes a context and calls the next middleware in the chain. Generic over context and result types.
type Middleware<TContext, TResult = void> = (
context: TContext,
next: () => Promise<TResult>,
) => Promise<TResult>;
Interface: NamedMiddleware
A middleware function with metadata — name, priority, and enabled flag for filtering and ordering.
interface NamedMiddleware<TContext, TResult = void> {
readonly name: string;
readonly handler: Middleware<TContext, TResult>;
readonly priority?: number; // default: 100 (lower = earlier)
readonly enabled?: boolean; // default: true
}
Type: MiddlewareFactory
A factory function that creates named middleware from optional configuration.
type MiddlewareFactory<TContext, TResult = void> = (
options?: Record<string, unknown>,
) => NamedMiddleware<TContext, TResult>;
MIDDLEWARE COMPOSITION
The composition module provides functions to combine, filter, sort, and wrap middleware functions.
Function: compose
Combines a list of middleware and a final handler into a single executable function. Middleware executes in order — first added, first executed.
function compose<TContext, TResult>(
middlewareList: readonly Middleware<TContext, TResult>[],
handler: (context: TContext) => Promise<TResult>,
): (context: TContext) => Promise<TResult>
import { compose } from "@zudojs/middleware";
const handler = async (ctx) => ({ status: "ok" });
const pipeline = compose([loggingMw, authMw], handler);
const result = await pipeline(requestContext);
Function: resolveMiddleware
Filters out disabled middleware, sorts by priority (lower = earlier), and extracts handler functions.
function resolveMiddleware<TContext, TResult>(
middlewareList: readonly NamedMiddleware<TContext, TResult>[],
): Middleware<TContext, TResult>[]
import { resolveMiddleware } from "@zudojs/middleware";
const middleware = [
{ name: "auth", handler: authHandler, priority: 10 },
{ name: "logging", handler: logHandler, priority: 50 },
{ name: "debug", handler: debugHandler, enabled: false },
];
const resolved = resolveMiddleware(middleware);
// [authHandler, logHandler] — debug excluded, auth before logging
Function: withTiming
Wraps a middleware with timing metadata. Logs a warning if execution exceeds 100ms.
function withTiming<TContext>(
name: string,
middleware: Middleware<TContext, void>,
): NamedMiddleware<TContext, void>
import { withTiming } from "@zudojs/middleware";
const timedAuth = withTiming("auth", async (ctx, next) => {
// verify token...
return next();
});
Constant: MAX_DEPTH
Internal safety limit of 100 to prevent infinite recursion from misused next() calls.
PIPELINE
The pipeline module creates execution-tracked middleware chains with structured results.
Interface: PipelineResult
// Discriminated on `success`: narrowing gives a non-optional `result`.
type PipelineResult<TResult> =
| PipelineSuccess<TResult>
| PipelineFailure;
interface PipelineSuccess<TResult> {
readonly success: true;
readonly result: TResult;
readonly durationMs: number;
readonly executedMiddleware: readonly string[];
readonly errors: readonly PipelineMiddlewareFailure[];
}
interface PipelineFailure {
readonly success: false;
readonly error: unknown;
readonly durationMs: number;
readonly executedMiddleware: readonly string[];
readonly errors: readonly PipelineMiddlewareFailure[];
}
Interface: PipelineOptions
interface PipelineOptions {
readonly maxMiddleware?: number; // default: 50
readonly errorMode?: PipelineErrorMode; // "capture" | "throw" | "continue"
readonly signal?: AbortSignal; // aborts between steps
readonly stopOnError?: boolean; // deprecated alias for errorMode
}
Function: createPipeline
Creates a pipeline that resolves named middleware, tracks execution, and returns a structured result.
function createPipeline<TContext, TResult>(
middlewareList: readonly NamedMiddleware<TContext, TResult>[],
handler: (context: TContext) => Promise<TResult>,
options?: PipelineOptions,
): (context: TContext) => Promise<PipelineResult<TResult>>
import { createPipeline } from "@zudojs/middleware";
const pipeline = createPipeline(
[authMiddleware, loggingMiddleware],
async (ctx) => ({ status: "ok" }),
{ maxMiddleware: 20, errorMode: "capture" },
);
const result = await pipeline({ method: "GET", path: "/api/users" });
result.success; // true
result.result; // { status: "ok" }
result.durationMs; // 12.5
result.executedMiddleware; // ["auth", "logging"]
Default Limits: The pipeline defaults to a maximum of 50 middleware; exceeding it throws MiddlewareLimitExceededError at construction, and compose() caps chain depth at MAX_DEPTH (100). errorMode chooses what a failure does: "capture" (default) reports it on the result, "throw" propagates it, "continue" records it and carries on.
BUILT-IN MIDDLEWARE
Ready-made middleware for common concerns. Each returns a NamedMiddleware with a descriptive name.
Interface: LoggingContext
interface LoggingContext {
readonly requestId?: string;
readonly path?: string;
readonly method?: string;
}
loggingMiddleware
Logs request start, completion time, and errors. Accepts an optional logger function (for example (line) => log.info(line)); without one it writes nothing.
import { loggingMiddleware } from "@zudojs/middleware";
const mw = loggingMiddleware((line) => console.info(line)); // no sink = no output
// Or provide a custom logger
const mw2 = loggingMiddleware((msg) => logger.info(msg));
errorMiddleware
Catches errors and invokes an optional callback. Runs at priority 0 (first in chain).
import { errorMiddleware } from "@zudojs/middleware";
const mw = errorMiddleware((error, ctx) => {
logger.error("Pipeline failed:", error);
});
// mw.name === "error-handler"
// mw.priority === 0 (runs first)
timeoutMiddleware
Rejects if the pipeline exceeds the specified timeout. Uses Promise.race internally.
import { timeoutMiddleware } from "@zudojs/middleware";
const mw = timeoutMiddleware(5000); // 5 second timeout
// Rejects with MiddlewareTimeoutError: 'Middleware "timeout" timed out after 5000ms.'
rateLimitMiddleware
Sliding window rate limiter per key. Requires context to have an optional key property.
import { rateLimitMiddleware } from "@zudojs/middleware";
const mw = rateLimitMiddleware(100, 60_000); // 100 requests per minute
// Context needs a key property
type Ctx = { readonly key?: string; /* ... */ };
GUARD RESPONSES
A guard is a middleware that decides whether a request may continue: is the caller logged in, allowed to do this, asking for the right tenant? When the answer is no, the guard stops the chain by answering the request itself, usually with 401 Unauthorized or 403 Forbidden. To do that it has to say which status, headers and body to send, and that is what a guard response is.
Why it exists
Many guards live in packages that sit below @zudojs/http: @zudojs/permissions' authorize() and @zudojs/tenancy's middleware, for example. They are not allowed to depend on the HTTP package, so they cannot build an HttpResponseContext. Before v1.1.0 they returned a plain { status, body, headers } object instead, and @zudojs/http ignored it: the handler never ran, but the client, caches and monitoring all saw 200 OK. A refusal that looks like a success is a security and debugging problem.
The fix is a small contract that both sides share. @zudojs/middleware sits below everyone, so a guard in any package can create a guard response here, and @zudojs/http (its router, HttpMiddlewarePipeline and RouteDispatcher) turns it into a real response with that status, those headers and that body.
Function: createGuardResponse
function createGuardResponse(init: {
readonly status: number; // integer 100-599
readonly body?: unknown; // omit for an empty body
readonly headers?: Readonly<Record<string, string>>;
}): GuardResponse
interface GuardResponse {
readonly [GUARD_RESPONSE]: true;
readonly status: number;
readonly body: unknown;
readonly headers: Readonly<Record<string, string>>; // lower-case names
}
- → A structured body (an object or array) gets
content-type: application/json; charset=utf-8 unless you set one. A string or Uint8Array body is sent as it is.
- → Header names are lower-cased, and every value must be a string (
TypeError otherwise).
- → A status that is not an integer from 100 to 599 throws
RangeError.
- → The result is frozen, so nothing downstream can change it.
Function: isGuardResponse and the GUARD_RESPONSE brand
Every guard response carries a brand: the property GUARD_RESPONSE, which is the registered symbol Symbol.for("zudojs.middleware.guardResponse"). isGuardResponse(value) checks for it, and only the brand counts. JSON cannot carry a symbol, so a request body or a handler's data that happens to have a status key can never be mistaken for a response. Because the symbol is registered, two copies of this package in one app still agree on it.
import { createGuardResponse, isGuardResponse } from "@zudojs/middleware";
const refusal = createGuardResponse({ status: 403, body: { error: "Forbidden" } });
console.log(refusal.status, refusal.body, refusal.headers);
// 403 { error: 'Forbidden' } { 'content-type': 'application/json; charset=utf-8' }
console.log(isGuardResponse(refusal)); // true
console.log(isGuardResponse({ status: 403, body: {}, headers: {} })); // false: no brand
console.log(isGuardResponse(JSON.parse(JSON.stringify(refusal)))); // false: JSON drops the symbol
try {
createGuardResponse({ status: 700 });
} catch (error) {
console.log(String(error));
// RangeError: Guard response status must be an integer in 100-599, got 700.
}
Using it with @zudojs/http
This guard refuses requests that have no API key. It returns the guard response instead of calling next(), so the handler never runs. With @zudojs/http installed too, you can run it as it is:
import { createGuardResponse } from "@zudojs/middleware";
import { createRouter, createRequestContext } from "@zudojs/http";
import type { HttpMiddleware } from "@zudojs/http";
const requireApiKey: HttpMiddleware = async (ctx, next) => {
if (!ctx.request.getHeader("x-api-key")) {
return createGuardResponse({
status: 401,
body: { error: "Unauthorized" },
headers: { "WWW-Authenticate": "ApiKey" },
});
}
return next();
};
const router = createRouter();
router.get("/reports", () => ({ reports: 3 }), { middleware: [requireApiKey] });
const attempts: Record<string, string>[] = [{}, { "x-api-key": "k-123" }];
for (const headers of attempts) {
const { response } = await router.dispatch(
createRequestContext({ method: "GET", url: "/reports", headers }),
);
console.log(response.status, response.headers, response.body);
}
// 401 {
// 'content-type': 'application/json; charset=utf-8',
// 'www-authenticate': 'ApiKey'
// } {"error":"Unauthorized"}
// 200 { 'content-type': 'application/json' } {"reports":3}
Common mistake: returning a plain object such as { status: 401, body: { error: "Unauthorized" } } from a guard. It has no brand, so it is treated as ordinary data and is not sent as a 401. Always build refusals with createGuardResponse(). The ready-made guards in @zudojs/permissions and @zudojs/tenancy already do.
ERROR HIERARCHY
All error types are defined in @zudojs/errors and re-exported by this package.
| Error |
When Thrown |
| MiddlewareError |
Base error for all middleware pipeline failures |
| MiddlewareTimeoutError |
A middleware exceeded its timeout duration |
| MiddlewareNextCalledMultipleTimesError |
A middleware called next() more than once |
Error Classes
import {
MiddlewareError,
MiddlewareTimeoutError,
MiddlewareNextCalledMultipleTimesError,
} from "@zudojs/middleware";
// Base middleware error
const err = new MiddlewareError("Pipeline failed", {
middlewareName: "auth",
cause: originalError,
});
// Timeout error
const timeout = new MiddlewareTimeoutError("auth", 5000);
// next() called multiple times
const misuse = new MiddlewareNextCalledMultipleTimesError("auth");
Error Handling Pattern
import { createPipeline } from "@zudojs/middleware";
const pipeline = createPipeline(
[authMiddleware, loggingMiddleware],
handler,
{ errorMode: "capture" },
);
const result = await pipeline(ctx);
if (!result.success) {
if (result.error instanceof MiddlewareTimeoutError) {
console.error("Pipeline timed out");
} else {
console.error("Pipeline failed:", result.error);
}
}
FULL INTEGRATION EXAMPLE
Complete working example: custom middleware, pipeline, logging, error handling, and rate limiting.
import {
createPipeline,
loggingMiddleware,
errorMiddleware,
timeoutMiddleware,
rateLimitMiddleware,
withTiming,
MiddlewareTimeoutError,
} from "@zudojs/middleware";
import type { NamedMiddleware } from "@zudojs/middleware";
// 1. Define a custom authentication middleware
type RequestContext = {
readonly method: string;
readonly path: string;
readonly key?: string;
readonly token?: string;
};
type HandlerResult = { status: string; path: string; timestamp: number };
const authMiddleware = withTiming<RequestContext, HandlerResult>("auth", async (ctx, next) => {
if (!ctx.token) {
throw new Error("Unauthorized: missing token");
}
// verify token...
console.log("[auth] Token verified");
return next();
});
// 2. Build the middleware stack
const middleware: readonly NamedMiddleware<RequestContext, HandlerResult>[] = [
errorMiddleware<HandlerResult>((error) => {
console.error("[error]", error);
}),
loggingMiddleware<HandlerResult>(),
timeoutMiddleware<RequestContext, HandlerResult>(10_000),
rateLimitMiddleware<RequestContext, HandlerResult>(100, 60_000),
authMiddleware,
];
// 3. Create the pipeline with a handler
const handler = async (ctx: RequestContext): Promise<HandlerResult> => {
return {
status: "ok",
path: ctx.path,
timestamp: Date.now(),
};
};
const pipeline = createPipeline(middleware, handler, {
maxMiddleware: 20,
errorMode: "capture",
});
// 4. Execute the pipeline
const result = await pipeline({
method: "GET",
path: "/api/users",
token: "bearer_abc123",
key: "user-123",
});
// 5. Handle the result
if (result.success) {
console.log("Response:", result.result);
console.log("Duration:", result.durationMs, "ms");
console.log("Middleware executed:", result.executedMiddleware);
// ["error-handler", "logging", "timeout", "rate-limit", "auth"]
} else {
if (result.error instanceof MiddlewareTimeoutError) {
console.error("Request timed out");
} else {
console.error("Request failed:", result.error);
}
}
COMPLETE EXPORT INDEX
Every name @zudojs/middleware exports from its package root at v1.1.0 — 40 in total, generated from the package’s own entry point rather than written by hand. The sections above explain the ones you reach for most; this is the exhaustive list, so nothing shipped is undocumented. Names not covered above are typically internal helpers and supporting types.
Show all 40 exports
Classes (7)
MiddlewareAbortedError MiddlewareDepthExceededError MiddlewareError MiddlewareLimitExceededError MiddlewareNextCalledMultipleTimesError MiddlewareRateLimitError MiddlewareTimeoutError
Functions (12)
compose createGuardResponse createPipeline errorMiddleware isGuardResponse loggingMiddleware rateLimitMiddleware resolveMiddleware resolveNamedMiddleware sanitizeLogValue timeoutMiddleware withTiming
Interfaces (15)
ComposeOptions GuardResponse GuardResponseInit LoggingContext LoggingOptions NamedMiddleware PipelineFailure PipelineMiddlewareFailure PipelineOptions PipelineSuccess RateLimitMiddleware RateLimitOptions RateLimitState TimeoutOptions TimingOptions
Type aliases (4)
Middleware MiddlewareFactory PipelineErrorMode PipelineResult
Constants (2)
GUARD_RESPONSE MAX_DEPTH