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