@lovrozagar/honey 0.1.0 → 0.2.0

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