@lovrozagar/honey 0.1.1 → 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 (262) hide show
  1. package/README.md +645 -542
  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 +188 -50
  191. package/src/body-limit.ts +2 -1
  192. package/src/build/index.ts +2 -9
  193. package/src/cli.ts +1 -2
  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 +1122 -1409
  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.ts +2 -8
  230. package/src/openapi/register.ts +1 -2
  231. package/src/openapi/scalar.ts +9 -9
  232. package/src/openapi/spec-factory.ts +2 -2
  233. package/src/openapi/spec.ts +26 -19
  234. package/src/openapi/swagger.ts +9 -9
  235. package/src/plugin.ts +226 -256
  236. package/src/powered-by.ts +2 -1
  237. package/src/pretty-json.ts +2 -1
  238. package/src/proxy.ts +1 -3
  239. package/src/python-type-emitter.ts +37 -12
  240. package/src/realtime/route.ts +1 -1
  241. package/src/realtime/runtime.ts +9 -12
  242. package/src/request-id.ts +3 -4
  243. package/src/request-to-curl.ts +1 -4
  244. package/src/response.ts +150 -199
  245. package/src/rust-type-emitter.ts +66 -25
  246. package/src/secure-headers.ts +2 -1
  247. package/src/serve.ts +3 -12
  248. package/src/server-timing.ts +2 -1
  249. package/src/static.ts +3 -4
  250. package/src/telemetry/otel.ts +1 -6
  251. package/src/testing.ts +2 -11
  252. package/src/timeout.ts +2 -1
  253. package/src/tree.ts +12 -27
  254. package/src/ts-type-emitter.ts +71 -15
  255. package/src/type-emitter.ts +226 -237
  256. package/src/type-extractor.ts +260 -334
  257. package/src/types.ts +168 -68
  258. package/src/validation.ts +7 -30
  259. package/src/ws/bun.ts +2 -9
  260. package/src/ws/cloudflare.ts +13 -16
  261. package/src/ws/deno.ts +31 -40
  262. package/src/ws/node.ts +4 -19
