Middleware is a list of functions that a request, or an action, passes through on its way to being handled. Each function, called a middleware, does one job, such as logging, checking who's signed in, or catching errors, then hands over to the next one. Web servers built with Express or Koa handle every HTTP request this way, and Redux sends every dispatched action through middleware before the reducer sees it. To add a feature, you add a function to the list, without changing the others.
In Koa, a middleware is a function (ctx, next). ctx, the context, is one object shared by every middleware for the same request: it holds the request, the response being built, and anything a middleware adds, such as ctx.user. next is a function that runs the rest of the list. A middleware can do some work, call next() to hand over, and once the rest has finished, do more work. That gives the onion picture: the request travels in through each layer in list order to the centre, then back out through the same layers in reverse. A middleware that doesn't call next() stops it there, and nothing further in runs.
// Logs how long the rest of the list took
const timer = async (ctx, next) => {
const start = Date.now();
await next();
console.log(`${ctx.path}: ${Date.now() - start} ms`);
};
// Adds the user to the context, or stops here with 401 Unauthorized
const auth = async (ctx, next) => {
ctx.user = await findUser(ctx.get("Authorization"));
if (!ctx.user) {
ctx.status = 401;
return; // no next(): the handler below never runs
}
await next();
};
const hello = (ctx) => {
ctx.body = `Hello, ${ctx.user.name}`;
};
// In Koa, app.use(timer), app.use(auth), and app.use(hello) add them in that order
| Step | A signed-in request | A request with no user |
|---|---|---|
| 1 | timer notes the start time and calls next(). | The same. |
| 2 | auth finds the user, sets ctx.user, and calls next(). | auth finds no user, sets the status to 401, and returns without calling next(). |
| 3 | hello sets the response body. It's the last, so it has no next() to call. | hello never runs. |
| 4 | auth's await next() finishes, and it has nothing more to do. | Nothing: auth has already finished. |
| 5 | timer's await next() finishes, and it logs the time. | The same: timer still logs the time. |
In Koa, next() returns a promise that settles once the rest of the list has finished. So a middleware can wait for it, time it, or wrap await next() in try...catch to turn an error from anywhere further in into a 500 response. Express middleware has the same idea in a different shape, (req, res, next), but its next() returns nothing to wait for. Work that should happen after the response, such as logging how long it took, is usually done in a listener for the response's finish event instead.
Redux middleware sits between dispatch and the reducer: every action you dispatch passes through it before the reducer sees it. It's used for logging actions, reporting crashes along with the action that caused them, and async work. Redux Thunk, which Redux Toolkit's configureStore adds by default, lets you dispatch a function instead of an action: the middleware calls that function with dispatch and getState, so it can fetch data and dispatch real actions when the response arrives.
A Redux middleware is a function in three layers: ({ getState, dispatch }) => (next) => (action) => result. The first layer gets the store's API, once, when the store is created. The second gets next, also once: the function that passes an action on to the next middleware, or, after the last one, to the store's own dispatch, which runs the reducer. The third layer runs for every action, and what it returns goes back to whoever dispatched the action or called its next.
// Logs every action, and the state once the reducer has run
const logger = ({ getState }) => (next) => (action) => {
console.log("action", action.type);
const result = next(action); // the rest of the chain, then the reducer
console.log("state", getState());
return result;
};
// Reports an error thrown further in, with the action that caused it
const crashReporter = () => (next) => (action) => {
try {
return next(action);
} catch (error) {
console.error("crashed on", action.type, error.message);
throw error;
}
};
// Dispatching { type: "incremented" } to a counter at 0, through both,
// logs "action incremented", then "state 1", and returns the action
| In the third layer | What happens |
|---|---|
return next(action) | Passes the action on, and returns what the rest of the chain returns. At the end of the chain, the store's dispatch returns the action. |
next({ ...action, at: Date.now() }) | Passes on a changed action: the middleware after this one, and the reducer, see the new one. |
Not calling next at all | Stops the action: the middleware after this one and the reducer never see it. Whatever this one returns goes back up the chain instead. |
dispatch(otherAction) | Starts otherAction from the first middleware, so it goes through all of them again. That's how the actions a thunk dispatches get logged too. |
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
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);
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);
const run = composeMiddleware([
async (ctx, next) => (await next()) * 2,
(ctx, next) => next().then((n) => n + 1),
() => 20,
]);
await expect(run({})).resolves.toBe(42);
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"]);
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();
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);
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 });
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);
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"]);
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).
I'm here to help with Compose middleware like Koa. I give hints, never the solution, so you get to solve it yourself. Ask for a hint, select code to ask about it, or press Ask Tuto why on a failing test.
Your first 3 answers need no account. for free to keep asking.