@zudojs/middleware
v1.1.3
Published
Composable middleware pipeline with composition, timing, error handling, and context propagation.
Maintainers
Readme
@zudojs/middleware
Composable middleware pipeline with composition, priority ordering, execution tracking, error handling, and built-in middleware.
Documentation: zudojs.oyinlola.site/docs/packages-middleware · For AI agents: Markdown version, llms.txt
Installation
npm install @zudojs/middlewareQuick Start
import {
createPipeline,
timeoutMiddleware,
loggingMiddleware,
rateLimitMiddleware,
} from "@zudojs/middleware";
interface RequestContext {
readonly method?: string;
readonly path?: string;
readonly key?: string;
}
// The handler runs after every middleware has called next().
const pipeline = createPipeline<RequestContext, string>(
[
timeoutMiddleware(5_000),
loggingMiddleware((line) => console.info(line)),
rateLimitMiddleware(100, 60_000),
],
async (context) => `handled ${context.method} ${context.path}`,
);
const outcome = await pipeline({
method: "GET",
path: "/users",
key: "client-1",
});
if (outcome.success) {
console.log(outcome.result); // "handled GET /users"
} else {
console.error(outcome.error);
}PipelineResult is a discriminated union: narrowing on success gives you a
non-optional result, so no ! is needed on the happy path.
Composition without the result wrapper
compose chains middleware around a handler and returns the handler's value
directly. Use it when you want the raw function rather than a
PipelineResult.
import { compose } from "@zudojs/middleware";
import type { Middleware } from "@zudojs/middleware";
const auth: Middleware<RequestContext, string> = async (context, next) => {
if (context.key === undefined) throw new Error("unauthenticated");
return next();
};
const handle = compose([auth], async (context) => `hello ${context.key}`);
await handle({ key: "client-1" });Priority and toggling
NamedMiddleware carries a name, a numeric priority (lower runs earlier,
default 100) and an enabled flag. createPipeline filters and orders them
for you, and reports which ones ran.
const pipeline = createPipeline(
[
{ name: "auth", handler: auth, priority: 10 },
{ name: "audit", handler: audit, priority: 90, enabled: false },
],
handler,
);
const outcome = await pipeline(context);
outcome.executedMiddleware; // ["auth"] — "audit" is disabledOrdering is stable, so middleware sharing a priority keeps its declared order.
Error handling
errorMode decides what a failure does:
| Mode | Behaviour |
| --------------------- | ----------------------------------------------------------------------------------------------------- |
| "capture" (default) | Stop and return { success: false, error }. |
| "throw" | Stop and let the error propagate to the caller. |
| "continue" | Record the failure, skip past the failing middleware, keep going. A handler failure is never skipped. |
Every failure is also collected in outcome.errors, each tagged with the name
of the middleware that threw (or "handler").
"continue"is not for security middleware. Stepping past a middleware that threw means an authentication or authorization step that failed is stepped past too, and the handler still runs. A run can come backsuccess: truewith entries inoutcome.errors, so under this mode checkoutcome.errorsas well asoutcome.success. Use"capture"(the default) or"throw"for any chain where a failing step must stop the request.
A middleware that catches a downstream failure and throws its own error in its place ends the run: there is no downstream result left to keep, so the pipeline reports the failure rather than a result that never existed.
const pipeline = createPipeline(list, handler, { errorMode: "continue" });
const outcome = await pipeline(context);
outcome.errors; // [{ name: "audit", error: … }]Cancellation
Pass an AbortSignal to stop a pipeline between steps. The signal is checked
before each middleware and before the handler; an aborted run fails with a
MiddlewareAbortedError.
const controller = new AbortController();
const pipeline = createPipeline(list, handler, { signal: controller.signal });Built-in middleware
loggingMiddleware(logger?, options?)— logs start and completion tologger, a(line: string) => voidsink such as(line) => log.info(line)from@zudojs/logger. Without a sink it writes nothing (it never falls back to the console). Every interpolated field is escaped, so a path containing newlines cannot forge log lines. Error messages are omitted unless you opt in withincludeErrorMessage.errorMiddleware(onError?, onReporterError?)— reports errors and rethrows them. A reporter that throws cannot replace the original error.timeoutMiddleware(timeoutMs, options?)— fails withMiddlewareTimeoutError. The timer is always cleared, so a fast request leaves nothing pending on the event loop.options.namenames the middleware itself, so two timeouts in one pipeline are distinguishable inexecutedMiddlewareand inoutcome.errors.rateLimitMiddleware(maxRequests, windowMs, options?)— a true sliding window keyed oncontext.key, with key eviction and a configurable cap. Rejections throwMiddlewareRateLimitError, which carriesretryAfterMsfor aRetry-Afterheader. A rejected request also counts as recent activity, so a throttled key is never the first one evicted when the key cap is reached.
import { MiddlewareRateLimitError } from "@zudojs/middleware";
if (outcome.error instanceof MiddlewareRateLimitError) {
respond(429, { retryAfterMs: outcome.error.retryAfterMs });
}Rate-limit state is per-instance and in-process: behind more than one replica, each process enforces its own limit.
Timing
withTiming wraps any middleware and reports slow executions to
options.logger. Without a logger nothing is reported. It is
transparent — the return value and any error pass through unchanged.
const timed = withTiming("db-lookup", lookup, {
thresholdMs: 50,
logger: (message) => logger.warn(message),
});Guard responses
A middleware that refuses a request — authentication, authorization, tenant
resolution — answers with createGuardResponse() instead of calling next():
import { createGuardResponse, isGuardResponse } from "@zudojs/middleware";
const requireApiKey = async (context, next) => {
if (!context.request.getHeader("x-api-key")) {
return createGuardResponse({
status: 401,
body: { error: "Unauthorized" },
headers: { "www-authenticate": "ApiKey" },
});
}
return next();
};@zudojs/http sends a guard response with its own status, headers and body
(a structured body as JSON, with content-type: application/json unless you
set one). It is the contract lower-tier packages use: @zudojs/permissions'
authorize() and @zudojs/tenancy's middleware return one for every 401,
403 and 404, without depending on @zudojs/http.
Only the brand counts. The object carries the registered symbol
GUARD_RESPONSE (Symbol.for("zudojs.middleware.guardResponse")), which JSON
cannot carry, so data that happens to have a status key is never mistaken
for a response, and isGuardResponse() is false for any plain
{ status, body, headers } object. The result is frozen; status must be an
integer in 100–599 (RangeError otherwise) and header values strings
(TypeError).
Errors
All errors extend MiddlewareError, which is the class owned by
@zudojs/errors (re-exported here). MiddlewareTimeoutError and
MiddlewareNextCalledMultipleTimesError are the @zudojs/errors classes
too, so an instanceof check against either import path matches, and a
single instanceof MiddlewareError catches everything from this package:
MiddlewareTimeoutError · MiddlewareNextCalledMultipleTimesError ·
MiddlewareLimitExceededError · MiddlewareDepthExceededError ·
MiddlewareRateLimitError · MiddlewareAbortedError
Use Cases
- HTTP middleware chains
- Event processing pipelines
- Command/query middleware
- Cross-cutting concerns