package/README.md CHANGED
@@ -38,6 +38,7 @@ This README is the full usage manual. An agent that reads only this file should
38
38
  - [Cloudflare Workers](#cloudflare-workers)
39
39
  - [Feature auto-load](#feature-auto-load)
40
40
  - [OpenAPI, docs, and manifest](#openapi-docs-and-manifest)
41
+ - [Meta spec](#meta-spec)
41
42
  - [WebSockets](#websockets)
42
43
  - [Realtime](#realtime)
43
44
  - [SSE and streaming](#sse-and-streaming)
@@ -57,6 +58,7 @@ This README is the full usage manual. An agent that reads only this file should
57
58
  - [E2E apps and runtimes](#e2e-apps-and-runtimes)
58
59
  - [Live Cloudflare proof](#live-cloudflare-proof)
59
60
  - [Bench](#bench)
61
+ - [Releases](#releases)
60
62
  - [License](#license)
61
63
 
62
64
  ## Start
@@ -71,9 +73,9 @@ bun run dev
71
73
  import { honey } from "@lovrozagar/honey"
72
74
 
73
75
  export const app = honey()
74
- .get("/health")
75
- .handler((ctx) => ctx.res.text("ok", "ok"))
76
- .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" })
77
79
 
78
80
  await app.serve({ cors: true, port: 3000 })
79
81
  ```
@@ -144,11 +146,11 @@ bun run dev
144
146
 
145
147
  That writes:
146
148
 
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 |
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 |
152
154
 
153
155
  Scripts added to `package.json`: `dev` (runs `src/server.ts`) and `generate` (`honey generate`).
154
156
 
@@ -167,14 +169,14 @@ import { honey } from "@lovrozagar/honey"
167
169
  import * as z from "zod"
168
170
 
169
171
  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" }))
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" }))
178
180
 
179
181
  await app.serve({ env: { DATABASE_URL: process.env.DATABASE_URL! }, port: 3000 })
180
182
  ```
@@ -190,9 +192,9 @@ Any other first argument prints usage and exits `1`.
190
192
 
191
193
  ### `honey init`
192
194
 
193
- | Flag | Meaning |
194
- |---|---|
195
- | `--cf` | Also write a Workers entry and `wrangler.jsonc` |
195
+ | Flag | Meaning |
196
+ | --------- | ----------------------------------------------------------------------- |
197
+ | `--cf` | Also write a Workers entry and `wrangler.jsonc` |
196
198
  | `--force` | Overwrite `src/app.ts`, `src/server.ts`, `vite.config.ts` if they exist |
197
199
 
198
200
  ### `honey generate`
@@ -201,38 +203,38 @@ Reads the Vite `honey()` plugin config (default `vite.config.ts`) and writes art
201
203
 
202
204
  If there is no Vite config, you must pass `--app`.
203
205
 
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 |
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 |
222
224
 
223
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.
224
226
 
225
227
  Default plugin resolution (when a flag/`true` enables the feature):
226
228
 
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 |
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 |
236
238
 
237
239
  Typical plugin config:
238
240
 
@@ -240,28 +242,28 @@ Typical plugin config:
240
242
  import { honey } from "@lovrozagar/honey/plugin"
241
243
 
242
244
  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
- ],
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
+ ],
265
267
  }
266
268
  ```
267
269
 
@@ -289,9 +291,9 @@ app.patch("/items/:id")
289
291
  app.delete("/items/:id")
290
292
  app.head("/items/:id")
291
293
  app.options("/items")
292
- app.all("/echo") // every method
294
+ app.all("/echo") // every method
293
295
  app.on("GET", "/health")
294
- app.on(["GET", "HEAD"], "/resource") // same handler, extra methods
296
+ app.on(["GET", "HEAD"], "/resource") // same handler, extra methods
295
297
  ```
296
298
 
297
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.
@@ -302,14 +304,14 @@ There is no `.head` convenience beyond `.head(path)` itself. Use `.on(["GET", "H
302
304
 
303
305
  ```ts
304
306
  app.get("/orgs/:orgId/members/:memberId").handler((ctx) => {
305
- ctx.params.orgId
306
- ctx.params.memberId
307
- return ctx.res.json("ok", ctx.params)
307
+ ctx.params.orgId
308
+ ctx.params.memberId
309
+ return ctx.res.json("ok", ctx.params)
308
310
  })
309
311
 
310
312
  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
+ // GET /files/a/b/c → ctx.params.path === "a/b/c"
314
+ return ctx.res.json("ok", { path: ctx.params.path })
313
315
  })
314
316
  ```
315
317
 
@@ -321,19 +323,19 @@ Optional extra validation of params (beyond “it is a string”):
321
323
 
322
324
  ```ts
323
325
  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 }))
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 }))
327
329
  ```
328
330
 
329
331
  ### Prefixes and slashes
330
332
 
331
333
  ```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
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
337
339
  ```
338
340
 
339
341
  - `basePath` only affects routes registered **after** the call, on that chain.
@@ -344,33 +346,36 @@ app.stripPrefix("/app") // inbound /app/api/x is matched as /api/x
344
346
 
345
347
  `ctx` is a `HoneyContext`. Fields:
346
348
 
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 |
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 |
367
369
 
368
370
  Reserved keys that middleware / `.context()` **cannot** overwrite: `req`, `res`, `env`, `params`, `headers`, `cookies`, `search`, `background`.
369
371
 
370
372
  ```ts
371
- app.context({ version: "1.0.0" }).get("/").handler((ctx) => {
372
- return ctx.res.json("ok", { version: ctx.version })
373
- })
373
+ app
374
+ .context({ version: "1.0.0" })
375
+ .get("/")
376
+ .handler((ctx) => {
377
+ return ctx.res.json("ok", { version: ctx.version })
378
+ })
374
379
  ```
375
380
 
376
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`.
@@ -381,36 +386,40 @@ Every `ctx.res.*` method takes a **status key** (except `noContent`, `redirect`,
381
386
 
382
387
  ```ts
383
388
  type ResponseOptions = {
384
- status?: number // override numeric status (redirect only by default)
385
- headers?: Record<string, string>
386
- cookies?: Record<string, CookieOptions>
389
+ status?: number // override numeric status (redirect only by default)
390
+ headers?: Record<string, string>
391
+ cookies?: Record<string, CookieOptions>
387
392
  }
388
393
 
389
394
  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
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
398
403
  }
399
404
  ```
400
405
 
401
406
  ```ts
402
407
  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
- })
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
+ )
407
416
  ctx.res.text("ok", "hello")
408
417
  ctx.res.html("ok", "<h1>Hi</h1>")
409
418
  ctx.res.csv("ok", "id,name\n1,Ada")
410
419
  ctx.res.xml("ok", "<ok/>")
411
420
  ctx.res.binary("ok", new Uint8Array([0x48, 0x49]))
412
421
  ctx.res.noContent()
413
- ctx.res.redirect("/elsewhere") // 302
422
+ ctx.res.redirect("/elsewhere") // 302
414
423
  ctx.res.redirect("/gone", { status: 301 })
415
424
  ctx.res.raw(new Response("passthrough"))
416
425
  ```
@@ -423,37 +432,37 @@ SSE / stream / generate: see [SSE and streaming](#sse-and-streaming).
423
432
 
424
433
  `ctx.res.json("created", data)` sets HTTP 201. Use the snake_case key, not the number, except `redirect({ status })`.
425
434
 
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 |
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 |
457
466
 
458
467
  `.output()` only accepts **2xx success keys** plus a `redirect` map. Error statuses belong on `.errors()`, not `.output()`.
459
468
 
@@ -463,33 +472,31 @@ SSE / stream / generate: see [SSE and streaming](#sse-and-streaming).
463
472
 
464
473
  ```ts
465
474
  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
- })
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
+ })
482
491
  ```
483
492
 
484
493
  Form + file (Zod `z.file()` or equivalent):
485
494
 
486
495
  ```ts
487
496
  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
- )
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 }))
493
500
  ```
494
501
 
495
502
  Leave the body for the handler (no Honey parse):
@@ -498,12 +505,12 @@ Leave the body for the handler (no Honey parse):
498
505
  import { readableStream } from "@lovrozagar/honey/input"
499
506
 
500
507
  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
- })
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
+ })
507
514
  ```
508
515
 
509
516
  Content-Type selection:
@@ -516,7 +523,10 @@ Valibot / ArkType work the same way — pass any Standard Schema:
516
523
 
517
524
  ```ts
