73. Hono: routing with one regex, middleware as an onion
Hono is a small, fast web framework built only on web-standard
Request/Response. It runs unchanged on Cloudflare Workers, Deno, Bun,
Node, Vercel and AWS Lambda, and it is the usual choice for the edge
functions in §62. Two parts of it are worth studying: middleware
composition and routing.
The onion: compose()
// dist/compose.js (Hono 4.13.13), abridged — "based on `koa-compose`"
const compose = (middleware, onError, onNotFound) => {
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;
let res, isError = false, handler;
if (middleware[i]) {
handler = middleware[i][0][0];
context.req.routeIndex = i;
} else handler = i === middleware.length && next || void 0;
if (handler) try {
res = await handler(context, () => dispatch(i + 1)); // next() = run the rest of the chain
} catch (err) {
if (err instanceof Error && onError) {
context.error = err;
res = await onError(err, context);
isError = true;
} else throw err;
}
else if (context.finalized === false && onNotFound) res = await onNotFound(context);
if (res && (context.finalized === false || isError)) context.res = res;
return context;
}
};
};
next is a closure over i + 1. Code before await next() runs on the
way in, code after it runs on the way out, so a timing middleware wraps
everything inside it like the layers of an onion. A middleware that
doesn't call next() short-circuits the rest, which is how auth rejects a
request. The i <= index guard catches the classic bug of calling next()
twice. Running the mini version (in the companion code):
auth: before next() ← timing → auth → handler, on the way in
timing: after next() ← …and back out through timing
/users/42 → user 42 (…ms)
/users/42 → 401 (…ms) ← no token: auth returned without calling next()
guard: next() called multiple times
Routing: all routes in one regular expression
Hono's default router is a SmartRouter that tries RegExpRouter first
and falls back to TrieRouter for route patterns the regex approach
can't express. The first time it matches a request, it replaces its own
match method with the winner's:
// dist/router/smart-router/router.js (Hono 4.13.13), abridged
match(method, path) {
for (; i < len; i++) {
const router = routers[i];
try {
for (let i = 0, len = routes.length; i < len; i++) router.add(...routes[i]);
res = router.match(method, path);
} catch (e) {
if (e instanceof UnsupportedPathError) continue; // this router can't express a route: try the next
throw e;
}
this.match = router.match.bind(router); // self-replacing method: no dispatch cost from now on
this.#routers = [router];
this.#routes = void 0;
break;
}
// …
}
RegExpRouter compiles every route into one regex, and finds out which
route matched with a neat trick:
// dist/router/reg-exp-router/matcher.js (Hono 4.13.13)
function match(method, path) {
const matchers = this.buildAllMatchers();
const match = ((method, path) => {
const matcher = matchers[method] || matchers["ALL"];
const staticMatch = matcher[2][path]; // static paths: one object lookup, no regex
if (staticMatch) return staticMatch;
const match = path.match(matcher[0]);
if (!match) return [[], emptyParam];
const index = match.indexOf("", 1); // ← which route matched?
return [matcher[1][index], match];
});
this.match = match; // self-replacing again: build once, then the fast path
return match(method, path);
}
Each route's alternative in the big regex ends with an empty capture
group (). Only the alternative that matched sets its groups, so the
matched route's () is the empty string "", and every other route's
groups are undefined. match.indexOf("", 1) finds that marker in one
call. Parameters can't be empty because they match [^/]+, so they can
never be confused with the marker. A mini version, run in Node:
function buildRouter(routes) {
const handlers = [], paramNames = [], staticMap = Object.create(null);
let group = 0;
const alternatives = [];
for (const [path, handler] of routes) {
if (!path.includes(":")) { staticMap[path] = { handler, params: {} }; continue; }
const names = [];
const source = path.replace(/:(\w+)/g, (_, name) => (names.push([name, ++group]), "([^/]+)"));
const marker = ++group; // this route's "()" group number
handlers[marker] = handler; paramNames[marker] = names;
alternatives.push(`${source}$()`);
}
const regex = new RegExp(`^(?:${alternatives.join("|")})`);
return {
regex,
match(path) {
if (staticMap[path]) return staticMap[path];
const m = path.match(regex);
if (!m) return null;
const marker = m.indexOf("", 1);
return { handler: handlers[marker], params: Object.fromEntries(paramNames[marker].map(([n, g]) => [n, m[g]])) };
},
};
}
const router = buildRouter([
["/health", () => "ok"],
["/users/:id", ({ params }) => `user ${params.id}`],
["/posts/:slug/comments/:cid", ({ params }) => `comment ${params.cid} on ${params.slug}`],
]);
router.regex.source; // ^(?:\/users\/([^/]+)$()|\/posts\/([^/]+)\/comments\/([^/]+)$())
router.match("/posts/hello-world/comments/7").params; // { slug: "hello-world", cid: "7" }
One regex means the regex engine (V8's Irregexp, compiled to machine code)
does the route search in native code, instead of a JavaScript loop over
hundreds of route patterns. The self-replacing match methods mean the
setup cost is paid once per isolate. On the edge, where isolates are
created constantly (§62), startup cost matters as much as throughput.
🎯 Challenges for Part XIII. (1) Add
useMemoanduseRefto the mini hooks. Both are about four lines on top of the hook array. (2) Adduntracked(fn)to the mini signals (hint: setcontext = nullwhilefnruns). (3) Make the mini router support*wildcards, then find the route pattern that makes Hono'sRegExpRouterthrowUnsupportedPathError. (4) Pick a library you use daily, runnpm packon it, and write its "how it works in 60 lines" chapter.