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.
"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:
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 todist/cjs/, type declarations todist/types/. - The root
package.jsonsays"type": "module"; a three-linepackage.cjs.jsoncontaining only"type": "commonjs"is copied intodist/cjs/anddist/types/after the build so Node treats those subtrees correctly. - A custom esbuild plugin (
addExtensioninbuild/build.ts) rewrites relative imports to include explicit.jsextensions and resolve directory imports to/index.js, keeping the ESM output spec-compliant without changing the authored source. - Another build step strips
privateclass fields from emitted.d.tsfiles (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:
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:
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.