518
525
  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))
526
+ app
527
+ .post("/v")
528
+ .input({ json: v.object({ n: v.number() }) })
529
+ .handler((ctx) => ctx.res.json("ok", ctx.input.json))
520
530
  ```
521
531
 
522
532
  ### Output
@@ -525,36 +535,36 @@ app.post("/v").input({ json: v.object({ n: v.number() }) }).handler((ctx) => ctx
525
535
 
526
536
  ```ts
527
537
  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" }))
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" }))
536
546
  ```
537
547
 
538
548
  Declared content types unlock the matching `ctx.res` method at the type level:
539
549
 
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
+ | 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`) |
550
560
 
551
561
  Always available: `noContent`, `raw`, `redirect`, `stream`. Also recognized in the map (no dedicated helper): `application/cbor`, `application/msgpack`, `application/pdf`.
552
562
 
553
563
  ```ts
554
564
  app
555
- .get("/go")
556
- .output({ redirect: { found: true, moved_permanently: true } })
557
- .handler((ctx) => ctx.res.redirect("/next"))
565
+ .get("/go")
566
+ .output({ redirect: { found: true, moved_permanently: true } })
567
+ .handler((ctx) => ctx.res.redirect("/next"))
558
568
  ```
559
569
 
560
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`).
@@ -569,30 +579,30 @@ app.outputValidation("always")
569
579
  import { defineErrors, HoneyError, honey } from "@lovrozagar/honey"
570
580
 
571
581
  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",
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",
579
589
  })
580
590
 
581
591
  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
- })
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
+ })
596
606
  ```
