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