← Runnable Projects

阅读中文版本

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.

beginnerjavascriptunverified

Project

node-middleware-pipeline

Entry point: pipeline.mjs

Install

node --version

Run

node pipeline.mjs

Test

node --test pipeline.test.mjs

Expected 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.