597
607
 
598
608
  Throw from a handler:
@@ -601,8 +611,8 @@ Throw from a handler:
601
611
  throw ctx.errors.org_slug_taken({ vars: { slug: "acme" } })
602
612
  throw ctx.errors.item_not_found({ reason: "deleted" })
603
613
  throw ctx.errors.unauthorized({
604
- fields: { token: [{ error_key: "required", message: "missing", path: "token" }] },
605
- headers: { "www-authenticate": "Bearer" },
614
+ fields: { token: [{ error_key: "required", message: "missing", path: "token" }] },
615
+ headers: { "www-authenticate": "Bearer" },
606
616
  })
607
617
  ```
608
618
 
@@ -610,13 +620,13 @@ Standard error JSON:
610
620
 
611
621
  ```json
612
622
  {
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" }
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" }
620
630
  }
621
631
  ```
622
632
 
@@ -626,12 +636,12 @@ Per-route:
626
636
 
627
637
  ```ts
628
638
  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
- })
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
+ })
635
645
  ```
636
646
 
637
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.
@@ -640,14 +650,14 @@ Formatters:
640
650
 
641
651
  ```ts
642
652
  app.defaultErrorFormatter((error, defaultShape) => ({
643
- ...defaultShape,
644
- request_id: "r1",
653
+ ...defaultShape,
654
+ request_id: "r1",
645
655
  }))
646
656
 
647
- app.defaultErrorFormatter(
648
- z.object({ error_key: z.string(), status: z.number() }),
649
- (error) => ({ error_key: error.errorKey, status: error.status }),
650
- )
657
+ app.defaultErrorFormatter(z.object({ error_key: z.string(), status: z.number() }), (error) => ({
658
+ error_key: error.errorKey,
659
+ status: error.status,
660
+ }))
651
661
 
652
662
  app.customErrorFormatter((error, data) => ({ ...data, error_key: error.errorKey }))
653
663
  ```
@@ -658,17 +668,17 @@ i18n (loads `@lovrozagar/honey/i18n` when called):
658
668
  import "@lovrozagar/honey/i18n"
659
669
 
660
670
  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
- },
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
+ },
672
682
  })
673
683
  ```
674
684
 
@@ -684,13 +694,13 @@ Framework-owned keys you do not declare: `validation_failed` (400), `output_vali
684
694
  import { createMiddleware } from "@lovrozagar/honey"
685
695
 
686
696
  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" } })
697
+ const token = ctx.req.headers.get("authorization")
698
+ if (!token) throw ctx.errors.unauthorized()
699
+ return next({ user: { id: "u-1" } })
690
700
  })
691
701
 
692
- app.use(withAuth) // global on this chain
693
- app.use("/admin", withAuth) // prefix-scoped; additions only exist under /admin
702
+ app.use(withAuth) // global on this chain
703
+ app.use("/admin", withAuth) // prefix-scoped; additions only exist under /admin
694
704
  ```
695
705
 
696
706
  `createMiddleware` infers additions from `next({ ... })`. Later handlers see `ctx.user`. Return `next()` with no argument to add nothing.
@@ -707,14 +717,16 @@ Import each from its path. Do **not** `import { cors } from "@lovrozagar/honey"`
707
717
  import { cors } from "@lovrozagar/honey/cors"
