Hono ships as 76 export subpaths in two module formats, and the entire pipeline that produces them is one Bun-executed script: build/build.ts. No bundler config files, no monorepo tooling -- Bun as the runner, esbuild as the compiler.

package.json
"build": "bun run --shell bun remove-dist && bun ./build/build.ts && bun run copy:package.cjs.json",
"postbuild": "publint",

One entry point per source file

The script does not bundle. It globs every .ts file under src/ (minus tests and two legacy Deno files) and hands each to esbuild as its own entry point:

build/build.ts
const ignorePatterns = [
  'src/**/*.test.ts',
  'src/mod.ts',
  'src/middleware.ts',
  'src/deno/**/*.ts',
].map((pattern) => new Glob(pattern))
const entryPoints: string[] = []
for await (const file of new Glob('src/**/*.ts').scan('.')) {
  if (!ignorePatterns.some((ignore) => ignore.match(file))) {
    entryPoints.push(file)
  }
}

This mirror-the-source-tree output is what makes the fine-grained exports map possible: hono/logger resolves to dist/middleware/logger/index.js, which imports only what the logger needs. Tree-shaking becomes the packaging strategy, not an optimization a downstream bundler must perform.

Dual format without duplication tricks

  • ESM goes to dist/, CJS to dist/cjs/, type declarations to dist/types/.
  • The root package.json says "type": "module"; a three-line package.cjs.json containing only "type": "commonjs" is copied into dist/cjs/ and dist/types/ after the build so Node treats those subtrees correctly.
  • A custom esbuild plugin (addExtension in build/build.ts) rewrites relative imports to include explicit .js extensions and resolve directory imports to /index.js, keeping the ESM output spec-compliant without changing the authored source.
  • Another build step strips private class fields from emitted .d.ts files (build/remove-private-fields.ts, invoked from the script) so internals do not leak into the public type surface.

The export map is validated, twice

Hono publishes to both npm and JSR, and the two manifests must agree. The build fails immediately if they drift:

build/build.ts
const [packageJsonExports, jsrJsonExports] = ['./package.json', './jsr.json'].map(readJsonExports)

// Validate exports of package.json and jsr.json
validateExports(packageJsonExports, jsrJsonExports, 'jsr.json')
validateExports(jsrJsonExports, packageJsonExports, 'package.json')

After the build, publint (the postbuild script) lints the published shape -- catching wrong types ordering, missing files, and ESM/CJS mismatches before they reach a registry.

Size is a release gate

CI builds the package and measures the minified, tree-shaken cost of the core entry with esbuild itself:

perf-measures/bundle-check/scripts/check-bundle-size.ts
await esbuild.build({
  entryPoints: ['dist/index.js'],
  bundle: true,
  minify: true,
  format: 'esm' as esbuild.Format,
  target: 'es2022',
  outfile: tempFilePath,
})

const bundleSize = fs.statSync(tempFilePath).size

The number is reported on every PR (octocov), which is how a zero-dependency framework keeps its "small" claim honest over time. Releases themselves go through np (bun run release), with prerelease running the Deno tests plus a full build first.

Sources: build/build.ts, package.json, package.cjs.json · last synced 2026-08-10 · 26de731 · version 4.13.1