# H3 resolves six runtimes from one package entry and keeps v1 on a separate domain

> The TypeScript framework resolves its root export through six runtime conditions and exports its rules subsystem as separate subpaths, while the README itself carries no API surface at all.

**h3js/h3** — ⚡️ Minimal H(TTP) framework built for high performance and portability 

- Repository: https://github.com/h3js/h3
- Website: https://h3.dev
- Stars: 5,444 · Forks: 368
- Language: TypeScript
- License: MIT
- Published: 2026-09-22 · Updated: 2026-09-22 · Language: en
- Canonical page: https://hysenlabs.com/projects/h3js-h3

## One package name, six runtime conditions in the root export

H3 describes itself as a minimal h(ttp) framework built for high performance and portability, and portability is the part the package manifest takes literally. The root export is not one file. It is a condition map, and each runtime resolves to a different compiled module:

```json
"exports": {
  "./package.json": "./package.json",
  ".": {
    "deno": "./dist/_entries/deno.mjs",
    "bun": "./dist/_entries/bun.mjs",
    "workerd": "./dist/_entries/cloudflare.mjs",
    "browser": "./dist/_entries/service-worker.mjs",
    "node": "./dist/_entries/node.mjs",
    "default": "./dist/_entries/generic.mjs"
  }
}
```

Two of those mappings are worth pausing on. The workerd condition points at cloudflare.mjs, so a worker and its Cloudflare entry share a build. The browser condition points at service-worker.mjs, which is a narrower target than the word browser suggests. Everything unrecognised falls through to generic.mjs, and `type` is set to module, so there is no CommonJS entry anywhere in the map. A require()-based consumer has no condition that resolves for it.

## Rules, tracing and cache live outside the core entry as their own subpaths

Past the runtime conditions, the manifest exports capability by path rather than by condition. There is ./tracing, then a ./rules namespace that opens into ./rules/cache, ./rules/proxy and ./rules/compiler. Alongside those sit explicit runtime pins for people who want to skip condition resolution: ./deno, ./bun, ./cloudflare, ./service-worker, ./node and ./generic. The pattern is that optional subsystems are separate entry points, which means importing the compiler subpath does not pull the cache subpath into your dependency graph. Two manifest fields make that work. `sideEffects` is false, so a bundler may drop modules whose exports you never touched, and the types field points at ./dist/_entries/generic.d.mts, one declaration file for every platform rather than one per runtime. Type checking is therefore identical across runtimes even though the runtime entry is not.

## The h3 binary ships, the source tree does not

The files array is short: bin and dist. The bin map points the h3 command at ./bin/h3.mjs, so the tarball carries an executable alongside the compiled output. The repository top level tells the other half of the story. It holds .config/, .github/, src/, test/, docs/, examples/, playground/, build.config.ts, tsconfig.json and vitest.config.mjs, and none of those paths appear in files. The consequence is practical: a consumer of the npm package gets a CLI and a dist tree, and a consumer who needs to read or patch the TypeScript sources has to work from a git clone. The same limit applies to the documentation and the playground, which exist in the repository but are not part of what downstream installs receive. If your plan involves vendoring the framework and editing it, the installed package is the wrong starting point.

## v1 documentation sits at a different domain from v2

The front page carries a note that the branch you are reading is the v2 active branch, and it points legacy readers at two separate places: the tree/v1 branch and the v1.h3.dev site. So the project maintains two major lines and two documentation domains at once, h3.dev for v2 and v1.h3.dev for the older line. A MIGRATION.md sits at the top level next to CHANGELOG.md, which tells you the move between lines is expected to need a written guide rather than a version bump. For anyone arriving from a search result, a blog post or a book, this split is the first thing to check. A page on v1.h3.dev describes a different set of exports from the six conditions listed above, and nothing in the manifest tells a reader which major a given snippet targets. Pin the major and keep the docs domain that matches it.

## Release candidates reached 33 before the 2.0.1 final

The three most recent releases are v2.0.1-rc.32 published on 2026-09-14, v2.0.1-rc.33 published on 2026-10-03, and v2.0.1 published on 2026-10-03. Two facts follow. The candidate counter climbed past thirty, so the 2.0 line went through a long candidate tail rather than a short one. And the last candidate and the final shipped on the same calendar day, 2026-10-03, which is also the date of the most recent push, so the final tag came out of the same working day as the release candidate before it. What none of this tells you is what changed between 2.0.0 and 2.0.1. The release list gives versions and dates, not a summary, and the detail lives in CHANGELOG.md. The repository is not archived, and the last push was on 2026-10-03, so the branch you clone is moving.