708
718
 
709
719
  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
- }))
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
+ )
718
730
  ```
719
731
 
720
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.
@@ -734,11 +746,13 @@ Safe methods (`GET`/`HEAD`/`OPTIONS`) pass. Non-form JSON also passes. Form post
734
746
  ```ts
735
747
  import { bodyLimit } from "@lovrozagar/honey/body-limit"
736
748
 
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
- }))
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
+ )
742
756
  ```
743
757
 
744
758
  Skipped for GET/HEAD/OPTIONS/DELETE. Oversize is **413** `content_too_large`. `limits` keys are matched with `startsWith` against `Content-Type`.
@@ -749,19 +763,22 @@ Skipped for GET/HEAD/OPTIONS/DELETE. Oversize is **413** `content_too_large`. `l
749
763
  import { createLogger, logger } from "@lovrozagar/honey/logger"
750
764
 
751
765
  const log = createLogger({
752
- level: "info", // trace | debug | info | warn | error | fatal
753
- base: { service: "api" },
754
- write: (line) => console.log(line),
766
+ level: "info", // trace | debug | info | warn | error | fatal
767
+ base: { service: "api" },
768
+ write: (line) => console.log(line),
755
769
  })
756
770
 
757
771
  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
- }))
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
+ )
765
782
  ```
766
783
 
767
784
  `createLogger` writes one JSON line per call (`level`, `msg`, `time`, plus `base`). `ctx.log.info("hello")` / `ctx.log.info({ k: 1 }, "hello")`.
@@ -771,12 +788,14 @@ app.use(logger({
771
788
  ```ts
772
789
  import { curlLogger } from "@lovrozagar/honey/curl-logger"
773
790
 
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
- }))
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
+ )
780
799
  ```
781
800
 
782
801
  #### `request-id` — `@lovrozagar/honey/request-id`
@@ -795,7 +814,7 @@ Adds `ctx.requestId` and echoes the header on the response. Reuses the inbound h
795
814
  ```ts
796
815
  import { etag } from "@lovrozagar/honey/etag"
797
816
 
798
- app.use(etag()) // weak ETag (default)
817
+ app.use(etag()) // weak ETag (default)
799
818
  app.use(etag({ weak: false }))
800
819
  ```
801
820
 
@@ -817,18 +836,20 @@ Slow handlers reject with **504** `request_timeout`.
817
836
  import { secureHeaders } from "@lovrozagar/honey/secure-headers"
818
837
 
819
838
  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
- }))
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
+ )
832
853
  ```
833
854
 
834
855
  Defaults when omitted: `x-content-type-options: nosniff`, `x-frame-options: SAMEORIGIN`, `referrer-policy: strict-origin-when-cross-origin`, `x-xss-protection: 0`.
@@ -840,9 +861,9 @@ import { serverTiming } from "@lovrozagar/honey/server-timing"
840
861
 
841
862
  app.use(serverTiming())
842
863
  app.get("/work").handler((ctx) => {
843
- ctx.timing.start("db", "query")
844
- ctx.timing.end("db")
845
- return ctx.res.json("ok", {})
864
+ ctx.timing.start("db", "query")
865
+ ctx.timing.end("db")
866
+ return ctx.res.json("ok", {})
846
867
  })
847
868
  ```
848
869
 
@@ -851,12 +872,14 @@ app.get("/work").handler((ctx) => {
851
872
  ```ts
852
873
  import { ipRestrict } from "@lovrozagar/honey/ip-restrict"
853
874
 
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
- }))
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
+ )
860
883
  ```
861
884
 
862
885
  Default IP: `cf-connecting-ip` only. Denied / not-allowed is **403**.
@@ -866,7 +889,7 @@ Default IP: `cf-connecting-ip` only. Denied / not-allowed is **403**.
866
889
  ```ts
867
890
  import { poweredBy } from "@lovrozagar/honey/powered-by"
