@lovrozagar/honey 1.0.0-rc.1 → 1.0.0-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,48 +1,100 @@
1
1
  # @lovrozagar/honey
2
2
 
3
- A typed web framework for the Fetch API. One app runs on Node, Bun and Cloudflare Workers, and its routes generate
4
- an OpenAPI 3.1 document, a route manifest and, through
5
- [`@lovrozagar/honey-clients`](https://www.npmjs.com/package/@lovrozagar/honey-clients), typed SDKs in six languages, a
6
- command-line client and an MCP server.
3
+ A typed web framework for the Fetch API whose routes are the source of its clients. You write a route once, with its
4
+ input and output schemas; honey serves it on Node, Bun or Cloudflare Workers, and
5
+ [`@lovrozagar/honey-clients`](https://www.npmjs.com/package/@lovrozagar/honey-clients) turns the same routes into an
6
+ OpenAPI 3.1 document, typed SDKs for six languages, a command-line client and an MCP server.
7
+
8
+ Nothing is inferred at runtime: the schemas you declare are what the handler receives, what the document says, and
9
+ what every generated client's types promise.
10
+
11
+ This page is the whole framework. Every TypeScript block below is type-checked by the repository's tests against this
12
+ package, so nothing here is pseudocode.
13
+
14
+ - [Install](#install)
15
+ - [A route, end to end](#a-route-end-to-end)
16
+ - [The app](#the-app)
17
+ - [Routing](#routing)
18
+ - [Input and output](#input-and-output)
19
+ - [Responses](#responses)
20
+ - [Errors](#errors)
21
+ - [Middleware](#middleware)
22
+ - [The request context](#the-request-context)
23
+ - [Cookies](#cookies)
24
+ - [Streaming, WebSockets and realtime](#streaming-websockets-and-realtime)
25
+ - [Serving on Node, Bun and Cloudflare Workers](#serving-on-node-bun-and-cloudflare-workers)
26
+ - [Static files and proxying](#static-files-and-proxying)
27
+ - [The OpenAPI document](#the-openapi-document)
28
+ - [The MCP server](#the-mcp-server)
29
+ - [Logging and tracing](#logging-and-tracing)
30
+ - [Internationalization and crypto](#internationalization-and-crypto)
31
+ - [Testing](#testing)
32
+ - [The build plugin and the honey CLI](#the-build-plugin-and-the-honey-cli)
33
+ - [Generated clients](#generated-clients)
34
+ - [Every export](#every-export)
35
+ - [Type helpers](#type-helpers)
36
+ - [Documentation](#documentation)
37
+ - [License](#license)
7
38
 
8
39
  ## Install
9
40
 
10
- 1.0 is out as a release candidate, published under npm's `next` tag, so an install without a tag still gives
11
- 0.7.3. Add `@next` until the final release:
41
+ 1.0 is out as a release candidate, published under npm's `next` tag, so an install without a tag still gives 0.7.3.
42
+ Add `@next` until the final release:
12
43
 
13
44
  ```sh
14
45
  npm install @lovrozagar/honey@next
46
+ npx honey init
15
47
  ```
16
48
 
17
- `bun add` and `pnpm add` work too. Node 22.12 or later, Bun 1.3 or later, or Cloudflare Workers.
49
+ Node 22.12 or later, Bun 1.3 or later, or Cloudflare Workers. A schema library is yours to pick: honey validates
50
+ through [Standard Schema](https://standardschema.dev), so Zod, Valibot, ArkType, Effect Schema and Yup all work. The
51
+ examples here use Zod.
52
+
53
+ `honey init` writes `src/app.ts`, `src/server.ts` and a Vite config; `honey init --cf` adds a Worker entry and a
54
+ `wrangler.jsonc`.
18
55
 
19
- ## Start
56
+ ## A route, end to end
20
57
 
21
- `npx honey init` writes `src/app.ts`, `src/server.ts` and a Vite config (`--cf` adds a Worker and its
22
- `wrangler.jsonc`). The app it writes:
58
+ Declare what a route takes and what it answers. `ctx.input` is typed by the schemas, and so is `ctx.res.json`'s
59
+ payload for the status key you name:
23
60
 
24
61
  ```ts
25
- import { honey } from "@lovrozagar/honey"
26
- import { serveOpenApi } from "@lovrozagar/honey/openapi"
62
+ import { defineErrors, honey } from "@lovrozagar/honey"
63
+ import * as z from "zod"
27
64
 
28
- export const app = serveOpenApi(
29
- honey()
30
- .get("/health")
31
- .handler((ctx) => ctx.res.text("ok", "ok")),
32
- { docs: "scalar", title: "Honey", version: "0.0.1" },
33
- )
65
+ const errors = defineErrors({ not_found: "not_found" })
66
+
67
+ const app = honey()
68
+
69
+ app
70
+ .get("/books/:id")
71
+ .input({ params: z.object({ id: z.string() }), search: z.object({ lang: z.string().optional() }) })
72
+ .output({ "application/json": { ok: z.object({ id: z.string(), title: z.string() }) } })
73
+ .errors(errors, "not_found")
74
+ .handler((ctx) => {
75
+ if (ctx.input.params.id === "0") throw errors.not_found()
76
+ return ctx.res.json("ok", { id: ctx.input.params.id, title: "Dune" })
77
+ })
78
+
79
+ app
80
+ .post("/books")
81
+ .input({ json: z.object({ title: z.string().min(1) }) })
82
+ .handler((ctx) => ctx.res.json("created", { id: "b-1", title: ctx.input.json.title }))
83
+
84
+ export default app
34
85
  ```
35
86
 
36
- And the server, `serve(app)` from `@lovrozagar/honey/serve` on Node and Bun:
87
+ Serving is the same for any app, and the reference UI comes from the document the routes already describe:
37
88
 
38
89
  ```ts
39
90
  import { honey } from "@lovrozagar/honey"
91
+ import { serveOpenApi } from "@lovrozagar/honey/openapi"
40
92
  import { serve } from "@lovrozagar/honey/serve"
41
93
 
42
94
  const app = honey()
43
95
  app.get("/health").handler((ctx) => ctx.res.text("ok", "ok"))
44
96
 
45
- await serve(app, { port: Number(process.env.PORT ?? 3000) })
97
+ await serve(serveOpenApi(app, { docs: "scalar", title: "Books", version: "1.0.0" }), { port: 3000 })
46
98
  ```
47
99
 
48
100
  ```sh
@@ -51,16 +103,1163 @@ curl http://127.0.0.1:3000/health
51
103
  # the API reference: open http://127.0.0.1:3000/docs
52
104
  ```
53
105
 
54
- `npx honey generate` writes the app's route tree, manifest, OpenAPI JSON and YAML and its types beside it.
106
+ Then generate a client from the app module itself, with no document to keep in step by hand:
107
+
108
+ ```sh
109
+ npx honey-clients generate --app src/app.ts --target ts --out generated
110
+ ```
111
+
112
+ ## The app
113
+
114
+ `honey<TEnv>()` returns an app typed to its runtime environment — a Worker's bindings, or whatever your adapter
115
+ passes. The chain derives handles that share one settings graph: `basePath()` rebases every route registered through
116
+ it, `context()` adds static values the handler finds on `ctx`, and `use()` installs middleware for that handle and
117
+ its descendants.
118
+
119
+ ```ts
120
+ import { honey } from "@lovrozagar/honey"
121
+
122
+ type Env = { GREETING: string }
123
+
124
+ const app = honey<Env>().basePath("/api").context({ startedAt: Date.now() })
125
+
126
+ app.get("/hello").handler((ctx) => ctx.res.json("ok", { greeting: ctx.env.GREETING, since: ctx.startedAt }))
127
+
128
+ export default app
129
+ ```
130
+
131
+ `context()` values are fixed at registration; per-request data belongs in middleware, which passes it through
132
+ `next({ … })`.
133
+
134
+ Settings sit on the same chain and apply to the whole app:
135
+
136
+ ```ts
137
+ import { honey } from "@lovrozagar/honey"
138
+
139
+ const app = honey()
140
+ .trailingSlash("strip")
141
+ .encodedSlashes("reject")
142
+ .outputValidation("always")
143
+ .errorFormat("problem+json")
144
+ .onNotFound((ctx) => new Response(`no route for ${new URL(ctx.req.url).pathname}`, { status: 404 }))
145
+ .onMethodNotAllowed((ctx) => new Response(null, { headers: { allow: ctx.allowed.join(", ") }, status: 405 }))
146
+
147
+ app.get("/").handler((ctx) => ctx.res.text("ok", "up"))
148
+ ```
149
+
150
+ | Setting | What it does |
151
+ | ------------------------------------------------ | ---------------------------------------------------------------------- |
152
+ | `basePath(prefix)` | rebases every route and OpenAPI path registered through the handle |
153
+ | `context(values)` | static values on `ctx`, typed into the handler |
154
+ | `use(mw)` / `use(path, mw)` | middleware for this handle and its descendants, or for one path |
155
+ | `route(prefix, sub)` | mounts a sub-app under a prefix |
156
+ | `meta(values)` | app-level route meta every route inherits |
157
+ | `errorFactory(factory)`, `defaultErrors(…)` | the error factory routes throw from, and the keys every route declares |
158
+ | `defaultBoundary(key)` | the error an unexpected throw becomes |
159
+ | `onError(handler)` | maps a thrown value to a response before error telemetry |
160
+ | `onNotFound(handler)`, `onMethodNotAllowed(…)` | the two misses |
161
+ | `logger(logger \| false)`, `requestLog(…)` | where lines go, and the one-line-per-request log |
162
+ | `telemetry(value)` | the tracing adapter (`otelAdapter`) |
163
+ | `errorFormat(format)` | `"honey"` (the default) or `"problem+json"` (RFC 9457) |
164
+ | `defaultErrorFormatter`, `customErrorFormatter` | your own error body, schema-checked |
165
+ | `outputValidation(mode)` | what a response that does not match its `.output()` does |
166
+ | `trailingSlash`, `encodedSlashes`, `stripPrefix` | path normalization, and the prefix a gateway already consumed |
167
+ | `wsAdapter(adapter)` | the WebSocket adapter, when `serve()` does not pick one |
168
+ | `fetch(request, env, ctx)` | the Fetch handler an adapter calls |
169
+
170
+ Full reference: [the app](https://github.com/lovrozagar/honey/blob/main/docs/app-settings.md).
171
+
172
+ ## Routing
173
+
174
+ One method per call (`get`, `post`, `put`, `patch`, `delete`, `head`, `options`), `all` for any, and `on` for a named
175
+ set. Paths take `:params` and a trailing `*` wildcard; `meta()` carries what the document says about the route.
176
+
177
+ ```ts
178
+ import { honey } from "@lovrozagar/honey"
179
+ import { serveOpenApi } from "@lovrozagar/honey/openapi"
180
+
181
+ const app = honey()
182
+
183
+ app
184
+ .get("/users/:id")
185
+ .meta({ operationId: "users.get", summary: "One user", tags: ["users"] })
186
+ .handler((ctx) => ctx.res.json("ok", { id: ctx.params.id }))
187
+
188
+ app.delete("/users/:id").handler((ctx) => ctx.res.noContent())
189
+
190
+ serveOpenApi(app, { title: "Users", version: "1.0.0", docs: "scalar" })
191
+ ```
192
+
193
+ Sub-apps mount with `route()`, and `on()` registers one handler for several methods:
194
+
195
+ ```ts
196
+ import { honey } from "@lovrozagar/honey"
197
+
198
+ const admin = honey()
199
+ admin.get("/stats").handler((ctx) => ctx.res.json("ok", { users: 1 }))
200
+
201
+ const app = honey()
202
+ app.route("/admin", admin)
203
+ app.on(["POST", "PUT"], "/webhooks/:source").handler((ctx) => ctx.res.json("accepted", { from: ctx.params.source }))
204
+ ```
205
+
206
+ A gateway merges the route trees its services publish, each tree tagged with meta of its own:
207
+
208
+ ```ts
209
+ import { mergeTree } from "@lovrozagar/honey"
210
+ import type { RouteTree } from "@lovrozagar/honey/tree"
211
+
212
+ declare const orders: RouteTree
213
+ declare const billing: RouteTree
214
+
215
+ const gateway = mergeTree([orders, { service: "orders" }], [billing, { service: "billing" }])
216
+ ```
217
+
218
+ Full reference: [routing](https://github.com/lovrozagar/honey/blob/main/docs/routing.md).
219
+
220
+ ## Input and output
221
+
222
+ `.input()` validates the request: `params`, `search`, `headers`, `json`, `form` and `text`, each a Standard Schema.
223
+ `.output()` declares what the route answers, by media type and status key, and the declared response headers. A
224
+ media type other than JSON is just another key, so an image or a CSV is as typed as a JSON body:
225
+
226
+ ```ts
227
+ import { honey } from "@lovrozagar/honey"
228
+ import * as z from "zod"
229
+
230
+ const app = honey()
231
+
232
+ app
233
+ .get("/avatar/:id")
234
+ .output({
235
+ "image/png": { ok: z.instanceof(Uint8Array) },
236
+ headers: { ok: z.object({ etag: z.string() }) },
237
+ })
238
+ .handler((ctx) =>
239
+ ctx.res.binary("ok", new Uint8Array([137, 80, 78, 71]), { contentType: "image/png", headers: { etag: '"v1"' } }),
240
+ )
241
+ ```
242
+
243
+ A body you do not want in memory is input too: `readableStream()` hands the handler the stream itself.
244
+
245
+ ```ts
246
+ import { honey } from "@lovrozagar/honey"
247
+ import { readableStream } from "@lovrozagar/honey/input"
248
+ import * as z from "zod"
249
+
250
+ const app = honey()
251
+
252
+ app
253
+ .post("/ingest")
254
+ .input({ json: readableStream(z.object({ chunks: z.number(), fileName: z.string() })) })
255
+ .handler(async (ctx) => {
256
+ /* the schema is what the document and the clients promise; the body itself stays on the request */
257
+ const reader = (ctx.req.body as ReadableStream<Uint8Array>).getReader()
258
+ await reader.cancel()
259
+ return ctx.res.json("accepted", { taken: true })
260
+ })
261
+ ```
262
+
263
+ Content negotiation, when a route answers in more than one shape, reads the request directly:
264
+
265
+ ```ts
266
+ import { accepts } from "@lovrozagar/honey/accepts"
267
+ import { honey } from "@lovrozagar/honey"
268
+
269
+ const app = honey()
270
+
271
+ app.get("/report").handler((ctx) => {
272
+ const type = accepts(ctx.req, ["text/csv", "application/json"])
273
+ if (type === "text/csv") return ctx.res.csv("ok", "name,count\nada,1\n")
274
+ return ctx.res.json("ok", [{ count: 1, name: "ada" }])
275
+ })
276
+ ```
277
+
278
+ Full reference: [input and output](https://github.com/lovrozagar/honey/blob/main/docs/input-output.md).
279
+
280
+ ## Responses
281
+
282
+ `ctx.res` builds a typed response: `json`, `text`, `html`, `xml`, `csv`, `binary`, `noContent`, `redirect`, `raw` for
283
+ a `Response` you made yourself, and `sse`, `stream` and `generate` for bodies that arrive over time. The first
284
+ argument is the status key the route declared, which is what makes the payload's type the right one.
285
+
286
+ ```ts
287
+ import { honey } from "@lovrozagar/honey"
288
+
289
+ const app = honey()
290
+
291
+ app.get("/page").handler((ctx) => ctx.res.html("ok", "<h1>hi</h1>"))
292
+ app.get("/feed").handler((ctx) => ctx.res.xml("ok", "<feed />"))
293
+ app.get("/old").handler((ctx) => ctx.res.redirect("/new"))
294
+ app.get("/nothing").handler((ctx) => ctx.res.noContent())
295
+ app.get("/bytes").handler((ctx) =>
296
+ ctx.res.stream(async (writable) => {
297
+ const writer = writable.getWriter()
298
+ await writer.write(new TextEncoder().encode("chunk"))
299
+ await writer.close()
300
+ }),
301
+ )
302
+ ```
303
+
304
+ Caching, entity tags and byte ranges are middleware and helpers over any of those:
305
+
306
+ ```ts
307
+ import { honey } from "@lovrozagar/honey"
308
+ import { cacheControl } from "@lovrozagar/honey/cache-control"
309
+ import { etag } from "@lovrozagar/honey/etag"
310
+ import { prettyJson } from "@lovrozagar/honey/pretty-json"
311
+ import { rangeResponse } from "@lovrozagar/honey/range"
312
+
313
+ const video = new Blob([new Uint8Array(1024)], { type: "video/mp4" })
314
+
315
+ const app = honey()
316
+ .use(etag())
317
+ .use(prettyJson({ query: "pretty" }))
318
+ .use(cacheControl({ public: true, maxAge: 60, staleWhileRevalidate: 300 }))
319
+
320
+ app.get("/prices").handler((ctx) => ctx.res.json("ok", { eur: 1.08 }))
321
+ app.get("/video").handler((ctx) => ctx.res.raw(rangeResponse(ctx.req, video)))
322
+ ```
323
+
324
+ `etag()` answers a conditional GET with 304, `rangeResponse` a `Range` request with 206, and `cacheControl` sets the
325
+ header once for every route under it. `httpDate` from the same subpath formats an HTTP date, and `notModified` from
326
+ `@lovrozagar/honey/etag` turns a response into its 304.
327
+
328
+ Streaming bodies clean up when the client leaves — the generator's `finally` runs:
329
+
330
+ ```ts run
331
+ import { honey } from "@lovrozagar/honey"
332
+ import { serve } from "@lovrozagar/honey/serve"
333
+
334
+ let ended = (_: string) => {}
335
+ const cleanedUp = new Promise<string>((resolve) => (ended = resolve))
336
+
337
+ const app = honey()
338
+
339
+ app.get("/ticks").handler((ctx) =>
340
+ ctx.res.sse(
341
+ async (stream) => {
342
+ try {
343
+ for (let tick = 1; !stream.closed; tick++) {
344
+ await stream.send({ data: String(tick), event: "tick", id: String(tick) })
345
+ await new Promise((resolve) => setTimeout(resolve, 50))
346
+ }
347
+ } finally {
348
+ stream.close()
349
+ ended("cleaned up")
350
+ }
351
+ },
352
+ { defaultRetry: 3000 },
353
+ ),
354
+ )
355
+
356
+ const server = await serve(app, { hostname: "127.0.0.1", port: 0 })
357
+ const leave = new AbortController()
358
+ const response = await fetch(`${server.url}/ticks`, { signal: leave.signal })
359
+ const reader = (response.body as ReadableStream<Uint8Array>).getReader()
360
+ const first = new TextDecoder().decode((await reader.read()).value)
361
+ console.log(first.includes("retry: 3000") ? "retry sent" : "no retry")
362
+ leave.abort()
363
+ console.log(await cleanedUp)
364
+ await server.close()
365
+ // prints: retry sent
366
+ // prints: cleaned up
367
+ ```
368
+
369
+ Full reference: [streaming](https://github.com/lovrozagar/honey/blob/main/docs/streaming.md),
370
+ [media types](https://github.com/lovrozagar/honey/blob/main/docs/capabilities/media-types.md),
371
+ [range requests](https://github.com/lovrozagar/honey/blob/main/docs/capabilities/range.md) and
372
+ [Cache-Control](https://github.com/lovrozagar/honey/blob/main/docs/capabilities/cache-control.md).
373
+
374
+ ## Errors
375
+
376
+ `defineErrors` maps your own keys to HTTP statuses and gives you the factory a route throws from. A route declares
377
+ which keys it can throw with `.errors()`, and those keys are what the document and every generated client know
378
+ about.
379
+
380
+ ```ts
381
+ import { defineErrors, honey } from "@lovrozagar/honey"
382
+
383
+ const errors = defineErrors({ out_of_stock: "conflict" })
384
+
385
+ const app = honey().errorFormat("problem+json")
386
+
387
+ app
388
+ .post("/orders")
389
+ .errors(errors, "out_of_stock")
390
+ .handler(() => {
391
+ throw errors.out_of_stock()
392
+ })
393
+ ```
394
+
395
+ `errorFormat("problem+json")` answers RFC 9457 Problem Details instead of honey's own body. Anything thrown becomes a
396
+ `HoneyError`; `onError` sees it first, and `ERROR_META` is the symbol a thrown value carries its status under, so a
397
+ value from elsewhere can present itself as one. `createErrorResponse` builds the body outside a request, which is
398
+ what a custom formatter or a test harness needs.
399
+
400
+ ```ts
401
+ import {
402
+ createErrorResponse,
403
+ defineErrors,
404
+ ERROR_META,
405
+ type ErrorFormatter,
406
+ HoneyError,
407
+ honey,
408
+ } from "@lovrozagar/honey"
409
+
410
+ const errors = defineErrors({ db_timeout: "service_unavailable" })
411
+
412
+ const app = honey().onError((error, ctx) => {
413
+ if (error instanceof HoneyError && error.errorKey === "db_timeout") return ctx.jsonFromError(error)
414
+ return undefined
415
+ })
416
+
417
+ app.get("/").handler((ctx) => ctx.res.text("ok", "up"))
418
+
419
+ /* a thrown value of your own, carrying the status honey should answer with */
420
+ const refusal = Object.assign(new Error("nope"), { [ERROR_META]: { errorKey: "forbidden", status: 403 } })
421
+
422
+ /* the body an error answers with: the default shape, plus whatever your API adds to it */
423
+ const formatter: ErrorFormatter = (error, defaultShape) => ({ ...defaultShape, retriable: error.status >= 500 })
424
+ const answer = createErrorResponse(errors.db_timeout(), formatter, null, { format: "problem+json" })
425
+ ```
426
+
427
+ Full reference: [problem+json](https://github.com/lovrozagar/honey/blob/main/docs/capabilities/problem-json.md) and
428
+ [logging, errors and tracing](https://github.com/lovrozagar/honey/blob/main/docs/observability.md).
429
+
430
+ ## Middleware
431
+
432
+ A middleware is a function of `(ctx, next)`. Calling `next({ … })` adds typed values to the context every route below
433
+ it sees — that is how per-request data (a user, a tenant, a database handle) reaches a handler without a global.
434
+
435
+ ```ts
436
+ import { type MiddlewareFn, honey } from "@lovrozagar/honey"
437
+ import { cors } from "@lovrozagar/honey/cors"
438
+ import { rateLimit } from "@lovrozagar/honey/rate-limit"
439
+ import { secureHeaders } from "@lovrozagar/honey/secure-headers"
440
+
441
+ type UserCtx = { user: { id: string } | null }
442
+
443
+ const withUser: MiddlewareFn<{ req: Request }, UserCtx> = (ctx, next) => {
444
+ const token = ctx.req.headers.get("authorization")
445
+ return next({ user: token === null ? null : { id: "u1" } })
446
+ }
447
+
448
+ const app = honey()
449
+ .use(secureHeaders())
450
+ .use(cors({ origin: ["https://app.example.com"], credentials: true }))
451
+ .use(rateLimit({ limit: 100, windowMs: 60_000 }))
452
+ .use(withUser)
453
+
454
+ app.get("/me").handler((ctx) => ctx.res.json("ok", { user: ctx.user }))
455
+ ```
456
+
457
+ `createMiddleware` is the same thing with the context type given once, and `defineMiddleware` keeps a middleware
458
+ reusable across apps:
459
+
460
+ ```ts
461
+ import { createMiddleware, defineMiddleware, honey } from "@lovrozagar/honey"
462
+
463
+ const requireKey = createMiddleware<{ req: Request }>((ctx, next) =>
464
+ ctx.req.headers.get("x-api-key") === null ? new Response(null, { status: 401 }) : next(),
465
+ )
466
+
467
+ const stamp = defineMiddleware({ fn: (_ctx: { req: Request }, next) => next({ at: Date.now() }) })
468
+
469
+ const app = honey().use(requireKey).use(stamp)
470
+ app.get("/me").handler((ctx) => ctx.res.json("ok", { at: ctx.at }))
471
+ ```
472
+
473
+ The bundled set, each its own subpath so an app that does not import it bundles none of it:
474
+
475
+ ```ts
476
+ import { honey } from "@lovrozagar/honey"
477
+ import { bodyLimit } from "@lovrozagar/honey/body-limit"
478
+ import { compress } from "@lovrozagar/honey/compress"
479
+ import { csrf } from "@lovrozagar/honey/csrf"
480
+ import { ipRestrict } from "@lovrozagar/honey/ip-restrict"
481
+ import { poweredBy } from "@lovrozagar/honey/powered-by"
482
+ import { requestId } from "@lovrozagar/honey/request-id"
483
+ import { serverTiming } from "@lovrozagar/honey/server-timing"
484
+ import { timeout } from "@lovrozagar/honey/timeout"
485
+
486
+ const app = honey()
487
+ .use(requestId())
488
+ .use(poweredBy())
489
+ .use(serverTiming())
490
+ .use(timeout({ duration: 10_000 }))
491
+ .use(bodyLimit({ maxSize: 1024 * 1024 }))
492
+ .use(compress({ threshold: 2048 }))
493
+ .use(csrf({ origin: ["https://app.example.com"] }))
494
+ .use(ipRestrict({ allowList: ["203.0.113.0/24"] }))
495
+
496
+ app.get("/report").handler((ctx) => ctx.res.text("ok", "row\n".repeat(5000)))
497
+ ```
498
+
499
+ | Middleware | From | What it does |
500
+ | ------------------------- | ---------------------------------- | ---------------------------------------------------------- |
501
+ | `cors(options)` | `@lovrozagar/honey/cors` | the CORS headers and the preflight answer |
502
+ | `secureHeaders(options?)` | `@lovrozagar/honey/secure-headers` | CSP, HSTS and the rest of the hardening headers |
503
+ | `csrf(options?)` | `@lovrozagar/honey/csrf` | refuses a cross-origin unsafe method |
504
+ | `rateLimit(options)` | `@lovrozagar/honey/rate-limit` | a window per key, with `memoryStore()` or one of your own |
505
+ | `compress(options?)` | `@lovrozagar/honey/compress` | gzip or brotli over a threshold |
506
+ | `cacheControl(options)` | `@lovrozagar/honey/cache-control` | the `Cache-Control` header, and `httpDate` |
507
+ | `etag(options?)` | `@lovrozagar/honey/etag` | an entity tag, 304 on a conditional GET, and `notModified` |
508
+ | `prettyJson(options?)` | `@lovrozagar/honey/pretty-json` | indents a JSON body when the query asks |
509
+ | `requestId(options?)` | `@lovrozagar/honey/request-id` | one id per request, on the response and in the log |
510
+ | `timeout(options)` | `@lovrozagar/honey/timeout` | a deadline per request |
511
+ | `bodyLimit(options)` | `@lovrozagar/honey/body-limit` | refuses a body over a size |
512
+ | `ipRestrict(options)` | `@lovrozagar/honey/ip-restrict` | an allow or deny list over `ctx.ip` |
513
+ | `serverTiming()` | `@lovrozagar/honey/server-timing` | `Server-Timing`, and `ctx.timing` to add to it |
514
+ | `poweredBy(options?)` | `@lovrozagar/honey/powered-by` | the `X-Powered-By` header |
515
+ | `staticFiles(options)` | `@lovrozagar/honey/static` | serves files under a prefix |
516
+ | `curlLogger(options?)` | `@lovrozagar/honey/curl-logger` | logs each request as the command that would repeat it |
517
+
518
+ Full reference: [middleware](https://github.com/lovrozagar/honey/blob/main/docs/middleware.md),
519
+ [rate limiting](https://github.com/lovrozagar/honey/blob/main/docs/capabilities/rate-limit.md) and
520
+ [compression](https://github.com/lovrozagar/honey/blob/main/docs/capabilities/compress.md).
521
+
522
+ ## The request context
523
+
524
+ The handler's `ctx` is one object, and these keys are reserved on it: `req`, `env`, `executionCtx`, `path`,
525
+ `params`, `search`, `searchAll`, `headers`, `input`, `ip`, `res`, `errors`, `error`, `meta`, `routePattern`,
526
+ `cookies`, `log`, `logger`, `signal`, `background` and `tap`. Everything else on `ctx` is yours, from `context()` or
527
+ from a middleware's `next({ … })`.
528
+
529
+ ```ts
530
+ import { honey } from "@lovrozagar/honey"
531
+
532
+ const app = honey()
533
+
534
+ app.get("/where/:id").handler((ctx) => {
535
+ ctx.log.info({ id: ctx.params.id }, "asked")
536
+ ctx.background(Promise.resolve())
537
+ return ctx.res.json("ok", {
538
+ accept: ctx.headers.accept ?? null,
539
+ aborted: ctx.signal.aborted,
540
+ id: ctx.params.id,
541
+ ip: ctx.ip,
542
+ pattern: ctx.routePattern,
543
+ path: ctx.path,
544
+ tags: ctx.searchAll.tag ?? [],
545
+ verbose: ctx.search.verbose ?? null,
546
+ })
547
+ })
548
+ ```
549
+
550
+ `background(promise)` keeps work alive past the response where the runtime allows it, `signal` aborts when the client
551
+ leaves, and `log` is the request's logger with the request id already bound. `HoneyContext` is the class behind it
552
+ and `HoneyRes` the response builder, both exported for a helper of your own that takes either.
553
+
554
+ ## Cookies
555
+
556
+ `ctx.cookies` reads them, and a response sets them. Signed cookies are a signer you build from your secrets, newest
557
+ first, so a rotation keeps the old ones verifying:
558
+
559
+ ```ts
560
+ import { honey } from "@lovrozagar/honey"
561
+ import { createCookieSigner } from "@lovrozagar/honey/cookie-sign"
562
+
563
+ const signer = createCookieSigner([process.env.COOKIE_SECRET ?? ""])
564
+
565
+ const app = honey()
566
+
567
+ app.post("/login").handler(async (ctx) => {
568
+ const session = await signer.sign("user-1")
569
+ const cookie = { value: session, httpOnly: true, secure: true, sameSite: "lax" as const, path: "/" }
570
+ return ctx.res.json("ok", { signedIn: true }, { cookies: { session: cookie } })
571
+ })
572
+
573
+ app.get("/me").handler(async (ctx) => {
574
+ const raw = ctx.cookies.session
575
+ const user = raw === undefined ? null : await signer.verify(raw)
576
+ return ctx.res.json("ok", { user })
577
+ })
578
+ ```
579
+
580
+ `@lovrozagar/honey/cookie` has the header functions on their own (`serializeCookie`, `parseCookieHeader`) and
581
+ `@lovrozagar/honey/cookie-sign` the one-shot `sign` and `verify`:
582
+
583
+ ```ts
584
+ import { parseCookieHeader, serializeCookie } from "@lovrozagar/honey/cookie"
585
+ import { sign, verify } from "@lovrozagar/honey/cookie-sign"
586
+
587
+ declare const req: Request
588
+ declare const SECRET: string
589
+ declare const OLD_SECRET: string
590
+
591
+ const jar = parseCookieHeader(req.headers.get("cookie") ?? "")
592
+ const header = serializeCookie("theme", { value: "dark", httpOnly: true, path: "/", sameSite: "lax" })
593
+
594
+ /* a signature bound to the cookie's name; verify takes every secret still accepted, newest first */
595
+ const signed = await sign("abc", SECRET, { name: "sid" })
596
+ const value = await verify(signed, [SECRET, OLD_SECRET], { name: "sid" })
597
+ ```
598
+
599
+ Full reference: [cookies](https://github.com/lovrozagar/honey/blob/main/docs/cookies.md).
600
+
601
+ ## Streaming, WebSockets and realtime
602
+
603
+ A WebSocket route is `ws(app, path)`; a resumable connection — one logical subscription over WebSocket, SSE or
604
+ long-poll, whichever the client can hold — is `realtime(app, path, options)`. Both publish to rooms, and a plain
605
+ route can publish into them.
606
+
607
+ ```ts
608
+ import { honey } from "@lovrozagar/honey"
609
+ import { realtime } from "@lovrozagar/honey/realtime"
610
+ import { ws } from "@lovrozagar/honey/ws"
611
+
612
+ const app = honey()
613
+
614
+ ws(app, "/echo").handler({
615
+ onMessage(_ctx, socket, data) {
616
+ socket.send(data)
617
+ },
618
+ })
619
+
620
+ const chat = realtime(app, "/chat/:room", {
621
+ namespace: "chat",
622
+ handler: (_ctx, conn) => {
623
+ conn.join("lobby")
624
+ conn.on("message", (payload) => conn.publish("lobby", payload))
625
+ },
626
+ })
627
+
628
+ app.post("/announce").handler(async (ctx) => {
629
+ chat.publish("lobby", await ctx.req.json())
630
+ return ctx.res.json("ok", { sent: true })
631
+ })
632
+ ```
633
+
634
+ A publisher outside a connection works the same way, which is what a ticker is:
635
+
636
+ ```ts
637
+ import { honey } from "@lovrozagar/honey"
638
+ import { realtime } from "@lovrozagar/honey/realtime"
639
+
640
+ const app = honey()
641
+
642
+ const prices = realtime(app, "/prices", {
643
+ namespace: "prices",
644
+ handler: (_ctx, conn) => {
645
+ conn.join("ticker")
646
+ },
647
+ })
648
+
649
+ setInterval(() => prices.publish("ticker", { eur: 1.08, at: Date.now() }), 1000)
650
+ ```
651
+
652
+ `serve()` picks the adapter for Node and Bun; a Worker sets one itself. `createWsSession` from
653
+ `@lovrozagar/honey/ws/session` is the session runtime those adapters share, for an adapter of your own:
654
+
655
+ ```ts
656
+ import { createWsSession } from "@lovrozagar/honey/ws/session"
657
+ import { bunWebSocket } from "@lovrozagar/honey/ws/bun"
658
+ import { cfWebSocket } from "@lovrozagar/honey/ws/cloudflare"
659
+ import { nodeWebSocket } from "@lovrozagar/honey/ws/node"
660
+ import { honey } from "@lovrozagar/honey"
661
+
662
+ /* each runtime's adapter; serve() sets the first two, a Worker entry the third */
663
+ const adapters = [bunWebSocket(), nodeWebSocket(), cfWebSocket()]
664
+ const app = honey().wsAdapter(cfWebSocket())
665
+
666
+ /* the ordered lane a custom adapter drives: open, then messages, then close */
667
+ const session = createWsSession({
668
+ callbacks: { onMessage: (_ctx, socket, data) => socket.send(data) },
669
+ ctx: {},
670
+ log: null,
671
+ reconnectToken: null,
672
+ route: "/echo",
673
+ })
674
+ ```
675
+
676
+ `nodeWebSocket()` needs the optional `ws` peer installed; the Bun and Workers adapters use their runtime's own
677
+ sockets. A resumed connection replays from the client's `Last-Event-ID` or `reconnect_token`.
678
+
679
+ Full reference: [WebSockets, realtime and SSE](https://github.com/lovrozagar/honey/blob/main/docs/realtime.md) and
680
+ [realtime resume](https://github.com/lovrozagar/honey/blob/main/docs/capabilities/realtime-resume.md).
681
+
682
+ ## Serving on Node, Bun and Cloudflare Workers
683
+
684
+ One app runs on each of them; only how it is served differs.
685
+
686
+ | Runtime | How it runs |
687
+ | ------------------ | --------------------------------------------------------------------------------- |
688
+ | Node | `serve(app)` from `@lovrozagar/honey/serve` |
689
+ | Bun | `serve(app)`, or `Bun.serve({ fetch: app.fetch })` |
690
+ | Cloudflare Workers | `export default app` — the Worker's `fetch` handler, with named exports beside it |
691
+
692
+ ```ts
693
+ import { detectRuntime, honey, toFetchRequest } from "@lovrozagar/honey"
694
+ import { serve } from "@lovrozagar/honey/serve"
695
+
696
+ const app = honey()
697
+ app.get("/health").handler((ctx) => ctx.res.text("ok", detectRuntime()))
698
+
699
+ /* a request from an adapter that handed you its own Request subclass */
700
+ declare const incoming: Request
701
+ const request = toFetchRequest(incoming)
702
+
703
+ const server = await serve(app, { hostname: "127.0.0.1", maxRequestBodySize: 8 * 1024 * 1024, port: 0 })
704
+ console.log(server.url)
705
+ await server.close()
706
+ ```
707
+
708
+ `@lovrozagar/honey/serve` is the one to reach for: it resolves the runtime itself and returns a `ServeHandle` with
709
+ the bound `url` and a `close()` that drains. `@lovrozagar/honey/node` is `node:http` directly, for an existing
710
+ server or a framework that hands you one:
711
+
712
+ ```ts
713
+ import { honey } from "@lovrozagar/honey"
714
+ import { serve } from "@lovrozagar/honey/node"
715
+
716
+ const app = honey()
717
+ app.get("/health").handler((ctx) => ctx.res.text("ok", "ok"))
718
+
719
+ const server = await serve(app, { env: {}, port: 0 })
720
+ await server.close()
721
+ ```
722
+
723
+ On Workers the app is the default export, and `app.fetch` the handler to wrap when you need to:
724
+
725
+ ```ts
726
+ import { honey } from "@lovrozagar/honey"
727
+
728
+ type Env = { readonly SECRET: string }
729
+
730
+ const app = honey<Env>()
731
+ app.get("/health").handler((ctx) => ctx.res.text("ok", "ok"))
732
+
733
+ export default { fetch: (...args: Parameters<typeof app.fetch>) => app.fetch(...args) }
734
+ ```
735
+
736
+ ## Static files and proxying
737
+
738
+ `staticFiles()` serves what a resolver of yours returns, so the bytes can come from a bundle, a KV store or a disk
739
+ read — honey never assumes a filesystem.
740
+
741
+ ```ts
742
+ import { honey } from "@lovrozagar/honey"
743
+ import { cacheControl } from "@lovrozagar/honey/cache-control"
744
+ import { staticFiles } from "@lovrozagar/honey/static"
745
+
746
+ const assets = new Map<string, Blob>([["/app.js", new Blob(["console.log(1)"])]])
747
+
748
+ const app = honey()
749
+ .use(cacheControl({ public: true, maxAge: 3600 }))
750
+ .use(staticFiles({ prefix: "/assets", resolve: (_ctx, path) => assets.get(path) ?? null }))
751
+ ```
752
+
753
+ `proxy()` is a handler that forwards, which is what makes an app a gateway: you own the destination call, so the
754
+ upstream is explicit and nothing is reachable by accident.
755
+
756
+ ```ts
757
+ import { honey } from "@lovrozagar/honey"
758
+ import { proxy } from "@lovrozagar/honey/proxy"
759
+
760
+ const app = honey()
761
+
762
+ app.all("/billing/*").handler(
763
+ proxy({
764
+ destination: (_ctx, url, init) => fetch(`https://billing.internal.example.com${url}`, init),
765
+ requestHeaders: { "x-gateway": "edge" },
766
+ timeout: 10_000,
767
+ }),
768
+ )
769
+ ```
770
+
771
+ Full reference: [static files](https://github.com/lovrozagar/honey/blob/main/docs/static.md) and
772
+ [proxying and gateways](https://github.com/lovrozagar/honey/blob/main/docs/proxy.md).
773
+
774
+ ## The OpenAPI document
775
+
776
+ `serveOpenApi(app, options)` returns the app with the document and its reference UI mounted; `generateOpenApi` gives
777
+ you the document as a value instead, for a file you commit or a build step. Tags, servers with variables,
778
+ externalDocs and webhooks are all declared, never guessed.
779
+
780
+ ```ts
781
+ import { honey } from "@lovrozagar/honey"
782
+ import { serveOpenApi } from "@lovrozagar/honey/openapi"
783
+
784
+ const app = honey()
785
+
786
+ app
787
+ .get("/orders")
788
+ .meta({ summary: "List orders", tags: ["orders"] })
789
+ .handler((ctx) => ctx.res.json("ok", []))
790
+
791
+ serveOpenApi(app, {
792
+ title: "Shop",
793
+ version: "1.2.0",
794
+ servers: [{ url: "https://{region}.api.example.com", variables: { region: { default: "eu" } } }],
795
+ tags: [{ name: "orders", description: "Placing and tracking orders" }],
796
+ docs: "scalar",
797
+ })
798
+ ```
799
+
800
+ The document as a value, the route manifest, YAML, and either reference page explicitly:
801
+
802
+ ```ts
803
+ import { honey } from "@lovrozagar/honey"
804
+ import { generateManifest, generateOpenApi, serveManifest, toYaml } from "@lovrozagar/honey/openapi"
805
+ import { scalar } from "@lovrozagar/honey/openapi/scalar"
806
+ import { swagger } from "@lovrozagar/honey/openapi/swagger"
807
+
808
+ const app = honey()
809
+ app.get("/orders").handler((ctx) => ctx.res.json("ok", []))
810
+
811
+ const document = generateOpenApi(app, { info: { title: "Shop", version: "1.2.0" } })
812
+ const manifest = generateManifest(app, {})
813
+ const yaml = toYaml(document)
814
+
815
+ serveManifest(app, { path: "/manifest.json" })
816
+
817
+ /* the two reference pages, if you mount one yourself rather than through docs: */
818
+ const pages = [scalar({ title: "Shop", url: "/openapi.json" }), swagger({ title: "Shop", url: "/openapi.json" })]
819
+ ```
820
+
821
+ Webhooks — the requests your API sends out — are declared with the document, signature and all, so a generated
822
+ client gets a typed parser and a verifier for them:
823
+
824
+ ```ts
825
+ import { honey } from "@lovrozagar/honey"
826
+ import { serveOpenApi } from "@lovrozagar/honey/openapi"
827
+ import * as z from "zod"
828
+
829
+ const app = honey()
830
+
831
+ serveOpenApi(app, {
832
+ title: "Shop",
833
+ version: "1.0.0",
834
+ webhooks: {
835
+ orderShipped: {
836
+ body: z.object({ orderId: z.string(), shippedAt: z.string() }),
837
+ signature: { algorithm: "hmac-sha256", encoding: "hex", header: "x-signature", prefix: "sha256=" },
838
+ summary: "An order left the warehouse",
839
+ },
840
+ },
841
+ })
842
+ ```
843
+
844
+ Full reference: [the OpenAPI document](https://github.com/lovrozagar/honey/blob/main/docs/openapi.md) and
845
+ [webhooks](https://github.com/lovrozagar/honey/blob/main/docs/capabilities/webhooks.md).
846
+
847
+ ## The MCP server
848
+
849
+ There are two ways to serve your API's tools over the Model Context Protocol, and they run in different places. The
850
+ standalone server `honey-clients generate --target mcp` writes needs Node, and is what a local stdio install
851
+ (Claude Desktop, Claude Code, Cursor) starts. The mounted endpoint below runs wherever the app runs — Workers
852
+ included — dispatches in process, and is guarded by the app's own auth. It is the one to use remotely.
853
+
854
+ `mcp(app, path, handler, options)` mounts the handler `codegen.mcp` generated beside your app:
855
+
856
+ ```ts
857
+ import { honey } from "@lovrozagar/honey"
858
+ import { type McpHandler, mcp } from "@lovrozagar/honey/mcp"
859
+
860
+ /* in the app: import { mcpHandler } from "./_gen/mcp.gen.ts" */
861
+ declare const mcpHandler: McpHandler
862
+
863
+ const app = honey()
864
+ mcp(app, "/mcp", mcpHandler, {
865
+ authenticate: (request) => /^ApiKey \S+$/.test(request.headers.get("authorization") ?? ""),
866
+ })
867
+ ```
868
+
869
+ Three opt-ins ride on the mount. `oauth()` makes the endpoint an OAuth 2.1 resource server (RFC 9728 metadata and
870
+ Bearer challenges), which is what ChatGPT requires; `uploads()` takes files out of band, so a large file never goes
871
+ through a tool argument; `urlFiles()` lets a file argument be an https URL from a host you list.
872
+
873
+ ```ts
874
+ import { honey } from "@lovrozagar/honey"
875
+ import { type McpHandler, mcp } from "@lovrozagar/honey/mcp"
876
+ import { oauth } from "@lovrozagar/honey/mcp/oauth"
877
+ import { memoryUploadStore, uploads } from "@lovrozagar/honey/mcp/uploads"
878
+ import { urlFiles } from "@lovrozagar/honey/mcp/url-files"
879
+
880
+ /* in the app: import { mcpHandler } from "./_gen/mcp.gen.ts" */
881
+ declare const mcpHandler: McpHandler
882
+ declare const UPLOAD_SECRET: string
883
+
884
+ const app = honey()
885
+ mcp(app, "/mcp", mcpHandler, {
886
+ authenticate: (request) => /^ApiKey \S+$/.test(request.headers.get("authorization") ?? ""),
887
+ oauth: oauth({
888
+ authorizationServers: ["https://auth.example.com"],
889
+ resource: "https://mcp.example.com/mcp",
890
+ resourceName: "Example",
891
+ scopesSupported: ["mcp:read"],
892
+ }),
893
+ uploads: uploads({ secret: UPLOAD_SECRET, store: memoryUploadStore() }),
894
+ urlFiles: urlFiles({ hosts: ["files.example.com"] }),
895
+ })
896
+ ```
897
+
898
+ `memoryUploadStore()` is for one process; on Workers the store is yours (a Durable Object for the claim, R2 for the
899
+ bytes), and the MCP page shows that end to end. Only a route marked `meta({ mcp: true })` becomes a tool, and each
900
+ tool's input and output schemas come from the route's own.
901
+
902
+ Full reference: [the generated MCP server](https://github.com/lovrozagar/honey/blob/main/docs/mcp.md) and
903
+ [MCP structured output](https://github.com/lovrozagar/honey/blob/main/docs/capabilities/mcp-structured-output.md).
904
+
905
+ ## Logging and tracing
906
+
907
+ `logger()` sets where lines go, `requestLog()` turns on the one line per request, and `onError` maps a thrown value
908
+ to a response before the error is reported.
909
+
910
+ ```ts
911
+ import { HoneyError, honey } from "@lovrozagar/honey"
912
+ import { createLogger } from "@lovrozagar/honey/logger"
913
+ import { requestId } from "@lovrozagar/honey/request-id"
914
+
915
+ const app = honey()
916
+ .use(requestId())
917
+ .logger(createLogger({ level: "info" }))
918
+ .requestLog(true)
919
+ .onError((error, ctx) => {
920
+ if (error instanceof HoneyError && error.errorKey === "db_timeout") return ctx.jsonFromError(error)
921
+ return undefined
922
+ })
923
+
924
+ app.get("/").handler((ctx) => ctx.res.text("ok", "up"))
925
+ ```
926
+
927
+ Tracing is an adapter: `otelAdapter` nests `http.request` → `middleware.*` → `http.handler` and continues an incoming
928
+ W3C `traceparent`, so a span from a caller stays one trace. With it on, the logger's child binds `trace_id` and
929
+ `span_id`, and `requestId` stays the client-facing id.
930
+
931
+ ```ts
932
+ import { honey } from "@lovrozagar/honey"
933
+ import { createLogger } from "@lovrozagar/honey/logger"
934
+ import { otelAdapter, type OtelApi } from "@lovrozagar/honey/telemetry/otel"
935
+
936
+ declare const api: OtelApi
937
+
938
+ const app = honey()
939
+ .logger(createLogger({ level: "info" }))
940
+ .telemetry(otelAdapter(api))
941
+ .requestLog(true)
942
+
943
+ app.get("/").handler((ctx) => ctx.res.text("ok", "up"))
944
+ ```
945
+
946
+ A request logged as the command that would repeat it is its own middleware, and the same formatter is available on
947
+ its own for a failing call you want to paste into a terminal — both redact credentials by default:
948
+
949
+ ```ts
950
+ import { honey } from "@lovrozagar/honey"
951
+ import { curlLogger } from "@lovrozagar/honey/curl-logger"
952
+ import { requestToCurl } from "@lovrozagar/honey/request-to-curl"
953
+
954
+ declare const req: Request
955
+
956
+ const app = honey().use(curlLogger())
957
+ app.get("/").handler((ctx) => ctx.res.text("ok", "up"))
958
+
959
+ /* the authorization header left out of the logged command */
960
+ const command = await requestToCurl(req, { excludeHeader: (name) => name === "authorization" })
961
+ ```
962
+
963
+ Full reference: [logging, errors and tracing](https://github.com/lovrozagar/honey/blob/main/docs/observability.md).
964
+
965
+ ## Internationalization and crypto
966
+
967
+ Error messages translate through a dictionary you supply, and ICU messages interpolate with plurals:
968
+
969
+ ```ts run
970
+ import { interpolate } from "@lovrozagar/honey/i18n"
971
+
972
+ const message = "{count, plural, one {# new message} other {# new messages}} for {name}"
973
+ console.log(interpolate(message, { count: 3, name: "Ada" }, "en"))
974
+ // prints: 3 new messages for Ada
975
+ ```
976
+
977
+ ```ts
978
+ import { defineErrors, honey } from "@lovrozagar/honey"
979
+ import { errorI18n, translateError } from "@lovrozagar/honey/i18n"
980
+
981
+ const errors = defineErrors({ not_found: "not_found" })
982
+
983
+ /* locale, then error key, then the ICU template that key's message reads from */
984
+ const translations = { errors: { de: { not_found: "Nicht gefunden" }, en: { not_found: "Not found" } } }
985
+
986
+ const app = honey().errorI18n(
987
+ errorI18n({ ...translations, resolveLocale: (ctx) => ctx.headers["accept-language"]?.slice(0, 2) ?? "en" }),
988
+ )
989
+
990
+ app.get("/books/:id").handler((ctx) => ctx.res.json("ok", { id: ctx.params.id }))
991
+
992
+ const german = translateError(errors.not_found(), translations, "de")
993
+ ```
994
+
995
+ Crypto is WebCrypto, with the comparison that does not leak by timing:
996
+
997
+ ```ts run
998
+ import { timingSafeEqual } from "@lovrozagar/honey/crypto"
999
+
1000
+ const expected = "s3cret-token"
1001
+ console.log(await timingSafeEqual("s3cret-token", expected), await timingSafeEqual("guess", expected))
1002
+ // prints: true false
1003
+ ```
1004
+
1005
+ Full reference: [internationalization](https://github.com/lovrozagar/honey/blob/main/docs/i18n.md) and
1006
+ [crypto](https://github.com/lovrozagar/honey/blob/main/docs/crypto.md).
1007
+
1008
+ ## Testing
1009
+
1010
+ `testClient(app, options)` calls the app the way a client would, in process by default — no port, no server — and
1011
+ over real HTTP when you ask for it. Request fields a handler reads but a test cannot easily forge, such as `ip`, are
1012
+ options rather than mocks.
1013
+
1014
+ ```ts run
1015
+ import { honey } from "@lovrozagar/honey"
1016
+ import { testClient } from "@lovrozagar/honey/testing"
1017
+
1018
+ const app = honey()
1019
+ app.get("/hello").handler((ctx) => ctx.res.json("ok", { hello: ctx.search.name ?? "world", ip: ctx.ip }))
1020
+
1021
+ const client = testClient(app, { env: {} })
1022
+ const answer = await client.get("/hello", { search: { name: "Ada" }, ip: "203.0.113.7" })
1023
+ console.log(answer.status, JSON.stringify(await answer.json()))
1024
+ // prints: 200 {"hello":"Ada","ip":"203.0.113.7"}
1025
+ ```
1026
+
1027
+ Full reference: [testing an app](https://github.com/lovrozagar/honey/blob/main/docs/testing.md) and
1028
+ [SDK conformance](https://github.com/lovrozagar/honey/blob/main/docs/conformance.md), which is how the generated
1029
+ clients are held to behaving alike.
1030
+
1031
+ ## The build plugin and the honey CLI
1032
+
1033
+ The Vite plugin writes the app's artifacts — the route tree, the types, the manifest, the OpenAPI document and the
1034
+ MCP handler — at build start, and again on each save in dev:
1035
+
1036
+ ```ts
1037
+ import { honey } from "@lovrozagar/honey/plugin"
1038
+ import { defineConfig } from "vite"
1039
+
1040
+ export default defineConfig({
1041
+ plugins: [honey({ app: "src/app.ts" })],
1042
+ })
1043
+ ```
1044
+
1045
+ What it generates is `codegen`, and each output takes a path or a config of its own:
1046
+
1047
+ ```ts
1048
+ import { honey } from "@lovrozagar/honey/plugin"
1049
+
1050
+ export default {
1051
+ plugins: [
1052
+ honey({
1053
+ app: "src/app.ts",
1054
+ codegen: {
1055
+ manifest: true,
1056
+ mcp: true,
1057
+ openApi: { title: "Shop", version: "1.0.0", securitySchemes: {} },
1058
+ tree: true,
1059
+ types: { baseCtxName: "AppCtx" },
1060
+ },
1061
+ watch: ["src/**/*.ts"],
1062
+ }),
1063
+ ],
1064
+ }
1065
+ ```
1066
+
1067
+ `@lovrozagar/honey/build` adds the production bundle for a deploy target, so `vite build` emits one file to run:
1068
+
1069
+ ```ts
1070
+ import { createBuildPlugin } from "@lovrozagar/honey/build"
1071
+ import { defineConfig } from "vite"
1072
+
1073
+ export default defineConfig({
1074
+ plugins: [createBuildPlugin({ target: "cloudflare", minify: true }, { entry: "src/app.ts", export: "app" })],
1075
+ })
1076
+ ```
1077
+
1078
+ The `honey` CLI does the same generation without Vite, which is what a CI step or a `package.json` script wants:
1079
+
1080
+ ```sh
1081
+ honey generate # reads vite.config.* and the honey() plugin in it
1082
+ honey generate --app src/app.ts --tree --types --manifest
1083
+ honey generate --config vite.config.ts --plugin 0 --watch
1084
+ honey generate --merge-tree src/_gen/routes.gen.ts # a gateway's merged tree
1085
+ honey init --cf --force
1086
+ ```
1087
+
1088
+ | Flag | What it does |
1089
+ | --------------------- | -------------------------------------------------------------------------- |
1090
+ | `--config <path>` | the Vite config holding `honey()` (default: `vite.config.{ts,mts,js,mjs}`) |
1091
+ | `--plugin <n>` | which `honey()` plugin in the config, 0-based |
1092
+ | `--app <path>` | the app entry; overrides the config's app, or replaces the config |
1093
+ | `--tree` | the route tree module |
1094
+ | `--types` | the ambient types |
1095
+ | `--manifest` | the route manifest |
1096
+ | `--merge-tree <path>` | the route tree source of a gateway |
1097
+ | `--watch` | generate again when files next to the app change |
1098
+
1099
+ The SDKs, the CLI and the MCP server are honey-clients's: `honey-clients generate --app <path>`.
1100
+
1101
+ Full reference: [the build plugin](https://github.com/lovrozagar/honey/blob/main/docs/plugin.md).
1102
+
1103
+ ## Generated clients
1104
+
1105
+ `honey-clients` reads the app module or the document and writes clients that know the routes:
1106
+
1107
+ ```sh
1108
+ npx honey-clients generate --app src/app.ts --target ts --target python --out generated
1109
+ ```
1110
+
1111
+ A call answers your value and throws a typed error by status; its `client.safe` mirror answers the same calls as a
1112
+ `Result` instead, so a failure is a value rather than a throw. All six SDKs have both styles.
1113
+
1114
+ ```ts sdk
1115
+ import { DecodeError, Kitchen, NotFoundError } from "./kitchen"
1116
+
1117
+ export function newClient(): Kitchen {
1118
+ return new Kitchen({
1119
+ baseURL: "https://api.example.com",
1120
+ headers: { authorization: `Bearer ${process.env.KITCHEN_TOKEN ?? ""}` },
1121
+ timeout: 10_000,
1122
+ })
1123
+ }
1124
+
1125
+ export async function organization(client: Kitchen, orgId: string): Promise<string | null> {
1126
+ try {
1127
+ await client.getApiV1OrganizationsByOrgId({ params: { orgId } })
1128
+ return orgId
1129
+ } catch (error) {
1130
+ if (error instanceof NotFoundError) return null
1131
+ throw error
1132
+ }
1133
+ }
1134
+
1135
+ export async function health(client: Kitchen): Promise<string> {
1136
+ const result = await client.safe.getApiHealth()
1137
+ if (result.ok) return "up"
1138
+ if (result.error instanceof DecodeError) return `unreadable answer: ${result.error.message}`
1139
+ return `answered ${result.error.status}`
1140
+ }
1141
+ ```
1142
+
1143
+ That block is a verbatim slice of
1144
+ [the compiled example](https://github.com/lovrozagar/honey/blob/main/docs/examples/ts/example.ts): the tests
1145
+ type-check it against an SDK generated from a real document, so it cannot drift from what the generator writes.
1146
+
1147
+ Full reference: [code generation](https://github.com/lovrozagar/honey/blob/main/docs/codegen.md),
1148
+ [the generated clients](https://github.com/lovrozagar/honey/blob/main/docs/client.md),
1149
+ [their per-language recipes](https://github.com/lovrozagar/honey/blob/main/docs/sdk-recipes.md),
1150
+ [the generated CLI](https://github.com/lovrozagar/honey/blob/main/docs/cli.md) and
1151
+ [releasing it](https://github.com/lovrozagar/honey/blob/main/docs/cli-release.md).
1152
+
1153
+ ## Every export
1154
+
1155
+ Each feature is a subpath: import what the app uses, and a bundle carries nothing else. An app that never imports
1156
+ `@lovrozagar/honey/ws` bundles no WebSocket code, and one that never imports `@lovrozagar/honey/openapi` no document
1157
+ generator.
1158
+
1159
+ | Export | What it holds |
1160
+ | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
1161
+ | `@lovrozagar/honey` | `honey`, `HoneyError`, `defineErrors`, `createMiddleware`, `defineMiddleware`, `mergeTree`, `detectRuntime`, and the type helpers below |
1162
+ | `@lovrozagar/honey/serve` | `serve(app, options)`: Node or Bun, whichever runs it |
1163
+ | `@lovrozagar/honey/node` | `serve` for `node:http` directly |
1164
+ | `@lovrozagar/honey/ws` | `ws(app, path)`, a WebSocket route |
1165
+ | `@lovrozagar/honey/ws/bun` | `bunWebSocket` |
1166
+ | `@lovrozagar/honey/ws/node` | `nodeWebSocket` (with the optional `ws` peer) |
1167
+ | `@lovrozagar/honey/ws/cloudflare` | `cfWebSocket` |
1168
+ | `@lovrozagar/honey/ws/session` | `createWsSession`: the session runtime a custom WebSocket adapter builds on |
1169
+ | `@lovrozagar/honey/realtime` | `realtime(app, path, options)`: a resumable connection over WebSocket, SSE and long-poll |
1170
+ | `@lovrozagar/honey/mcp` | `mcp(app, path, handler, options?)`: the app's tools over MCP's Streamable HTTP, the handler `codegen.mcp` writes |
1171
+ | `@lovrozagar/honey/mcp/oauth` | `oauth(options)`: `mcp()`'s opt-in OAuth 2.1 resource server (RFC 9728 metadata, Bearer challenges) |
1172
+ | `@lovrozagar/honey/mcp/uploads` | `uploads(options)`, `memoryUploadStore()`: `mcp()`'s opt-in out-of-band file uploads (the file-uploads extension's flow) |
1173
+ | `@lovrozagar/honey/mcp/url-files` | `urlFiles(options)`: `mcp()`'s opt-in file arguments as https URLs from listed hosts |
1174
+ | `@lovrozagar/honey/openapi` | `serveOpenApi`, `serveManifest`, `generateOpenApi`, `generateManifest`, `toYaml` |
1175
+ | `@lovrozagar/honey/openapi/scalar` | `scalar`, the Scalar API reference page |
1176
+ | `@lovrozagar/honey/openapi/swagger` | `swagger`, the Swagger UI page |
1177
+ | `@lovrozagar/honey/errors` | `defineErrors` (also on the root) |
1178
+ | `@lovrozagar/honey/i18n` | `errorI18n`, `translateError`, `interpolate`, `createInterpolator` |
1179
+ | `@lovrozagar/honey/input` | `readableStream()`, a streamed request body as input |
1180
+ | `@lovrozagar/honey/range` | `rangeResponse`, a `Range` request answered from bytes |
1181
+ | `@lovrozagar/honey/cache-control` | `cacheControl`, `httpDate` |
1182
+ | `@lovrozagar/honey/compress` | `compress` |
1183
+ | `@lovrozagar/honey/etag` | `etag`, `notModified` |
1184
+ | `@lovrozagar/honey/rate-limit` | `rateLimit`, `memoryStore` |
1185
+ | `@lovrozagar/honey/cors` | `cors` |
1186
+ | `@lovrozagar/honey/csrf` | `csrf` |
1187
+ | `@lovrozagar/honey/body-limit` | `bodyLimit` |
1188
+ | `@lovrozagar/honey/timeout` | `timeout` |
1189
+ | `@lovrozagar/honey/ip-restrict` | `ipRestrict` |
1190
+ | `@lovrozagar/honey/request-id` | `requestId` |
1191
+ | `@lovrozagar/honey/secure-headers` | `secureHeaders` |
1192
+ | `@lovrozagar/honey/server-timing` | `serverTiming` |
1193
+ | `@lovrozagar/honey/powered-by` | `poweredBy` |
1194
+ | `@lovrozagar/honey/pretty-json` | `prettyJson` |
1195
+ | `@lovrozagar/honey/logger` | `createLogger` |
1196
+ | `@lovrozagar/honey/curl-logger` | `curlLogger`, a request logged as a command that repeats it |
1197
+ | `@lovrozagar/honey/telemetry/otel` | `otelAdapter` |
1198
+ | `@lovrozagar/honey/static` | `staticFiles` |
1199
+ | `@lovrozagar/honey/proxy` | `proxy`, a route that forwards to an upstream |
1200
+ | `@lovrozagar/honey/accepts` | `accepts`, content negotiation |
1201
+ | `@lovrozagar/honey/cookie` | `serializeCookie`, `parseCookieHeader` |
1202
+ | `@lovrozagar/honey/cookie-sign` | `sign`, `verify`, `createCookieSigner` |
1203
+ | `@lovrozagar/honey/crypto` | `timingSafeEqual` |
1204
+ | `@lovrozagar/honey/request-to-curl` | `requestToCurl` |
1205
+ | `@lovrozagar/honey/testing` | `testClient`, an app called in process or over `serve` |
1206
+ | `@lovrozagar/honey/tree` | the route tree's types and helpers |
1207
+ | `@lovrozagar/honey/plugin` | `honey()`, the Vite plugin that generates the app's artifacts |
1208
+ | `@lovrozagar/honey/build` | `createBuildPlugin`, a production bundle for a deploy target |
1209
+
1210
+ The root export's values: `honey()` and the `Honey` class behind it, `HoneyContext` and `HoneyRes` (the handler's
1211
+ `ctx` and its `ctx.res`), `HoneyError`, `defineErrors` and `ERROR_META`, `createErrorResponse`, `createMiddleware`
1212
+ and `defineMiddleware`, `mergeTree`, `detectRuntime` and `toFetchRequest`.
1213
+
1214
+ ## Type helpers
1215
+
1216
+ Exported from `@lovrozagar/honey`, each over the app you built (`typeof app`). This is how a test, a client or a
1217
+ helper of yours stays tied to the routes rather than to a copy of their shapes.
1218
+
1219
+ ```ts
1220
+ import { honey, type InferRouteInput, type InferRoutePaths, type InferRoutes } from "@lovrozagar/honey"
1221
+ import * as z from "zod"
1222
+
1223
+ const app = honey()
1224
+ .get("/books/:id")
1225
+ .input({ params: z.object({ id: z.string() }) })
1226
+ .handler((ctx) => ctx.res.json("ok", { id: ctx.input.params.id }))
1227
+
1228
+ type Paths = InferRoutePaths<typeof app>
1229
+ type Routes = InferRoutes<typeof app>
1230
+ type BookInput = InferRouteInput<typeof app, "/books/:id", "get">
1231
+
1232
+ const path: Paths = "/books/:id"
1233
+ declare const input: BookInput
1234
+ const id: string = input.params.id
1235
+ ```
1236
+
1237
+ | Type | What it describes |
1238
+ | -------------------------------------------- | ---------------------------------------------------------------- |
1239
+ | `InferRoutes<typeof app>` | every path, its methods, and each one's input, output and errors |
1240
+ | `InferRoutePaths<typeof app>` | the paths, as a union |
1241
+ | `InferRouteMethods<typeof app, Path>` | one path's methods |
1242
+ | `InferRouteInput<typeof app, Path, Method>` | a route's validated input |
1243
+ | `InferRouteOutput<typeof app, Path, Method>` | a route's output map |
1244
+ | `InferRouteErrors<typeof app, Path, Method>` | a route's declared error keys |
1245
+ | `InferRouteMeta<typeof app, Path, Method>` | a route's meta |
1246
+ | `InferRouteCtx<typeof app, Path, Method>` | a route's handler `ctx` |
1247
+ | `InferCtx<typeof app>` | the app's handler context |
1248
+ | `InferEnv<typeof app>` | `ctx.env` |
1249
+ | `InferMeta<typeof app>` | the app-level meta |
1250
+ | `InferErrorFactory<typeof app>` | the error factory |
1251
+ | `InferBasePath<typeof app>` | the base path, as a string |
1252
+ | `InferMethods<typeof app>` | the methods the app serves |
55
1253
 
56
1254
  ## Documentation
57
1255
 
58
- - [The docs](https://github.com/lovrozagar/honey/blob/main/docs/README.md): routing, input and output, middleware,
59
- streaming, WebSockets and realtime, the OpenAPI document, testing, and every capability new in 1.0.
60
- - [Generated SDKs, the CLI and MCP](https://github.com/lovrozagar/honey/blob/main/docs/codegen.md), and their
61
- [per-language recipes](https://github.com/lovrozagar/honey/blob/main/docs/sdk-recipes.md).
62
- - [Every package export](https://github.com/lovrozagar/honey/blob/main/docs/packages.md).
63
- - [Migrating from 0.7](https://github.com/lovrozagar/honey/blob/main/docs/migration.md).
1256
+ Every page's examples are compiled by the repository's tests.
1257
+
1258
+ - [All the docs](https://github.com/lovrozagar/honey/blob/main/docs/README.md)
1259
+ - [Migrating from 0.7](https://github.com/lovrozagar/honey/blob/main/docs/migration.md)
1260
+ - [Package exports](https://github.com/lovrozagar/honey/blob/main/docs/packages.md)
1261
+ - [What a tarball ships, and why](https://github.com/lovrozagar/honey/blob/main/packages/core/docs/packaging.md)
1262
+ - [The repository](https://github.com/lovrozagar/honey)
64
1263
 
65
1264
  ## License
66
1265