Onion-Style Middleware Pipeline
A tiny Express-style middleware composer where each layer can act before and after the next, with tests proving execution order and error capture.
Project
node-middleware-pipeline
Entry point: pipeline.mjs
Install
node --versionRun
node pipeline.mjsTest
node --test pipeline.test.mjsExpected output
The demo prints the execution order enter-a, enter-b, core, exit-b, exit-a followed by the final context field set by the core.Dependencies
- node >=20
What it does
A 20-line Express-style middleware composer: each layer runs before and after the next, and omitting next short-circuits the chain.
export function compose(...middlewares) {
return function dispatch(i, ctx) {
const mw = middlewares[i];
if (!mw) return Promise.resolve();
return Promise.resolve(mw(ctx, () => dispatch(i + 1, ctx)));
};
}
export function runPipeline(middlewares, ctx) {
const dispatch = compose(...middlewares);
return dispatch(0, ctx);
}
const trace = [];
const mwA = async (ctx, next) => {
trace.push('enter-a');
await next();
trace.push('exit-a');
};
const mwB = async (ctx, next) => {
trace.push('enter-b');
await next();
trace.push('exit-b');
};
const core = async (ctx) => {
ctx.coreRan = true;
trace.push('core');
};
const ctx = {};
await runPipeline([mwA, mwB, core], ctx);
console.log(trace.join(','));
console.log('coreRan=' + ctx.coreRan);
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { runPipeline } from './pipeline.mjs';
test('middlewares wrap the core onion-style', async () => {
const order = [];
const tag = (name) => async (ctx, next) => {
order.push('in-' + name);
await next();
order.push('out-' + name);
};
const ctx = {};
await runPipeline([tag('a'), tag('b'), async (c) => { order.push('core'); c.done = true; }], ctx);
assert.deepEqual(order, ['in-a', 'in-b', 'core', 'out-b', 'out-a']);
assert.equal(ctx.done, true);
});
test('a throwing middleware rejects and later layers stop', async () => {
const order = [];
await assert.rejects(runPipeline([
async (_ctx, next) => { order.push('first'); await next(); },
async () => { throw new Error('boom'); },
async () => { order.push('never'); },
], {}));
assert.deepEqual(order, ['first']);
});
test('omitting next stops the chain without error', async () => {
const ctx = {};
await runPipeline([async (c) => { c.shortCircuit = true; }, async (c) => { c.never = true; }], ctx);
assert.equal(ctx.shortCircuit, true);
assert.equal(ctx.never, undefined);
});
Run and verify
node --test pipeline.test.mjs
node pipeline.mjs
Run the demo to watch the onion order print, then run the tests for error propagation and short-circuit cases.
Expected Output
The test run should print three passing tests for the onion order, rejection propagation, and short-circuit semantics. The demo’s console.log output should print the execution trace joined by commas, then the context field the core layer set:
enter-a,enter-b,core,exit-b,exit-a
coreRan=true
Each middleware runs code before and after the layers it wraps — a enters first and exits last, b nests inside it, and the core runs at the center — which is the same onion model Express and Koa compose use. A middleware that never calls next simply stops the chain without error.
Limitations
There is no built-in error-handler middleware convention; rejections propagate to the caller and must be caught there.