868
891
 
869
- app.use(poweredBy()) // x-powered-by: Honey
892
+ app.use(poweredBy()) // x-powered-by: Honey
870
893
  app.use(poweredBy({ name: "api" }))
871
894
  ```
872
895
 
@@ -875,7 +898,7 @@ app.use(poweredBy({ name: "api" }))
875
898
  ```ts
876
899
  import { prettyJson } from "@lovrozagar/honey/pretty-json"
877
900
 
878
- app.use(prettyJson()) // ?pretty=
901
+ app.use(prettyJson()) // ?pretty=
879
902
  app.use(prettyJson({ query: "pretty", space: 2 }))
880
903
  ```
881
904
 
@@ -885,11 +908,11 @@ Only rewrites `application/json` when the query string contains the key.
885
908
 
886
909
  ```ts
887
910
  const users = honey()
888
- .basePath("/users")
889
- .get("/:id")
890
- .handler((ctx) => ctx.res.json("ok", { id: ctx.params.id }))
911
+ .basePath("/users")
912
+ .get("/:id")
913
+ .handler((ctx) => ctx.res.json("ok", { id: ctx.params.id }))
891
914
 
892
- const app = honey().route(users) // merges routes, realtime, taps, static map
915
+ const app = honey().route(users) // merges routes, realtime, taps, static map
893
916
  ```
894
917
 
895
918
  `.route(sub)` copies the sub-app's tree into this one. Duplicate paths throw.
@@ -900,18 +923,20 @@ Taps run **after** a successful handler. They do not run on thrown errors.
900
923
 
901
924
  ```ts
902
925
  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
- })
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
+ })
915
940
  ```
916
941
 
917
942
  ### Proxy routes
@@ -919,18 +944,16 @@ const app = honey()
919
944
  Finish a route with `.proxy()` instead of `.handler()`:
920
945
 
921
946
  ```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
- })
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
+ })
934
957
  ```
935
958
 
936
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.
@@ -940,17 +963,19 @@ app
940
963
  ```ts
941
964
  import { staticFiles } from "@lovrozagar/honey/static"
942
965
 
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
- }))
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
+ )
954
979
  ```
955
980
 
956
981
  GET/HEAD only. `..` segments are rejected. `resolve` returning `null` falls through to the next route.
@@ -971,14 +996,14 @@ import { otelAdapter } from "@lovrozagar/honey/telemetry/otel"
971
996
  app.telemetry(otelAdapter({ tracer }))
972
997
  // or a hand-rolled adapter:
973
998
  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 }) => {},
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 }) => {},
982
1007
  })
983
1008
  ```
984
1009
 
@@ -1001,27 +1026,27 @@ const snapshot = app.toRouteTree()
1001
1026
 
1002
1027
  ```ts
1003
1028
  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
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
1009
1034
  })
1010
1035
 
1011
- handle.url // http://127.0.0.1:3000
1036
+ handle.url // http://127.0.0.1:3000
1012
1037
  handle.port
1013
1038
  handle.hostname
1014
- handle.runtime // "bun" | "node" | "deno"
1039
+ handle.runtime // "bun" | "node" | "deno"
1015
1040
  await handle.close()
1016
1041
  ```
1017
1042
 
1018
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`.
1019
1044
 
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` |
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` |
1025
1050
 
1026
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`.
1027
1052
 
@@ -1051,7 +1076,7 @@ import { app } from "./app.ts"
1051
1076
  app.wsAdapter(cfWebSocket())
1052
1077
 
1053
1078
  export default {
1054
- fetch: (req: Request, env: Env, ctx: ExecutionContext) => app.fetch(req, env, ctx),
1079
+ fetch: (req: Request, env: Env, ctx: ExecutionContext) => app.fetch(req, env, ctx),
1055
1080
  }
