Read OSS

Architecture Overview and Code Navigation

Beginner

Prerequisites

  • Basic familiarity with TypeScript
  • Understanding of web frameworks and the Request/Response API
  • General knowledge of npm package structure

Architecture Overview and Code Navigation

Hono is a web framework that runs on every major JavaScript runtime — Cloudflare Workers, Deno, Bun, AWS Lambda, and Node.js — with a bundle size starting at under 14 KB. To achieve this, the codebase is organized around a principle of aggressive decomposition: a three-layer class hierarchy separates the public API from router selection from framework logic, five distinct router implementations trade off between cold-start speed and steady-state throughput, and 60+ package export paths let bundlers tree-shake away everything you don't use.

This article will orient you in the codebase so you can navigate it with confidence. We'll trace the path from import { Hono } from 'hono' through the class chain, map the directory structure, understand the preset system, and see how the build pipeline packages it all for consumption.

The Three-Layer Class Hierarchy

When you write import { Hono } from 'hono', the resolution chain passes through three files before you reach the actual framework logic. Understanding this chain is the first key to navigating the codebase.

Layer 1: The public re-export. src/index.ts is the package entry point. It re-exports the Hono class and a curated set of types — Env, Handler, MiddlewareHandler, Context, HonoRequest, and the client inference types. This file is 52 lines of pure re-exports. Its job is to define the public API surface and nothing else.

Layer 2: The router selector. src/hono.ts is a 34-line subclass that exists for exactly one reason: to plug in the default router configuration. The constructor sets this.router to a SmartRouter wrapping RegExpRouter and TrieRouter:

constructor(options: HonoOptions<E> = {}) {
  super(options)
  this.router =
    options.router ??
    new SmartRouter({
      routers: [new RegExpRouter(), new TrieRouter()],
    })
}

Layer 3: The framework engine. src/hono-base.ts contains HonoBase — the 540-line class with all the routing logic, fetch(), #dispatch(), middleware composition, error handling, route(), basePath(), and mount(). Note that the class is declared as class Hono internally but exported as HonoBase on line 539. This naming convention lets each preset re-export its own class as Hono without conflict.

flowchart TD
    A["import { Hono } from 'hono'"] --> B["src/index.ts<br/>Re-exports Hono + types"]
    B --> C["src/hono.ts<br/>Hono extends HonoBase<br/>Sets SmartRouter(RegExp + Trie)"]
    C --> D["src/hono-base.ts<br/>class Hono (exported as HonoBase)<br/>540 lines of framework logic"]

Tip: The hono-base.ts class declares router!: Router<[H, RouterRoute]> with a definite assignment assertion (the !). That's because it's designed as an "abstract" class — you must set this.router in the subclass constructor. TypeScript's strict mode is satisfied by the assertion, not by initialization in the base class.

Directory Structure and Subsystems

The src/ directory maps cleanly to Hono's major subsystems. Here's an annotated directory map:

Directory Purpose Key Files
src/ (root) Core framework hono-base.ts, context.ts, request.ts, compose.ts, router.ts, types.ts
src/router/ 5 router implementations reg-exp-router/, trie-router/, smart-router/, linear-router/, pattern-router/
src/middleware/ ~25 built-in middleware cors/, jwt/, bearer-auth/, cache/, compress/, combine/, csrf/, etc.
src/adapter/ Platform adapters aws-lambda/, cloudflare-workers/, deno/, bun/, netlify/, lambda-edge/, etc.
src/client/ RPC client (hc) client.ts, types.ts, utils.ts
src/jsx/ Dual JSX runtime base.ts, streaming.ts, dom/, context.ts
src/helper/ Utility modules html/, cookie/, factory/, testing/, streaming/
src/validator/ Validation integration validator.ts
src/utils/ Internal utilities url.ts, html.ts, body.ts, cookie.ts, jwt/

The core request path involves just five files: src/hono-base.ts, src/compose.ts, src/context.ts, src/request.ts, and src/router.ts. Everything else — routers, middleware, adapters, JSX — plugs in through clean interfaces.

