@lovrozagar/honey
v0.7.3
Published
Type-safe API framework built on Web Standards. Builder-pattern DX, precompiled radix tree in production.
Maintainers
Readme
Honey
Type-safe API framework on Web Standards. Builder-pattern DX, precompiled radix-tree router, OpenAPI, and generated clients for TypeScript, Python, Go, and Rust.
This repo is the source of the @lovrozagar/honey npm package. The CLI binary is still honey.
This README is the full usage manual. An agent that reads only this file should be able to build, serve, generate, and consume a Honey app without opening source.
Table of contents
- Start
- What Honey is
- Install
- First app
- CLI
- Builder
- Serve
- OpenAPI, docs, and manifest
- WebSockets
- Realtime
- SSE and streaming
- Generated clients
- Type inference
- Testing
- Utilities
- Package exports
- Repository layout
- Develop
- Releases
- License
Start
bun add @lovrozagar/honey
honey init
bun run devimport { honey } from "@lovrozagar/honey"
export const app = honey()
.get("/health")
.handler((ctx) => ctx.res.text("ok", "ok"))
.openapi({ docs: "scalar", title: "My API", version: "1.0.0" })
await app.serve({ cors: true, port: 3000 })curl http://127.0.0.1:3000/health
# ok
curl http://127.0.0.1:3000/openapi.json
# open http://127.0.0.1:3000/docshoney init writes src/app.ts, src/server.ts, vite.config.ts, and dev / generate scripts. honey init --cf also writes wrangler.jsonc and a worker that exports fetch.
honey generate loads the app in a fresh process with app.serve() stubbed, so the one-file shape above does not start a listener while generating, and the process exits when it is done. Other top-level side effects in the app module (database clients, timers) still run during generation; keep them behind a function or in src/server.ts.
app.serve() detects bun / node / deno and loads only that WebSocket adapter. Pass runtime to pin it. Cloudflare Workers cannot listen — export fetch: (req, env, ctx) => app.fetch(req, env, ctx).
app.serve(), app.openapi(), and app.errorI18n() load their implementations when called. A fetch-only production bundle does not include listen adapters, spec generation, or i18n. Spec and docs routes stay out of the generated document.
honey generate # writes src/_gen/
honey generate --watchWhat Honey is
Honey is a single TypeScript builder that becomes a Web-standard fetch(request, env) handler. The same app runs on Bun, Node, Deno, and Cloudflare Workers.
- Builder DX.
honey().get("/users/:id").input(...).handler(...)is how you write routes. Types flow from path params, Standard Schema input, output maps, and middleware additions intoctx. - Radix tree. Routes compile into a radix tree. Production can load a generated tree so unknown paths 404 without walking a catch-all.
- Web Standards. Handlers see
Requestand returnResponse.app.fetch()is the public entry. On Node,serve()wrapsIncomingMessage/ServerResponseso the hot path does not build a native Fetch pair. - OpenAPI from the app.
app.openapi()serves JSON and YAML from the same document. Scalar or Swagger mounts at/docs. Spec and docs routes are internal — they do not appear in the spec they serve. - Clients from the spec.
honey generatewrites route types, a manifest, OpenAPI, and optional SDKs (TypeScript, Python, Go, Rust) plus an optional Go CLI.
Honey is not Express with types bolted on. There is no req.body parser stack. Input is declared per route with Standard Schema (Zod, Valibot, ArkType, Effect Schema, Yup — anything that implements the Standard Schema ~standard interface).
Install
Requires Bun 1.3+ to develop this repo. The published package runs on Node 22.12 or later, Bun, Deno, and Cloudflare Workers; it ships compiled JavaScript, so plain Node imports it from node_modules without a TypeScript loader. The honey CLI and the Vite plugin run on Node from npm as well as on Bun.
bun add @lovrozagar/honey
# or
npm add @lovrozagar/honey
# or
pnpm add @lovrozagar/honeyThe CLI ships with the package (honey in package.json bin). After install:
honey init
honey generateThe published package depends only on jiti (for generate/watch). Validation libraries are your choice. Two optional peer dependencies:
ws, for WebSocket and realtime routes on Node (npm install ws). Bun, Deno and Workers have WebSockets built in. Without it, a WebSocket upgrade on Node answers 500 and logs the install hint.ts-morph, as a dev dependency, for type generation (codegen.types).
First app
honey init is the supported scaffold.
mkdir my-api && cd my-api
bun init -y
bun add @lovrozagar/honey
honey init
bun run devThat writes:
| File | Role |
| ---------------- | -------------------------------------------------------- |
| src/app.ts | App export: health route + openapi({ docs: "scalar" }) |
| src/server.ts | await app.serve({ port }) |
| vite.config.ts | honey({ app: "src/app.ts" }) plugin |
Scripts added to package.json: dev (runs src/server.ts) and generate (honey generate).
Cloudflare from the start:
honey init --cfAlso writes src/worker.ts (export default { fetch }) and wrangler.jsonc. Existing files refuse to overwrite unless you pass --force.
Minimal app without the scaffold:
import { honey } from "@lovrozagar/honey"
import * as z from "zod"
const app = honey<{ DATABASE_URL: string }>()
.basePath("/api")
.trailingSlash("strip")
.get("/health")
.handler((ctx) => ctx.res.json("ok", { status: "ok" }))
.post("/users")
.input({ json: z.object({ email: z.string().email(), name: z.string() }) })
.output({ "application/json": { created: z.object({ id: z.string() }) } })
.handler((ctx) => ctx.res.json("created", { id: "u-1" }))
await app.serve({ env: { DATABASE_URL: process.env.DATABASE_URL! }, port: 3000 })CLI
honey generate [--watch] [--config <path>] [--plugin <n>] [--app <path>] [flags]
honey init [--cf] [--force]Flags take --flag value or --flag=value. Unknown flags, stray arguments, a missing value and a --config that does not exist are errors: the CLI prints usage and exits 1. Any other first argument does the same.
honey init
| Flag | Meaning |
| --------- | ----------------------------------------------------------------------- |
| --cf | Also write a Workers entry and wrangler.jsonc (alias --cloudflare) |
| --force | Overwrite src/app.ts, src/server.ts, vite.config.ts if they exist |
honey init adds @lovrozagar/honey to dependencies. In an existing package.json it keeps your dev / generate scripts and type unless you pass --force.
honey generate
Reads the Vite honey() plugin config (default vite.config.ts) and writes artifacts next to the app. Jiti loads the app and the Vite config with fsCache and moduleCache off, so a second generate after you add a route is not stale.
If there is no Vite config, you must pass --app.
The CLI loads the app on the runtime that started it. Started from a Bun script (bun run generate, bunx honey), it runs under that Bun even though the bin's shebang is node, so an app that relies on Bun's module loading (text imports such as .md, Bun-only APIs) generates as it runs. Under Node it stays on Node. Set HONEY_NO_BUN_HANDOFF=1 to keep Node.
A route tree that is stale or was written by an older honey does not stop the generation that replaces it: while honey generate loads the app, routeTree() ignores such a tree and the routes the app registers are used instead. Outside generation the stale tree still fails with an error naming the routes.
| Flag | Meaning |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------- |
| --watch | Regenerate when the route tree checksum changes. Ignores _gen / .gen.*. Requires --app or plugin app. |
| --config <path> | Vite config to load (default vite.config.ts) |
| --plugin <n> | Which honey() plugin in the config, 0-based, when it holds several |
| --app <path> | App module, overrides plugin app |
| --tree | Enable writing the generated route tree |
| --types | Enable writing TypeScript route types (needs ts-morph) |
| --manifest | Enable writing manifest.gen.json |
| --sdk | Enable TypeScript SDK at src/_gen (same as codegen.sdk: true) |
| --cli | Enable Go CLI; requires --cli-out and --cli-binary-name |
| --merge-tree <path> | Merge this generated tree into the write |
| --cli-out <dir> | Go CLI output directory |
| --cli-binary-name <name> | Binary name |
| --cli-config-name <name> | Optional config file name |
| --cli-default-base-url <url> | Default base URL baked into the CLI |
| --cli-env-prefix <prefix> | Env prefix for CLI config |
| --cli-module-path <path> | Go module path |
| --cli-sdk-module-path <path> | Import an existing Go SDK instead of embedding one |
CLI boolean flags turn features on. They do not turn plugin-configured features off. Plugin config is the source of truth for paths and SDK ports.
Default plugin resolution (when a flag/true enables the feature):
| Artifact | Default path | Default on? |
| ---------------------- | -------------------------------------------- | ---------------------------------- |
| Route tree | src/_gen/routes.gen.ts | yes (tree defaults on) |
| Types | src/_gen/types.gen.d.ts | no |
| Manifest | src/_gen/manifest.gen.json | no |
| OpenAPI | src/_gen/openapi.gen.json (+ YAML sibling) | no, until codegen.openApi is set |
| TS SDK | src/_gen/sdk.*.gen.ts | no |
| Python / Go / Rust SDK | only if codegen.sdk.ports is set | no |
| Go CLI | only if codegen.cli object is set | no |
Typical plugin config:
import { honey } from "@lovrozagar/honey/plugin"
export default {
plugins: [
honey({
app: "src/app.ts",
watch: ["src/**/*.ts"],
codegen: {
tree: true,
types: true,
manifest: true,
openApi: { title: "My API", version: "1.0.0" },
sdk: {
name: "MySDK",
ports: {
typescript: { outDir: "src/_gen" },
python: { outDir: "sdk/python" },
go: { outDir: "sdk/go", modulePath: "example.com/myapi" },
rust: { outDir: "sdk/rust", crateName: "myapi" },
},
},
cli: { out: "cli", binaryName: "myapi" },
},
}),
],
}watch patterns are anchored at the project root (src/**/*.ts matches src/a/b.ts, never node_modules/x/src/a.ts). A save that matches still gets its normal HMR update; generated outputs never retrigger a generation.
codegen.invalidate (default "warn") lists mutations that declare no invalidate but have
sibling read routes they plausibly affect. "error" to fail the build; "off" to silence it.
generateFromApp(app) (from @lovrozagar/honey/plugin) is the in-process helper: it returns { routeTree, manifest?, openApi?, openApiYaml? } without writing files.
Builder
Create the app
import { honey } from "@lovrozagar/honey"
const app = honey<{ DATABASE_URL: string; API_KEY: string }>()The type argument is ctx.env. Pass the same object to app.fetch(req, env) or app.serve({ env }). On Cloudflare, env is the Worker bindings object.
Routes and methods
app.get("/items")
app.post("/items")
app.put("/items/:id")
app.patch("/items/:id")
app.delete("/items/:id")
app.head("/items/:id")
app.options("/items")
app.all("/echo") // every method
app.on(["GET"], "/health") // methods are always a list
app.on(["GET", "HEAD"], "/resource") // same handler, extra methodsEach verb returns a route builder. Chain .input(), .output(), .errors(), .boundary(), .meta(), then finish with .handler() or .proxy(). After .handler() you are back on the app, so you can keep chaining routes.
There is no .head convenience beyond .head(path) itself. Use .on(["GET", "HEAD"], path) when GET and HEAD share a handler.
Path matching
app.get("/orgs/:orgId/members/:memberId").handler((ctx) => {
ctx.params.orgId
ctx.params.memberId
return ctx.res.json("ok", ctx.params)
})
app.get("/files/*path").handler((ctx) => {
// GET /files/a/b/c → ctx.params.path === "a/b/c"
return ctx.res.json("ok", { path: ctx.params.path })
}):nameis one whole segment. Names use letters, digits,_,$and-;/f/:name.jsonis rejected at registration.:name?is an optional last segment:/items/:id?serves/itemsand/items/42.*name(or bare*, read asctx.params["*"]) is the remainder of the path and must be the last segment.- Patterns are normalized:
admin/x,//admin/xand/admin/x/all register/admin/x;basePath("/api/")is/api..and..segments, query strings and fragments are rejected. - Precedence at each segment: static, then param, then wildcard. When the preferred branch dead-ends — no route further down, or no route for the request's method — the router backtracks to the next one. With
GET /users/me/settingsandGET /users/:id/profile,/users/me/profilereaches the second; withGET /u/meandDELETE /u/:id,DELETE /u/mereaches the second. A 405 lists every method of every route that matches the path. - Static paths (
/health) use an O(1) map. Dynamic and wildcard segments walk the radix tree.
Request paths
Every request path is normalized once, before anything looks at it. The router, scoped middleware, ctx.path, staticFiles() and proxy() all see the same result, on every runtime:
- Empty segments collapse:
//admin///usersis/admin/users. - Dot segments resolve:
/files/../adminis/admin, never above the root;%2ecounts as a dot. - A backslash is a
/, and characters a URL path cannot hold raw (space, controls,",<,>,`,{,}, non-ASCII) are percent-encoded — what URL parsing does, so Deno (which hands the app the raw target) and Bun or Node (which parse it) route the same bytes the same way. - An encoded
/or\(%2F,%5C) is 400: a segment that decodes to a separator means one thing to this router and another to a proxy upstream or a file system.app.encodedSlashes("allow")keeps them encoded in the path so they decode into a param (/repos/group%2Fprojectgivesid === "group/project"); only allow them when nothing downstream treats a decoded slash as a separator. - Everything else stays percent-encoded in
ctx.path; params are decoded when extracted. - A trailing slash is kept;
trailingSlash()decides what it means.
On Node, serve() also answers 400 for a Host header that is not host[:port] and for request targets other than /path or http(s)://host/path; on Deno, serve() (and Deno.serve(app.fetch)) reject such a Host, since Deno builds the request URL from it.
Optional extra validation of params (beyond “it is a string”):
app
.get("/orgs/:orgId")
.input({ params: z.object({ orgId: z.string().uuid() }) })
.handler((ctx) => ctx.res.json("ok", { id: ctx.input.params.orgId }))Prefixes and slashes
const api = app.basePath("/api") // routes registered on `api` are prefixed
app.trailingSlash("strip") // 308 /health/ → /health (Location is relative: /health)
app.trailingSlash("enforce") // 308 /health → /health/
app.trailingSlash("ignore") // both match (default)
app.stripPrefix("/app") // inbound /app/api/x is matched as /api/xbasePathreturns a new handle; only routes registered through it (or handles derived from it) are prefixed.stripPrefixis a gateway rewrite. Requests without the prefix still match. It will not strip a partial segment (/appleis not stripped by/app)..use(mw)/.basePath()/.context()/.meta()return a new handle on the same app. A route captures its handle's chain, prefix, context values and meta when it is registered; serving any handle of the app behaves the same. The handle you call them on is unchanged, soapp.use(auth)as a bare statement installs nothing — finalize (the first request,toRouteTree()or codegen) throws when ause(mw)handle never registers, mounts or serves anything, and when a route builder never got.handler().- Lifecycle. An app is built, then finalized: the first request,
toRouteTree()or codegen resolves every route's full middleware chain, scopes and error keys once into a per-app table, and requests only look routes up in it. Registering a route, a scope or a mount afterwards (dev/HMR patching, lazy plugins) is allowed: it moves the app to a new epoch, and the next request finalizes again. Served documents cache per epoch too. Finalize is where misconfiguration surfaces — ause(mw)handle that registers nothing, a builder without.handler(), a stale generated tree — as an error naming the route. - Settings —
trailingSlash,stripPrefix,encodedSlashes,trustProxy,errorFactory,defaultErrors,defaultBoundary,outputValidation,onError,onNotFound,onMethodNotAllowed, error formatters,errorI18n,logger,telemetry,tap,wsAdapter— belong to the app: calling one on any handle applies to every handle and every route, registered before or after.
Context
ctx is a HoneyContext. Fields:
| Field | Meaning |
| ------------------------------ | ------------------------------------------------------------------------------------------ |
| ctx.req | Web Request; on Node serve, a Request-shaped view (see below) |
| ctx.res | HoneyRes — see Responses |
| ctx.env | Bindings you passed to fetch / serve |
| ctx.params | Path params (:id, *path) |
| ctx.search / ctx.searchAll | First value / all values of the query string (lazy) |
| ctx.headers / ctx.cookies | Lazy records (lowercase cookie names as sent) |
| ctx.ip | Client IP, canonical; see Client address |
| ctx.input | Validated input when .input() is declared |
| ctx.errors | Typed error factory when .errorFactory() is set |
| ctx.meta | Merged route + chain .meta() |
| ctx.path | Normalized request path after stripPrefix |
| ctx.signal | Aborts on disconnect, timeout() or shutdown; see SSE and streaming |
| ctx.routePattern | Registered pattern, e.g. /users/:id |
| ctx.realtime | { publish, namespace(name).publish } when realtime routes exist |
| ctx.tap(key, payload) | Queue a tap (only if .taps() was declared) |
| ctx.background(promise) | waitUntil on Workers, otherwise fire-and-forget |
| ctx.executionCtx | Workers ExecutionContext when fetch received one |
| ctx.log | Present when logger({ instance }) middleware ran |
| ctx.requestId | Present when requestId() middleware ran |
| ctx.timing | Present when serverTiming() middleware ran |
Reserved keys that middleware / .context() cannot overwrite: every field above that Honey sets (req, res, env, params, headers, cookies, search, searchAll, ip, path, signal, meta, errors, …).
ctx.search, ctx.searchAll, ctx.headers and validated search/headers/form records have no prototype, so a query like ?__proto__=x or ?constructor=x is plain data. Read them with ctx.search.key, key in ctx.search or Object.hasOwn(ctx.search, key); they have no hasOwnProperty method.
On Node, ctx.req reads the IncomingMessage directly instead of building a Fetch Request per request. It behaves like one (headers, body methods, clone(), signal; the same conformance tests run against both), but it is not one: ctx.req instanceof Request is false, and new Request(ctx.req) or fetch(ctx.req) throw. Pass it through toFetchRequest(ctx.req) (from @lovrozagar/honey) first; on other runtimes that returns ctx.req itself. The Fetch Request then owns the body, so read it through one or the other.
Client address
ctx.ip is the client's address in canonical form (::ffff:1.2.3.4 is 1.2.3.4, IPv6 lowercase and compressed), or null when unknown. clientInfo(ctx) (from @lovrozagar/honey) also returns the protocol and host the client used. One app setting decides all three, and every feature that needs them (ipRestrict, loggers, proxy()) reads the same answer:
import "@lovrozagar/honey/trust" // needed for a hop count or a list; not for the default
app.trustProxy(false) // default: the TCP peer is the client; X-Forwarded-* is ignored
app.trustProxy(1) // one reverse proxy in front, appending to X-Forwarded-For (nginx, most load balancers)
app.trustProxy(["10.0.0.0/8", "fd00::/8"]) // proxies recognized by address; the first other hop is the client- The peer address comes from the runtime: the socket on Node,
server.requestIP()on Bun,info.remoteAddron Deno,CF-Connecting-IPon Cloudflare Workers (the edge sets it; a client cannot).app.fetch(req, env)without a server reports no peer; in tests passtestClient(app, { env, ip: "203.0.113.1" }). - With a hop count, the client is the
X-Forwarded-Forentry the outermost trusted proxy wrote; entries further left were sent by the client and are never read. With ranges, trusted hops are skipped from the right.X-Forwarded-ProtoandX-Forwarded-Hostare read only through a trusted hop. - A value at the position the setting reads that is not an address makes
ctx.ipnull, never a guess.X-Real-IP,ForwardedandTrue-Client-IPare not read.
app
.context({ version: "1.0.0" })
.get("/")
.handler((ctx) => {
return ctx.res.json("ok", { version: ctx.version })
})ctx.res.json("ok", data) is branded at the type level (TypedResponse<"application/json", "ok">) and is a real Response on Bun / Workers. On Node serve(), known-size bodies skip new Response() and write with writeHead / end.
Responses
Every ctx.res.* method takes a status key (except noContent, redirect, sse, stream, generate, raw). Optional third argument (or last for the exceptions):
type ResponseOptions = {
status?: number // override numeric status (redirect only by default)
headers?: Record<string, string>
cookies?: Record<string, CookieOptions>
}
type CookieOptions = {
value: string
domain?: string
expires?: Date
httpOnly?: boolean
maxAge?: number
path?: string
sameSite?: "lax" | "none" | "strict"
secure?: boolean
}ctx.res.json("ok", { id: "1" })
ctx.res.json(
"created",
{ id: "1" },
{
headers: { "x-request-id": "r1" },
cookies: { sid: { value: "abc", httpOnly: true, sameSite: "lax", path: "/" } },
},
)
ctx.res.text("ok", "hello")
ctx.res.html("ok", "<h1>Hi</h1>")
ctx.res.csv("ok", "id,name\n1,Ada")
ctx.res.xml("ok", "<ok/>")
ctx.res.binary("ok", new Uint8Array([0x48, 0x49]))
ctx.res.noContent()
ctx.res.redirect("/elsewhere") // 302
ctx.res.redirect("/gone", { status: 301 })
ctx.res.raw(new Response("passthrough"))__Host- cookies require secure: true, path: "/", and no domain.
SSE / stream / generate: see SSE and streaming.
Status keys
ctx.res.json("created", data) sets HTTP 201. Use the snake_case key, not the number, except redirect({ status }).
| Key | Code | Key | Code |
| --------------------------------- | ---- | --------------------------------- | ---- |
| ok | 200 | bad_request | 400 |
| created | 201 | unauthorized | 401 |
| accepted | 202 | payment_required | 402 |
| non_authoritative_information | 203 | forbidden | 403 |
| no_content | 204 | not_found | 404 |
| reset_content | 205 | method_not_allowed | 405 |
| partial_content | 206 | not_acceptable | 406 |
| multi_status | 207 | proxy_authentication_required | 407 |
| already_reported | 208 | request_timeout | 408 |
| im_used | 226 | conflict | 409 |
| multiple_choices | 300 | gone | 410 |
| moved_permanently | 301 | length_required | 411 |
| found | 302 | precondition_failed | 412 |
| see_other | 303 | content_too_large | 413 |
| not_modified | 304 | uri_too_long | 414 |
| temporary_redirect | 307 | unsupported_media_type | 415 |
| permanent_redirect | 308 | range_not_satisfiable | 416 |
| internal_server_error | 500 | expectation_failed | 417 |
| not_implemented | 501 | im_a_teapot | 418 |
| bad_gateway | 502 | misdirected_request | 421 |
| service_unavailable | 503 | unprocessable_entity | 422 |
| gateway_timeout | 504 | locked | 423 |
| http_version_not_supported | 505 | failed_dependency | 424 |
| variant_also_negotiates | 506 | too_early | 425 |
| insufficient_storage | 507 | upgrade_required | 426 |
| loop_detected | 508 | precondition_required | 428 |
| not_extended | 510 | too_many_requests | 429 |
| network_authentication_required | 511 | request_header_fields_too_large | 431 |
| | | unavailable_for_legal_reasons | 451 |
.output() only accepts 2xx success keys plus a redirect map. Error statuses belong on .errors(), not .output().
Input
.input() takes Standard Schema objects. json and form are mutually exclusive (one Content-Type per request).
app
.put("/in/all/:resourceId")
.input({
json: z.object({ title: z.string() }),
search: z.object({ draft: z.coerce.boolean().optional() }),
headers: z.object({ "x-request-id": z.string() }),
cookies: z.object({ sid: z.string() }),
params: z.object({ resourceId: z.string().min(1) }),
})
.handler((ctx) => {
ctx.input.json.title
ctx.input.search.draft
ctx.input.headers["x-request-id"]
ctx.input.cookies.sid
ctx.input.params.resourceId
return ctx.res.json("ok", { ok: true })
})Form + file (Zod z.file() or equivalent):
app
.post("/upload")
.input({ form: z.object({ title: z.string(), upload: z.file() }) })
.handler((ctx) => ctx.res.json("ok", { name: ctx.input.form.upload.name, title: ctx.input.form.title }))Leave the body for the handler (no Honey parse):
import { readableStream } from "@lovrozagar/honey/input"
app
.post("/pipe")
.input({ json: readableStream(z.unknown()) })
.handler(async (ctx) => {
const body = ctx.req.body
return ctx.res.json("ok", { piped: body !== null })
})Content-Type selection (case-insensitive, parameters ignored):
application/jsonor any+jsontype (application/vnd.api+json) →jsonapplication/x-www-form-urlencodedormultipart/form-data→form(req.formData())- Anything else — a missing Content-Type, a look-alike such as
application/jsonx, or a header with two media types — is 415unsupported_media_type. - A body the declared parser cannot read (malformed JSON or multipart, an empty JSON body) is 400 with
error_key: "malformed_body"andvars.format.onErrorsees it as that 400HoneyError. - Invalid input is 400 with
error_key: "validation_failed"andfields: { name: [{ error_key, message, path }] }.
A json or form schema is validated for every method that declares it, DELETE and OPTIONS included (a missing body is 415). Declaring one on a GET or HEAD route throws at registration — put that data in search.
Repeated keys in search and form follow one policy, read from the schema's JSON Schema (Zod 4.2+, ArkType):
- A key the schema types as an array always gets an array, even for one value — so a single file validates against
z.array(z.instanceof(File)). - A key typed as a scalar gets the first value, the same one
ctx.search.keyreturns. - With schemas that expose no JSON Schema (Valibot, Yup, Effect), one value is a scalar and a repeated key is an array.
// ?tag=a&tag=b&page=1&page=2
app
.get("/items")
.input({ search: z.object({ tag: z.array(z.string()), page: z.coerce.number() }) })
.handler((ctx) => ctx.res.json("ok", ctx.input.search)) // { tag: ["a", "b"], page: 1 }Cookies with a repeated name keep the first; headers follow Fetch (repeated values joined with , ).
Valibot / ArkType work the same way — pass any Standard Schema:
import * as v from "valibot"
app
.post("/v")
.input({ json: v.object({ n: v.number() }) })
.handler((ctx) => ctx.res.json("ok", ctx.input.json))Output
.output() maps content type → status key → schema. Only 2xx keys plus redirect.
app
.post("/items")
.output({
"application/json": {
created: z.object({ id: z.string() }),
ok: z.object({ id: z.string() }),
},
})
.handler((ctx) => ctx.res.json("created", { id: "1" }))Declared content types unlock the matching ctx.res method at the type level:
| Content type | Method |
| -------------------------- | ------------------------------------- |
| application/json | json |
| text/plain | text |
| text/html | html |
| text/csv | csv |
| application/xml | xml |
| application/octet-stream | binary |
| text/event-stream | sse |
| redirect | redirect (map of 3xx keys → true) |
Always available: noContent, raw, redirect, stream. Also recognized in the map (no dedicated helper): application/cbor, application/msgpack, application/pdf.
app
.get("/go")
.output({ redirect: { found: true, moved_permanently: true } })
.handler((ctx) => ctx.res.redirect("/next")).outputValidation("off" | "dev" | "always") controls runtime checks. "dev" (typical) validates when NODE_ENV !== "production". Mismatch is 500 (output_validation_failed or output_content_type_mismatch).
app.outputValidation("always")Errors
import { defineErrors, HoneyError, honey } from "@lovrozagar/honey"
const errors = defineErrors({
unauthorized: "unauthorized",
org_slug_taken: "conflict",
item_not_found: {
status: "not_found",
schema: z.object({ reason: z.string() }),
},
api_error: "internal_server_error",
orgs_unavailable: "service_unavailable",
})
const app = honey()
.errorFactory(errors)
.defaultErrors("unauthorized") // every route may throw these
.defaultBoundary("api_error") // unexpected throws become this key
.onError((error, ctx) => {
if (error instanceof HoneyError && error.errorKey === "org_slug_taken") {
return ctx.jsonFromError(error)
}
return undefined // fall through to default handling
})
.onNotFound((ctx) => ctx.jsonFromError(errors.item_not_found({ reason: "no route" })))
.onMethodNotAllowed((ctx) => {
// ctx.allowed is the Allow list
return new Response(null, { status: 405, headers: { allow: ctx.allowed.join(", ") } })
})Throw from a handler:
throw ctx.errors.org_slug_taken({ vars: { slug: "acme" } })
throw ctx.errors.item_not_found({ reason: "deleted" })
throw ctx.errors.unauthorized({
fields: { token: [{ error_key: "required", message: "missing", path: "token" }] },
headers: { "www-authenticate": "Bearer" },
})Standard error JSON (matches OpenAPI / generated _ErrEnvelope):
{
"error_key": "org_slug_taken",
"status": 409,
"status_key": "conflict",
"message": "org_slug_taken",
"success": false,
"fields": {}
}vars are ICU input only — used by .errorI18n() to interpolate the top-level message, and included in HoneyError.serialize for logs. They are not part of the public envelope. With i18n on, clients read the interpolated message (e.g. "Slug acme is taken"); they must not re-translate from vars + error_key.
For structured client extras (numbers a CLI can chart, typed payloads), use a custom-schema error (defineErrors({ key: { status, schema } })) and its data body — not vars.
Custom-schema errors serialize the schema payload as the body (not the envelope), unless you set customErrorFormatter.
Per-route:
app
.post("/orgs")
.errors("org_slug_taken") // default errors ("unauthorized") need no listing
.boundary("orgs_unavailable") // this route's unexpected-throw key
.handler((ctx) => {
throw ctx.errors.org_slug_taken({ vars: { slug: "x" } })
}).errors(factory, ...keys) also accepts the factory object as the first argument (kitchen style). Undeclared HoneyError keys fail the boundary check and become the boundary error.
Formatters:
app.defaultErrorFormatter((error, defaultShape) => ({
...defaultShape,
request_id: "r1",
}))
app.defaultErrorFormatter(z.object({ error_key: z.string(), status: z.number() }), (error) => ({
error_key: error.errorKey,
status: error.status,
}))
app.customErrorFormatter((error, data) => ({ ...data, error_key: error.errorKey }))i18n (loads @lovrozagar/honey/i18n when called):
import "@lovrozagar/honey/i18n"
app.errorI18n({
errors: {
en: { org_slug_taken: "Slug {slug} is taken", unauthorized: "Sign in" },
de: { org_slug_taken: "Name {slug} ist vergeben", unauthorized: "Anmeldung nötig" },
},
fieldNames: {
en: { email: "Email" },
},
resolveLocale: (ctx) => {
const accept = ctx.req.headers.get("accept-language")
return accept?.startsWith("de") ? "de" : "en"
},
})Messages are ICU: {slug}, {n, number}, {n, plural, one {# item} other {# items}}, {k, select, a {A} other {X}}. Pass matching values as vars on the throw. cause is reserved and is not a template var. Field path labels use fieldNames; field message strings are not interpolated with top-level vars.
HoneyError fields: errorKey, status (number), statusKey, fields, vars (ICU / logs only), data, headers. HoneyError.serialize(err) is a JSON-safe dump for logs (includes vars when set; never put on the public JSON).
Framework-owned keys you do not declare: validation_failed (400), malformed_body (400), bad_request (400, e.g. an encoded / in the path), forbidden (403, csrf / ipRestrict), output_validation_failed (500), plus routing / content-negotiation keys (not_found, method_not_allowed, unsupported_media_type, content_too_large, …).
Middleware
import { createMiddleware, defineErrors, honey } from "@lovrozagar/honey"
const errors = defineErrors({ unauthorized: "unauthorized" })
const app = honey().errorFactory(errors)
const withAuth = createMiddleware(async (ctx: { req: Request }, next) => {
const token = ctx.req.headers.get("authorization")
if (!token) throw errors.unauthorized()
return next({ user: { id: "u-1" } })
})
const authed = app.use(withAuth) // routes registered on `authed` run withAuth
authed.get("/me").handler((ctx) => ctx.res.json("ok", ctx.user))
app.use("/admin", withAuth) // scoped: every request under /admin, whichever handle registered the routecreateMiddleware infers additions from next({ ... }). Later handlers see ctx.user. Return next() with no argument to add nothing. Type the ctx parameter with the fields the middleware reads ({ req: Request }); a middleware can run on any route, so it throws errors from the factory itself (errors.unauthorized()) rather than ctx.errors, which handlers get typed. Without an error factory, throw a HoneyError.
.use(mw) returns a new handle and leaves the one it is called on unchanged: keep the returned value (or keep chaining) and register routes on it. app.use(mw) as a bare statement installs nothing, and the first request (or codegen) throws to say so.
What a route runs is fixed when it is registered or mounted, in this order: the chain of the handle it was registered on (a mounting handle's chain first), then every scope that covers it, then its own .get(...).use(mw), then input validation.
Scopes guard request paths.
app.use("/admin", mw)uses the route grammar (/orgs/:id,/admin/*) and covers every request whose path is under it — including requests that reachall("/*")or/:section/users, where the request path is checked at runtime. It applies to routes registered before or after it, on any handle, and to mounted sub-apps.Errors become responses at every
next(). A throw in a handler or a middleware is turned into the error response at that layer, so every middleware around it gets aResponsefromawait next()and its post-next()code (CORS headers, request id, logging, timing) runs on errors too. Atry/catcharoundnext()no longer sees throws: readctx.errorinstead — theHoneyErrorbehind the error response (errorKey,status, andcause, the original throw when it was not aHoneyError),undefinedwhen nothing failed. 404 and 405 are plain responses and leave it unset.const metrics = createMiddleware(async (ctx, next) => { const res = await next() record(res.status, ctx.error?.errorKey) return res })404, 405 and CORS preflight run the same pipeline: the middleware every route of the app starts with, plus the scopes covering the request path, with
ctx.errors,ctx.pathand context values set. A preflight (OPTIONSwithAccess-Control-Request-Method) runs the chain of the route for the requested method, never its handler.Middleware
errorsandmetacount for every route the middleware runs on — chain, scoped or route level.Mounting (
parent.route(sub)): the sub's routes keep the sub's error factory, default errors, boundary, output validation, taps and context values; the parent'sonError, formatters, logger and telemetry serve them. A sub-app's ownfetchkeeps serving the sub alone — mounting copies its routes and never changes it.
Shipped middleware
Import each from its path. Do not import { cors } from "@lovrozagar/honey". Each example keeps the handle use() returns — register routes on api. To run one for the whole app, put it at the start of the chain every route is registered on (const app = honey().use(cors())).
cors — @lovrozagar/honey/cors
import { cors } from "@lovrozagar/honey/cors"
const api = app.use(cors())
const custom = app.use(
cors({
origin: "https://app.example.com", // or "*" | string[] | (origin) => boolean
credentials: true, // needs an explicit origin (string, list or predicate); with "*" or none, cors() throws
methods: ["GET", "POST"],
headers: ["authorization", "content-type"],
exposeHeaders: ["x-request-id"],
maxAge: 86400,
}),
)No Origin header → no CORS headers are added. Preflight is OPTIONS + access-control-request-method. app.serve({ cors: true }) runs cors() with defaults before every route, 404 and preflight of the app. app.serve({ cors: { origin } }) passes the object through, and throws at serve() for credentials: true without an origin.
credentials: truerequires an explicitorigin: a wildcard would let every site make credentialed requests and read the answers, so construction throws.- The opaque origin
null(sandboxed iframes,file:) is never reflected; any page can produce it. - Every response carries
Vary: Originunless the policy is a plain"*", so a shared cache never serves one origin's answer to another. 101 upgrade responses pass through untouched. - What it does not cover: CORS only decides what a browser lets a page read. It does not stop a cross-site form post or
no-corsfetch from reaching the handler — usecsrf()for that.
csrf — @lovrozagar/honey/csrf
import { csrf } from "@lovrozagar/honey/csrf"
const api = app.use(csrf({ origin: "https://app.example.com" }))Every unsafe request is checked, whatever its Content-Type (the algorithm of Go's http.CrossOriginProtection):
GET,HEADandOPTIONSpass.- With
Sec-Fetch-Site(every current browser sends it):same-originandnonepass.same-siteandcross-sitepass only for anOrigininorigin(a string, list or predicate) — a sibling subdomain is same-site but not trusted. - Without it: a request with no
Originpasses (curl, server-to-server, clients a browser cannot drive cross-site), anOriginwhose host equalsHostpasses, an allow-listedOriginpasses, anything else is rejected.
Failure is 403 forbidden. Cross-origin browser callers of your JSON API must be listed in origin. What it does not cover: safe methods (keep GET side-effect free), and same-origin attackers (XSS).
body-limit — @lovrozagar/honey/body-limit
import { bodyLimit } from "@lovrozagar/honey/body-limit"
const api = app.use(
bodyLimit({
maxSize: 1_048_576,
limits: { "application/json": 64_000, "multipart/": 10_485_760 },
trustContentLength: false, // default; true skips counting when Content-Length is in range
}),
)Applies to every request that has a body, DELETE included. Oversize is 413 content_too_large; the rest of the upload is drained up to 1 MiB so the client sees the 413, then the stream is cancelled. limits keys are media-type prefixes matched case-insensitively against the request's media type (parameters dropped, the shared Content-Type parser); the longest matching key wins. Node and Bun serve() also cap every request body at 128 MiB (maxRequestBodySize), with or without this middleware.
logger — @lovrozagar/honey/logger
import { createLogger, logger } from "@lovrozagar/honey/logger"
const log = createLogger({
level: "info", // trace | debug | info | warn | error | fatal
base: { service: "api" },
write: (line) => console.log(line),
})
const api = app.use(logger())
const custom = app.use(
logger({
instance: log, // pino-shaped; sets ctx.log
skip: (data) => data.path === "/health",
log: (data) => {
// used when instance is omitted
console.log(`${data.method} ${data.path} ${data.status} ${data.duration}ms`)
},
}),
)createLogger writes one JSON line per call (level, msg, time, plus base). ctx.log.info("hello") / ctx.log.info({ k: 1 }, "hello"). The logged path is the normalized ctx.path (no query string). A throwing log, skip or sink is reported with console.error and never turns the response into a 500.
curl-logger — @lovrozagar/honey/curl-logger
import { curlLogger, defaultRedactHeader, defaultRedactQueryParam } from "@lovrozagar/honey/curl-logger"
const api = app.use(
curlLogger({
body: { maxBytes: 2048, allowContentTypes: ["application/json"] },
redactHeader: (name, value) => (name === "x-tenant" ? "***" : defaultRedactHeader(name, value)),
redactQueryParam: (name, value) => defaultRedactQueryParam(name, value),
skip: (data) => data.path === "/health", // { duration, method, path, requestId, status }
}),
)Credentials are masked by default: authorization, cookie, set-cookie, proxy-authorization, API-key and token headers, and token-like query params (token, access_token, api_key, sig, code, …) print as [REDACTED]. A custom redactHeader / redactQueryParam replaces the default — compose with the exported defaultRedactHeader / defaultRedactQueryParam to extend it; return null to drop the header or param. skip runs before the curl command is built. Every part of the command is shell-quoted (-X 'POST', $'…' for control characters), so a logged command is safe to paste. A failing callback never fails the request.
request-id — @lovrozagar/honey/request-id
import { requestId } from "@lovrozagar/honey/request-id"
const api = app.use(requestId())
const custom = app.use(requestId({ header: "x-request-id", generator: () => crypto.randomUUID() }))Adds ctx.requestId and echoes the header on the response. Reuses an inbound id only when it is 1–128 characters of A-Z a-z 0-9 . _ : + / = - (UUIDs, ULIDs, base64, W3C trace ids); anything else is replaced with a generated id, so a client cannot put arbitrary text into your logs. validate: (id) => boolean overrides the check.
etag — @lovrozagar/honey/etag
import { etag } from "@lovrozagar/honey/etag"
const api = app.use(etag()) // weak ETag (default)
const custom = app.use(etag({ weak: false }))GET/HEAD only, and only on 200 responses. Never reads a stream: sse(), stream(), generate() and native bodies without a content-length pass through untagged. A handler-set ETag is kept. Responds 304 when If-None-Match matches (weak comparison, lists and *).
timeout — @lovrozagar/honey/timeout
import { timeout } from "@lovrozagar/honey/timeout"
const api = app.use(timeout({ duration: 5_000 }))Slow handlers reject with 504 request_timeout, and ctx.signal aborts with a TimeoutError so the handler's work stops: pass the signal to fetch(), your database driver or anything else that takes one. Work that ignores it runs to completion and its response is dropped.
secure-headers — @lovrozagar/honey/secure-headers
import { secureHeaders } from "@lovrozagar/honey/secure-headers"
const api = app.use(secureHeaders())
const custom = app.use(
secureHeaders({
contentSecurityPolicy: "default-src 'self'",
strictTransportSecurity: "max-age=63072000; includeSubDomains",
permissionsPolicy: "camera=()",
referrerPolicy: "strict-origin-when-cross-origin", // or false to omit
xContentTypeOptions: "nosniff", // or false
xFrameOptions: "SAMEORIGIN",
xXssProtection: "0",
crossOriginOpenerPolicy: "same-origin",
crossOriginEmbedderPolicy: "require-corp",
crossOriginResourcePolicy: "same-site",
}),
)Defaults when omitted: x-content-type-options: nosniff, x-frame-options: SAMEORIGIN, referrer-policy: strict-origin-when-cross-origin, x-xss-protection: 0. A header the handler already set wins (a route's stricter CSP is never overwritten). Works on immutable responses (fetch(), Response.redirect()) and leaves 101 upgrades alone.
server-timing — @lovrozagar/honey/server-timing
import { serverTiming } from "@lovrozagar/honey/server-timing"
const api = app.use(serverTiming())
api.get("/work").handler((ctx) => {
ctx.timing.start("db", "query")
ctx.timing.end("db")
return ctx.res.json("ok", {})
})ip-restrict — @lovrozagar/honey/ip-restrict
import { ipRestrict } from "@lovrozagar/honey/ip-restrict"
import "@lovrozagar/honey/trust"
const api = app
.trustProxy(1) // behind one reverse proxy; see Client address
.use(
ipRestrict({
allowList: ["127.0.0.1", "10.0.0.0/8"],
denyList: ["192.168.1.50"],
}),
)- The client address is
ctx.ip, decided byapp.trustProxy()— see Client address.getIp: (req) => ...replaces it with your own source. - Rules and addresses are compared as numbers, so
::ffff:203.0.113.7,203.0.113.7and203.0.113.7:51234are one address, and2001:DB8::1equals2001:db8::1. - Construction throws on an invalid rule (
10.0.0.0/33,010.0.0.1,abc), onallowList: [](it would reject everyone), and with no list at all. - Fails closed: a request whose address is unknown or not an address is 403, for allow and deny lists alike. Denied / not-allowed is 403.
- What it does not cover: it trusts whatever
trustProxy()says. Behind a proxy you did not declare, every request has the proxy's address.
powered-by — @lovrozagar/honey/powered-by
import { poweredBy } from "@lovrozagar/honey/powered-by"
const api = app.use(poweredBy()) // x-powered-by: Honey
const custom = app.use(poweredBy({ name: "api" }))pretty-json — @lovrozagar/honey/pretty-json
import { prettyJson } from "@lovrozagar/honey/pretty-json"
const api = app.use(prettyJson()) // ?pretty=
const custom = app.use(prettyJson({ query: "pretty", space: 2 }))Rewrites buffered application/json (and +json) bodies when the query string has a parameter with exactly that name (?pretty, ?pretty=1; not ?prettyx). Streams, empty bodies and invalid JSON pass through untouched.
What the middleware does not cover
| Middleware | Does not cover |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| cors | Requests reaching the handler: it only controls what a browser lets a page read. Pair it with csrf for cookie-authenticated writes. |
| csrf | Safe methods (keep GET side-effect free), same-origin attackers (XSS), non-browser clients (they have no ambient credentials to abuse). |
| ipRestrict | Proxies you did not declare in trustProxy(): behind one, every request has the proxy's address. |
| bodyLimit | Bodies a handler reads before the middleware runs. Node and Bun serve() add their own cap (maxRequestBodySize). |
| timeout | Work that ignores ctx.signal keeps running after the 504. |
| secureHeaders | Content it does not see: a header the handler set wins, and the CSP is yours to write. |
| staticFiles | What resolve does with the path: it is safe to join onto a root, but symlinks inside the root are followed if your file API follows them. |
| proxy() | Where requests go: build the URL from a fixed origin in destination, never from request data. Upstream responses are passed through, not filtered. |
| requestId | Uniqueness of inbound ids: a client can still send an id that collides with another request's. |
| loggers | Secrets in bodies (curlLogger({ body })) and in header or param names the default patterns miss; extend the redact callbacks. |
Composition
const users = honey()
.basePath("/users")
.get("/:id")
.handler((ctx) => ctx.res.json("ok", { id: ctx.params.id }))
const app = honey().route(users) // merges routes, realtime, taps, static map
const v1 = honey().route("/v1", users) // serves /v1/users/:id.route(sub) copies the sub-app's routes into this one, under the chain of the handle you call it on: app.use(auth).route(admin) runs auth on every admin route. The sub's paths land under that handle's basePath, after the sub's own: app.basePath("/v1").route(users) serves /v1/users/:id, and route("/v1", users) is the same thing. The sub's scoped middleware and realtime routes move with it. Duplicate paths throw.
Taps
Taps run after a successful handler. They do not run on thrown errors.
const app = honey()
.taps<{ audit: { action: string } }>()
.tap("audit", (ctx, payload) => {
ctx.background(
fetch("https://logs.example.com", {
method: "POST",
body: JSON.stringify({ path: ctx.req.url, ...payload }),
}),
)
})
.post("/items")
.handler((ctx) => {
ctx.tap("audit", { action: "create" })
return ctx.res.json("created", { id: "1" })
})Proxy routes
Finish a route with .proxy() instead of .handler(). The proxy is not part of the core bundle: import its entry once in the app, or .proxy() throws at registration.
import "@lovrozagar/honey/proxy"
app.all("/upstream/*path").proxy({
destination: (ctx, url, init) => fetch(`https://api.internal${url}`, init),
rewriteUrl: (url) => url.replace(/^\/upstream/, ""),
requestHeaders: { "x-forwarded-by": "honey" },
// or requestHeaders: (ctx, headers) => { headers.set("x-user", ctx.user.id) }
timeout: 10_000, // time to response headers (default 60 s); 0 for none; disabled for WS upgrades
idleTimeout: 30_000, // max gap between body chunks; omit for none (SSE)
onResponse: (ctx, response) => {
response.headers.set("x-proxied", "1")
},
})destination receives the normalized path + query (after rewriteUrl) and a prepared RequestInit (method, headers, body for every method but GET/HEAD, a signal following ctx.signal and the timeouts, redirect: "manual"). Hop-by-hop headers and headers named in Connection are stripped both ways; the client's Host, Expect and forwarding headers are dropped, and X-Forwarded-For/-Proto/-Host describe the client as trustProxy() decided (forwardedHeaders: false to omit). Encoded responses lose Content-Encoding/Content-Length because fetch() decodes them (decoded: false for destinations returning encoded bytes). Network failures answer 502, a headers timeout 504, any other throw 500. onResponse gets a mutable copy and is not called for 101 upgrades.
What it does not cover: destination decides where the request goes — build the URL from a fixed origin (as above), never from request data.
Static files
import { staticFiles } from "@lovrozagar/honey/static"
const site = app.use(
staticFiles({
prefix: "/assets",
resolve: async (_ctx, filePath) => {
const file = Bun.file(`./public${filePath}`)
if (!(await file.exists())) return null
return new Response(file)
},
headers: { "cache-control": "public, max-age=3600" },
// or headers: (filePath) => ({ "content-type": mime(filePath) })
rewritePath: (filePath) => (filePath === "/" ? "/index.html" : filePath),
}),
)GET/HEAD only. resolve returning null falls through to the next route.
staticFilesmatchesctx.path— the normalized path the router saw, afterstripPrefix— on whole segments:prefix: "/assets"serves/assets/x, never/assets-private/x.filePathis decoded once and contains no.,.., empty, NUL,:,/or\segments, so joining it onto a root directory cannot leave the root, on POSIX or Windows. Anything else, including malformed percent-encoding, falls through to the next handler. ArewritePathresult is checked again.headers(filePath)receives the same pathresolvegot. Headers are added copy-on-write, so aResponsewith immutable headers (fromfetch()or a WorkersASSETSbinding) works.
Logging, telemetry, production tree
App-level logger (Honey internals, not request logs):
app.logger({ warn: (msg, ...args) => console.warn(msg, ...args) })OpenTelemetry-shaped adapter:
import { otelAdapter } from "@lovrozagar/honey/telemetry/otel"
app.telemetry(otelAdapter({ tracer }))
// or a hand-rolled adapter:
app.telemetry({
onRequest: ({ req }) => {},
onRoute: ({ method, path, route, params }) => {},
onHandler: ({ route, status, duration }) => {},
onResponse: ({ status, duration }) => {},
onError: ({ error, duration }) => {},
onMiddleware: ({ name, duration, error }) => {},
onNotFound: ({ method, path }) => {},
onMethodNotAllowed: ({ allowed }) => {},
})onResponse fires exactly once for every request that fired onRequest — trailing-slash redirects, 404, 405, preflights, upgrades and errors included. route is the matched pattern (/users/:id); otel records it as http.route and never puts the query string in an attribute (url.path, http.url without query or fragment).
Production tree: load the generated routes.gen.ts before registering routes. The tree supplies only the router topology (route ids at the leaves) and per-route data (meta, error keys); every route the app registers afterwards binds to its leaf by METHOD /pattern. Several apps can load the same module: it is frozen, and each app serves its own handlers.
import { routeTree } from "./_gen/routes.gen.ts"
const app = honey().routeTree(routeTree)
app.get("/health").handler((ctx) => ctx.res.text("ok", "ok"))- A route the app registers that the tree does not hold, or a tree from an older honey, means the generated file is stale: the first request throws an error naming the routes. Run
honey generate. - A tree leaf the app does not register is answered with 404 but still documented by
app.openapi(). - Spec, docs and manifest routes are not part of the generated tree;
app.openapi()mounts them either way. routeTree()must come before any route registration and can be called once.
app.toRouteTree() snapshots an app — a copy of its topology plus each route's data and live handler. mergeTree from honey / honey/tree merges several trees (optional extra meta per tree) without touching its inputs. Loading a snapshot or a merge serves those live handlers; loading the same tree twice gives two independent apps.
Gateways: a gateway loads a tree that holds downstream routes it does not implement, and registers a root wildcard (app.all("/*")) as its catch-all. Every leaf without a local handler is delegated to the catch-all, which then runs with that route's own pattern (ctx.routePattern), params, meta and scoped middleware. Paths that are not in the tree 404 — for every method, including HEAD — without reaching the catch-all.
import { routeTree } from "./_gen/routes.gen.ts"
import "@lovrozagar/honey/proxy"
const gateway = honey().routeTree(routeTree)
gateway.all("/*").proxy({ destination: (ctx) => `https://${ctx.meta.worker}.internal${ctx.path}` })Gateway codegen: point codegen.mergeTree at a generate-only module that merges live downstream apps with app.toRouteTree(). Generated routes.gen.ts omits JSON Schema, so merging another service's generated tree documents no request or response bodies. honey generate copies the merged schemas onto the gateway app before it writes OpenAPI; the gateway runtime still serves its own generated tree.
// gateway/src/route-tree.ts — codegen only, never imported at runtime
import { mergeTree } from "@lovrozagar/honey"
import { app as usersApp } from "@acme/users/app"
export const tree = mergeTree([usersApp.toRouteTree(), { worker: "users" }])app.fetch(request, env, executionCtx?) is always valid. Sync handlers return a Response directly; async handlers and middleware return a Promise<Response>. Callers should await app.fetch(...).
Serve
Bun, Node, Deno
const handle = await app.serve({
port: 3000,
hostname: "0.0.0.0",
cors: true, // or a CORS options object
env: { DATABASE_URL },
runtime: "bun", // optional; detected if omitted
})
handle.url // http://127.0.0.1:3000
handle.port
handle.hostname
handle.runtime // "bun" | "node" | "deno"
await handle.close() // or close(timeoutMs), default 1000Defaults: port 3000, hostname 0.0.0.0 (Deno defaults to 127.0.0.1). Bound 0.0.0.0 / :: is printed as 127.0.0.1 in url.
More options:
| Option | Runtimes | Meaning |
| -------------------- | --------- | ---------------------------------------------------------------------------------------- |
| maxRequestBodySize | Node, Bun | Largest request body in bytes; bigger is 413 and the connection closes. Default 128 MiB. |
| headersTimeout | Node | server.headersTimeout (ms) |
| requestTimeout | Node | server.requestTimeout (ms) |
| keepAliveTimeout | Node | server.keepAliveTimeout (ms)
