Hono's Onion Model - Middleware compose logic
Hono is a fast and lightweight web framework for Node.js. Its RPC-like API makes TypeScript fullstack communication more efficient with type hints. Hono’s high performance largely comes from its simple middleware compose logic.
I dug into Hono’s middleware compose logic and implemented a minimal onion model from scratch.
Basic usage of Hono’s Middleware
The processing order follows the steps below, which is called “onion model”:
import { Hono } from "hono";
const app = new Hono();
// global middleware
app.use("*", async (c, next) => {
console.log("step1. middleware processing request");
// key line
await next();
console.log("step3. middleware processing response");
});
app.get("/", (c) => c.text("step2. context been processed")); //router middleware
export default app;
How this next() function works in Hono
Source Code
You will find the key logic of next() in the following code from Hono’s GitHub repo:
Here is a simplified version of the code which stripped out the TS type definitions and the edge-case error handling:
export const compose = (middleware) => {
return (context, next) => {
let index = -1;
return dispatch(0);
async function dispatch(i) {
if (i <= index) throw new Error("next() called multiple times");
index = i;
const fn = middleware[i] || next;
if (!fn) return context;
await fn(context, () => dispatch(i + 1));
}
};
};
Three things in this code may be confusing:
- recursive function
dispatch - the
closurevariableindex - when composing, there is an extra
nextparameter passed in
The extra next is the most intriguing part. But before we delve too deeply into it, let’s just try to build a simple middleware processing pipeline by plain Promise and a little recursive function.
Building From Scratch
- Define a simple
reqobject
const req = {
method: "GET",
path: "/api/getUserInfo",
rsp: "", // we will use this to store the response for simple mock
};
- Ignore the path, we just define two global middleware
const m1_timer = async (req, next) => {
let start = Date.now();
console.log("timer start!");
await next();
let end = Date.now();
const duration = end - start;
console.log(`timer end,duration:${duration}ms`);
};
const m2_req_logger = async (req, next) => {
console.log(`-> ${req.method} ${req.path}`);
await next();
console.log(`<- ${JSON.stringify(req.rsp)}`);
};
- Define a
router, which just modify thereqobject to add anameandagefield
const router = async (req) => {
console.log("process req");
req.rsp = {
name: "admin",
age: 12,
};
};
We all know the running order of these two middleware is m1_timer -> m2_req_logger -> router, and then the response will be processed by m2_req_logger and followed by m1_timer again.
So the key is the next() function — we want it to call the next middleware in the order of the middleware list.
This gives us:
const middleware_list = [m1_timer, m2_req_logger, router];
- The
dispatchfunction
async function dispatch(i) {
const cw = middleware_list[i]; // 2. get the currentMiddleware
const next = () => {
dispatch(i + 1);
}; // 3. get the nextMiddleware caller
await cw(req, next); // 4. call the currentMiddleware
}
await dispatch(0); // 1. start the dispatch, the first middleware is m1_timer
Runtime Flow
- Into the middleware
m1_timer
const m1_timer = async (req, next) => {
let start = Date.now(); // 5. start the timer
console.log("timer start!");
await next(); // 6. next() is a dispatch() for the next middleware
let end = Date.now(); // 7. the following part will be wrapped into the promise.then(), and executed after the nextMiddleware (aka m2_req_logger) is entirely finished (aka been marked as fulfilled)
console.log(`timer end,duration:${duration}ms`);
};
/* dispatch calling, and passed next middleware caller to m2_req_logger*/
- Into the middleware
m2_req_logger
const m2_req_logger = async (req, next) => {
console.log(`-> ${req.method} ${req.path}`); // 8. log the request
await next(); // 9. this next() will call the router
console.log(`<- ${JSON.stringify(req.rsp)}`); // ditto
};
- Into the
router
const router = async (req) => {
console.log("process req"); // 10. log and modify the req object
req.rsp = {
name: "admin",
age: 12,
};
};
- Back to the
m2_req_logger
/* there is no next() in the router, actually the dispatch(3) won't be received by the router.*/
/*so the promise following dispatch(2) will be resolved, which means we return to the m2_req_logger*/
const m2_req_logger = async (req, next) => {
console.log(`-> ${req.method} ${req.path}`);
await next();
console.log(`<- ${JSON.stringify(req.rsp)}`); // 11. this log line will be executed
};
- Back to the
m1_timer
/* and ditto, after that, we return to the m1_timer*/
const m1_timer = async (req, next) => {
let start = Date.now(); //
console.log("timer start!");
await next();
let end = Date.now(); // 12. this computation and log line will be executed
console.log(`timer end,duration:${duration}ms`);
};
The Final Result
timer start!
-> GET /api/getUserInfo
process req
<- {"name":"admin","age":12}
timer end,duration:4ms
So finally we find out that dispatch() is next() — we pass the index number to this function, and when it is called, it will execute the relevant middleware logic. Meanwhile, the next dispatch() to call its subsequent middleware will be renamed as next() and passed to the current middleware runtime.
The closure Variable index
Now let’s think about a condition: what if someone calls next() multiple times in the middleware logic?
For example:
const m1_timer = async (req, next) => {
let start = Date.now();
await next();
await next(); //call next() again!
let end = Date.now();
console.log(`timer end,duration:${duration}ms`);
};
If you are familiar with how Promise works, it will be easy to understand that its subsequent middleware will be executed again — which means all the subsequent middlewares along with the router handler will be executed twice cascadingly.
Here is the example:
const req = {
method: "GET",
path: "/api/getUserInfo",
rsp: "",
};
const m1 = async (req, next) => {
console.log("m1 before");
await next();
await next();
console.log("m1 after");
};
const m2 = async (req, next) => {
console.log("m2 before");
await next();
console.log("m2 after");
};
const m3 = async (req, next) => {
console.log("m3 before");
await next();
console.log("m3 after");
};
const r4 = async (req) => {
console.log("r4 processing request");
req.rsp = { name: "admin", age: 12 };
};
const middleware_list = [m1, m2, m3, r4];
async function dispatch(i) {
const fn = middleware_list[i];
if (!fn) return;
await fn(req, () => dispatch(i + 1));
}
await dispatch(0);
The output is:
m1 before
m2 before
m3 before
r4 processing request
m3 after
m2 after
m2 before // m1 calls next() multiple times, so all of the subsequent middlewares been executed again (start from the m2)//
m3 before
r4 processing request
m3 after
m2 after
m1 after
So we need a foolproof way to avoid this problem because any production code with this kind of bug is critical.
A straightforward way to solve this is to use a tracking variable to ensure the middleware index won’t be called again. So we need a one-time variable to track it for each handling of incoming request. A closure variable is an ideal solution.
Hono does it this way:
function compose(middleware_list) {
return async (req) => {
let index = -1; //closure variable
await dispatch(0);
async function dispatch(i) {
//in the example above, when m2 was called the second time, the i will be 1, and
if (i <= index)
throw new Error(
`next() called multiple times, now the index is ${index} and i is ${i}`
);
index = i;
const fn = middleware_list[i];
const next = () => dispatch(i + 1);
await fn(req, next);
}
};
}
const composed = compose(middleware_list);
const start = async () => {
await composed(req);
};
start();
Run the code above again, you will get the following output:
m1 before
m2 before
m3 before
r4 processing request
m3 after
m2 after
ERROR!
/tmp/yzODUs5eJS/main.js:39
if (i<=index) throw new Error(`next() called multiple times for index is ${index} and i is ${i}`);
^
Error: next() called multiple times for index is 3 and i is 1
at dispatch (/tmp/yzODUs5eJS/main.js:39:29)
at next (/tmp/yzODUs5eJS/main.js:42:28)
at m1 (/tmp/yzODUs5eJS/main.js:10:9)
at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
at async dispatch (/tmp/yzODUs5eJS/main.js:43:9)
at async /tmp/yzODUs5eJS/main.js:36:5
at async start (/tmp/yzODUs5eJS/main.js:51:5)
Node.js v22.21.1
Obviously, the final index stops at the router, but when m2 was called the second time, the i is still 1 because dispatch(1) was passed to it.
I will continue to explain why the real Hono compose() realization asks for an extra parameter next which we thought was intriguing but unnecessary.