graph TD
    HB[hono-base.ts] --> CO[compose.ts]
    HB --> CTX[context.ts]
    HB --> R[router.ts interface]
    CTX --> REQ[request.ts]
    R --> RR[RegExpRouter]
    R --> TR[TrieRouter]
    R --> SR[SmartRouter]
    R --> LR[LinearRouter]
    R --> PR[PatternRouter]
    HB --> MW[middleware/*]
    HB --> AD[adapter/*]

Presets: Router Selection as a Constructor Concern

Hono's design insight is that router selection is a deployment concern, not a framework concern. The same HonoBase class powers all configurations — only the router differs. This is expressed through presets: thin subclasses that wire in a specific router combination.

There are three presets:

Import Path Preset Router Configuration Best For
hono Default SmartRouter(RegExpRouter, TrieRouter) Long-running servers
hono/tiny Tiny PatternRouter Smallest bundle (~14 KB)
hono/quick Quick SmartRouter(LinearRouter, TrieRouter) Serverless cold starts

The src/preset/tiny.ts preset is just 20 lines — it extends HonoBase and sets this.router = new PatternRouter(). The src/preset/quick.ts preset is similarly concise, using SmartRouter with LinearRouter and TrieRouter.

flowchart LR
    subgraph Default["hono (default)"]
        SM1[SmartRouter] --> RE[RegExpRouter]
        SM1 --> TR1[TrieRouter]
    end
    subgraph Quick["hono/quick"]
        SM2[SmartRouter] --> LR[LinearRouter]
        SM2 --> TR2[TrieRouter]
    end
    subgraph Tiny["hono/tiny"]
        PR[PatternRouter]
    end

The default preset tries RegExpRouter first — it compiles all routes into a single regex for O(1) matching. If any route pattern is too complex (e.g., mixing wildcards with named parameters in certain ways), RegExpRouter throws UnsupportedPathError, and SmartRouter falls through to TrieRouter. We'll explore this mechanism in detail in Part 3.

Tip: If you know your routes are simple, the default preset is fastest at steady state. If you're deploying to a serverless platform where cold start time matters more than per-request throughput, use hono/quick. If bundle size is your constraint, use hono/tiny.

Module Export System and Build Pipeline

Hono's package.json contains 60+ export paths, enabling imports like hono/cors, hono/jwt, hono/aws-lambda, and hono/jsx. This is not merely cosmetic — it's the foundation of Hono's tree-shaking story. Each export path maps to a separate module, so bundlers can include only the code you actually import.

The build pipeline in build/build.ts runs three tasks in parallel:

  1. ESM build via esbuild — bundles each src/**/*.ts file to dist/ with .js extensions
  2. CJS build via esbuild — outputs to dist/cjs/ for Node.js require() compatibility
  3. Type declarations via tsc --emitDeclarationOnly — outputs .d.ts files to dist/types/
flowchart TD
    SRC["src/**/*.ts"] --> ESM["esbuild → dist/ (ESM)"]
    SRC --> CJS["esbuild → dist/cjs/ (CJS)"]
    SRC --> DTS["tsc → dist/types/ (.d.ts)"]
    DTS --> POST["removePrivateFields()<br/>Strip #private from .d.ts"]
    subgraph Validation
        PKG["package.json exports"] <-->|validateExports| JSR["jsr.json exports"]
    end

Two post-build steps are worth noting. First, the script calls removePrivateFields() on all .d.ts files to strip TypeScript's #private field declarations — these are an implementation detail that shouldn't leak into the public type surface. Second, validateExports() cross-checks the export paths in package.json against jsr.json (for Deno's JSR registry), ensuring they stay in sync.

The build uses a custom esbuild plugin called addExtension that rewrites import paths to include .js extensions — necessary for proper ESM resolution in Node.js. The entry points are globbed from src/**/*.ts, excluding test files and Deno-specific modules.

The Constructor: Dynamic Method Assignment

One detail in hono-base.ts that might surprise you: the HTTP method functions (get, post, put, delete, options, patch, all) are not defined as class methods. They're assigned dynamically in the constructor at src/hono-base.ts#L126-L173:

const allMethods = [...METHODS, METHOD_NAME_ALL_LOWERCASE]
allMethods.forEach((method) => {
  this[method] = (args1: string | H, ...args: H[]) => {
    if (typeof args1 === 'string') {
      this.#path = args1
    } else {
      this.#addRoute(method, this.#path, args1)
    }
    args.forEach((handler) => {
      this.#addRoute(method, this.#path, handler)
    })
    return this as any
  }
})

This supports two calling conventions: app.get('/path', handler) and the chained form app.get('/path').get(handler). When the first argument is a string, it sets this.#path; when it's a handler, it registers the route with the previously set path. The type-level counterparts are declared on the class as get!: HandlerInterface<...> — definite assignment assertions that TypeScript resolves through the constructor's dynamic assignment.

Putting It Together

Here's the mental model for navigating Hono's source:

  • To understand how requests flow: read hono-base.tscompose.tscontext.tsrequest.ts
  • To understand routing: read router.ts (interface), then any router in router/
  • To understand a middleware: read the middleware's index.ts — they're self-contained
  • To understand platform support: read an adapter in adapter/
  • To understand types: read types.ts — it's where HandlerInterface and ToSchema live

The codebase is remarkably flat. There are no dependency injection frameworks, no plugin registries, no configuration inheritance chains. Just classes, functions, and interfaces.

In the next article, we'll trace a single HTTP request through the entire framework — from fetch() through #dispatch(), router matching, middleware composition, and response building — to see how these pieces fit together at runtime.