@lovrozagar/honey 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,63 @@
1
1
  # Honey
2
2
 
3
- Type-safe API framework on Web Standards. Builder-pattern DX, precompiled radix-tree router, OpenAPI, and generated clients.
3
+ Type-safe API framework on Web Standards. Builder-pattern DX, precompiled radix-tree router, OpenAPI, and generated clients for TypeScript, Python, Go, and Rust.
4
+
5
+ This repo is the source of the [`@lovrozagar/honey`](https://www.npmjs.com/package/@lovrozagar/honey) npm package. The CLI binary is still `honey`.
6
+
7
+ 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.
8
+
9
+ ## Table of contents
10
+
11
+ - [Start](#start)
12
+ - [What Honey is](#what-honey-is)
13
+ - [Install](#install)
14
+ - [First app](#first-app)
15
+ - [CLI](#cli)
16
+ - [`honey init`](#honey-init)
17
+ - [`honey generate`](#honey-generate)
18
+ - [Builder](#builder)
19
+ - [Create the app](#create-the-app)
20
+ - [Routes and methods](#routes-and-methods)
21
+ - [Path matching](#path-matching)
22
+ - [Prefixes and slashes](#prefixes-and-slashes)
23
+ - [Context](#context)
24
+ - [Responses](#responses)
25
+ - [Status keys](#status-keys)
26
+ - [Input](#input)
27
+ - [Output](#output)
28
+ - [Errors](#errors)
29
+ - [Middleware](#middleware)
30
+ - [Shipped middleware](#shipped-middleware)
31
+ - [Composition](#composition)
32
+ - [Taps](#taps)
33
+ - [Proxy routes](#proxy-routes)
34
+ - [Static files](#static-files)
35
+ - [Logging, telemetry, production tree](#logging-telemetry-production-tree)
36
+ - [Serve](#serve)
37
+ - [Bun, Node, Deno](#bun-node-deno)
38
+ - [Cloudflare Workers](#cloudflare-workers)
39
+ - [Feature auto-load](#feature-auto-load)
40
+ - [OpenAPI, docs, and manifest](#openapi-docs-and-manifest)
41
+ - [WebSockets](#websockets)
42
+ - [Realtime](#realtime)
43
+ - [SSE and streaming](#sse-and-streaming)
44
+ - [Generated clients](#generated-clients)
45
+ - [Plugin codegen config](#plugin-codegen-config)
46
+ - [TypeScript `createClient`](#typescript-createclient)
47
+ - [Generated SDK usage](#generated-sdk-usage)
48
+ - [Go CLI](#go-cli)
49
+ - [Programmatic codegen](#programmatic-codegen)
50
+ - [Type inference](#type-inference)
51
+ - [Testing](#testing)
52
+ - [Utilities](#utilities)
53
+ - [Package exports](#package-exports)
54
+ - [Repository layout](#repository-layout)
55
+ - [Develop](#develop)
56
+ - [Test matrix](#test-matrix)
57
+ - [E2E apps and runtimes](#e2e-apps-and-runtimes)
58
+ - [Live Cloudflare proof](#live-cloudflare-proof)
59
+ - [Bench](#bench)
60
+ - [License](#license)
4
61
 
5
62
  ## Start
6
63
 
@@ -40,11 +97,1441 @@ honey generate # writes src/_gen/
40
97
  honey generate --watch
41
98
  ```
42
99
 
100
+ ## What Honey is
101
+
102
+ 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.
103
+
104
+ - **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 into `ctx`.
105
+ - **Radix tree.** Routes compile into a radix tree. Production can load a generated tree so unknown paths 404 without walking a catch-all.
106
+ - **Web Standards.** Handlers see `Request` and return `Response`. `app.fetch()` is the public entry. On Node, `serve()` wraps `IncomingMessage` / `ServerResponse` so the hot path does not build a native Fetch pair.
107
+ - **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.
108
+ - **Clients from the spec.** `honey generate` writes route types, a manifest, OpenAPI, and optional SDKs (TypeScript, Python, Go, Rust) plus an optional Go CLI.
109
+
110
+ 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).
111
+
112
+ ## Install
113
+
114
+ Requires [Bun](https://bun.sh) 1.3+ to develop this repo. Consumers can run the published package on Bun, Node, Deno, or Workers.
115
+
116
+ ```bash
117
+ bun add @lovrozagar/honey
118
+ # or
119
+ npm add @lovrozagar/honey
120
+ # or
121
+ pnpm add @lovrozagar/honey
122
+ ```
123
+
124
+ The CLI ships with the package (`honey` in `package.json` `bin`). After install:
125
+
126
+ ```bash
127
+ honey init
128
+ honey generate
129
+ ```
130
+
131
+ Peer-free runtime: the published package depends only on `jiti` for generate/watch. Validation libraries are your choice. Type generation (`codegen.types`) needs `ts-morph` as a **dev** dependency.
132
+
133
+ ## First app
134
+
135
+ `honey init` is the supported scaffold.
136
+
137
+ ```bash
138
+ mkdir my-api && cd my-api
139
+ bun init -y
140
+ bun add @lovrozagar/honey
141
+ honey init
142
+ bun run dev
143
+ ```
144
+
145
+ That writes:
146
+
147
+ | File | Role |
148
+ |---|---|
149
+ | `src/app.ts` | App export: health route + `openapi({ docs: "scalar" })` |
150
+ | `src/server.ts` | `await app.serve({ port })` |
151
+ | `vite.config.ts` | `honey({ app: "src/app.ts" })` plugin |
152
+
153
+ Scripts added to `package.json`: `dev` (runs `src/server.ts`) and `generate` (`honey generate`).
154
+
155
+ Cloudflare from the start:
156
+
157
+ ```bash
158
+ honey init --cf
159
+ ```
160
+
161
+ Also writes `src/worker.ts` (`export default { fetch }`) and `wrangler.jsonc`. Existing files refuse to overwrite unless you pass `--force`.
162
+
163
+ Minimal app without the scaffold:
164
+
165
+ ```ts
166
+ import { honey } from "@lovrozagar/honey"
167
+ import * as z from "zod"
168
+
169
+ const app = honey<{ DATABASE_URL: string }>()
170
+ .basePath("/api")
171
+ .trailingSlash("strip")
172
+ .get("/health")
173
+ .handler((ctx) => ctx.res.json("ok", { status: "ok" }))
174
+ .post("/users")
175
+ .input({ json: z.object({ email: z.string().email(), name: z.string() }) })
176
+ .output({ "application/json": { created: z.object({ id: z.string() }) } })
177
+ .handler((ctx) => ctx.res.json("created", { id: "u-1" }))
178
+
179
+ await app.serve({ env: { DATABASE_URL: process.env.DATABASE_URL! }, port: 3000 })
180
+ ```
181
+
182
+ ## CLI
183
+
184
+ ```
185
+ honey generate [--watch] [--config <path>] [--app <path>] [flags]
186
+ honey init [--cf] [--force]
187
+ ```
188
+
189
+ Any other first argument prints usage and exits `1`.
190
+
191
+ ### `honey init`
192
+
193
+ | Flag | Meaning |
194
+ |---|---|
195
+ | `--cf` | Also write a Workers entry and `wrangler.jsonc` |
196
+ | `--force` | Overwrite `src/app.ts`, `src/server.ts`, `vite.config.ts` if they exist |
197
+
198
+ ### `honey generate`
199
+
200
+ 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.
201
+
202
+ If there is no Vite config, you must pass `--app`.
203
+
204
+ | Flag | Meaning |
205
+ |---|---|
206
+ | `--watch` | Regenerate when the route tree checksum changes. Ignores `_gen` / `.gen.*`. Requires `--app` or plugin `app`. |
207
+ | `--config <path>` | Vite config to load (default `vite.config.ts`) |
208
+ | `--app <path>` | App module, overrides plugin `app` |
209
+ | `--tree` | Enable writing the generated route tree |
210
+ | `--types` | Enable writing TypeScript route types (needs `ts-morph`) |
211
+ | `--manifest` | Enable writing `manifest.gen.json` |
212
+ | `--sdk` | Enable TypeScript SDK at `src/_gen` (same as `codegen.sdk: true`) |
213
+ | `--cli` | Enable Go CLI; **requires** `--cli-out` and `--cli-binary-name` |
214
+ | `--merge-tree <path>` | Merge this generated tree into the write |
215
+ | `--cli-out <dir>` | Go CLI output directory |
216
+ | `--cli-binary-name <name>` | Binary name |
217
+ | `--cli-config-name <name>` | Optional config file name |
218
+ | `--cli-default-base-url <url>` | Default base URL baked into the CLI |
219
+ | `--cli-env-prefix <prefix>` | Env prefix for CLI config |
220
+ | `--cli-module-path <path>` | Go module path |
221
+ | `--cli-sdk-module-path <path>` | Import an existing Go SDK instead of embedding one |
222
+
223
+ 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.
224
+
225
+ Default plugin resolution (when a flag/`true` enables the feature):
226
+
227
+ | Artifact | Default path | Default on? |
228
+ |---|---|---|
229
+ | Route tree | `src/_gen/routes.gen.ts` | yes (`tree` defaults on) |
230
+ | Types | `src/_gen/types.gen.d.ts` | no |
231
+ | Manifest | `src/_gen/manifest.gen.json` | no |
232
+ | OpenAPI | `src/_gen/openapi.gen.json` (+ YAML sibling) | no, until `codegen.openApi` is set |
233
+ | TS SDK | `src/_gen/sdk.*.gen.ts` | no |
234
+ | Python / Go / Rust SDK | only if `codegen.sdk.ports` is set | no |
235
+ | Go CLI | only if `codegen.cli` object is set | no |
236
+
237
+ Typical plugin config:
238
+
239
+ ```ts
240
+ import { honey } from "@lovrozagar/honey/plugin"
241
+
242
+ export default {
243
+ plugins: [
244
+ honey({
245
+ app: "src/app.ts",
246
+ watch: ["src/**/*.ts"],
247
+ codegen: {
248
+ tree: true,
249
+ types: true,
250
+ manifest: true,
251
+ openApi: { title: "My API", version: "1.0.0" },
252
+ sdk: {
253
+ name: "MySDK",
254
+ ports: {
255
+ typescript: { outDir: "src/_gen" },
256
+ python: { outDir: "sdk/python" },
257
+ go: { outDir: "sdk/go", modulePath: "example.com/myapi" },
258
+ rust: { outDir: "sdk/rust", crateName: "myapi" },
259
+ },
260
+ },
261
+ cli: { out: "cli", binaryName: "myapi" },
262
+ },
263
+ }),
264
+ ],
265
+ }
266
+ ```
267
+
268
+ `generateFromApp(app)` (from `@lovrozagar/honey/plugin`) is the in-process helper: it returns `{ routeTree, manifest?, openApi?, openApiYaml? }` without writing files.
269
+
270
+ ## Builder
271
+
272
+ ### Create the app
273
+
274
+ ```ts
275
+ import { honey } from "@lovrozagar/honey"
276
+
277
+ const app = honey<{ DATABASE_URL: string; API_KEY: string }>()
278
+ ```
279
+
280
+ 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.
281
+
282
+ ### Routes and methods
283
+
284
+ ```ts
285
+ app.get("/items")
286
+ app.post("/items")
287
+ app.put("/items/:id")
288
+ app.patch("/items/:id")
289
+ app.delete("/items/:id")
290
+ app.head("/items/:id")
291
+ app.options("/items")
292
+ app.all("/echo") // every method
293
+ app.on("GET", "/health")
294
+ app.on(["GET", "HEAD"], "/resource") // same handler, extra methods
295
+ ```
296
+
297
+ Each 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.
298
+
299
+ There is no `.head` convenience beyond `.head(path)` itself. Use `.on(["GET", "HEAD"], path)` when GET and HEAD share a handler.
300
+
301
+ ### Path matching
302
+
303
+ ```ts
304
+ app.get("/orgs/:orgId/members/:memberId").handler((ctx) => {
305
+ ctx.params.orgId
306
+ ctx.params.memberId
307
+ return ctx.res.json("ok", ctx.params)
308
+ })
309
+
310
+ app.get("/files/*path").handler((ctx) => {
311
+ // GET /files/a/b/c → ctx.params.path === "a/b/c"
312
+ return ctx.res.json("ok", { path: ctx.params.path })
313
+ })
314
+ ```
315
+
316
+ - `:name` is one segment.
317
+ - `*name` is the remainder of the path.
318
+ - Static paths (`/health`) use an O(1) map. Dynamic and wildcard segments walk the radix tree.
319
+
320
+ Optional extra validation of params (beyond “it is a string”):
321
+
322
+ ```ts
323
+ app
324
+ .get("/orgs/:orgId")
325
+ .input({ params: z.object({ orgId: z.string().uuid() }) })
326
+ .handler((ctx) => ctx.res.json("ok", { id: ctx.input.params.orgId }))
327
+ ```
328
+
329
+ ### Prefixes and slashes
330
+
331
+ ```ts
332
+ app.basePath("/api") // every later route is prefixed
333
+ app.trailingSlash("strip") // 308 /health/ → /health
334
+ app.trailingSlash("enforce") // 308 /health → /health/
335
+ app.trailingSlash("ignore") // both match (default)
336
+ app.stripPrefix("/app") // inbound /app/api/x is matched as /api/x
337
+ ```
338
+
339
+ - `basePath` only affects routes registered **after** the call, on that chain.
340
+ - `stripPrefix` is a gateway rewrite. Requests without the prefix still match. It will not strip a partial segment (`/apple` is not stripped by `/app`).
341
+ - `.use()` / `.basePath()` / `.context()` / `.meta()` clone the builder and share the route graph with the parent.
342
+
343
+ ### Context
344
+
345
+ `ctx` is a `HoneyContext`. Fields:
346
+
347
+ | Field | Meaning |
348
+ |---|---|
349
+ | `ctx.req` | Web `Request` (on Node serve, a Request-shaped wrapper) |
350
+ | `ctx.res` | `HoneyRes` — see [Responses](#responses) |
351
+ | `ctx.env` | Bindings you passed to `fetch` / `serve` |
352
+ | `ctx.params` | Path params (`:id`, `*path`) |
353
+ | `ctx.search` / `ctx.searchAll` | First value / all values of the query string (lazy) |
354
+ | `ctx.headers` / `ctx.cookies` | Lazy records (lowercase cookie names as sent) |
355
+ | `ctx.input` | Validated input when `.input()` is declared |
356
+ | `ctx.errors` | Typed error factory when `.errorFactory()` is set |
357
+ | `ctx.meta` | Merged route + chain `.meta()` |
358
+ | `ctx.path` | Request pathname after `stripPrefix` |
359
+ | `ctx.routePattern` | Registered pattern, e.g. `/users/:id` |
360
+ | `ctx.realtime` | `{ publish(topic, data) }` when realtime routes exist |
361
+ | `ctx.tap(key, payload)` | Queue a tap (only if `.taps()` was declared) |
362
+ | `ctx.background(promise)` | `waitUntil` on Workers, otherwise fire-and-forget |
363
+ | `ctx.executionCtx` | Workers `ExecutionContext` when `fetch` received one |
364
+ | `ctx.log` | Present when `logger({ instance })` middleware ran |
365
+ | `ctx.requestId` | Present when `requestId()` middleware ran |
366
+ | `ctx.timing` | Present when `serverTiming()` middleware ran |
367
+
368
+ Reserved keys that middleware / `.context()` **cannot** overwrite: `req`, `res`, `env`, `params`, `headers`, `cookies`, `search`, `background`.
369
+
370
+ ```ts
371
+ app.context({ version: "1.0.0" }).get("/").handler((ctx) => {
372
+ return ctx.res.json("ok", { version: ctx.version })
373
+ })
374
+ ```
375
+
376
+ `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`.
377
+
378
+ ### Responses
379
+
380
+ Every `ctx.res.*` method takes a **status key** (except `noContent`, `redirect`, `sse`, `stream`, `generate`, `raw`). Optional third argument (or last for the exceptions):
381
+
382
+ ```ts
383
+ type ResponseOptions = {
384
+ status?: number // override numeric status (redirect only by default)
385
+ headers?: Record<string, string>
386
+ cookies?: Record<string, CookieOptions>
387
+ }
388
+
389
+ type CookieOptions = {
390
+ value: string
391
+ domain?: string
392
+ expires?: Date
393
+ httpOnly?: boolean
394
+ maxAge?: number
395
+ path?: string
396
+ sameSite?: "lax" | "none" | "strict"
397
+ secure?: boolean
398
+ }
399
+ ```
400
+
401
+ ```ts
402
+ ctx.res.json("ok", { id: "1" })
403
+ ctx.res.json("created", { id: "1" }, {
404
+ headers: { "x-request-id": "r1" },
405
+ cookies: { sid: { value: "abc", httpOnly: true, sameSite: "lax", path: "/" } },
406
+ })
407
+ ctx.res.text("ok", "hello")
408
+ ctx.res.html("ok", "<h1>Hi</h1>")
409
+ ctx.res.csv("ok", "id,name\n1,Ada")
410
+ ctx.res.xml("ok", "<ok/>")
411
+ ctx.res.binary("ok", new Uint8Array([0x48, 0x49]))
412
+ ctx.res.noContent()
413
+ ctx.res.redirect("/elsewhere") // 302
414
+ ctx.res.redirect("/gone", { status: 301 })
415
+ ctx.res.raw(new Response("passthrough"))
416
+ ```
417
+
418
+ `__Host-` cookies require `secure: true`, `path: "/"`, and no `domain`.
419
+
420
+ SSE / stream / generate: see [SSE and streaming](#sse-and-streaming).
421
+
422
+ ### Status keys
423
+
424
+ `ctx.res.json("created", data)` sets HTTP 201. Use the snake_case key, not the number, except `redirect({ status })`.
425
+
426
+ | Key | Code | Key | Code |
427
+ |---|---|---|---|
428
+ | `ok` | 200 | `bad_request` | 400 |
429
+ | `created` | 201 | `unauthorized` | 401 |
430
+ | `accepted` | 202 | `payment_required` | 402 |
431
+ | `non_authoritative_information` | 203 | `forbidden` | 403 |
432
+ | `no_content` | 204 | `not_found` | 404 |
433
+ | `reset_content` | 205 | `method_not_allowed` | 405 |
434
+ | `partial_content` | 206 | `not_acceptable` | 406 |
435
+ | `multi_status` | 207 | `proxy_authentication_required` | 407 |
436
+ | `already_reported` | 208 | `request_timeout` | 408 |
437
+ | `im_used` | 226 | `conflict` | 409 |
438
+ | `multiple_choices` | 300 | `gone` | 410 |
439
+ | `moved_permanently` | 301 | `length_required` | 411 |
440
+ | `found` | 302 | `precondition_failed` | 412 |
441
+ | `see_other` | 303 | `content_too_large` | 413 |
442
+ | `not_modified` | 304 | `uri_too_long` | 414 |
443
+ | `temporary_redirect` | 307 | `unsupported_media_type` | 415 |
444
+ | `permanent_redirect` | 308 | `range_not_satisfiable` | 416 |
445
+ | `internal_server_error` | 500 | `expectation_failed` | 417 |
446
+ | `not_implemented` | 501 | `im_a_teapot` | 418 |
447
+ | `bad_gateway` | 502 | `misdirected_request` | 421 |
448
+ | `service_unavailable` | 503 | `unprocessable_entity` | 422 |
449
+ | `gateway_timeout` | 504 | `locked` | 423 |
450
+ | `http_version_not_supported` | 505 | `failed_dependency` | 424 |
451
+ | `variant_also_negotiates` | 506 | `too_early` | 425 |
452
+ | `insufficient_storage` | 507 | `upgrade_required` | 426 |
453
+ | `loop_detected` | 508 | `precondition_required` | 428 |
454
+ | `not_extended` | 510 | `too_many_requests` | 429 |
455
+ | `network_authentication_required` | 511 | `request_header_fields_too_large` | 431 |
456
+ | | | `unavailable_for_legal_reasons` | 451 |
457
+
458
+ `.output()` only accepts **2xx success keys** plus a `redirect` map. Error statuses belong on `.errors()`, not `.output()`.
459
+
460
+ ### Input
461
+
462
+ `.input()` takes Standard Schema objects. `json` and `form` are mutually exclusive (one Content-Type per request).
463
+
464
+ ```ts
465
+ app
466
+ .put("/in/all/:resourceId")
467
+ .input({
468
+ json: z.object({ title: z.string() }),
469
+ search: z.object({ draft: z.coerce.boolean().optional() }),
470
+ headers: z.object({ "x-request-id": z.string() }),
471
+ cookies: z.object({ sid: z.string() }),
472
+ params: z.object({ resourceId: z.string().min(1) }),
473
+ })
474
+ .handler((ctx) => {
475
+ ctx.input.json.title
476
+ ctx.input.search.draft
477
+ ctx.input.headers["x-request-id"]
478
+ ctx.input.cookies.sid
479
+ ctx.input.params.resourceId
480
+ return ctx.res.json("ok", { ok: true })
481
+ })
482
+ ```
483
+
484
+ Form + file (Zod `z.file()` or equivalent):
485
+
486
+ ```ts
487
+ app
488
+ .post("/upload")
489
+ .input({ form: z.object({ title: z.string(), upload: z.file() }) })
490
+ .handler((ctx) =>
491
+ ctx.res.json("ok", { name: ctx.input.form.upload.name, title: ctx.input.form.title }),
492
+ )
493
+ ```
494
+
495
+ Leave the body for the handler (no Honey parse):
496
+
497
+ ```ts
498
+ import { readableStream } from "@lovrozagar/honey/input"
499
+
500
+ app
501
+ .post("/pipe")
502
+ .input({ json: readableStream(z.unknown()) })
503
+ .handler(async (ctx) => {
504
+ const body = ctx.req.body
505
+ return ctx.res.json("ok", { piped: body !== null })
506
+ })
507
+ ```
508
+
509
+ Content-Type selection:
510
+
511
+ - `application/json` → `json`
512
+ - `application/x-www-form-urlencoded` or `multipart/form-data` → `form` (`req.formData()`)
513
+ - Invalid input is **400** with `error_key: "validation_failed"` and `fields: { name: [{ error_key, message, path }] }`.
514
+
515
+ Valibot / ArkType work the same way — pass any Standard Schema:
516
+
517
+ ```ts
518
+ import * as v from "valibot"
519
+ app.post("/v").input({ json: v.object({ n: v.number() }) }).handler((ctx) => ctx.res.json("ok", ctx.input.json))
520
+ ```
521
+
522
+ ### Output
523
+
524
+ `.output()` maps content type → status key → schema. Only 2xx keys plus `redirect`.
525
+
526
+ ```ts
527
+ app
528
+ .post("/items")
529
+ .output({
530
+ "application/json": {
531
+ created: z.object({ id: z.string() }),
532
+ ok: z.object({ id: z.string() }),
533
+ },
534
+ })
535
+ .handler((ctx) => ctx.res.json("created", { id: "1" }))
536
+ ```
537
+
538
+ Declared content types unlock the matching `ctx.res` method at the type level:
539
+
540
+ | Content type | Method |
541
+ |---|---|
542
+ | `application/json` | `json` |
543
+ | `text/plain` | `text` |
544
+ | `text/html` | `html` |
545
+ | `text/csv` | `csv` |
546
+ | `application/xml` | `xml` |
547
+ | `application/octet-stream` | `binary` |
548
+ | `text/event-stream` | `sse` |
549
+ | `redirect` | `redirect` (map of 3xx keys → `true`) |
550
+
551
+ Always available: `noContent`, `raw`, `redirect`, `stream`. Also recognized in the map (no dedicated helper): `application/cbor`, `application/msgpack`, `application/pdf`.
552
+
553
+ ```ts
554
+ app
555
+ .get("/go")
556
+ .output({ redirect: { found: true, moved_permanently: true } })
557
+ .handler((ctx) => ctx.res.redirect("/next"))
558
+ ```
559
+
560
+ `.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`).
561
+
562
+ ```ts
563
+ app.outputValidation("always")
564
+ ```
565
+
566
+ ### Errors
567
+
568
+ ```ts
569
+ import { defineErrors, HoneyError, honey } from "@lovrozagar/honey"
570
+
571
+ const errors = defineErrors({
572
+ unauthorized: "unauthorized",
573
+ org_slug_taken: "conflict",
574
+ item_not_found: {
575
+ status: "not_found",
576
+ schema: z.object({ reason: z.string() }),
577
+ },
578
+ api_error: "internal_server_error",
579
+ })
580
+
581
+ const app = honey()
582
+ .errorFactory(errors)
583
+ .defaultErrors("unauthorized") // every route may throw these
584
+ .defaultBoundary("api_error") // unexpected throws become this key
585
+ .onError((error, ctx) => {
586
+ if (error instanceof HoneyError && error.errorKey === "org_slug_taken") {
587
+ return ctx.jsonFromError(error)
588
+ }
589
+ return undefined // fall through to default handling
590
+ })
591
+ .onNotFound((ctx) => ctx.jsonFromError(errors.item_not_found({ reason: "no route" })))
592
+ .onMethodNotAllowed((ctx) => {
593
+ // ctx.allowed is the Allow list
594
+ return new Response(null, { status: 405, headers: { allow: ctx.allowed.join(", ") } })
595
+ })
596
+ ```
597
+
598
+ Throw from a handler:
599
+
600
+ ```ts
601
+ throw ctx.errors.org_slug_taken({ vars: { slug: "acme" } })
602
+ throw ctx.errors.item_not_found({ reason: "deleted" })
603
+ throw ctx.errors.unauthorized({
604
+ fields: { token: [{ error_key: "required", message: "missing", path: "token" }] },
605
+ headers: { "www-authenticate": "Bearer" },
606
+ })
607
+ ```
608
+
609
+ Standard error JSON:
610
+
611
+ ```json
612
+ {
613
+ "error_key": "org_slug_taken",
614
+ "status": 409,
615
+ "status_key": "conflict",
616
+ "message": "org_slug_taken",
617
+ "success": false,
618
+ "fields": {},
619
+ "vars": { "slug": "acme" }
620
+ }
621
+ ```
622
+
623
+ Custom-schema errors serialize **the schema payload** as the body (not the envelope), unless you set `customErrorFormatter`.
624
+
625
+ Per-route:
626
+
627
+ ```ts
628
+ app
629
+ .post("/orgs")
630
+ .errors("org_slug_taken", "unauthorized")
631
+ .boundary("api_error") // this route's unexpected-throw key
632
+ .handler((ctx) => {
633
+ throw ctx.errors.org_slug_taken({ vars: { slug: "x" } })
634
+ })
635
+ ```
636
+
637
+ `.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.
638
+
639
+ Formatters:
640
+
641
+ ```ts
642
+ app.defaultErrorFormatter((error, defaultShape) => ({
643
+ ...defaultShape,
644
+ request_id: "r1",
645
+ }))
646
+
647
+ app.defaultErrorFormatter(
648
+ z.object({ error_key: z.string(), status: z.number() }),
649
+ (error) => ({ error_key: error.errorKey, status: error.status }),
650
+ )
651
+
652
+ app.customErrorFormatter((error, data) => ({ ...data, error_key: error.errorKey }))
653
+ ```
654
+
655
+ i18n (loads `@lovrozagar/honey/i18n` when called):
656
+
657
+ ```ts
658
+ import "@lovrozagar/honey/i18n"
659
+
660
+ app.errorI18n({
661
+ errors: {
662
+ en: { org_slug_taken: "Slug {slug} is taken", unauthorized: "Sign in" },
663
+ de: { org_slug_taken: "Name {slug} ist vergeben", unauthorized: "Anmeldung nötig" },
664
+ },
665
+ fieldNames: {
666
+ en: { email: "Email" },
667
+ },
668
+ resolveLocale: (ctx) => {
669
+ const accept = ctx.req.headers.get("accept-language")
670
+ return accept?.startsWith("de") ? "de" : "en"
671
+ },
672
+ })
673
+ ```
674
+
675
+ Messages are ICU: `{slug}`, `{n, number}`, `{n, plural, one {# item} other {# items}}`, `{k, select, a {A} other {X}}`. `cause` is reserved and is not a template var.
676
+
677
+ `HoneyError` fields: `errorKey`, `status` (number), `statusKey`, `fields`, `vars`, `data`, `headers`. `HoneyError.serialize(err)` is a JSON-safe dump for logs.
678
+
679
+ Framework-owned keys you do not declare: `validation_failed` (400), `output_validation_failed` (500), plus routing / content-negotiation keys.
680
+
681
+ ### Middleware
682
+
683
+ ```ts
684
+ import { createMiddleware } from "@lovrozagar/honey"
685
+
686
+ const withAuth = createMiddleware(async (ctx, next) => {
687
+ const token = ctx.req.headers.get("authorization")
688
+ if (!token) throw ctx.errors.unauthorized()
689
+ return next({ user: { id: "u-1" } })
690
+ })
691
+
692
+ app.use(withAuth) // global on this chain
693
+ app.use("/admin", withAuth) // prefix-scoped; additions only exist under /admin
694
+ ```
695
+
696
+ `createMiddleware` infers additions from `next({ ... })`. Later handlers see `ctx.user`. Return `next()` with no argument to add nothing.
697
+
698
+ `.use()` clones the builder. Keep the returned value (or keep chaining) if later routes should see the middleware.
699
+
700
+ ### Shipped middleware
701
+
702
+ Import each from its path. Do **not** `import { cors } from "@lovrozagar/honey"`.
703
+
704
+ #### `cors` — `@lovrozagar/honey/cors`
705
+
706
+ ```ts
707
+ import { cors } from "@lovrozagar/honey/cors"
708
+
709
+ app.use(cors())
710
+ app.use(cors({
711
+ origin: "https://app.example.com", // or "*" | string[] | (origin) => boolean
712
+ credentials: true, // wildcard origin is echoed (spec-safe)
713
+ methods: ["GET", "POST"],
714
+ headers: ["authorization", "content-type"],
715
+ exposeHeaders: ["x-request-id"],
716
+ maxAge: 86400,
717
+ }))
718
+ ```
719
+
720
+ No `Origin` header → middleware is a no-op. Preflight is `OPTIONS` + `access-control-request-method`. `app.serve({ cors: true })` is `cors()` with defaults. `app.serve({ cors: { origin } })` passes the object through.
721
+
722
+ #### `csrf` — `@lovrozagar/honey/csrf`
723
+
724
+ ```ts
725
+ import { csrf } from "@lovrozagar/honey/csrf"
726
+
727
+ app.use(csrf({ origin: "https://app.example.com" }))
728
+ ```
729
+
730
+ Safe methods (`GET`/`HEAD`/`OPTIONS`) pass. Non-form JSON also passes. Form posts (`urlencoded` / `multipart` / `text/plain`) need `Sec-Fetch-Site: same-origin` or a matching `Origin`. Failure is **403** `forbidden`.
731
+
732
+ #### `body-limit` — `@lovrozagar/honey/body-limit`
733
+
734
+ ```ts
735
+ import { bodyLimit } from "@lovrozagar/honey/body-limit"
736
+
737
+ app.use(bodyLimit({
738
+ maxSize: 1_048_576,
739
+ limits: { "application/json": 64_000, "multipart/": 10_485_760 },
740
+ trustContentLength: false, // default; true skips counting when Content-Length is in range
741
+ }))
742
+ ```
743
+
744
+ Skipped for GET/HEAD/OPTIONS/DELETE. Oversize is **413** `content_too_large`. `limits` keys are matched with `startsWith` against `Content-Type`.
745
+
746
+ #### `logger` — `@lovrozagar/honey/logger`
747
+
748
+ ```ts
749
+ import { createLogger, logger } from "@lovrozagar/honey/logger"
750
+
751
+ const log = createLogger({
752
+ level: "info", // trace | debug | info | warn | error | fatal
753
+ base: { service: "api" },
754
+ write: (line) => console.log(line),
755
+ })
756
+
757
+ app.use(logger())
758
+ app.use(logger({
759
+ instance: log, // pino-shaped; sets ctx.log
760
+ skip: (data) => data.path === "/health",
761
+ log: (data) => { // used when instance is omitted
762
+ console.log(`${data.method} ${data.path} ${data.status} ${data.duration}ms`)
763
+ },
764
+ }))
765
+ ```
766
+
767
+ `createLogger` writes one JSON line per call (`level`, `msg`, `time`, plus `base`). `ctx.log.info("hello")` / `ctx.log.info({ k: 1 }, "hello")`.
768
+
769
+ #### `curl-logger` — `@lovrozagar/honey/curl-logger`
770
+
771
+ ```ts
772
+ import { curlLogger } from "@lovrozagar/honey/curl-logger"
773
+
774
+ app.use(curlLogger({
775
+ body: { maxBytes: 2048, allowContentTypes: ["application/json"] },
776
+ redactHeader: (name, value) => (name === "authorization" ? "Bearer ***" : value),
777
+ redactQueryParam: (name, value) => (name === "token" ? "***" : value),
778
+ skip: (data) => data.path === "/health",
779
+ }))
780
+ ```
781
+
782
+ #### `request-id` — `@lovrozagar/honey/request-id`
783
+
784
+ ```ts
785
+ import { requestId } from "@lovrozagar/honey/request-id"
786
+
787
+ app.use(requestId())
788
+ app.use(requestId({ header: "x-request-id", generator: () => crypto.randomUUID() }))
789
+ ```
790
+
791
+ Adds `ctx.requestId` and echoes the header on the response. Reuses the inbound header when present.
792
+
793
+ #### `etag` — `@lovrozagar/honey/etag`
794
+
795
+ ```ts
796
+ import { etag } from "@lovrozagar/honey/etag"
797
+
798
+ app.use(etag()) // weak ETag (default)
799
+ app.use(etag({ weak: false }))
800
+ ```
801
+
802
+ GET/HEAD only. Skips 4xx and streaming bodies. Responds **304** when `If-None-Match` matches.
803
+
804
+ #### `timeout` — `@lovrozagar/honey/timeout`
805
+
806
+ ```ts
807
+ import { timeout } from "@lovrozagar/honey/timeout"
808
+
809
+ app.use(timeout({ duration: 5_000 }))
810
+ ```
811
+
812
+ Slow handlers reject with **504** `request_timeout`.
813
+
814
+ #### `secure-headers` — `@lovrozagar/honey/secure-headers`
815
+
816
+ ```ts
817
+ import { secureHeaders } from "@lovrozagar/honey/secure-headers"
818
+
819
+ app.use(secureHeaders())
820
+ app.use(secureHeaders({
821
+ contentSecurityPolicy: "default-src 'self'",
822
+ strictTransportSecurity: "max-age=63072000; includeSubDomains",
823
+ permissionsPolicy: "camera=()",
824
+ referrerPolicy: "strict-origin-when-cross-origin", // or false to omit
825
+ xContentTypeOptions: "nosniff", // or false
826
+ xFrameOptions: "SAMEORIGIN",
827
+ xXssProtection: "0",
828
+ crossOriginOpenerPolicy: "same-origin",
829
+ crossOriginEmbedderPolicy: "require-corp",
830
+ crossOriginResourcePolicy: "same-site",
831
+ }))
832
+ ```
833
+
834
+ Defaults when omitted: `x-content-type-options: nosniff`, `x-frame-options: SAMEORIGIN`, `referrer-policy: strict-origin-when-cross-origin`, `x-xss-protection: 0`.
835
+
836
+ #### `server-timing` — `@lovrozagar/honey/server-timing`
837
+
838
+ ```ts
839
+ import { serverTiming } from "@lovrozagar/honey/server-timing"
840
+
841
+ app.use(serverTiming())
842
+ app.get("/work").handler((ctx) => {
843
+ ctx.timing.start("db", "query")
844
+ ctx.timing.end("db")
845
+ return ctx.res.json("ok", {})
846
+ })
847
+ ```
848
+
849
+ #### `ip-restrict` — `@lovrozagar/honey/ip-restrict`
850
+
851
+ ```ts
852
+ import { ipRestrict } from "@lovrozagar/honey/ip-restrict"
853
+
854
+ app.use(ipRestrict({
855
+ allowList: ["127.0.0.1", "10.0.0.0/8"],
856
+ denyList: ["192.168.1.50"],
857
+ trustProxy: false, // true: X-Forwarded-For / X-Real-IP after CF
858
+ getIp: (req) => req.headers.get("cf-connecting-ip"),
859
+ }))
860
+ ```
861
+
862
+ Default IP: `cf-connecting-ip` only. Denied / not-allowed is **403**.
863
+
864
+ #### `powered-by` — `@lovrozagar/honey/powered-by`
865
+
866
+ ```ts
867
+ import { poweredBy } from "@lovrozagar/honey/powered-by"
868
+
869
+ app.use(poweredBy()) // x-powered-by: Honey
870
+ app.use(poweredBy({ name: "api" }))
871
+ ```
872
+
873
+ #### `pretty-json` — `@lovrozagar/honey/pretty-json`
874
+
875
+ ```ts
876
+ import { prettyJson } from "@lovrozagar/honey/pretty-json"
877
+
878
+ app.use(prettyJson()) // ?pretty=
879
+ app.use(prettyJson({ query: "pretty", space: 2 }))
880
+ ```
881
+
882
+ Only rewrites `application/json` when the query string contains the key.
883
+
884
+ ### Composition
885
+
886
+ ```ts
887
+ const users = honey()
888
+ .basePath("/users")
889
+ .get("/:id")
890
+ .handler((ctx) => ctx.res.json("ok", { id: ctx.params.id }))
891
+
892
+ const app = honey().route(users) // merges routes, realtime, taps, static map
893
+ ```
894
+
895
+ `.route(sub)` copies the sub-app's tree into this one. Duplicate paths throw.
896
+
897
+ ### Taps
898
+
899
+ Taps run **after** a successful handler. They do not run on thrown errors.
900
+
901
+ ```ts
902
+ const app = honey()
903
+ .taps<{ audit: { action: string } }>()
904
+ .tap("audit", (ctx, payload) => {
905
+ ctx.background(fetch("https://logs.example.com", {
906
+ method: "POST",
907
+ body: JSON.stringify({ path: ctx.req.url, ...payload }),
908
+ }))
909
+ })
910
+ .post("/items")
911
+ .handler((ctx) => {
912
+ ctx.tap("audit", { action: "create" })
913
+ return ctx.res.json("created", { id: "1" })
914
+ })
915
+ ```
916
+
917
+ ### Proxy routes
918
+
919
+ Finish a route with `.proxy()` instead of `.handler()`:
920
+
921
+ ```ts
922
+ app
923
+ .all("/upstream/*path")
924
+ .proxy({
925
+ destination: (ctx, url, init) => fetch(`https://api.internal${url}`, init),
926
+ rewriteUrl: (url) => url.replace(/^\/upstream/, ""),
927
+ requestHeaders: { "x-forwarded-by": "honey" },
928
+ // or requestHeaders: (ctx, headers) => { headers.set("x-user", ctx.user.id) }
929
+ timeout: 10_000, // or (ctx) => 5_000; default 30_000; disabled for WS upgrades
930
+ onResponse: (ctx, response) => {
931
+ response.headers.set("x-proxied", "1")
932
+ },
933
+ })
934
+ ```
935
+
936
+ `destination` receives path + query (after `rewriteUrl`) and a prepared `RequestInit` (method, headers, body, signal, redirect). Hop-by-hop headers are stripped. `onResponse` is not called for 101 upgrades.
937
+
938
+ ### Static files
939
+
940
+ ```ts
941
+ import { staticFiles } from "@lovrozagar/honey/static"
942
+
943
+ app.use(staticFiles({
944
+ prefix: "/assets",
945
+ resolve: async (_ctx, filePath) => {
946
+ const file = Bun.file(`./public${filePath}`)
947
+ if (!(await file.exists())) return null
948
+ return new Response(file)
949
+ },
950
+ headers: { "cache-control": "public, max-age=3600" },
951
+ // or headers: (filePath) => ({ "content-type": mime(filePath) })
952
+ rewritePath: (filePath) => (filePath === "/" ? "/index.html" : filePath),
953
+ }))
954
+ ```
955
+
956
+ GET/HEAD only. `..` segments are rejected. `resolve` returning `null` falls through to the next route.
957
+
958
+ ### Logging, telemetry, production tree
959
+
960
+ App-level logger (Honey internals, not request logs):
961
+
962
+ ```ts
963
+ app.logger({ warn: (msg, ...args) => console.warn(msg, ...args) })
964
+ ```
965
+
966
+ OpenTelemetry-shaped adapter:
967
+
968
+ ```ts
969
+ import { otelAdapter } from "@lovrozagar/honey/telemetry/otel"
970
+
971
+ app.telemetry(otelAdapter({ tracer }))
972
+ // or a hand-rolled adapter:
973
+ app.telemetry({
974
+ onRequest: ({ req }) => {},
975
+ onRoute: ({ method, path, params }) => {},
976
+ onHandler: ({ status, duration }) => {},
977
+ onResponse: ({ status, duration }) => {},
978
+ onError: ({ error, duration }) => {},
979
+ onMiddleware: ({ name, duration, error }) => {},
980
+ onNotFound: ({ method, path }) => {},
981
+ onMethodNotAllowed: ({ allowed }) => {},
982
+ })
983
+ ```
984
+
985
+ Production tree (unknown paths 404 without a catch-all walk):
986
+
987
+ ```ts
988
+ import { routeTree } from "./_gen/routes.gen.ts"
989
+
990
+ app.routeTree(routeTree)
991
+ const snapshot = app.toRouteTree()
992
+ ```
993
+
994
+ `mergeTree` from `honey` / `honey/tree` merges several trees (optional extra meta per tree).
995
+
996
+ `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(...)`.
997
+
998
+ ## Serve
999
+
1000
+ ### Bun, Node, Deno
1001
+
1002
+ ```ts
1003
+ const handle = await app.serve({
1004
+ port: 3000,
1005
+ hostname: "0.0.0.0",
1006
+ cors: true, // or a CORS options object
1007
+ env: { DATABASE_URL },
1008
+ runtime: "bun", // optional; detected if omitted
1009
+ })
1010
+
1011
+ handle.url // http://127.0.0.1:3000
1012
+ handle.port
1013
+ handle.hostname
1014
+ handle.runtime // "bun" | "node" | "deno"
1015
+ await handle.close()
1016
+ ```
1017
+
1018
+ Defaults: `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`.
1019
+
1020
+ | Runtime | How it listens |
1021
+ |---|---|
1022
+ | Bun | `Bun.serve({ fetch })` + `honey/ws/bun` |
1023
+ | Node | `node:http` + `honey/serve` + `honey/ws/node` |
1024
+ | Deno | `Deno.serve` + `honey/ws/deno` |
1025
+
1026
+ On Node, import `honey/serve` (or call `app.serve()`, which loads it) so the listen implementation is registered. The Node adapter wraps `IncomingMessage` instead of `new Request()` on the inbound hot path, and writes known JSON/text bodies with `res.end` instead of draining a Fetch `Response`.
1027
+
1028
+ Low-level Node listen (same adapter, no runtime detect):
1029
+
1030
+ ```ts
1031
+ import { serve } from "@lovrozagar/honey/node"
1032
+ const server = serve(app, { env: {}, port: 3000, hostname: "0.0.0.0" })
1033
+ await server.shutdown()
1034
+ ```
1035
+
1036
+ `runtime: "cloudflare"` throws. Workers cannot listen.
1037
+
1038
+ Detect without serving:
1039
+
1040
+ ```ts
1041
+ import { detectRuntime } from "@lovrozagar/honey"
1042
+ detectRuntime() // "bun" | "node" | "deno" | "cloudflare"
1043
+ ```
1044
+
1045
+ ### Cloudflare Workers
1046
+
1047
+ ```ts
1048
+ import { cfWebSocket } from "@lovrozagar/honey/ws/cloudflare"
1049
+ import { app } from "./app.ts"
1050
+
1051
+ app.wsAdapter(cfWebSocket())
1052
+
1053
+ export default {
1054
+ fetch: (req: Request, env: Env, ctx: ExecutionContext) => app.fetch(req, env, ctx),
1055
+ }
1056
+ ```
1057
+
1058
+ `honey init --cf` writes this stub. Local e2e uses wrangler / workerd. The live proof worker is documented under [Live Cloudflare proof](#live-cloudflare-proof).
1059
+
1060
+ Adapters if you wire them yourself: `bunWebSocket` (`honey/ws/bun`), `nodeWebSocket` (`honey/ws/node`), `denoWebSocket` (`honey/ws/deno`), `cfWebSocket` (`honey/ws/cloudflare`). `app.serve()` attaches the matching one.
1061
+
1062
+ ### Feature auto-load
1063
+
1064
+ `honey()` stays fetch-only until you opt in:
1065
+
1066
+ | Call or import | Loads |
1067
+ |---|---|
1068
+ | `app.serve()` or `import "@lovrozagar/honey/serve"` | Listen adapters |
1069
+ | `app.openapi()` / `app.manifest()` or `import "@lovrozagar/honey/openapi"` | Spec + docs |
1070
+ | `app.errorI18n()` or `import "@lovrozagar/honey/i18n"` | Error i18n |
1071
+
1072
+ Production bundles that only call `app.fetch` do not pull listen, OpenAPI, or i18n code. The load uses an opaque `import(["@lovrozagar/honey", name].join("/"))` so bundlers do not follow unused feature entries.
1073
+
1074
+ `createBuildPlugin` (`honey/build`) is a Vite plugin that injects those imports per target (`bun` | `node` | `deno` | `cloudflare`).
1075
+
1076
+ ```ts
1077
+ import { createBuildPlugin } from "@lovrozagar/honey/build"
1078
+
1079
+ createBuildPlugin(
1080
+ { target: "bun", port: 3000, minify: true, outDir: "dist", external: [] },
1081
+ { entry: "src/app.ts", export: "app" }, // or export: "default"
1082
+ )
1083
+ ```
1084
+
1085
+ ## OpenAPI, docs, and manifest
1086
+
1087
+ ```ts
1088
+ app.openapi({
1089
+ title: "My API",
1090
+ version: "1.0.0",
1091
+ description: "Optional",
1092
+ docs: "scalar", // or "swagger"
1093
+ docsPath: "/docs", // default /docs, then /reference if /docs is taken
1094
+ path: "/openapi", // stem → /openapi.json, .yaml, .yml
1095
+ filterRoutes: (route) => route.path !== "/debug",
1096
+ securitySchemes: {
1097
+ bearerAuth: { type: "http", scheme: "bearer" },
1098
+ },
1099
+ })
1100
+
1101
+ app.manifest()
1102
+ app.manifest({ path: "/manifest.json" })
1103
+ ```
1104
+
1105
+ Served (cached, invalidated when the route graph changes):
1106
+
1107
+ | Path | Body |
1108
+ |---|---|
1109
+ | `/openapi.json` | OpenAPI 3.1 |
1110
+ | `/openapi.yaml` / `/openapi.yml` | Same document as YAML |
1111
+ | `/docs` | Scalar or Swagger UI pointing at the JSON spec |
1112
+ | `/manifest.json` | Route methods, paths, middleware names, error keys |
1113
+
1114
+ Those routes are marked internal. They do not appear inside the spec or the manifest. If `/docs` is already a user route, pass `docsPath` or Honey throws.
1115
+
1116
+ `.meta({ operationId, tags, summary, description })` on a route feeds the document. App-level `.meta<Shape>()` / `.meta({ auth: "required" })` sets defaults and constrains route meta.
1117
+
1118
+ Generate-time sanitize (plugin `codegen.openApi.sanitize`):
1119
+
1120
+ ```ts
1121
+ {
1122
+ stripSecuritySchemes: ["legacy"],
1123
+ stripSecurityRequirements: ["legacy"],
1124
+ stripXExtensions: true, // or ["x-internal"]
1125
+ }
1126
+ ```
1127
+
1128
+ ## WebSockets
1129
+
1130
+ ```ts
1131
+ app.ws("/echo-ws").handler({
1132
+ onOpen(_ctx, ws) {
1133
+ ws.send("connected")
1134
+ ws.send({ hello: true }) // objects are JSON.stringified
1135
+ },
1136
+ onMessage(_ctx, ws, data) {
1137
+ ws.send(data) // string | ArrayBuffer
1138
+ },
1139
+ onReconnect(_ctx, ws, token) {
1140
+ ws.send(JSON.stringify({ event: "reconnected", token }))
1141
+ },
1142
+ onClose(_ctx, _ws, code, reason) {},
1143
+ onError(_ctx, _ws, error) {},
1144
+ })
1145
+ ```
1146
+
1147
+ `ws` is `{ send, close, readyState, raw }`. `readyState` is `0|1|2|3`. Sends before open are buffered (max 32). Middleware on the chain runs for the HTTP upgrade. Auth middleware works: throw to reject.
1148
+
1149
+ A GET **without** `Upgrade: websocket` to a WS/realtime path returns **426** with `upgrade: websocket`.
1150
+
1151
+ Reconnect: pass `?reconnect_token=` on the next handshake; Honey calls `onReconnect` instead of `onOpen`.
1152
+
1153
+ `app.serve()` attaches the runtime adapter. On Cloudflare, call `app.wsAdapter(cfWebSocket())` yourself.
1154
+
1155
+ ## Realtime
1156
+
1157
+ Honey's room protocol (not a raw socket). Transports: `ws`, `sse`, `longpoll`.
1158
+
1159
+ ```ts
1160
+ app.realtime("/realtime/chat/:roomId", {
1161
+ reconnectBuffer: 32,
1162
+ use: [withAuth], // extra middleware for this socket only
1163
+ handler: (ctx, conn) => {
1164
+ conn.join("room:default")
1165
+ conn.send({ event: "joined", id: conn.id, userId: conn.userId })
1166
+ conn.on("message", (payload) => {
1167
+ conn.publish("room:default", payload)
1168
+ })
1169
+ conn.on("close", (_reason) => {
1170
+ conn.leave("room:default")
1171
+ })
1172
+ },
1173
+ })
1174
+
1175
+ app.post("/realtime/broadcast/:topic").handler(async (ctx) => {
1176
+ const body = await ctx.req.json()
1177
+ ctx.realtime.publish(ctx.params.topic, body)
1178
+ return ctx.res.json("ok", { published: true })
1179
+ })
1180
+ ```
1181
+
1182
+ `conn`:
1183
+
1184
+ | Field | Meaning |
1185
+ |---|---|
1186
+ | `id` | Connection id |
1187
+ | `userId` | `string \| null` |
1188
+ | `transport` | `"ws" \| "sse" \| "longpoll"` |
1189
+ | `state` | Mutable bag |
1190
+ | `join(topic)` / `leave(topic)` | Topic membership |
1191
+ | `send(payload)` | To this connection |
1192
+ | `publish(topic, payload)` | To everyone on the topic |
1193
+ | `close(reason?)` | Disconnect |
1194
+ | `on("message" \| "close", fn)` | Inbound / teardown |
1195
+
1196
+ Duplicate `realtime()` paths throw. Cloudflare's isolate-local bus does **not** cross isolates — REST publish to another connection is skipped on the CF e2e env.
1197
+
1198
+ ## SSE and streaming
1199
+
1200
+ ```ts
1201
+ app.get("/events").handler((ctx) =>
1202
+ ctx.res.sse(
1203
+ async (stream) => {
1204
+ if (stream.lastEventId) {
1205
+ await stream.send({ event: "resume", data: `from ${stream.lastEventId}` })
1206
+ }
1207
+ await stream.send({ event: "tick", data: "hello", id: "1", retry: 3000 })
1208
+ await stream.send({ event: "data", data: { n: 42 }, id: "2" })
1209
+ stream.close()
1210
+ },
1211
+ { defaultRetry: 3000, keepalive: 15_000 },
1212
+ ),
1213
+ )
1214
+ ```
1215
+
1216
+ `Last-Event-Id` is read automatically. Event names and ids must not contain newlines. `keepalive` writes `: heartbeat` comments.
1217
+
1218
+ ```ts
1219
+ app.get("/stream").handler((ctx) =>
1220
+ ctx.res.stream(async (writable) => {
1221
+ const w = writable.getWriter()
1222
+ await w.write(new TextEncoder().encode("chunk"))
1223
+ await w.close()
1224
+ }),
1225
+ )
1226
+
1227
+ app.get("/gen").handler((ctx) =>
1228
+ ctx.res.generate(
1229
+ (async function* () {
1230
+ yield "one"
1231
+ yield "two"
1232
+ })(),
1233
+ { contentType: "text/plain", status: 200 },
1234
+ ),
1235
+ )
1236
+ ```
1237
+
43
1238
  ## Generated clients
44
1239
 
45
- Honey emits TypeScript, Python, Go, and Rust SDKs from the same spec. See [the SDK index](./docs/sdk.md).
1240
+ One OpenAPI document, four printers. Parity: typed operations, typed errors, `onAuthExpired` + one 401 retry, cancellation, per-call timeout/headers, request/response hooks, invalidation, SSE, realtime, WebSocket, streaming bodies, `onLog`. Python and Rust also emit a sync runtime.
1241
+
1242
+ ### Plugin codegen config
1243
+
1244
+ See [honey generate](#honey-generate). `codegen.sdk: true` is TypeScript-only into `src/_gen`. Object form selects ports. `codegen.sdk.specs: ["a.json"]` builds from existing specs (no app required for SDK). `codegen.cli=true` is invalid — pass `{ out, binaryName }`.
1245
+
1246
+ ### TypeScript `createClient`
1247
+
1248
+ Hand-typed client against `InferRoutes<typeof app>` (no generate):
1249
+
1250
+ ```ts
1251
+ import { createClient, isClientError } from "@lovrozagar/honey/client"
1252
+
1253
+ const client = createClient<typeof app>({
1254
+ baseURL: "http://127.0.0.1:3000",
1255
+ headers: { authorization: "Bearer t" },
1256
+ timeout: 10_000,
1257
+ throwOnError: false,
1258
+ credentials: "include",
1259
+ onAuthExpired: async () => "new-token",
1260
+ onRequest: [async (ctx) => { ctx.headers.set("x-trace", "1") }],
1261
+ onResponse: [async (ctx) => ctx.response],
1262
+ })
1263
+
1264
+ const res = await client.get("/api/health")
1265
+ const created = await client.post("/api/users", { json: { email: "a@b.com", name: "Ada" } })
1266
+
1267
+ client.$url("/api/users/:id", { params: { id: "1" }, search: { x: "1" } })
1268
+ client.$path("/api/users/:id", { params: { id: "1" } })
1269
+ const ws = client.ws("/echo-ws", { reconnectToken: "t" })
1270
+ ```
1271
+
1272
+ Per-call options: `json`, `form`, `search`, `params`, `headers`, `cookies`, `timeout`, `signal`, `lastEventId`.
1273
+
1274
+ `throwOnError: true` throws `ClientError` subclasses (`BadRequestError`, `UnauthorizedError`, …). `isClientError(e)` is the guard.
1275
+
1276
+ ### Generated SDK usage
1277
+
1278
+ After `honey generate` with `codegen.sdk.ports.typescript`:
1279
+
1280
+ ```ts
1281
+ import { MySDK, isClientError, UnauthorizedError } from "./_gen/sdk.index.gen.ts"
1282
+
1283
+ const sdk = new MySDK({
1284
+ baseURL: "http://127.0.0.1:3000",
1285
+ headers: { Authorization: "Bearer t" },
1286
+ onAuthExpired: () => Promise.resolve("new-token"),
1287
+ onLog: (entry) => console.debug(entry.event, entry.operation, entry.duration_ms),
1288
+ onRequest: [(ctx) => { ctx.headers["X-Trace-Id"] = crypto.randomUUID() }],
1289
+ throwOnError: true,
1290
+ timeout: 10_000,
1291
+ invalidation: { staleTime: 5 },
1292
+ })
1293
+
1294
+ const user = await sdk.createUser({ json: { email: "a@b.com", name: "Ada" } })
1295
+ ```
1296
+
1297
+ Python / Go / Rust: point `ports.*.outDir` at a folder and import that package (`replace` in `go.mod`, `path =` in Cargo). Sync clients exist for Python and Rust only.
1298
+
1299
+ Capability details and four-language snippets: [SDK index](./docs/sdk.md) and [examples](https://github.com/lovrozagar/honey/tree/main/packages/core/examples).
1300
+
1301
+ ### Go CLI
1302
+
1303
+ ```ts
1304
+ codegen: {
1305
+ cli: {
1306
+ out: "cli",
1307
+ binaryName: "myapi",
1308
+ modulePath: "example.com/myapi/cli",
1309
+ sdkModulePath: "example.com/myapi", // omit to embed the SDK
1310
+ defaultBaseURL: "http://127.0.0.1:3000",
1311
+ envPrefix: "MYAPI",
1312
+ configName: "myapi",
1313
+ },
1314
+ }
1315
+ ```
1316
+
1317
+ Equivalent CLI: `honey generate --cli --cli-out cli --cli-binary-name myapi`.
1318
+
1319
+ ### Programmatic codegen
1320
+
1321
+ ```ts
1322
+ import {
1323
+ generateOpenApi,
1324
+ generateManifest,
1325
+ generateSDK,
1326
+ generateRouteTreeFromApp,
1327
+ mergeSpecs,
1328
+ sanitizeOpenApiSpec,
1329
+ } from "@lovrozagar/honey/codegen"
1330
+ import { generateGoCLI } from "@lovrozagar/honey/codegen-go-cli"
1331
+
1332
+ const spec = await generateOpenApi(app, { info: { title: "API", version: "1" } })
1333
+ const { files } = generateSDK(spec, { name: "MySDK", stem: "sdk" })
1334
+ ```
1335
+
1336
+ Language printers used by the plugin (`generatePythonSDK`, `generateGoSDK`, `generateRustSDK`) are not separate package exports — call `honey generate`.
1337
+
1338
+ ## Type inference
1339
+
1340
+ Exported from `@lovrozagar/honey`. They describe the **app you built**:
1341
+
1342
+ | Type | Meaning |
1343
+ |---|---|
1344
+ | `InferRoutes<typeof app>` | Path → methods → input/output/errors |
1345
+ | `InferRoutePaths<typeof app>` | Union of paths |
1346
+ | `InferRouteMethods<typeof app, Path>` | Methods on one path |
1347
+ | `InferRouteInput<typeof app, Path, Method>` | Validated input |
1348
+ | `InferRouteOutput<typeof app, Path, Method>` | Output map |
1349
+ | `InferRouteErrors<typeof app, Path, Method>` | Declared error keys |
1350
+ | `InferRouteMeta<typeof app, Path, Method>` | Route meta |
1351
+ | `InferRouteCtx<typeof app, Path, Method>` | Handler ctx for one route |
1352
+ | `InferCtx<typeof app>` | Handler context (no `res` brand) |
1353
+ | `InferEnv<typeof app>` | `ctx.env` |
1354
+ | `InferMeta<typeof app>` | App-level meta |
1355
+ | `InferErrorFactory<typeof app>` | Error factory |
1356
+ | `InferBasePath<typeof app>` | Base path string |
1357
+ | `StatusKey` / `SuccessStatusKey` | Status-key unions |
1358
+ | `HoneyCtx<TEnv>` | Untyped context shape |
1359
+ | `HoneyServeOptions` / `ServeHandle` / `ServeRuntime` | Serve types |
1360
+ | `WSHandler` / `WSContext` / `WSAdapter` | Socket types |
1361
+ | `ConnContext` / `RealtimeRouteOpts` | Realtime types |
1362
+
1363
+ ```ts
1364
+ import type { InferCtx, InferRouteInput, InferRoutes } from "@lovrozagar/honey"
1365
+
1366
+ type AppRoutes = InferRoutes<typeof app>
1367
+ type CreateInput = InferRouteInput<typeof app, "/api/users", "post">
1368
+ type Ctx = InferCtx<typeof app>
1369
+ ```
1370
+
1371
+ ## Testing
1372
+
1373
+ ```ts
1374
+ import { testClient } from "@lovrozagar/honey/testing"
1375
+
1376
+ const client = testClient(app, { env: { DATABASE_URL: "…" }, cookies: true })
1377
+
1378
+ const res = await client.get("/api/health")
1379
+ await client.post("/api/users", {
1380
+ json: { email: "a@b.com", name: "Ada" },
1381
+ headers: { authorization: "Bearer t" },
1382
+ search: { dry: "1" },
1383
+ })
1384
+ await client.post("/login", { form: { user: "a", pass: "b" } })
1385
+ ```
1386
+
1387
+ Methods: `get` `post` `put` `patch` `delete` `head` `options` `request`. `cookies: true` stores `Set-Cookie` and sends them back.
1388
+
1389
+ ## Utilities
1390
+
1391
+ ```ts
1392
+ import { accepts } from "@lovrozagar/honey/accepts"
1393
+ accepts(req, ["application/json", "text/html"]) // best match or first if no Accept
1394
+
1395
+ import { serializeCookie } from "@lovrozagar/honey/cookie"
1396
+ serializeCookie("sid", { value: "abc", httpOnly: true, path: "/" })
1397
+
1398
+ import { sign, verify } from "@lovrozagar/honey/cookie-sign"
1399
+ const signed = await sign("abc", SECRET)
1400
+ const raw = await verify(signed, [SECRET, OLD_SECRET]) // null if none match
1401
+
1402
+ import { timingSafeEqual } from "@lovrozagar/honey/crypto"
1403
+ await timingSafeEqual(a, b)
1404
+
1405
+ import { requestToCurl } from "@lovrozagar/honey/request-to-curl"
1406
+ await requestToCurl(req, { excludeHeader: (n) => n === "authorization" })
1407
+ ```
1408
+
1409
+ `honey/openapi/spec`, `honey/openapi/scalar`, `honey/openapi/swagger` are the internals `app.openapi()` loads. `honey/cli` is the generate/init binary. `honey/codegen/extract` is the ts-morph extractor used by `--types`.
1410
+
1411
+ ## Package exports
1412
+
1413
+ Import features from their path.
1414
+
1415
+ | Export | Purpose |
1416
+ |---|---|
1417
+ | `@lovrozagar/honey` | `honey`, `defineErrors`, `HoneyError`, `createMiddleware`, `HoneyRes`, Infer* types, `mergeTree`, `detectRuntime` |
1418
+ | `honey/serve` | Register Node/Bun/Deno listen |
1419
+ | `honey/node` | Low-level Node `serve()` |
1420
+ | `honey/openapi` | Register spec generation |
1421
+ | `honey/openapi/spec` `honey/openapi/scalar` `honey/openapi/swagger` | Spec / UI pieces |
1422
+ | `honey/i18n` | Register error i18n |
1423
+ | `@lovrozagar/honey/plugin` | Vite plugin + `generateFromApp` |
1424
+ | `honey/client` / `honey/client/sdk` | Typed TS client runtime / generated-SDK helpers |
1425
+ | `honey/cors` `honey/csrf` `honey/body-limit` `honey/logger` `honey/curl-logger` `honey/etag` `honey/timeout` `honey/request-id` `honey/secure-headers` `honey/server-timing` `honey/ip-restrict` `honey/powered-by` `honey/pretty-json` `honey/static` | Middleware |
1426
+ | `honey/proxy` | `ProxyConfig` type (`.proxy()` lives on the route builder) |
1427
+ | `honey/input` | `readableStream()` |
1428
+ | `honey/testing` | `testClient` |
1429
+ | `honey/ws/bun` `honey/ws/node` `honey/ws/deno` `honey/ws/cloudflare` | WS adapters |
1430
+ | `honey/accepts` `honey/cookie` `honey/cookie-sign` `honey/crypto` `honey/request-to-curl` | Utilities |
1431
+ | `honey/telemetry/otel` | `otelAdapter` |
1432
+ | `honey/codegen` | `generateOpenApi`, `generateSDK`, `generateManifest`, trees, sanitize |
1433
+ | `honey/codegen-go-cli` | `generateGoCLI` |
1434
+ | `honey/codegen/extract` | ts-morph chain extractor |
1435
+ | `honey/build` | `createBuildPlugin` |
1436
+ | `honey/tree` | Tree types + `mergeTree` |
1437
+ | `honey/cli` | `honey` binary |
1438
+ | `honey/errors` | `defineErrors` (also on the root export) |
1439
+
1440
+ ## Repository layout
1441
+
1442
+ ```
1443
+ packages/core/ published `honey` package (src, tests, docs, examples)
1444
+ e2e/apps/ consumer apps (kitchen, defaults, compose, surface, gateway)
1445
+ e2e/{bun,node,deno,cf-workers}/ runtime hosts + Playwright
1446
+ e2e/run.ts env × app runner
1447
+ bench/ bombardier vs Hono / Elysia / Express / Nest (bun + node)
1448
+ ```
1449
+
1450
+ `e2e/apps/*` own the tests. Runtimes only listen. `HONEY_E2E_APP` and `HONEY_E2E_ENV` select which app and host.
1451
+
1452
+ ## Develop
1453
+
1454
+ `packages/core` is the published package. `e2e/*` imports `honey` over `workspace:*`.
1455
+
1456
+ Requires [Bun](https://bun.sh) 1.3+.
1457
+
1458
+ ```bash
1459
+ bun install
1460
+ bun run generate # honey generate for every e2e app
1461
+ bun run test # core unit + in-process integration (default CI)
1462
+ bun run test:consumers # e2e apps import honey like a real app
1463
+ bun run test:e2e # Playwright, bun × every e2e app
1464
+ bun run test:e2e:node # same matrix against Node (tsx)
1465
+ bun run test:e2e:deno # same matrix against Deno
1466
+ bun run test:e2e:cf # same matrix against local wrangler / workerd
1467
+ bun run test:e2e:all # bun + node + deno + local cf × every e2e app
1468
+ bun run typecheck # core src (TypeScript 7)
1469
+ bun run typecheck:consumers # every e2e app + generated types
1470
+ bun run lint # oxlint
1471
+ bun run lint:fix # oxlint --fix
1472
+ bun run fmt # oxfmt
1473
+ bun run fmt:check # oxfmt --check
1474
+ ```
1475
+
1476
+ Opt-in locally (CI `harness` job runs the first one):
1477
+
1478
+ ```bash
1479
+ bun run test:harness # TS / Go / Python / Rust / MCP compile + behavioral
1480
+ bun run test:harness:rust # rust-only subset of the same loop
1481
+ bun run test:all # default suite + language harnesses
1482
+ ```
1483
+
1484
+ Typecheck stays strict. Do not weaken `strict` or add `as any` to make it pass.
1485
+
1486
+ ### Test matrix
1487
+
1488
+ | Command | What it proves |
1489
+ |---|---|
1490
+ | `bun run test` | Core unit + integration. Default CI gate. |
1491
+ | `bun run test:consumers` | Each e2e app imports `honey` and hits a few routes in-process. |
1492
+ | `bun run test:e2e` | Playwright against Bun listen, every app. |
1493
+ | `bun run test:e2e:node` | Same tests, Node (`tsx`) listen. |
1494
+ | `bun run test:e2e:deno` | Same tests, Deno listen. |
1495
+ | `bun run test:e2e:cf` | Same tests, local workerd. Kitchen REST publish is skipped (`HONEY_E2E_ENV=cf`). |
1496
+ | `bun run test:harness` | Generated SDKs compile and behave. |
1497
+
1498
+ ```bash
1499
+ bun e2e/run.ts --env node --app kitchen
1500
+ bun e2e/run.ts --env all --app surface
1501
+ bun e2e/run.ts --env bun --mode prod
1502
+ ```
1503
+
1504
+ ### E2E apps and runtimes
1505
+
1506
+ | App | Covers |
1507
+ |---|---|
1508
+ | `kitchen` | Auth, CRUD, i18n, OpenAPI, SSE, WS, realtime, trailing slash, errors |
1509
+ | `defaults` | Empty-middleware app, root OpenAPI, no CORS by default |
1510
+ | `compose` | `.route()` groups, Scalar collision with a user `/docs` |
1511
+ | `surface` | Every input source, output type, method, SSE, WS, uploads |
1512
+ | `gateway` | `stripPrefix` + enforce slash, Swagger behind a prefix |
1513
+
1514
+ Runtimes: `e2e/bun`, `e2e/node`, `e2e/deno`, `e2e/cf-workers`.
1515
+
1516
+ ### Live Cloudflare proof
1517
+
1518
+ Same kitchen app on workerd: https://honey-cf-e2e.lovro-zagar5.workers.dev
1519
+
1520
+ See `e2e/cf-workers/README.md`. Redeploy with `bun run deploy:e2e:cf`. Live soak: `bun run test:live:cf`.
1521
+
1522
+ ### Bench
1523
+
1524
+ `bench/` compares Honey, Hono, Elysia, Express, and Nest on Bun and Node (naked + Zod). Bombardier, 10s / 100 connections, localhost.
1525
+
1526
+ ```bash
1527
+ bun run --filter @honey/bench build:all
1528
+ bun run --filter @honey/bench bench:bun
1529
+ bun run --filter @honey/bench bench:node
1530
+ ```
1531
+
1532
+ Numbers and bundle sizes: [`bench/RESULTS.md`](https://github.com/lovrozagar/honey/blob/main/bench/RESULTS.md).
46
1533
 
47
- Full framework guide (builder, serve, OpenAPI, WS, e2e, bench): the [repository README](https://github.com/lovrozagar/honey#table-of-contents).
1534
+ Python runtime tests skip without `httpx` (`pip install httpx`). Rust cargo tests skip without `cargo` or when `HONEY_RUST_INTEGRATION=0`. Go tests skip without `go`. Cargo artifacts go to `.cache/cargo-target`, not `/tmp`.
48
1535
 
49
1536
  ## License
50
1537