Package reference
Mirrors the package README (single source). Install @basaltkit/hono v2.0.0 — npm · source.
<p align="center"> <a href="https://basaltkit-docs.pages.dev"> <img src="https://basaltkit-docs.pages.dev/social-card.png" alt="Basalt" width="440"> </a> </p>
@basaltkit/hono
The Basalt adapter for Hono: the same typed routes, enrichers, and guards you'd use with Fastify or Express, running on Hono — on Node.js, Bun, Deno, or edge platforms (Cloudflare Workers, Vercel Edge, …). You need this when you want to take your Basalt API outside classic Node, or when you're already using Hono.
What this module solves
Hono is a small, very fast web framework built on top of standard web APIs (fetch's Request/Response). Because of that, it runs almost everywhere: Node.js, Bun, Deno, and edge runtimes — servers that execute your code in hundreds of locations close to users. But, like other frameworks, Hono on its own doesn't validate data or standardize errors.
This module connects Hono to Basalt. Routes (a path + HTTP method, e.g. POST /echo) are defined with the route() function from @basaltkit/http, with Zod schemas that validate the body, query, and URL parameters — and give you TypeScript types for free. The adapter converts each Hono request into Basalt's neutral format, runs the shared pipeline (validation, enrichers — functions that enrich the request context — and guards — functions that can reject it, e.g. authentication), and returns responses with errors in a stable format.
The main benefit is portability: a route written here runs unchanged on @basaltkit/fastify and @basaltkit/express — the three adapters are equals. Routes, enrichers, guards, the per-request context, standardized errors, the neutral 404, ETags (meta.etag), SSE, per-route rate limits and the boot-time guarded-meta check all behave identically. The neutral edge plugins (security, health, metrics, tracing, OpenAPI) from @basaltkit/http work on Hono exactly the same way.
Installation
pnpm add @basaltkit/hono @basaltkit/core @basaltkit/http hono zodhono (version 4+) is a peer dependency — you install it. To serve on Node.js you also need pnpm add @hono/node-server; on Bun, Deno, or edge nothing extra is needed.
Get started in 5 minutes
Step 1 — install the packages (command above, plus @hono/node-server if you're on Node).
Step 2 — create a server.ts file:
import { serve } from '@hono/node-server'
import { createApp } from '@basaltkit/core'
import { route } from '@basaltkit/http'
import { HONO, honoPlugin } from '@basaltkit/hono'
import { z } from 'zod'
// 1. Define a route: method, URL, validation, and handler (the function that responds).
const echo = route({
method: 'POST',
url: '/echo',
body: z.object({ n: z.number() }), // the body must have a number n
async handler({ body, reply }) {
// body.n is already validated and typed as number
return reply.code(201).send({ doubled: body.n * 2 })
},
})
// 2. Create the Basalt app with the Hono plugin and boot it.
const app = await createApp({ plugins: [honoPlugin({ routes: [echo] })] }).boot()
// 3. Get the Hono app from the container and serve it on Node.
serve({ fetch: app.container.get(HONO).fetch, port: 3000 })
console.log('Listening on http://localhost:3000')Step 3 — run it and test:
npx tsx server.ts
curl -X POST http://localhost:3000/echo \
-H 'content-type: application/json' \
-d '{"n":21}'
# → {"doubled":42} (status 201)
curl -X POST http://localhost:3000/echo \
-H 'content-type: application/json' \
-d '{"n":"nope"}'
# → 400 {"error":{"code":"HTTP_VALIDATION","part":"body","issues":[...]}}On an edge runtime (Cloudflare Workers, Bun, Deno) you export fetch instead of calling serve:
const app = await createApp({ plugins: [honoPlugin({ routes: [echo] })] }).boot()
export default app.container.get(HONO) // the runtime calls .fetch for youUsage guide
Routes with params, query, and errors
import { HttpError, route } from '@basaltkit/http'
import { z } from 'zod'
const hello = route({
method: 'GET',
url: '/hello/:name', // :name is a dynamic URL parameter
params: z.object({ name: z.string() }),
async handler({ params }) {
return { hello: params.name }
},
})
const boom = route({
method: 'GET',
url: '/boom',
async handler() {
// Intentional error: becomes a 418 response with a stable code
throw new HttpError(418, 'TEAPOT', "I'm a teapot")
},
})Unexpected errors respond with 500 and { error: { code: 'INTERNAL_ERROR', ... } } — the same format as the other adapters, without exposing internal details.
Request body: what the adapter interprets
The adapter reads the body based on the Content-Type media type, the same rule as Fastify and Express: application/json or a +json type (parameters and case ignored) → JSON; application/x-www-form-urlencoded → Hono's parseBody(); anything else → the text as a string. text/plain; application/json is not JSON — it is CORS-safelisted, so a cross-site page could send it without a preflight. Malformed JSON answers 400 BAD_REQUEST ("Malformed request body."); an empty body is undefined. GET/HEAD requests never have a body.
The rest of the neutral request matches the other adapters too: request.url is the path and query string (/items?x=1, not the absolute URL), and a repeated query key is an array (?a=1&a=2 → { a: ['1', '2'] }).
A multipart/form-data body is only read inside the route handler: pre-hooks and after-hooks never see it. On an upload() route (body: upload({ … }) from @basaltkit/http), the raw ReadableStream goes, unbuffered, to the neutral streaming parser after enrichers and guards ran. The route's own maxBytes applies there, not bodyLimit. On any other route, a multipart body is still bounded by bodyLimit and parsed with parseBody(). A rawBody() route is treated the same way — nothing reads or buffers its body before the handler. See the @basaltkit/http README.
Raw request bodies — rawBody()
body: rawBody({ maxBytes? }) from @basaltkit/http gives a route the untouched request bytes — what a webhook signature (Stripe, Paddle, Dropbox, GitHub) is computed over. Nothing to configure: for those paths the plugin's bounded pre-read and its pre/after hooks step aside, so the web Request's own stream still carries the octets and the neutral pipeline reads them after enrichers and guards, without parsing.
The route's own maxBytes bounds it, not bodyLimit — the same arrangement upload() has, and for the same reason: the cap must be enforced after the guards, not before them. See the @basaltkit/http README.
Body-size limit — bodyLimit
Hono and edge runtimes impose no default cap on a request body, so an upload is unbounded unless something stops it. The plugin installs a guard that rejects any request whose Content-Length exceeds the limit with 413 and { error: { code: 'PAYLOAD_TOO_LARGE', message: … } } — before the body is read. Every other body (chunked, streamed, or with a small declared Content-Length) is counted as it is read and rejected the moment it crosses the limit, so the cap holds on the bytes actually received — the declared length is never trusted on its own. Routes mounted with registerRoutes() alone enforce the same limit.
import { DEFAULT_BODY_LIMIT, honoPlugin } from '@basaltkit/hono'
honoPlugin({ routes, bodyLimit: 5 * 1024 * 1024 }) // 5 MiB
DEFAULT_BODY_LIMIT // 1_048_576 — 1 MiB, the defaultClient IP (request.ip)
Per-client rate limiting (securityPlugin) and the IP login throttle key on request.ip. By default the adapter reads the socket address on @hono/node-server and Bun — never a client-controlled header. On other runtimes (edge, Deno) or behind a trusted proxy, pass a resolver; without one, a one-time warning is printed and rate limits share one bucket.
honoPlugin({ routes, getClientIp: (c) => c.req.header('cf-connecting-ip') }) // CloudflareOnly read X-Forwarded-For if a proxy you control overwrites it.
Guarded route meta — the boot check
If a route declares meta.auth, meta.can or meta.teamRole and no registered plugin enforces that key, the route would serve unprotected. honoPlugin refuses to boot: it calls assertRoutesGuarded() in its boot phase and throws UnguardedRouteMetaError (code HTTP_UNGUARDED_ROUTE_META), naming every offending route and key, before a single request is served.
Fix it by registering the enforcing plugin (auth → authPlugin, can → permissionsPlugin, teamRole → teamsPlugin). If protection really does happen at an outer edge (a Worker in front, a gateway), waive it explicitly:
honoPlugin({ routes, allowUnguardedMeta: true }) // waive every key
honoPlugin({ routes, allowUnguardedMeta: ['auth'] }) // waive one keyFastify and Express run the identical check with the identical option.
The neutral 404
Unmatched routes get the same JSON body on every adapter — { "error": { "code": "NOT_FOUND", "message": "Route not found." } } (NOT_FOUND_RESPONSE from @basaltkit/http) — instead of Hono's plain-text default. An app that calls hono.notFound(…) later still wins (Hono keeps the last handler); pass notFound: false to opt out entirely.
Streaming a body — stream()
A handler returning stream(source, { contentType, contentLength?, filename? }) from @basaltkit/http becomes a Response backed by a web ReadableStream, so it streams on Node, Bun, Deno and edge alike. The stream pulls only when the consumer has room (real backpressure), the first chunk is pulled before the Response exists so an immediate failure is still a JSON error, request.signal aborting destroys the source, and a failure mid-stream errors the body — reported once through onError as STREAM_FAILED — instead of appending anything to bytes already sent. HEAD answers with the headers and no body. Same handler code as on Fastify and Express.
Streaming — SSE
A handler returning sse(producer) from @basaltkit/http becomes a Response backed by a web ReadableStream, so it streams on Node, Bun, Deno and edge alike. Client aborts (request.signal) are relayed to stream.onClose(). The headers set before it — CORS, security headers, rate-limit counters, x-request-id — are kept on the stream, so a cross-origin EventSource passes its CORS check. Same handler code as on Fastify and Express.
Enrichers and guards (authentication, tenancy, …)
Plugins register these functions in the container's metadata "buckets"; the adapter applies them to every route. Real example (from the package's tests):
import { createApp, definePlugin, ensureMetadata, tryCtx } from '@basaltkit/core'
import { HttpError, route, type RequestEnricher, type RouteGuard } from '@basaltkit/http'
import { HONO, honoPlugin } from '@basaltkit/hono'
// Enricher: attaches the tenant to the request context.
const enricher: RequestEnricher = ({ request, context }) => {
const tenant = request.headers['x-tenant-id']
if (typeof tenant === 'string') (context as { tenant?: unknown }).tenant = { id: tenant }
}
// Guard: rejects the request by throwing an error; reads the route's meta.
const guard: RouteGuard = ({ route: def, request }) => {
if (def.meta?.['auth'] && !request.headers['authorization']) {
throw new HttpError(401, 'AUTH_REQUIRED', 'Authentication required.')
}
}
const myPlugin = definePlugin({
name: 'my:http',
register({ container }) {
const metadata = ensureMetadata(container)
metadata.add('http:enrichers', enricher)
metadata.add('http:guards', guard)
},
})
const secure = route({
method: 'GET',
url: '/secure',
meta: { auth: true }, // the guard reads this
async handler() {
const tenant = (tryCtx() as { tenant?: { id: string } })?.tenant?.id ?? null
return { ok: true, tenant }
},
})
const app = await createApp({ plugins: [myPlugin, honoPlugin({ routes: [secure] })] }).boot()Without Authorization → 401 AUTH_REQUIRED; with x-tenant-id: acme the handler sees tenant: 'acme' through the request context.
Neutral edge plugins
Imported from @basaltkit/http and work on Hono without changes:
import { createApp } from '@basaltkit/core'
import { healthPlugin, metricsPlugin, route, securityPlugin } from '@basaltkit/http'
import { HONO, honoPlugin } from '@basaltkit/hono'
const ping = route({ method: 'GET', url: '/ping', async handler() { return { pong: true } } })
const app = await createApp({
plugins: [
honoPlugin({ routes: [ping] }),
securityPlugin({ rateLimit: { limit: 100, windowMs: 60_000 } }), // secure headers + 429 above limit
healthPlugin({ checks: { db: () => ({ ok: true }) } }), // GET /livez and /readyz
metricsPlugin(), // GET /metrics (Prometheus)
],
}).boot()All options for these plugins are documented in the @basaltkit/http README.
> On distributed edge runtimes, remember that in-memory stores (rate limit, metrics) live per instance — use shared stores (e.g. Redis/KV) when you need global values.
Testing without opening ports
Since Hono speaks fetch, testing means calling hono.fetch with a normal Request — that's how this package's own tests work:
import { createApp } from '@basaltkit/core'
import { route } from '@basaltkit/http'
import { HONO, honoPlugin } from '@basaltkit/hono'
const ping = route({ method: 'GET', url: '/ping', async handler() { return { pong: true } } })
const app = await createApp({ plugins: [honoPlugin({ routes: [ping] })] }).boot()
const hono = app.container.get(HONO)
const res = await hono.fetch(new Request('http://local/ping'))
console.log(res.status, await res.json()) // 200 { pong: true }
await app.shutdown()Advanced: registerRoutes() without the plugin
Mount Basalt routes onto an existing Hono app, without the Basalt lifecycle:
import { Hono } from 'hono'
import { route } from '@basaltkit/http'
import { registerRoutes } from '@basaltkit/hono'
const app = new Hono()
const ping = route({ method: 'GET', url: '/ping', async handler() { return { pong: true } } })
registerRoutes(app, [ping]) // container, enrichers, and guards are optional
export default appIn this mode errors are still standardized (each handler wraps toErrorResponse) and each handler still enforces the body limit on the bytes read (DEFAULT_BODY_LIMIT unless you pass bodyLimit), but there are no edge plugins and no route registration for OpenAPI/CLI.
API reference
honoPlugin(options?) → Basalt plugin (basalt:hono)
| Option | Type | Required? | Default | Description |
|---|---|---|---|---|
routes | BasaltRoute[] | No | [] | Routes (created with route() from @basaltkit/http) to mount. |
allowUnguardedMeta | boolean | string[] | No | fail loud at boot | Waives the boot check that every route declaring security meta (auth, can, teamRole) has a registered guard enforcing it (UnguardedRouteMetaError otherwise). true waives everything (edge/gateway auth); an array waives specific keys. Never waives the route-meta validators plugins register (InvalidRouteMetaError). |
app | Hono | No | new Hono() | Bring your own Hono app; otherwise a new one is created. |
notFound | boolean | No | true | Serve NOT_FOUND_RESPONSE (the neutral JSON 404) for unmatched routes. A later hono.notFound(…) of your own still wins; false opts out entirely. |
bodyLimit | number | No | DEFAULT_BODY_LIMIT = 1_048_576 (1 MiB) | Maximum request body in bytes, enforced on the bytes read. A request whose Content-Length exceeds it is rejected 413 PAYLOAD_TOO_LARGE before the body is read; a chunked/streamed body is cut off at the limit — Hono/edge has no default cap of its own. An upload() route is bounded by its own maxBytes instead (streamed, never buffered). |
getClientIp | (c: Context) => string | undefined | No | defaultClientIp (socket address on @hono/node-server / Bun) | Resolves request.ip for rate limiting and the IP login throttle. |
onError | HttpErrorReporter | No | console.error/console.warn | Where failed requests are reported. |
errorHandler | boolean | No | true | Install an app.onError that answers errors raised outside a route handler (a failing pre-hook or edge route, an unreadable body) with the neutral JSON envelope and reports them through onError, instead of Hono's plain-text 500. An HTTPException thrown by your own Hono middleware keeps its response. Pass false if you install your own onError (Hono keeps only the last one). |
Behavior: registers the Hono app on the HONO token and an HttpServerCollector on the HTTP_SERVER token. On the app:booted event it mounts, in order: after-hooks middleware (metrics/tracing, measuring duration; a failing hook is reported as AFTER_HOOK_FAILED and never replaces the response), pre-hooks middleware (security/CORS/rate limit; if one of these responds, the route doesn't run), the Basalt routes, and the extra edge plugin routes (/livez, /metrics, /openapi.json, …). Publishes the routes to the 'http:routes' metadata bucket for OpenAPI/CLI/SDK.
> Note: this plugin has no shutdown step of its own — Basalt never starts a listener for you, so stopping the server (serve from @hono/node-server, etc.) is your responsibility.
DEFAULT_BODY_LIMIT
1_048_576 (1 MiB) — the default for honoPlugin({ bodyLimit }), exported so you can reason about it or reuse it.
Errors
| Error | Code | HTTP | When |
|---|---|---|---|
RequestValidationError | HTTP_VALIDATION | 400 | body/query/params failed its Zod schema. Response carries part + issues[]. |
HttpError(status, code, message) | yours | yours | Thrown deliberately from any layer. |
UnguardedRouteMetaError | HTTP_UNGUARDED_ROUTE_META | — (boot) | A route declares a guarded key (auth/can/teamRole/scopes/subscribed/feature) with no guard enforcing it. Waive with allowUnguardedMeta. |
InvalidRouteMetaError | HTTP_INVALID_ROUTE_META | — (boot) | A plugin's route-meta validator (http:meta-validators) refused a value — e.g. teamsPlugin and an unknown meta.teamRole. Not waivable. |
| — | NOT_FOUND | 404 | No route matched (unless notFound: false). |
| — | BAD_REQUEST | 400 | A JSON body could not be parsed. |
| — | PAYLOAD_TOO_LARGE | 413 | The body (declared or actually read) exceeds bodyLimit. Same { error: { code, message } } envelope as every other error. |
| — | RATE_LIMITED | 429 | securityPlugin's limiter rejected the request. |
| — | INTERNAL_ERROR | 500 | Any other thrown error. The real message never reaches the client. |
HONO
Dependency injection token (Token<Hono>): app.container.get(HONO) returns the Hono app — use hono.fetch to serve (on Node via @hono/node-server, or export it in an edge runtime) and for testing.
registerRoutes(app, routes, container?, enrichers?, guards?, onError?, getClientIp?, bodyLimit?)
| Parameter | Type | Required? | Default | Description |
|---|---|---|---|---|
app | Hono | Yes | — | Hono app to mount onto. |
routes | BasaltRoute[] | Yes | — | Routes to mount (via app.on(method, url, handler)). |
container | Container | No | — | DI container; without it there's no per-request scope or enrichers/guards. |
enrichers | RequestEnricher[] | No | [] | Functions that enrich the context before the guards. |
guards | RouteGuard[] | No | [] | Functions that can reject the request (by throwing an error). |
onError | HttpErrorReporter | No | console.error/console.warn | Where failed requests are reported. A throwing reporter never changes the response. |
getClientIp | ClientIpResolver | No | defaultClientIp | Resolves request.ip. |
bodyLimit | number | No | DEFAULT_BODY_LIMIT | Maximum request body in bytes, counted while reading (a declared Content-Length is never trusted to bound the bytes). |
What to import from where
This package exports honoPlugin, registerRoutes, HONO, DEFAULT_BODY_LIMIT, defaultClientIp, ClientIpResolver, and HonoPluginOptions. Everything else — route, HttpError, RequestValidationError, NOT_FOUND_RESPONSE, sse, securityPlugin, RedisRateLimitStore, healthPlugin, metricsPlugin, tracingPlugin, openapiPlugin, escapeHtml/pageCsp, types like RequestEnricher/RouteGuard — is imported from @basaltkit/http. (Unlike @basaltkit/fastify, this package re-exports nothing; that is a naming choice, not a capability gap.)
Common errors and solutions (FAQ)
"Cannot find module 'hono'." Hono is a peer dependency: pnpm add hono.
"Nothing responds on Node." Hono doesn't open ports on its own on Node — you need @hono/node-server: serve({ fetch: hono.fetch, port: 3000 }).
"I tried import { route } from '@basaltkit/hono' and it failed." The route() function isn't exported by this package — import it from @basaltkit/http (it's neutral by design: the same route runs on Fastify and Express).
"body arrives as undefined." Send the Content-Type: application/json header; without it the adapter doesn't parse the body as JSON. Also note that GET/HEAD never have a body.
"400 HTTP_VALIDATION on a GET with a correct query." In the query, everything arrives as text — use z.coerce.number() / z.coerce.boolean() in your schemas.
"The edge plugins don't respond (/metrics returns 404)." They're mounted on the app:booted event: make sure you call await createApp({...}).boot() before serving, and that honoPlugin is in the plugins list (it's the one that registers HTTP_SERVER).
"The rate limit resets out of nowhere on edge." Each edge instance has its own memory; MemoryRateLimitStore isn't shared across locations. Implement RateLimitStore on top of shared storage.
How it connects to other modules
@basaltkit/core—honoPluginis a Basalt plugin (definePlugin) in thecreateApp → bootlifecycle; uses theContainer(tokensHONO,HTTP_SERVER), the metadata buckets, and the per-request context (ctx()/tryCtx()).@basaltkit/http— providesroute(), therunRoute()pipeline (validation, enrichers, guards),toErrorResponse(), and the edge plugins. This adapter converts Hono'sContextinto the neutralHttpRequest/HttpReplyand turns the result into a standard webResponse.@basaltkit/fastify/@basaltkit/express— sibling adapters: the same routes, enrichers, guards, and edge plugins run on any of them without changes; switching frameworks (or runtime — Node → edge) is just switching the plugin.@basaltkit/auth/@basaltkit/tenancy/@basaltkit/permissions— register guards/enrichers on'http:guards'/'http:enrichers'and read the routes'meta(e.g.meta: { auth: true }); this adapter applies them automatically.@basaltkit/sdkand the CLI — consume the'http:routes'bucket (routes + Zod schemas) that this plugin publishes.@basaltkit/testing—createTestApp({ adapter: 'hono' })runs your suite against this adapter in-process (hono.fetch(new Request(…)), no socket).
Guides: Adapters · Testing · Security · Production