Read OSS

The Architecture of Hono: A Map of the Codebase

Beginner

Prerequisites

  • Basic TypeScript familiarity
  • General understanding of HTTP request/response cycle
  • Some prior exposure to a Node.js or edge-runtime web framework

The Architecture of Hono: A Map of the Codebase

Most web frameworks tell you what they do. Hono is worth studying for how it does it. In under 540 lines of core engine code, it delivers a framework that runs identically on Cloudflare Workers, Deno, Bun, Node.js, and AWS Lambda — with zero runtime dependencies and a type system that propagates route definitions all the way to an auto-generated RPC client. This article is the map. Every subsequent article in this series will reference the landmarks we establish here.

What Is Hono and Why Does It Exist?

Hono (炎, Japanese for "flame") is an ultrafast web framework built exclusively on Web Standards APIs. That single design constraint — depend only on Request, Response, URL, and fetch — is what makes multi-runtime support possible without adapter shims in the hot path.

The project targets v4.x (4.12.9 at the time of this writing) and weighs roughly 92,000 lines including tests. Yet the core — what actually handles your request — is a single file of ~540 lines. Everything else is opt-in: routers, middleware, JSX rendering, validators, and runtime adapters, each importable from its own sub-path.

The entry point says it all. src/index.ts re-exports the Hono class and a handful of core types. That's the entire public surface of import { Hono } from 'hono'.

Repository Directory Structure

Before diving into any implementation detail, let's build a mental model of where things live:

Directory Purpose
src/hono.ts Concrete Hono class (injects default router)
src/hono-base.ts Router-agnostic core engine (~540 lines)
src/router/ Five router implementations
src/middleware/ 25+ built-in middleware modules
src/helper/ Utility helpers (cookie, html, factory, etc.)
src/adapter/ Runtime adapters (AWS Lambda, Bun, Cloudflare, Deno, Node.js)
src/client/ hc — the typed RPC client
src/validator/ Validation middleware core
src/jsx/ Full JSX engine (SSR, streaming, client-side DOM)
src/preset/ Alternative Hono configurations (tiny, quick)
src/utils/ Shared utilities (URL parsing, HTML escaping, encoding)
build/ esbuild-based build pipeline
runtime-tests/ Per-runtime test suites (Deno, Bun, Cloudflare, etc.)
graph TD
    subgraph Core
        HB[hono-base.ts<br/>~540 lines]
        H[hono.ts]
        C[compose.ts]
        CTX[context.ts]
        REQ[request.ts]
    end

    subgraph Routers
        SR[SmartRouter]
        RER[RegExpRouter]
        TR[TrieRouter]
        LR[LinearRouter]
        PR[PatternRouter]
    end

    subgraph Presets
        P1[default: hono.ts]
        P2[tiny: PatternRouter]
        P3[quick: LinearRouter+TrieRouter]
    end

    H --> HB
    HB --> C
    HB --> CTX
    CTX --> REQ
    H --> SR
    SR --> RER
    SR --> TR
    P2 --> PR
    P3 --> LR
    P3 --> TR

Tip: When exploring an unfamiliar feature, start with the src/middleware/<name>/index.ts or src/helper/<name>/index.ts convention. Every middleware and helper follows this exact pattern.

The Two-Class Pattern: HonoBase and Hono

The most important architectural decision in the codebase is the split between HonoBase and Hono. The comment at src/hono-base.ts#L114-L117 says it explicitly:

This class is like an abstract class and does not have a router.
To use it, inherit the class and implement router in the constructor.

HonoBase contains everything about request handling: route registration, the #dispatch() pipeline, fetch(), request(), .route(), .mount(), error handling, and the compose() integration. What it deliberately omits is the router choice.

The concrete src/hono.ts then extends HonoBase and injects a default router in its constructor:

export class Hono<E, S, BasePath> extends HonoBase<E, S, BasePath> {
  constructor(options: HonoOptions<E> = {}) {
    super(options)
    this.router =
      options.router ??
      new SmartRouter({
        routers: [new RegExpRouter(), new TrieRouter()],
      })
  }
}

