middleware-kit
middleware-kit
A tiny, runtime-agnostic middleware composition engine based on the
onion execution model (before → await next() → after).
Compose reusable async pipelines for HTTP servers, RPC, WebSockets,
background jobs, CLIs, or any workflow built around next() — not
another HTTP framework, a composition primitive you build on top of.
bun add middleware-kit
Why
koa-compose already exists as
a ~20-line standalone package. middleware-kit keeps that same
minimal core, but adds tooling that stays completely opt-in:
- Runtime-agnostic cancellation
- Static pipeline introspection (
explain) - Runtime tracing (
traceable) - First-class TypeScript generics
- Structured runtime errors
The core dispatch loop (compose()) is deliberately blind to
logging, retries, priorities, metrics, or any other cross-cutting
concern. Those are implemented as decorators that wrap middleware
before it reaches compose(), keeping the execution engine small,
predictable, and easy to audit.
Features
| Feature | Supported |
|---|---|
| Runtime-agnostic | |
| TypeScript-first, generic context | |
| Onion execution model | |
| Static pipeline introspection | |
| Runtime tracing | |
| Structured errors | |
Cancellation (AbortSignal-compatible) |
|
| HTTP-independent |
Quick start
import { compose } from "middleware-kit";
const pipeline = compose([
async (ctx, next) => {
console.log("before A");
await next();
console.log("after A");
},
async (ctx, next) => {
console.log("before B");
await next();
console.log("after B");
},
]);
await pipeline({});
// before A
// before B
// after B
// after A
See examples/basic-usage.ts for a
fuller example combining compose, explain, traceable, error
handling, and cancellation in one pipeline.
Typed context
compose() is generic over the context shape, so every middleware
in the array is checked against the same type:
interface AuthContext {
user?: { id: string };
}
const pipeline = compose<AuthContext>([authMiddleware, permissionsMiddleware]);
Named middlewares
Plain functions work fine, but naming a middleware makes it show up
in explain() output, trace events, and error messages instead of
an autogenerated anonymous_N:
const auth = {
name: "auth",
handler: async (ctx, next) => {
ctx.user = await authenticate(ctx);
await next();
},
meta: { critical: true }, // free-form, your own tooling can read this
};
compose([auth, cacheMiddleware, controller]);
Error handling
Outer middlewares can catch errors thrown by inner ones, the same
way try/catch works around nested synchronous function calls:
compose([
async (ctx, next) => {
try {
await next();
} catch (err) {
console.error("caught:", err);
}
},
async () => {
throw new Error("inner failure");
},
]);
Errors propagate whether they're thrown synchronously or via a rejected promise — both are normalized the same way. You can also attach a global hook that runs before an error propagates, useful for centralized error reporting:
await pipeline(ctx, {
onError: (err, layer) => report(err, layer.name),
});
Calling next() more than once in the same middleware throws a
MultipleNextCallError rather than silently re-running the
downstream pipeline.
Cancellation
compose() accepts any object implementing the lightweight
CancellationSignal interface. A real AbortController works out
of the box:
const controller = new AbortController();
const pipeline = compose([longRunningMiddleware]);
await pipeline({}, { signal: controller.signal });
// rejects with AbortError if aborted before or during execution
Custom implementations are also supported — useful in environments
without a native AbortSignal:
await pipeline(ctx, {
signal: { aborted: false },
});
Because cancellation is typed against this minimal interface instead
of the DOM's AbortSignal directly, middleware-kit works the same
way across browsers, Node.js, Bun, Deno, workers, and custom
JavaScript runtimes.
explain() — static introspection
Describes a pipeline's shape without executing anything — useful for debug tooling, CI checks on pipeline order, or generating docs:
import { explain } from "middleware-kit";
const middlewares = [
{ name: "logger", handler: loggerMw },
{ name: "auth", handler: authMw },
{ name: "controller", handler: controllerMw },
];
console.log(explain(middlewares).asText);
// 1. logger
// 2. auth
// 3. controller
traceable() — runtime tracing
Wraps middlewares with enter/exit/error instrumentation
before composition, so compose() itself never changes and there
is zero overhead when tracing isn't used:
import { compose, traceable } from "middleware-kit";
const events = [];
const pipeline = compose(
traceable(middlewares, { onEvent: (e) => events.push(e) })
);
await pipeline({});
console.table(events);
Events carry a type ("enter" | "exit" | "error"), the
originating layer, and a durationMs on exit/error. Because
traceable() returns a plain middleware array, it composes with
other decorators you might add later (a future withRetry(), for
instance) and works with any compose-compatible engine — not just
this one.
Design principles
- The core stays small.
compose()is deliberately blind to priorities, retries, and logging — those are decorators applied to the middleware array before it reachescompose(). - Not HTTP-specific. Context is a generic
TContext; use it for HTTP, WebSockets, workers, jobs, RPC, message queues, CLIs, or state machines. - Zero-cost when unused.
explainandtraceableare opt-in layers with no effect on the hot path if you never call them. - Runtime-agnostic. No dependency on browser APIs or any specific runtime — runs anywhere modern JavaScript runs.
API reference
| Export | Description |
|---|---|
compose(middlewares) |
Builds an executable pipeline from an array of middlewares. |
explain(middlewares) |
Static, non-executing description of a pipeline's shape. |
traceable(middlewares, options) |
Wraps middlewares with enter/exit/error event instrumentation. |
normalize(middlewares) |
Converts a mixed array of functions/NamedMiddleware into a uniform NamedMiddleware[]. |
CancellationSignal |
Minimal, runtime-independent cancellation interface accepted by compose(). |
AbortError |
Thrown when a pipeline is cancelled via a CancellationSignal. |
MultipleNextCallError |
Thrown when next() is called more than once in the same layer. |
Full type-level documentation (JSDoc/TSDoc) lives alongside each
export in src/types.ts,
src/compose.ts,
src/explain.ts, and
src/trace.ts.
Project structure
middleware-kit/
├── src/
│ ├── compose.ts # core dispatch engine
│ ├── explain.ts # static pipeline introspection
│ ├── trace.ts # runtime tracing decorator
│ ├── types.ts # shared types and errors
│ └── index.ts # public exports
├── examples/
│ └── basic-usage.ts
└── test/
├── compose.test.ts
└── trace.test.ts
Development
bun install # install dependencies
bun run test # run the test suite (vitest)
bun run typecheck # type-check without emitting
bun run check # lint + format check (biome)
bun run build # produce dist/ (esm + cjs + .d.ts) via tsup
License
MIT