Skip to the code
cf.

Compose middleware like Koa

Servers like Koa handle a request by passing a context object through a list of middleware functions. Write composeMiddleware(middleware), where middleware is an array of functions. It returns a function, run(ctx, next), that runs them on ctx and returns a promise.

Each middleware is called with two arguments: ctx, the same object for every middleware in the run, and next, a function that takes no arguments. Calling next() runs the next middleware in the array.

next() always returns a promise. It resolves once the next middleware and everything after it have finished, with the value that middleware returned.

So a middleware can do some work, await next(), then do more. The work before next() runs in array order, and the work after it in reverse order. A middleware that doesn't call next() ends the chain: the ones after it don't run.

A middleware can be an async function or a regular one. The promise from run resolves with what the first middleware returns, once it has finished.

If run is given a next of its own, it runs after the last middleware, as one more middleware at the end of the array. That lets one composed function be a middleware in another. At the end of the chain, next() resolves with undefined.

When a middleware throws or rejects, the next() that ran it rejects with that error, so an earlier middleware can catch it by wrapping await next() in try. If nothing catches it, the promise from run rejects with it. run itself never throws.

Calling next() a second time in the same middleware is a bug. That call rejects with an Error whose message is next() called multiple times, and doesn't run anything.

run can be called any number of times, even while an earlier run hasn't finished. Every run is independent.

const app = composeMiddleware([
  async (ctx, next) => {
    ctx.log.push("start");
    await next();
    ctx.log.push("end");
  },
  async (ctx, next) => {
    try {
      await next();
    } catch (error) {
      ctx.status = 500;
    }
  },
  (ctx) => {
    if (!ctx.user) throw new Error("no user");
    ctx.log.push("hello " + ctx.user);
    ctx.status = 200;
  },
]);
 
const ok = { user: "ada", log: [] };
await app(ok);
ok.log;     // ["start", "hello ada", "end"]
ok.status;  // 200
 
const anonymous = { log: [] };
await app(anonymous); // resolves: the second middleware caught the error
anonymous.log;        // ["start", "end"]
anonymous.status;     // 500

What the tests check

  1. runs the middleware in onion order, all with the same context
    const received = [];
    const step = (name) => async (ctx, next) => {
      received.push(ctx);
      ctx.log.push(name + " before");
      await next();
      ctx.log.push(name + " after");
    };
    const run = composeMiddleware([
      step("a"),
      step("b"),
      async (ctx) => {
        received.push(ctx);
        ctx.log.push("c");
      },
    ]);
    const ctx = { log: [] };
    await run(ctx);
    expect(ctx.log).toEqual(["a before", "b before", "c", "b after", "a after"]);
    expect(received).toHaveLength(3);
    expect(received.every((each) => each === ctx)).toBe(true);
    
  2. next() waits for the rest of the chain to finish
    let open;
    const gate = new Promise((resolve) => (open = resolve));
    const run = composeMiddleware([
      async (ctx, next) => {
        await next();
        ctx.log.push("a after");
      },
      async (ctx, next) => {
        await gate;
        ctx.log.push("b");
        await next();
      },
      async (ctx) => {
        await gate;
        ctx.log.push("c");
      },
    ]);
    const ctx = { log: [] };
    let finished = false;
    run(ctx).then(() => {
      finished = true;
    });
    await flush();
    expect(finished).toBe(false);
    expect(ctx.log).toEqual([]);
    open();
    await flush();
    expect(ctx.log).toEqual(["b", "c", "a after"]);
    expect(finished).toBe(true);
    
  3. resolves with what each middleware returns
    const run = composeMiddleware([
      async (ctx, next) => (await next()) * 2,
      (ctx, next) => next().then((n) => n + 1),
      () => 20,
    ]);
    await expect(run({})).resolves.toBe(42);
    
  4. a middleware that doesn't call next() ends the chain
    const later = spy();
    const run = composeMiddleware([
      async (ctx, next) => {
        ctx.log.push("a before");
        await next();
        ctx.log.push("a after");
      },
      async (ctx) => {
        ctx.log.push("b");
      },
      later,
    ]);
    const ctx = { log: [] };
    await run(ctx);
    expect(later).not.toHaveBeenCalled();
    expect(ctx.log).toEqual(["a before", "b", "a after"]);
    
  5. after the last middleware, next() runs run's own next
    const outer = spy(async (ctx, next) => {
      ctx.log.push("outer");
      await next();
      return "from outer";
    });
    const run = composeMiddleware([
      async (ctx, next) => {
        ctx.result = await next();
      },
    ]);
    const ctx = { log: [] };
    await run(ctx, outer);
    expect(outer).toHaveBeenCalledTimes(1);
    expect(outer.calls[0].args[0]).toBe(ctx);
    expect(ctx.log).toEqual(["outer"]);
    expect(ctx.result).toBe("from outer");
    const alone = { log: [] };
    await run(alone);
    expect(alone.result).toBeUndefined();
    
  6. rejects when a middleware throws or rejects, without throwing itself
    const failing = composeMiddleware([
      () => {
        throw new TypeError("bad input");
      },
    ]);
    let result;
    expect(() => {
      result = failing({});
    }).not.toThrow();
    await expect(result).rejects.toThrow(TypeError);
     
    const later = composeMiddleware([
      async (ctx, next) => {
        await next();
      },
      async () => {
        throw new RangeError("not found");
      },
    ]);
    await expect(later({})).rejects.toThrow(RangeError);
    
  7. an earlier middleware can catch a later one's error
    const run = composeMiddleware([
      async (ctx, next) => {
        try {
          await next();
        } catch (error) {
          ctx.status = 500;
        }
      },
      (ctx, next) =>
        next().catch((error) => {
          ctx.seen = error.message;
          throw error;
        }),
      () => {
        throw new Error("boom");
      },
    ]);
    const ctx = {};
    await expect(run(ctx)).resolves.toBeUndefined();
    expect(ctx).toEqual({ seen: "boom", status: 500 });
    
  8. calling next() twice in one middleware rejects
    const later = spy();
    const run = composeMiddleware([
      async (ctx, next) => {
        await next();
        await next();
      },
      later,
    ]);
    let error;
    await run({}).catch((caught) => {
      error = caught;
    });
    expect(error).toBeInstanceOf(Error);
    expect(error.message).toBe("next() called multiple times");
    expect(later).toHaveBeenCalledTimes(1);
    
  9. each run is independent, even while another is pending
    let open;
    const gate = new Promise((resolve) => (open = resolve));
    const run = composeMiddleware([
      async (ctx, next) => {
        await gate;
        await next();
      },
      async (ctx) => {
        ctx.log.push("b");
      },
    ]);
    const first = { log: [] };
    const second = { log: [] };
    const both = Promise.all([run(first), run(second)]);
    open();
    await both;
    await run(first);
    expect(first.log).toEqual(["b", "b"]);
    expect(second.log).toEqual(["b"]);
    

Learn more

solution.jsSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).