1056
1081
  ```
1057
1082
 
@@ -1063,11 +1088,11 @@ Adapters if you wire them yourself: `bunWebSocket` (`honey/ws/bun`), `nodeWebSoc
1063
1088
 
1064
1089
  `honey()` stays fetch-only until you opt in:
1065
1090
 
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 |
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 |
1071
1096
 
1072
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.
1073
1098
 
@@ -1077,8 +1102,8 @@ Production bundles that only call `app.fetch` do not pull listen, OpenAPI, or i1
1077
1102
  import { createBuildPlugin } from "@lovrozagar/honey/build"
1078
1103
 
1079
1104
  createBuildPlugin(
1080
- { target: "bun", port: 3000, minify: true, outDir: "dist", external: [] },
1081
- { entry: "src/app.ts", export: "app" }, // or export: "default"
1105
+ { target: "bun", port: 3000, minify: true, outDir: "dist", external: [] },
1106
+ { entry: "src/app.ts", export: "app" }, // or export: "default"
1082
1107
  )
1083
1108
  ```
1084
1109
 
@@ -1086,16 +1111,16 @@ createBuildPlugin(
1086
1111
 
1087
1112
  ```ts
1088
1113
  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
- },
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
+ },
1099
1124
  })
1100
1125
 
1101
1126
  app.manifest()
@@ -1104,12 +1129,12 @@ app.manifest({ path: "/manifest.json" })
1104
1129
 
1105
1130
  Served (cached, invalidated when the route graph changes):
1106
1131
 
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 |
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 |
1113
1138
 
1114
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.
1115
1140
 
@@ -1125,22 +1150,74 @@ Generate-time sanitize (plugin `codegen.openApi.sanitize`):
1125
1150
  }
1126
1151
  ```
1127
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
+
1128
1205
  ## WebSockets
1129
1206
 
1130
1207
  ```ts
1131
1208
  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) {},
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) {},
1144
1221
  })
1145
1222
  ```
1146
1223
 
@@ -1158,40 +1235,40 @@ Honey's room protocol (not a raw socket). Transports: `ws`, `sse`, `longpoll`.
1158
1235
 
1159
1236
  ```ts
1160
1237
  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
- },
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
+ },
1173
1250
  })
1174
1251
 
1175
1252
  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 })
1253
+ const body = await ctx.req.json()
1254
+ ctx.realtime.publish(ctx.params.topic, body)
1255
+ return ctx.res.json("ok", { published: true })
1179
1256
  })
1180
1257
  ```
1181
1258
 
1182
1259
  `conn`:
1183
1260
 
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 |
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 |
1195
1272
 
1196
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.
1197
1274
 
@@ -1199,17 +1276,17 @@ Duplicate `realtime()` paths throw. Cloudflare's isolate-local bus does **not**
1199
1276
 
1200
1277
  ```ts
1201
1278
  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
- ),
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
+ ),
1213
1290
  )
1214
1291
  ```
1215
1292
 
@@ -1217,21 +1294,21 @@ app.get("/events").handler((ctx) =>
1217
1294
 
1218
1295
  ```ts
1219
1296
  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
- }),
1297
+ ctx.res.stream(async (writable) => {
1298
+ const w = writable.getWriter()
1299
+ await w.write(new TextEncoder().encode("chunk"))
1300
+ await w.close()
1301
+ }),
1225
1302
  )
1226
1303
 
1227
1304
  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
- ),
1305
+ ctx.res.generate(
1306
+ (async function* () {
1307
+ yield "one"
1308
+ yield "two"
1309
+ })(),
1310
+ { contentType: "text/plain", status: 200 },
1311
+ ),
1235
1312
  )