## Contributing needs Corepack, oxlint, oxfmt and a typos check

The local development path is five steps: clone the repository, install the latest LTS version of Node.js, enable Corepack, install dependencies with pnpm, then run the tests. The last three are script invocations, and in this manifest pnpm dev is not a development server:

```bash
corepack enable
pnpm install
pnpm dev
pnpm test
```

`dev` maps to vitest, and the build maps to obuild, with build.config.ts at the root carrying the build options. Linting is split in two: `lint` runs oxlint . && oxfmt --check ., and `fmt` runs automd && oxlint --fix . && oxfmt ., so the formatter is oxfmt with its own .oxfmtrc.json and the linter is oxlint with .oxlintrc.json. There is also a typos.toml, which is a spelling configuration, and a renovate.json for dependency updates. For running something by hand rather than testing, the scripts named play:bun, play:node, play:plain and play:web each point at a file under test/fixture/, one fixture per runtime. pnpm-workspace.yaml and examples/package.json make the repository a workspace root.

## The repository carries no API surface, only filenames

Read the front page as a signpost rather than a reference. It gives a pronunciation, one sentence of description, the v2 branch note, a link to h3.dev, a contribution summary and the licence. There is no handler signature, no list of exported names, and no code example. Everything you need to evaluate the framework sits on the documentation site. The examples directory hints at the feature surface from filenames alone: auth.mjs, body.mjs, cookies.mjs, cors.mjs, errors.mjs, headers.mjs, middleware.mjs, nested-app.mjs, plugin.mjs, query.mjs, query-params.mjs, redirect.mjs, response-types.mjs, router.mjs, server-sent-events.mjs, status.mjs, url-params.mjs, vite.mjs and websocket.mjs. Two names in that list, handler-fetch.mjs and handler-obj.mjs, sit side by side, which points to two supported handler shapes without stating which one to reach for. The benchmark scripts bench:bun and bench:node exist too, bench:node running node --expose-gc --allow-natives-syntax, and no results ship with them.

## Conclusion

H3 fits a team that serves the same handler code from Node, Bun, Deno, Cloudflare Workers and browsers, and that reads its documentation off h3.dev rather than from the repository. It is a poor fit for a CommonJS consumer, since the exports map carries no require condition, and a poor fit for anyone who needs to patch internals from the installed package, since files is limited to bin and dist. Before adopting, read the rules and tracing subpaths on the docs site, confirm your own patterns pass the compiler subpath, and pin the major version, because v1 documentation lives at a different domain.

## FAQ

### Which runtimes does the H3 package support?

The root export in package.json carries six conditions: deno, bun, workerd, browser, node and a default fallback, each pointing at its own file under dist/_entries/. Unmatched environments land on generic.mjs, and there is no CommonJS entry in the map.

### How do I set up H3 for local development?

Clone the repository, install the latest LTS version of Node.js, run corepack enable, then pnpm install. The front page says tests run with pnpm dev or pnpm test, and in package.json dev maps to vitest rather than a dev server.

### Is H3 v1 still available?

Yes, on a separate line. The front page notes that the branch you are reading is the v2 active branch, and points legacy readers to the tree/v1 branch and the v1.h3.dev documentation site, while v2 documentation lives at h3.dev.

### Does the published H3 package include the source code?

No. The files array lists bin and dist only, with the h3 command mapped to ./bin/h3.mjs. The src/, test/, docs/, examples/ and playground/ directories exist in the repository but are not part of what the package ships.

### What extras does H3 expose beyond its core entry?

package.json exports ./tracing plus a ./rules namespace containing ./rules/cache, ./rules/proxy and ./rules/compiler, along with explicit runtime pins such as ./node and ./deno. Types resolve to ./dist/_entries/generic.d.mts for every platform.

### Which licence is H3 released under?

MIT. Both package.json and the front page state MIT, and a LICENSE file sits at the top of the repository. The page credits @pi0 and the community.

## Sources

- [h3js/h3 on GitHub](https://github.com/h3js/h3)
- [License: MIT](https://github.com/h3js/h3/blob/main/LICENSE)
- [Project website](https://h3.dev)
- [README](https://github.com/h3js/h3/blob/main/README.md)
- [Releases](https://github.com/h3js/h3/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/h3js-h3