This is the Strategy pattern at its cleanest. The presets are just alternative subclasses that inject different routers.

classDiagram
    class HonoBase {
        +router: Router
        +fetch()
        +request()
        -dispatch()
        +route()
        +mount()
        +onError()
        +notFound()
    }
    class Hono {
        +constructor(): SmartRouter~RegExpRouter+TrieRouter~
    }
    class HonoTiny {
        +constructor(): PatternRouter
    }
    class HonoQuick {
        +constructor(): SmartRouter~LinearRouter+TrieRouter~
    }
    HonoBase <|-- Hono
    HonoBase <|-- HonoTiny
    HonoBase <|-- HonoQuick

Presets: tiny, quick, and Default

Each preset optimises for a different axis:

Preset Import Path Router(s) Optimised For
Default hono SmartRouter → RegExpRouter + TrieRouter Throughput (steady-state performance)
tiny hono/tiny PatternRouter only Bundle size (<12 kB)
quick hono/quick SmartRouter → LinearRouter + TrieRouter Cold-start speed (serverless)

The tiny preset at src/preset/tiny.ts is just 20 lines — it extends HonoBase and assigns new PatternRouter(). That's the entire implementation.

The quick preset at src/preset/quick.ts swaps RegExpRouter for LinearRouter. LinearRouter has O(1) registration time but O(n) match time — the right trade-off when your function boots cold on every invocation and serves only a handful of requests before being evicted.

Tip: If you're deploying to a serverless environment where cold starts matter more than steady-state throughput, import { Hono } from 'hono/quick' is a one-line swap that can shave milliseconds off your initialization time.

The 150+ Export Paths System

Open package.json and look at the exports field. Every sub-path import like hono/cors, hono/jwt, or hono/aws-lambda is individually mapped:

{
  "./cors": {
    "types": "./dist/types/middleware/cors/index.d.ts",
    "import": "./dist/middleware/cors/index.js",
    "require": "./dist/cjs/middleware/cors/index.js"
  }
}

Each entry provides three things: TypeScript types, ESM import, and CJS require. This has two critical benefits:

  1. Tree-shaking: Bundlers only pull in what you actually import. Using hono/cors doesn't pull in hono/jwt.
  2. Dual-format support: The same package works in ESM-first runtimes (Deno, Bun, modern Node) and legacy CJS consumers.

The jsr.json file mirrors these exports for the Deno / JSR registry, and the build script validates they stay in sync.

Build Pipeline

The build system lives in build/build.ts and is refreshingly straightforward:

flowchart LR
    A["src/**/*.ts"] --> B["esbuild (ESM)"]
    A --> C["esbuild (CJS)"]
    A --> D["tsc --emitDeclarationOnly"]
    B --> E["dist/*.js"]
    C --> F["dist/cjs/*.js"]
    D --> G["dist/types/*.d.ts"]
    G --> H["removePrivateFields()"]
    subgraph Validation
        I["validateExports()"]
    end
    I -.-> B

Key design choices worth noting:

  1. No bundling for ESM output — the addExtension plugin rewrites imports to add .js extensions but marks everything external. Each source file maps 1:1 to an output file.
  2. Parallel builds — ESM, CJS, and type declarations run concurrently via Promise.all().
  3. Export validation — before building, validateExports() checks that every key in package.json exports exists in jsr.json and vice versa. This prevents drift between npm and Deno consumers.
  4. Private field removal — TypeScript's #private fields leak into .d.ts files. A post-build step strips them for cleaner public API types.

The build globs all src/**/*.ts files (excluding tests and Deno-specific modules) as entry points. This means adding a new middleware is as simple as creating src/middleware/<name>/index.ts and adding the corresponding export path to package.json — no build configuration changes needed.

What's Next

With this mental map in hand, you understand where to find anything in Hono's codebase and why it's structured as it is. In the next article, we'll trace the exact journey a request takes from app.fetch() through the dispatch pipeline, the middleware composition engine, and Context construction — the heartbeat of the entire framework.