1236
1313
  ```
1237
1314
 
@@ -1251,14 +1328,18 @@ Hand-typed client against `InferRoutes<typeof app>` (no generate):
1251
1328
  import { createClient, isClientError } from "@lovrozagar/honey/client"
1252
1329
 
1253
1330
  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],
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],
1262
1343
  })
1263
1344
 
1264
1345
  const res = await client.get("/api/health")
@@ -1281,14 +1362,18 @@ After `honey generate` with `codegen.sdk.ports.typescript`:
1281
1362
  import { MySDK, isClientError, UnauthorizedError } from "./_gen/sdk.index.gen.ts"
1282
1363
 
1283
1364
  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 },
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 },
1292
1377
  })
1293
1378
 
1294
1379
  const user = await sdk.createUser({ json: { email: "a@b.com", name: "Ada" } })
@@ -1320,12 +1405,12 @@ Equivalent CLI: `honey generate --cli --cli-out cli --cli-binary-name myapi`.
1320
1405
 
1321
1406
  ```ts
1322
1407
  import {
1323
- generateOpenApi,
1324
- generateManifest,
1325
- generateSDK,
1326
- generateRouteTreeFromApp,
1327
- mergeSpecs,
1328
- sanitizeOpenApiSpec,
1408
+ generateOpenApi,
1409
+ generateManifest,
1410
+ generateSDK,
1411
+ generateRouteTreeFromApp,
1412
+ mergeSpecs,
1413
+ sanitizeOpenApiSpec,
1329
1414
  } from "@lovrozagar/honey/codegen"
1330
1415
  import { generateGoCLI } from "@lovrozagar/honey/codegen-go-cli"
1331
1416
 
@@ -1339,26 +1424,26 @@ Language printers used by the plugin (`generatePythonSDK`, `generateGoSDK`, `gen
1339
1424
 
1340
1425
  Exported from `@lovrozagar/honey`. They describe the **app you built**:
1341
1426
 
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 |
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 |
1362
1447
 
1363
1448
  ```ts
1364
1449
  import type { InferCtx, InferRouteInput, InferRoutes } from "@lovrozagar/honey"
@@ -1377,9 +1462,9 @@ const client = testClient(app, { env: { DATABASE_URL: "…" }, cookies: true })
1377
1462
 
1378
1463
  const res = await client.get("/api/health")
1379
1464
  await client.post("/api/users", {
1380
- json: { email: "a@b.com", name: "Ada" },
1381
- headers: { authorization: "Bearer t" },
1382
- search: { dry: "1" },
1465
+ json: { email: "a@b.com", name: "Ada" },
1466
+ headers: { authorization: "Bearer t" },
1467
+ search: { dry: "1" },
1383
1468
  })
1384
1469
  await client.post("/login", { form: { user: "a", pass: "b" } })
1385
1470
  ```
@@ -1412,30 +1497,30 @@ await requestToCurl(req, { excludeHeader: (n) => n === "authorization" })
1412
1497
 
1413
1498
  Import features from their path.
1414
1499
 
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) |
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) |
1439
1524
 
1440
1525
  ## Repository layout
1441
1526
 
@@ -1485,31 +1570,43 @@ Typecheck stays strict. Do not weaken `strict` or add `as any` to make it pass.
1485
1570
 
1486
1571
  ### Test matrix
1487
1572
 
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. |
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. |
1497
1583
 
1498
1584
  ```bash
1499
1585
  bun e2e/run.ts --env node --app kitchen
1500
1586
  bun e2e/run.ts --env all --app surface
1501
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
1502
1592
  ```
1503
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
+
1504
1601
  ### E2E apps and runtimes
1505
1602
 
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 |
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 |
1513
1610
 
1514
1611
  Runtimes: `e2e/bun`, `e2e/node`, `e2e/deno`, `e2e/cf-workers`.
1515
1612
 
@@ -1533,6 +1630,12 @@ Numbers and bundle sizes: [`bench/RESULTS.md`](https://github.com/lovrozagar/hon
1533
1630
 
1534
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`.
1535
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.
1636
+
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.
1638
+
1536
1639
  ## License
1537
1640
 
1538
1641
  MIT