Frontend Labs
⋯

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 useMemo and useRef to the mini hooks. Both are about four lines on top of the hook array. (2) Add untracked(fn) to the mini signals (hint: set context = null while fn runs). (3) Make the mini router support * wildcards, then find the route pattern that makes Hono's RegExpRouter throw UnsupportedPathError. (4) Pick a library you use daily, run npm pack on it, and write its "how it works in 60 lines" chapter.


Generated from real-life-JS-guide.md · 8 Oct 2026 16:36