@lovrozagar/honey 1.0.0-rc.1 → 1.0.0-rc.3
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 +1226 -27
- package/dist/index.d.ts +1 -1
- package/dist/meta/meta-spec-merge.d.ts +1 -1
- package/dist/routing/app-types.d.ts +15 -21
- package/dist/routing/app.d.ts +5 -5
- package/dist/routing/meta-types.d.ts +34 -0
- package/dist/routing/meta-types.js +5 -0
- package/dist/routing/route-builder.d.ts +2 -2
- package/dist/telemetry/logger.d.ts +2 -2
- package/dist/telemetry/logger.js +16 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,48 +1,100 @@
|
|
|
1
1
|
# @lovrozagar/honey
|
|
2
2
|
|
|
3
|
-
A typed web framework for the Fetch API
|
|
4
|
-
|
|
5
|
-
[`@lovrozagar/honey-clients`](https://www.npmjs.com/package/@lovrozagar/honey-clients)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
56
|
+
## A route, end to end
|
|
20
57
|
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
62
|
+
import { defineErrors, honey } from "@lovrozagar/honey"
|
|
63
|
+
import * as z from "zod"
|
|
27
64
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
- [
|
|
61
|
-
|
|
62
|
-
- [
|
|
63
|
-
- [
|
|
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
|
|