npm.io
1.0.0 • Published 3d ago

middleware-kit

Licence
MIT
Version
1.0.0
Deps
0
Size
48 kB
Vulns
0
Weekly
0

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 reaches compose().
  • 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. explain and traceable are 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

Keywords