@lovrozagar/honey 0.1.0 → 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 +1596 -6
- 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 +195 -51
- package/src/body-limit.ts +2 -1
- package/src/build/index.ts +2 -9
- package/src/cli.ts +2 -3
- 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 +1168 -1412
- 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-request.ts +2 -1
- package/src/node.ts +9 -10
- 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
|
@@ -1,6 +1,65 @@
|
|
|
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
|
+
- [Meta spec](#meta-spec)
|
|
42
|
+
- [WebSockets](#websockets)
|
|
43
|
+
- [Realtime](#realtime)
|
|
44
|
+
- [SSE and streaming](#sse-and-streaming)
|
|
45
|
+
- [Generated clients](#generated-clients)
|
|
46
|
+
- [Plugin codegen config](#plugin-codegen-config)
|
|
47
|
+
- [TypeScript `createClient`](#typescript-createclient)
|
|
48
|
+
- [Generated SDK usage](#generated-sdk-usage)
|
|
49
|
+
- [Go CLI](#go-cli)
|
|
50
|
+
- [Programmatic codegen](#programmatic-codegen)
|
|
51
|
+
- [Type inference](#type-inference)
|
|
52
|
+
- [Testing](#testing)
|
|
53
|
+
- [Utilities](#utilities)
|
|
54
|
+
- [Package exports](#package-exports)
|
|
55
|
+
- [Repository layout](#repository-layout)
|
|
56
|
+
- [Develop](#develop)
|
|
57
|
+
- [Test matrix](#test-matrix)
|
|
58
|
+
- [E2E apps and runtimes](#e2e-apps-and-runtimes)
|
|
59
|
+
- [Live Cloudflare proof](#live-cloudflare-proof)
|
|
60
|
+
- [Bench](#bench)
|
|
61
|
+
- [Releases](#releases)
|
|
62
|
+
- [License](#license)
|
|
4
63
|
|
|
5
64
|
## Start
|
|
6
65
|
|
|
@@ -14,9 +73,9 @@ bun run dev
|
|
|
14
73
|
import { honey } from "@lovrozagar/honey"
|
|
15
74
|
|
|
16
75
|
export const app = honey()
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
76
|
+
.get("/health")
|
|
77
|
+
.handler((ctx) => ctx.res.text("ok", "ok"))
|
|
78
|
+
.openapi({ docs: "scalar", title: "My API", version: "1.0.0" })
|
|
20
79
|
|
|
21
80
|
await app.serve({ cors: true, port: 3000 })
|
|
22
81
|
```
|
|
@@ -40,11 +99,1542 @@ honey generate # writes src/_gen/
|
|
|
40
99
|
honey generate --watch
|
|
41
100
|
```
|
|
42
101
|
|
|
102
|
+
## What Honey is
|
|
103
|
+
|
|
104
|
+
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.
|
|
105
|
+
|
|
106
|
+
- **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`.
|
|
107
|
+
- **Radix tree.** Routes compile into a radix tree. Production can load a generated tree so unknown paths 404 without walking a catch-all.
|
|
108
|
+
- **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.
|
|
109
|
+
- **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.
|
|
110
|
+
- **Clients from the spec.** `honey generate` writes route types, a manifest, OpenAPI, and optional SDKs (TypeScript, Python, Go, Rust) plus an optional Go CLI.
|
|
111
|
+
|
|
112
|
+
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).
|
|
113
|
+
|
|
114
|
+
## Install
|
|
115
|
+
|
|
116
|
+
Requires [Bun](https://bun.sh) 1.3+ to develop this repo. Consumers can run the published package on Bun, Node, Deno, or Workers.
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
bun add @lovrozagar/honey
|
|
120
|
+
# or
|
|
121
|
+
npm add @lovrozagar/honey
|
|
122
|
+
# or
|
|
123
|
+
pnpm add @lovrozagar/honey
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The CLI ships with the package (`honey` in `package.json` `bin`). After install:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
honey init
|
|
130
|
+
honey generate
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
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.
|
|
134
|
+
|
|
135
|
+
## First app
|
|
136
|
+
|
|
137
|
+
`honey init` is the supported scaffold.
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
mkdir my-api && cd my-api
|
|
141
|
+
bun init -y
|
|
142
|
+
bun add @lovrozagar/honey
|
|
143
|
+
honey init
|
|
144
|
+
bun run dev
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
That writes:
|
|
148
|
+
|
|
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 |
|
|
154
|
+
|
|
155
|
+
Scripts added to `package.json`: `dev` (runs `src/server.ts`) and `generate` (`honey generate`).
|
|
156
|
+
|
|
157
|
+
Cloudflare from the start:
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
honey init --cf
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Also writes `src/worker.ts` (`export default { fetch }`) and `wrangler.jsonc`. Existing files refuse to overwrite unless you pass `--force`.
|
|
164
|
+
|
|
165
|
+
Minimal app without the scaffold:
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
import { honey } from "@lovrozagar/honey"
|
|
169
|
+
import * as z from "zod"
|
|
170
|
+
|
|
171
|
+
const app = honey<{ DATABASE_URL: string }>()
|
|
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" }))
|
|
180
|
+
|
|
181
|
+
await app.serve({ env: { DATABASE_URL: process.env.DATABASE_URL! }, port: 3000 })
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## CLI
|
|
185
|
+
|
|
186
|
+
```
|
|
187
|
+
honey generate [--watch] [--config <path>] [--app <path>] [flags]
|
|
188
|
+
honey init [--cf] [--force]
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Any other first argument prints usage and exits `1`.
|
|
192
|
+
|
|
193
|
+
### `honey init`
|
|
194
|
+
|
|
195
|
+
| Flag | Meaning |
|
|
196
|
+
| --------- | ----------------------------------------------------------------------- |
|
|
197
|
+
| `--cf` | Also write a Workers entry and `wrangler.jsonc` |
|
|
198
|
+
| `--force` | Overwrite `src/app.ts`, `src/server.ts`, `vite.config.ts` if they exist |
|
|
199
|
+
|
|
200
|
+
### `honey generate`
|
|
201
|
+
|
|
202
|
+
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.
|
|
203
|
+
|
|
204
|
+
If there is no Vite config, you must pass `--app`.
|
|
205
|
+
|
|
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 |
|
|
224
|
+
|
|
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.
|
|
226
|
+
|
|
227
|
+
Default plugin resolution (when a flag/`true` enables the feature):
|
|
228
|
+
|
|
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 |
|
|
238
|
+
|
|
239
|
+
Typical plugin config:
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
import { honey } from "@lovrozagar/honey/plugin"
|
|
243
|
+
|
|
244
|
+
export default {
|
|
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
|
+
],
|
|
267
|
+
}
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
`generateFromApp(app)` (from `@lovrozagar/honey/plugin`) is the in-process helper: it returns `{ routeTree, manifest?, openApi?, openApiYaml? }` without writing files.
|
|
271
|
+
|
|
272
|
+
## Builder
|
|
273
|
+
|
|
274
|
+
### Create the app
|
|
275
|
+
|
|
276
|
+
```ts
|
|
277
|
+
import { honey } from "@lovrozagar/honey"
|
|
278
|
+
|
|
279
|
+
const app = honey<{ DATABASE_URL: string; API_KEY: string }>()
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
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.
|
|
283
|
+
|
|
284
|
+
### Routes and methods
|
|
285
|
+
|
|
286
|
+
```ts
|
|
287
|
+
app.get("/items")
|
|
288
|
+
app.post("/items")
|
|
289
|
+
app.put("/items/:id")
|
|
290
|
+
app.patch("/items/:id")
|
|
291
|
+
app.delete("/items/:id")
|
|
292
|
+
app.head("/items/:id")
|
|
293
|
+
app.options("/items")
|
|
294
|
+
app.all("/echo") // every method
|
|
295
|
+
app.on("GET", "/health")
|
|
296
|
+
app.on(["GET", "HEAD"], "/resource") // same handler, extra methods
|
|
297
|
+
```
|
|
298
|
+
|
|
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.
|
|
300
|
+
|
|
301
|
+
There is no `.head` convenience beyond `.head(path)` itself. Use `.on(["GET", "HEAD"], path)` when GET and HEAD share a handler.
|
|
302
|
+
|
|
303
|
+
### Path matching
|
|
304
|
+
|
|
305
|
+
```ts
|
|
306
|
+
app.get("/orgs/:orgId/members/:memberId").handler((ctx) => {
|
|
307
|
+
ctx.params.orgId
|
|
308
|
+
ctx.params.memberId
|
|
309
|
+
return ctx.res.json("ok", ctx.params)
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
app.get("/files/*path").handler((ctx) => {
|
|
313
|
+
// GET /files/a/b/c → ctx.params.path === "a/b/c"
|
|
314
|
+
return ctx.res.json("ok", { path: ctx.params.path })
|
|
315
|
+
})
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
- `:name` is one segment.
|
|
319
|
+
- `*name` is the remainder of the path.
|
|
320
|
+
- Static paths (`/health`) use an O(1) map. Dynamic and wildcard segments walk the radix tree.
|
|
321
|
+
|
|
322
|
+
Optional extra validation of params (beyond “it is a string”):
|
|
323
|
+
|
|
324
|
+
```ts
|
|
325
|
+
app
|
|
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 }))
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
### Prefixes and slashes
|
|
332
|
+
|
|
333
|
+
```ts
|
|
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
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
- `basePath` only affects routes registered **after** the call, on that chain.
|
|
342
|
+
- `stripPrefix` is a gateway rewrite. Requests without the prefix still match. It will not strip a partial segment (`/apple` is not stripped by `/app`).
|
|
343
|
+
- `.use()` / `.basePath()` / `.context()` / `.meta()` clone the builder and share the route graph with the parent.
|
|
344
|
+
|
|
345
|
+
### Context
|
|
346
|
+
|
|
347
|
+
`ctx` is a `HoneyContext`. Fields:
|
|
348
|
+
|
|
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 |
|
|
369
|
+
|
|
370
|
+
Reserved keys that middleware / `.context()` **cannot** overwrite: `req`, `res`, `env`, `params`, `headers`, `cookies`, `search`, `background`.
|
|
371
|
+
|
|
372
|
+
```ts
|
|
373
|
+
app
|
|
374
|
+
.context({ version: "1.0.0" })
|
|
375
|
+
.get("/")
|
|
376
|
+
.handler((ctx) => {
|
|
377
|
+
return ctx.res.json("ok", { version: ctx.version })
|
|
378
|
+
})
|
|
379
|
+
```
|
|
380
|
+
|
|
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`.
|
|
382
|
+
|
|
383
|
+
### Responses
|
|
384
|
+
|
|
385
|
+
Every `ctx.res.*` method takes a **status key** (except `noContent`, `redirect`, `sse`, `stream`, `generate`, `raw`). Optional third argument (or last for the exceptions):
|
|
386
|
+
|
|
387
|
+
```ts
|
|
388
|
+
type ResponseOptions = {
|
|
389
|
+
status?: number // override numeric status (redirect only by default)
|
|
390
|
+
headers?: Record<string, string>
|
|
391
|
+
cookies?: Record<string, CookieOptions>
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
type CookieOptions = {
|
|
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
|
|
403
|
+
}
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
```ts
|
|
407
|
+
ctx.res.json("ok", { id: "1" })
|
|
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
|
+
)
|
|
416
|
+
ctx.res.text("ok", "hello")
|
|
417
|
+
ctx.res.html("ok", "<h1>Hi</h1>")
|
|
418
|
+
ctx.res.csv("ok", "id,name\n1,Ada")
|
|
419
|
+
ctx.res.xml("ok", "<ok/>")
|
|
420
|
+
ctx.res.binary("ok", new Uint8Array([0x48, 0x49]))
|
|
421
|
+
ctx.res.noContent()
|
|
422
|
+
ctx.res.redirect("/elsewhere") // 302
|
|
423
|
+
ctx.res.redirect("/gone", { status: 301 })
|
|
424
|
+
ctx.res.raw(new Response("passthrough"))
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
`__Host-` cookies require `secure: true`, `path: "/"`, and no `domain`.
|
|
428
|
+
|
|
429
|
+
SSE / stream / generate: see [SSE and streaming](#sse-and-streaming).
|
|
430
|
+
|
|
431
|
+
### Status keys
|
|
432
|
+
|
|
433
|
+
`ctx.res.json("created", data)` sets HTTP 201. Use the snake_case key, not the number, except `redirect({ status })`.
|
|
434
|
+
|
|
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 |
|
|
466
|
+
|
|
467
|
+
`.output()` only accepts **2xx success keys** plus a `redirect` map. Error statuses belong on `.errors()`, not `.output()`.
|
|
468
|
+
|
|
469
|
+
### Input
|
|
470
|
+
|
|
471
|
+
`.input()` takes Standard Schema objects. `json` and `form` are mutually exclusive (one Content-Type per request).
|
|
472
|
+
|
|
473
|
+
```ts
|
|
474
|
+
app
|
|
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
|
+
})
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
Form + file (Zod `z.file()` or equivalent):
|
|
494
|
+
|
|
495
|
+
```ts
|
|
496
|
+
app
|
|
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 }))
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
Leave the body for the handler (no Honey parse):
|
|
503
|
+
|
|
504
|
+
```ts
|
|
505
|
+
import { readableStream } from "@lovrozagar/honey/input"
|
|
506
|
+
|
|
507
|
+
app
|
|
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
|
+
})
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
Content-Type selection:
|
|
517
|
+
|
|
518
|
+
- `application/json` → `json`
|
|
519
|
+
- `application/x-www-form-urlencoded` or `multipart/form-data` → `form` (`req.formData()`)
|
|
520
|
+
- Invalid input is **400** with `error_key: "validation_failed"` and `fields: { name: [{ error_key, message, path }] }`.
|
|
521
|
+
|
|
522
|
+
Valibot / ArkType work the same way — pass any Standard Schema:
|
|
523
|
+
|
|
524
|
+
```ts
|
|
525
|
+
import * as v from "valibot"
|
|
526
|
+
app
|
|
527
|
+
.post("/v")
|
|
528
|
+
.input({ json: v.object({ n: v.number() }) })
|
|
529
|
+
.handler((ctx) => ctx.res.json("ok", ctx.input.json))
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
### Output
|
|
533
|
+
|
|
534
|
+
`.output()` maps content type → status key → schema. Only 2xx keys plus `redirect`.
|
|
535
|
+
|
|
536
|
+
```ts
|
|
537
|
+
app
|
|
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" }))
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
Declared content types unlock the matching `ctx.res` method at the type level:
|
|
549
|
+
|
|
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`) |
|
|
560
|
+
|
|
561
|
+
Always available: `noContent`, `raw`, `redirect`, `stream`. Also recognized in the map (no dedicated helper): `application/cbor`, `application/msgpack`, `application/pdf`.
|
|
562
|
+
|
|
563
|
+
```ts
|
|
564
|
+
app
|
|
565
|
+
.get("/go")
|
|
566
|
+
.output({ redirect: { found: true, moved_permanently: true } })
|
|
567
|
+
.handler((ctx) => ctx.res.redirect("/next"))
|
|
568
|
+
```
|
|
569
|
+
|
|
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`).
|
|
571
|
+
|
|
572
|
+
```ts
|
|
573
|
+
app.outputValidation("always")
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
### Errors
|
|
577
|
+
|
|
578
|
+
```ts
|
|
579
|
+
import { defineErrors, HoneyError, honey } from "@lovrozagar/honey"
|
|
580
|
+
|
|
581
|
+
const errors = defineErrors({
|
|
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",
|
|
589
|
+
})
|
|
590
|
+
|
|
591
|
+
const app = honey()
|
|
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
|
+
})
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
Throw from a handler:
|
|
609
|
+
|
|
610
|
+
```ts
|
|
611
|
+
throw ctx.errors.org_slug_taken({ vars: { slug: "acme" } })
|
|
612
|
+
throw ctx.errors.item_not_found({ reason: "deleted" })
|
|
613
|
+
throw ctx.errors.unauthorized({
|
|
614
|
+
fields: { token: [{ error_key: "required", message: "missing", path: "token" }] },
|
|
615
|
+
headers: { "www-authenticate": "Bearer" },
|
|
616
|
+
})
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
Standard error JSON:
|
|
620
|
+
|
|
621
|
+
```json
|
|
622
|
+
{
|
|
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" }
|
|
630
|
+
}
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
Custom-schema errors serialize **the schema payload** as the body (not the envelope), unless you set `customErrorFormatter`.
|
|
634
|
+
|
|
635
|
+
Per-route:
|
|
636
|
+
|
|
637
|
+
```ts
|
|
638
|
+
app
|
|
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
|
+
})
|
|
645
|
+
```
|
|
646
|
+
|
|
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.
|
|
648
|
+
|
|
649
|
+
Formatters:
|
|
650
|
+
|
|
651
|
+
```ts
|
|
652
|
+
app.defaultErrorFormatter((error, defaultShape) => ({
|
|
653
|
+
...defaultShape,
|
|
654
|
+
request_id: "r1",
|
|
655
|
+
}))
|
|
656
|
+
|
|
657
|
+
app.defaultErrorFormatter(z.object({ error_key: z.string(), status: z.number() }), (error) => ({
|
|
658
|
+
error_key: error.errorKey,
|
|
659
|
+
status: error.status,
|
|
660
|
+
}))
|
|
661
|
+
|
|
662
|
+
app.customErrorFormatter((error, data) => ({ ...data, error_key: error.errorKey }))
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
i18n (loads `@lovrozagar/honey/i18n` when called):
|
|
666
|
+
|
|
667
|
+
```ts
|
|
668
|
+
import "@lovrozagar/honey/i18n"
|
|
669
|
+
|
|
670
|
+
app.errorI18n({
|
|
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
|
+
},
|
|
682
|
+
})
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
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.
|
|
686
|
+
|
|
687
|
+
`HoneyError` fields: `errorKey`, `status` (number), `statusKey`, `fields`, `vars`, `data`, `headers`. `HoneyError.serialize(err)` is a JSON-safe dump for logs.
|
|
688
|
+
|
|
689
|
+
Framework-owned keys you do not declare: `validation_failed` (400), `output_validation_failed` (500), plus routing / content-negotiation keys.
|
|
690
|
+
|
|
691
|
+
### Middleware
|
|
692
|
+
|
|
693
|
+
```ts
|
|
694
|
+
import { createMiddleware } from "@lovrozagar/honey"
|
|
695
|
+
|
|
696
|
+
const withAuth = createMiddleware(async (ctx, next) => {
|
|
697
|
+
const token = ctx.req.headers.get("authorization")
|
|
698
|
+
if (!token) throw ctx.errors.unauthorized()
|
|
699
|
+
return next({ user: { id: "u-1" } })
|
|
700
|
+
})
|
|
701
|
+
|
|
702
|
+
app.use(withAuth) // global on this chain
|
|
703
|
+
app.use("/admin", withAuth) // prefix-scoped; additions only exist under /admin
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
`createMiddleware` infers additions from `next({ ... })`. Later handlers see `ctx.user`. Return `next()` with no argument to add nothing.
|
|
707
|
+
|
|
708
|
+
`.use()` clones the builder. Keep the returned value (or keep chaining) if later routes should see the middleware.
|
|
709
|
+
|
|
710
|
+
### Shipped middleware
|
|
711
|
+
|
|
712
|
+
Import each from its path. Do **not** `import { cors } from "@lovrozagar/honey"`.
|
|
713
|
+
|
|
714
|
+
#### `cors` — `@lovrozagar/honey/cors`
|
|
715
|
+
|
|
716
|
+
```ts
|
|
717
|
+
import { cors } from "@lovrozagar/honey/cors"
|
|
718
|
+
|
|
719
|
+
app.use(cors())
|
|
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
|
+
)
|
|
730
|
+
```
|
|
731
|
+
|
|
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.
|
|
733
|
+
|
|
734
|
+
#### `csrf` — `@lovrozagar/honey/csrf`
|
|
735
|
+
|
|
736
|
+
```ts
|
|
737
|
+
import { csrf } from "@lovrozagar/honey/csrf"
|
|
738
|
+
|
|
739
|
+
app.use(csrf({ origin: "https://app.example.com" }))
|
|
740
|
+
```
|
|
741
|
+
|
|
742
|
+
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`.
|
|
743
|
+
|
|
744
|
+
#### `body-limit` — `@lovrozagar/honey/body-limit`
|
|
745
|
+
|
|
746
|
+
```ts
|
|
747
|
+
import { bodyLimit } from "@lovrozagar/honey/body-limit"
|
|
748
|
+
|
|
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
|
+
)
|
|
756
|
+
```
|
|
757
|
+
|
|
758
|
+
Skipped for GET/HEAD/OPTIONS/DELETE. Oversize is **413** `content_too_large`. `limits` keys are matched with `startsWith` against `Content-Type`.
|
|
759
|
+
|
|
760
|
+
#### `logger` — `@lovrozagar/honey/logger`
|
|
761
|
+
|
|
762
|
+
```ts
|
|
763
|
+
import { createLogger, logger } from "@lovrozagar/honey/logger"
|
|
764
|
+
|
|
765
|
+
const log = createLogger({
|
|
766
|
+
level: "info", // trace | debug | info | warn | error | fatal
|
|
767
|
+
base: { service: "api" },
|
|
768
|
+
write: (line) => console.log(line),
|
|
769
|
+
})
|
|
770
|
+
|
|
771
|
+
app.use(logger())
|
|
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
|
+
)
|
|
782
|
+
```
|
|
783
|
+
|
|
784
|
+
`createLogger` writes one JSON line per call (`level`, `msg`, `time`, plus `base`). `ctx.log.info("hello")` / `ctx.log.info({ k: 1 }, "hello")`.
|
|
785
|
+
|
|
786
|
+
#### `curl-logger` — `@lovrozagar/honey/curl-logger`
|
|
787
|
+
|
|
788
|
+
```ts
|
|
789
|
+
import { curlLogger } from "@lovrozagar/honey/curl-logger"
|
|
790
|
+
|
|
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
|
+
)
|
|
799
|
+
```
|
|
800
|
+
|
|
801
|
+
#### `request-id` — `@lovrozagar/honey/request-id`
|
|
802
|
+
|
|
803
|
+
```ts
|
|
804
|
+
import { requestId } from "@lovrozagar/honey/request-id"
|
|
805
|
+
|
|
806
|
+
app.use(requestId())
|
|
807
|
+
app.use(requestId({ header: "x-request-id", generator: () => crypto.randomUUID() }))
|
|
808
|
+
```
|
|
809
|
+
|
|
810
|
+
Adds `ctx.requestId` and echoes the header on the response. Reuses the inbound header when present.
|
|
811
|
+
|
|
812
|
+
#### `etag` — `@lovrozagar/honey/etag`
|
|
813
|
+
|
|
814
|
+
```ts
|
|
815
|
+
import { etag } from "@lovrozagar/honey/etag"
|
|
816
|
+
|
|
817
|
+
app.use(etag()) // weak ETag (default)
|
|
818
|
+
app.use(etag({ weak: false }))
|
|
819
|
+
```
|
|
820
|
+
|
|
821
|
+
GET/HEAD only. Skips 4xx and streaming bodies. Responds **304** when `If-None-Match` matches.
|
|
822
|
+
|
|
823
|
+
#### `timeout` — `@lovrozagar/honey/timeout`
|
|
824
|
+
|
|
825
|
+
```ts
|
|
826
|
+
import { timeout } from "@lovrozagar/honey/timeout"
|
|
827
|
+
|
|
828
|
+
app.use(timeout({ duration: 5_000 }))
|
|
829
|
+
```
|
|
830
|
+
|
|
831
|
+
Slow handlers reject with **504** `request_timeout`.
|
|
832
|
+
|
|
833
|
+
#### `secure-headers` — `@lovrozagar/honey/secure-headers`
|
|
834
|
+
|
|
835
|
+
```ts
|
|
836
|
+
import { secureHeaders } from "@lovrozagar/honey/secure-headers"
|
|
837
|
+
|
|
838
|
+
app.use(secureHeaders())
|
|
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
|
+
)
|
|
853
|
+
```
|
|
854
|
+
|
|
855
|
+
Defaults when omitted: `x-content-type-options: nosniff`, `x-frame-options: SAMEORIGIN`, `referrer-policy: strict-origin-when-cross-origin`, `x-xss-protection: 0`.
|
|
856
|
+
|
|
857
|
+
#### `server-timing` — `@lovrozagar/honey/server-timing`
|
|
858
|
+
|
|
859
|
+
```ts
|
|
860
|
+
import { serverTiming } from "@lovrozagar/honey/server-timing"
|
|
861
|
+
|
|
862
|
+
app.use(serverTiming())
|
|
863
|
+
app.get("/work").handler((ctx) => {
|
|
864
|
+
ctx.timing.start("db", "query")
|
|
865
|
+
ctx.timing.end("db")
|
|
866
|
+
return ctx.res.json("ok", {})
|
|
867
|
+
})
|
|
868
|
+
```
|
|
869
|
+
|
|
870
|
+
#### `ip-restrict` — `@lovrozagar/honey/ip-restrict`
|
|
871
|
+
|
|
872
|
+
```ts
|
|
873
|
+
import { ipRestrict } from "@lovrozagar/honey/ip-restrict"
|
|
874
|
+
|
|
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
|
+
)
|
|
883
|
+
```
|
|
884
|
+
|
|
885
|
+
Default IP: `cf-connecting-ip` only. Denied / not-allowed is **403**.
|
|
886
|
+
|
|
887
|
+
#### `powered-by` — `@lovrozagar/honey/powered-by`
|
|
888
|
+
|
|
889
|
+
```ts
|
|
890
|
+
import { poweredBy } from "@lovrozagar/honey/powered-by"
|
|
891
|
+
|
|
892
|
+
app.use(poweredBy()) // x-powered-by: Honey
|
|
893
|
+
app.use(poweredBy({ name: "api" }))
|
|
894
|
+
```
|
|
895
|
+
|
|
896
|
+
#### `pretty-json` — `@lovrozagar/honey/pretty-json`
|
|
897
|
+
|
|
898
|
+
```ts
|
|
899
|
+
import { prettyJson } from "@lovrozagar/honey/pretty-json"
|
|
900
|
+
|
|
901
|
+
app.use(prettyJson()) // ?pretty=
|
|
902
|
+
app.use(prettyJson({ query: "pretty", space: 2 }))
|
|
903
|
+
```
|
|
904
|
+
|
|
905
|
+
Only rewrites `application/json` when the query string contains the key.
|
|
906
|
+
|
|
907
|
+
### Composition
|
|
908
|
+
|
|
909
|
+
```ts
|
|
910
|
+
const users = honey()
|
|
911
|
+
.basePath("/users")
|
|
912
|
+
.get("/:id")
|
|
913
|
+
.handler((ctx) => ctx.res.json("ok", { id: ctx.params.id }))
|
|
914
|
+
|
|
915
|
+
const app = honey().route(users) // merges routes, realtime, taps, static map
|
|
916
|
+
```
|
|
917
|
+
|
|
918
|
+
`.route(sub)` copies the sub-app's tree into this one. Duplicate paths throw.
|
|
919
|
+
|
|
920
|
+
### Taps
|
|
921
|
+
|
|
922
|
+
Taps run **after** a successful handler. They do not run on thrown errors.
|
|
923
|
+
|
|
924
|
+
```ts
|
|
925
|
+
const app = honey()
|
|
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
|
+
})
|
|
940
|
+
```
|
|
941
|
+
|
|
942
|
+
### Proxy routes
|
|
943
|
+
|
|
944
|
+
Finish a route with `.proxy()` instead of `.handler()`:
|
|
945
|
+
|
|
946
|
+
```ts
|
|
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
|
+
})
|
|
957
|
+
```
|
|
958
|
+
|
|
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.
|
|
960
|
+
|
|
961
|
+
### Static files
|
|
962
|
+
|
|
963
|
+
```ts
|
|
964
|
+
import { staticFiles } from "@lovrozagar/honey/static"
|
|
965
|
+
|
|
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
|
+
)
|
|
979
|
+
```
|
|
980
|
+
|
|
981
|
+
GET/HEAD only. `..` segments are rejected. `resolve` returning `null` falls through to the next route.
|
|
982
|
+
|
|
983
|
+
### Logging, telemetry, production tree
|
|
984
|
+
|
|
985
|
+
App-level logger (Honey internals, not request logs):
|
|
986
|
+
|
|
987
|
+
```ts
|
|
988
|
+
app.logger({ warn: (msg, ...args) => console.warn(msg, ...args) })
|
|
989
|
+
```
|
|
990
|
+
|
|
991
|
+
OpenTelemetry-shaped adapter:
|
|
992
|
+
|
|
993
|
+
```ts
|
|
994
|
+
import { otelAdapter } from "@lovrozagar/honey/telemetry/otel"
|
|
995
|
+
|
|
996
|
+
app.telemetry(otelAdapter({ tracer }))
|
|
997
|
+
// or a hand-rolled adapter:
|
|
998
|
+
app.telemetry({
|
|
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 }) => {},
|
|
1007
|
+
})
|
|
1008
|
+
```
|
|
1009
|
+
|
|
1010
|
+
Production tree (unknown paths 404 without a catch-all walk):
|
|
1011
|
+
|
|
1012
|
+
```ts
|
|
1013
|
+
import { routeTree } from "./_gen/routes.gen.ts"
|
|
1014
|
+
|
|
1015
|
+
app.routeTree(routeTree)
|
|
1016
|
+
const snapshot = app.toRouteTree()
|
|
1017
|
+
```
|
|
1018
|
+
|
|
1019
|
+
`mergeTree` from `honey` / `honey/tree` merges several trees (optional extra meta per tree).
|
|
1020
|
+
|
|
1021
|
+
`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(...)`.
|
|
1022
|
+
|
|
1023
|
+
## Serve
|
|
1024
|
+
|
|
1025
|
+
### Bun, Node, Deno
|
|
1026
|
+
|
|
1027
|
+
```ts
|
|
1028
|
+
const handle = await app.serve({
|
|
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
|
|
1034
|
+
})
|
|
1035
|
+
|
|
1036
|
+
handle.url // http://127.0.0.1:3000
|
|
1037
|
+
handle.port
|
|
1038
|
+
handle.hostname
|
|
1039
|
+
handle.runtime // "bun" | "node" | "deno"
|
|
1040
|
+
await handle.close()
|
|
1041
|
+
```
|
|
1042
|
+
|
|
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`.
|
|
1044
|
+
|
|
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` |
|
|
1050
|
+
|
|
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`.
|
|
1052
|
+
|
|
1053
|
+
Low-level Node listen (same adapter, no runtime detect):
|
|
1054
|
+
|
|
1055
|
+
```ts
|
|
1056
|
+
import { serve } from "@lovrozagar/honey/node"
|
|
1057
|
+
const server = serve(app, { env: {}, port: 3000, hostname: "0.0.0.0" })
|
|
1058
|
+
await server.shutdown()
|
|
1059
|
+
```
|
|
1060
|
+
|
|
1061
|
+
`runtime: "cloudflare"` throws. Workers cannot listen.
|
|
1062
|
+
|
|
1063
|
+
Detect without serving:
|
|
1064
|
+
|
|
1065
|
+
```ts
|
|
1066
|
+
import { detectRuntime } from "@lovrozagar/honey"
|
|
1067
|
+
detectRuntime() // "bun" | "node" | "deno" | "cloudflare"
|
|
1068
|
+
```
|
|
1069
|
+
|
|
1070
|
+
### Cloudflare Workers
|
|
1071
|
+
|
|
1072
|
+
```ts
|
|
1073
|
+
import { cfWebSocket } from "@lovrozagar/honey/ws/cloudflare"
|
|
1074
|
+
import { app } from "./app.ts"
|
|
1075
|
+
|
|
1076
|
+
app.wsAdapter(cfWebSocket())
|
|
1077
|
+
|
|
1078
|
+
export default {
|
|
1079
|
+
fetch: (req: Request, env: Env, ctx: ExecutionContext) => app.fetch(req, env, ctx),
|
|
1080
|
+
}
|
|
1081
|
+
```
|
|
1082
|
+
|
|
1083
|
+
`honey init --cf` writes this stub. Local e2e uses wrangler / workerd. The live proof worker is documented under [Live Cloudflare proof](#live-cloudflare-proof).
|
|
1084
|
+
|
|
1085
|
+
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.
|
|
1086
|
+
|
|
1087
|
+
### Feature auto-load
|
|
1088
|
+
|
|
1089
|
+
`honey()` stays fetch-only until you opt in:
|
|
1090
|
+
|
|
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 |
|
|
1096
|
+
|
|
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.
|
|
1098
|
+
|
|
1099
|
+
`createBuildPlugin` (`honey/build`) is a Vite plugin that injects those imports per target (`bun` | `node` | `deno` | `cloudflare`).
|
|
1100
|
+
|
|
1101
|
+
```ts
|
|
1102
|
+
import { createBuildPlugin } from "@lovrozagar/honey/build"
|
|
1103
|
+
|
|
1104
|
+
createBuildPlugin(
|
|
1105
|
+
{ target: "bun", port: 3000, minify: true, outDir: "dist", external: [] },
|
|
1106
|
+
{ entry: "src/app.ts", export: "app" }, // or export: "default"
|
|
1107
|
+
)
|
|
1108
|
+
```
|
|
1109
|
+
|
|
1110
|
+
## OpenAPI, docs, and manifest
|
|
1111
|
+
|
|
1112
|
+
```ts
|
|
1113
|
+
app.openapi({
|
|
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
|
+
},
|
|
1124
|
+
})
|
|
1125
|
+
|
|
1126
|
+
app.manifest()
|
|
1127
|
+
app.manifest({ path: "/manifest.json" })
|
|
1128
|
+
```
|
|
1129
|
+
|
|
1130
|
+
Served (cached, invalidated when the route graph changes):
|
|
1131
|
+
|
|
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 |
|
|
1138
|
+
|
|
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.
|
|
1140
|
+
|
|
1141
|
+
`.meta({ operationId, tags, summary, description })` on a route feeds the document. App-level `.meta<Shape>()` / `.meta({ auth: "required" })` sets defaults and constrains route meta.
|
|
1142
|
+
|
|
1143
|
+
Generate-time sanitize (plugin `codegen.openApi.sanitize`):
|
|
1144
|
+
|
|
1145
|
+
```ts
|
|
1146
|
+
{
|
|
1147
|
+
stripSecuritySchemes: ["legacy"],
|
|
1148
|
+
stripSecurityRequirements: ["legacy"],
|
|
1149
|
+
stripXExtensions: true, // or ["x-internal"]
|
|
1150
|
+
}
|
|
1151
|
+
```
|
|
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
|
+
|
|
1205
|
+
## WebSockets
|
|
1206
|
+
|
|
1207
|
+
```ts
|
|
1208
|
+
app.ws("/echo-ws").handler({
|
|
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) {},
|
|
1221
|
+
})
|
|
1222
|
+
```
|
|
1223
|
+
|
|
1224
|
+
`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.
|
|
1225
|
+
|
|
1226
|
+
A GET **without** `Upgrade: websocket` to a WS/realtime path returns **426** with `upgrade: websocket`.
|
|
1227
|
+
|
|
1228
|
+
Reconnect: pass `?reconnect_token=` on the next handshake; Honey calls `onReconnect` instead of `onOpen`.
|
|
1229
|
+
|
|
1230
|
+
`app.serve()` attaches the runtime adapter. On Cloudflare, call `app.wsAdapter(cfWebSocket())` yourself.
|
|
1231
|
+
|
|
1232
|
+
## Realtime
|
|
1233
|
+
|
|
1234
|
+
Honey's room protocol (not a raw socket). Transports: `ws`, `sse`, `longpoll`.
|
|
1235
|
+
|
|
1236
|
+
```ts
|
|
1237
|
+
app.realtime("/realtime/chat/:roomId", {
|
|
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
|
+
},
|
|
1250
|
+
})
|
|
1251
|
+
|
|
1252
|
+
app.post("/realtime/broadcast/:topic").handler(async (ctx) => {
|
|
1253
|
+
const body = await ctx.req.json()
|
|
1254
|
+
ctx.realtime.publish(ctx.params.topic, body)
|
|
1255
|
+
return ctx.res.json("ok", { published: true })
|
|
1256
|
+
})
|
|
1257
|
+
```
|
|
1258
|
+
|
|
1259
|
+
`conn`:
|
|
1260
|
+
|
|
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 |
|
|
1272
|
+
|
|
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.
|
|
1274
|
+
|
|
1275
|
+
## SSE and streaming
|
|
1276
|
+
|
|
1277
|
+
```ts
|
|
1278
|
+
app.get("/events").handler((ctx) =>
|
|
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
|
+
),
|
|
1290
|
+
)
|
|
1291
|
+
```
|
|
1292
|
+
|
|
1293
|
+
`Last-Event-Id` is read automatically. Event names and ids must not contain newlines. `keepalive` writes `: heartbeat` comments.
|
|
1294
|
+
|
|
1295
|
+
```ts
|
|
1296
|
+
app.get("/stream").handler((ctx) =>
|
|
1297
|
+
ctx.res.stream(async (writable) => {
|
|
1298
|
+
const w = writable.getWriter()
|
|
1299
|
+
await w.write(new TextEncoder().encode("chunk"))
|
|
1300
|
+
await w.close()
|
|
1301
|
+
}),
|
|
1302
|
+
)
|
|
1303
|
+
|
|
1304
|
+
app.get("/gen").handler((ctx) =>
|
|
1305
|
+
ctx.res.generate(
|
|
1306
|
+
(async function* () {
|
|
1307
|
+
yield "one"
|
|
1308
|
+
yield "two"
|
|
1309
|
+
})(),
|
|
1310
|
+
{ contentType: "text/plain", status: 200 },
|
|
1311
|
+
),
|
|
1312
|
+
)
|
|
1313
|
+
```
|
|
1314
|
+
|
|
43
1315
|
## Generated clients
|
|
44
1316
|
|
|
45
|
-
|
|
1317
|
+
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.
|
|
1318
|
+
|
|
1319
|
+
### Plugin codegen config
|
|
1320
|
+
|
|
1321
|
+
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 }`.
|
|
1322
|
+
|
|
1323
|
+
### TypeScript `createClient`
|
|
1324
|
+
|
|
1325
|
+
Hand-typed client against `InferRoutes<typeof app>` (no generate):
|
|
1326
|
+
|
|
1327
|
+
```ts
|
|
1328
|
+
import { createClient, isClientError } from "@lovrozagar/honey/client"
|
|
1329
|
+
|
|
1330
|
+
const client = createClient<typeof app>({
|
|
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],
|
|
1343
|
+
})
|
|
1344
|
+
|
|
1345
|
+
const res = await client.get("/api/health")
|
|
1346
|
+
const created = await client.post("/api/users", { json: { email: "a@b.com", name: "Ada" } })
|
|
1347
|
+
|
|
1348
|
+
client.$url("/api/users/:id", { params: { id: "1" }, search: { x: "1" } })
|
|
1349
|
+
client.$path("/api/users/:id", { params: { id: "1" } })
|
|
1350
|
+
const ws = client.ws("/echo-ws", { reconnectToken: "t" })
|
|
1351
|
+
```
|
|
1352
|
+
|
|
1353
|
+
Per-call options: `json`, `form`, `search`, `params`, `headers`, `cookies`, `timeout`, `signal`, `lastEventId`.
|
|
1354
|
+
|
|
1355
|
+
`throwOnError: true` throws `ClientError` subclasses (`BadRequestError`, `UnauthorizedError`, …). `isClientError(e)` is the guard.
|
|
1356
|
+
|
|
1357
|
+
### Generated SDK usage
|
|
1358
|
+
|
|
1359
|
+
After `honey generate` with `codegen.sdk.ports.typescript`:
|
|
1360
|
+
|
|
1361
|
+
```ts
|
|
1362
|
+
import { MySDK, isClientError, UnauthorizedError } from "./_gen/sdk.index.gen.ts"
|
|
1363
|
+
|
|
1364
|
+
const sdk = new MySDK({
|
|
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 },
|
|
1377
|
+
})
|
|
1378
|
+
|
|
1379
|
+
const user = await sdk.createUser({ json: { email: "a@b.com", name: "Ada" } })
|
|
1380
|
+
```
|
|
1381
|
+
|
|
1382
|
+
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.
|
|
1383
|
+
|
|
1384
|
+
Capability details and four-language snippets: [SDK index](./docs/sdk.md) and [examples](https://github.com/lovrozagar/honey/tree/main/packages/core/examples).
|
|
1385
|
+
|
|
1386
|
+
### Go CLI
|
|
1387
|
+
|
|
1388
|
+
```ts
|
|
1389
|
+
codegen: {
|
|
1390
|
+
cli: {
|
|
1391
|
+
out: "cli",
|
|
1392
|
+
binaryName: "myapi",
|
|
1393
|
+
modulePath: "example.com/myapi/cli",
|
|
1394
|
+
sdkModulePath: "example.com/myapi", // omit to embed the SDK
|
|
1395
|
+
defaultBaseURL: "http://127.0.0.1:3000",
|
|
1396
|
+
envPrefix: "MYAPI",
|
|
1397
|
+
configName: "myapi",
|
|
1398
|
+
},
|
|
1399
|
+
}
|
|
1400
|
+
```
|
|
1401
|
+
|
|
1402
|
+
Equivalent CLI: `honey generate --cli --cli-out cli --cli-binary-name myapi`.
|
|
1403
|
+
|
|
1404
|
+
### Programmatic codegen
|
|
1405
|
+
|
|
1406
|
+
```ts
|
|
1407
|
+
import {
|
|
1408
|
+
generateOpenApi,
|
|
1409
|
+
generateManifest,
|
|
1410
|
+
generateSDK,
|
|
1411
|
+
generateRouteTreeFromApp,
|
|
1412
|
+
mergeSpecs,
|
|
1413
|
+
sanitizeOpenApiSpec,
|
|
1414
|
+
} from "@lovrozagar/honey/codegen"
|
|
1415
|
+
import { generateGoCLI } from "@lovrozagar/honey/codegen-go-cli"
|
|
1416
|
+
|
|
1417
|
+
const spec = await generateOpenApi(app, { info: { title: "API", version: "1" } })
|
|
1418
|
+
const { files } = generateSDK(spec, { name: "MySDK", stem: "sdk" })
|
|
1419
|
+
```
|
|
1420
|
+
|
|
1421
|
+
Language printers used by the plugin (`generatePythonSDK`, `generateGoSDK`, `generateRustSDK`) are not separate package exports — call `honey generate`.
|
|
1422
|
+
|
|
1423
|
+
## Type inference
|
|
1424
|
+
|
|
1425
|
+
Exported from `@lovrozagar/honey`. They describe the **app you built**:
|
|
1426
|
+
|
|
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 |
|
|
1447
|
+
|
|
1448
|
+
```ts
|
|
1449
|
+
import type { InferCtx, InferRouteInput, InferRoutes } from "@lovrozagar/honey"
|
|
1450
|
+
|
|
1451
|
+
type AppRoutes = InferRoutes<typeof app>
|
|
1452
|
+
type CreateInput = InferRouteInput<typeof app, "/api/users", "post">
|
|
1453
|
+
type Ctx = InferCtx<typeof app>
|
|
1454
|
+
```
|
|
1455
|
+
|
|
1456
|
+
## Testing
|
|
1457
|
+
|
|
1458
|
+
```ts
|
|
1459
|
+
import { testClient } from "@lovrozagar/honey/testing"
|
|
1460
|
+
|
|
1461
|
+
const client = testClient(app, { env: { DATABASE_URL: "…" }, cookies: true })
|
|
1462
|
+
|
|
1463
|
+
const res = await client.get("/api/health")
|
|
1464
|
+
await client.post("/api/users", {
|
|
1465
|
+
json: { email: "a@b.com", name: "Ada" },
|
|
1466
|
+
headers: { authorization: "Bearer t" },
|
|
1467
|
+
search: { dry: "1" },
|
|
1468
|
+
})
|
|
1469
|
+
await client.post("/login", { form: { user: "a", pass: "b" } })
|
|
1470
|
+
```
|
|
1471
|
+
|
|
1472
|
+
Methods: `get` `post` `put` `patch` `delete` `head` `options` `request`. `cookies: true` stores `Set-Cookie` and sends them back.
|
|
1473
|
+
|
|
1474
|
+
## Utilities
|
|
1475
|
+
|
|
1476
|
+
```ts
|
|
1477
|
+
import { accepts } from "@lovrozagar/honey/accepts"
|
|
1478
|
+
accepts(req, ["application/json", "text/html"]) // best match or first if no Accept
|
|
1479
|
+
|
|
1480
|
+
import { serializeCookie } from "@lovrozagar/honey/cookie"
|
|
1481
|
+
serializeCookie("sid", { value: "abc", httpOnly: true, path: "/" })
|
|
1482
|
+
|
|
1483
|
+
import { sign, verify } from "@lovrozagar/honey/cookie-sign"
|
|
1484
|
+
const signed = await sign("abc", SECRET)
|
|
1485
|
+
const raw = await verify(signed, [SECRET, OLD_SECRET]) // null if none match
|
|
1486
|
+
|
|
1487
|
+
import { timingSafeEqual } from "@lovrozagar/honey/crypto"
|
|
1488
|
+
await timingSafeEqual(a, b)
|
|
1489
|
+
|
|
1490
|
+
import { requestToCurl } from "@lovrozagar/honey/request-to-curl"
|
|
1491
|
+
await requestToCurl(req, { excludeHeader: (n) => n === "authorization" })
|
|
1492
|
+
```
|
|
1493
|
+
|
|
1494
|
+
`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`.
|
|
1495
|
+
|
|
1496
|
+
## Package exports
|
|
1497
|
+
|
|
1498
|
+
Import features from their path.
|
|
1499
|
+
|
|
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) |
|
|
1524
|
+
|
|
1525
|
+
## Repository layout
|
|
1526
|
+
|
|
1527
|
+
```
|
|
1528
|
+
packages/core/ published `honey` package (src, tests, docs, examples)
|
|
1529
|
+
e2e/apps/ consumer apps (kitchen, defaults, compose, surface, gateway)
|
|
1530
|
+
e2e/{bun,node,deno,cf-workers}/ runtime hosts + Playwright
|
|
1531
|
+
e2e/run.ts env × app runner
|
|
1532
|
+
bench/ bombardier vs Hono / Elysia / Express / Nest (bun + node)
|
|
1533
|
+
```
|
|
1534
|
+
|
|
1535
|
+
`e2e/apps/*` own the tests. Runtimes only listen. `HONEY_E2E_APP` and `HONEY_E2E_ENV` select which app and host.
|
|
1536
|
+
|
|
1537
|
+
## Develop
|
|
1538
|
+
|
|
1539
|
+
`packages/core` is the published package. `e2e/*` imports `honey` over `workspace:*`.
|
|
1540
|
+
|
|
1541
|
+
Requires [Bun](https://bun.sh) 1.3+.
|
|
1542
|
+
|
|
1543
|
+
```bash
|
|
1544
|
+
bun install
|
|
1545
|
+
bun run generate # honey generate for every e2e app
|
|
1546
|
+
bun run test # core unit + in-process integration (default CI)
|
|
1547
|
+
bun run test:consumers # e2e apps import honey like a real app
|
|
1548
|
+
bun run test:e2e # Playwright, bun × every e2e app
|
|
1549
|
+
bun run test:e2e:node # same matrix against Node (tsx)
|
|
1550
|
+
bun run test:e2e:deno # same matrix against Deno
|
|
1551
|
+
bun run test:e2e:cf # same matrix against local wrangler / workerd
|
|
1552
|
+
bun run test:e2e:all # bun + node + deno + local cf × every e2e app
|
|
1553
|
+
bun run typecheck # core src (TypeScript 7)
|
|
1554
|
+
bun run typecheck:consumers # every e2e app + generated types
|
|
1555
|
+
bun run lint # oxlint
|
|
1556
|
+
bun run lint:fix # oxlint --fix
|
|
1557
|
+
bun run fmt # oxfmt
|
|
1558
|
+
bun run fmt:check # oxfmt --check
|
|
1559
|
+
```
|
|
1560
|
+
|
|
1561
|
+
Opt-in locally (CI `harness` job runs the first one):
|
|
1562
|
+
|
|
1563
|
+
```bash
|
|
1564
|
+
bun run test:harness # TS / Go / Python / Rust / MCP compile + behavioral
|
|
1565
|
+
bun run test:harness:rust # rust-only subset of the same loop
|
|
1566
|
+
bun run test:all # default suite + language harnesses
|
|
1567
|
+
```
|
|
1568
|
+
|
|
1569
|
+
Typecheck stays strict. Do not weaken `strict` or add `as any` to make it pass.
|
|
1570
|
+
|
|
1571
|
+
### Test matrix
|
|
1572
|
+
|
|
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. |
|
|
1583
|
+
|
|
1584
|
+
```bash
|
|
1585
|
+
bun e2e/run.ts --env node --app kitchen
|
|
1586
|
+
bun e2e/run.ts --env all --app surface
|
|
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
|
|
1592
|
+
```
|
|
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
|
+
|
|
1601
|
+
### E2E apps and runtimes
|
|
1602
|
+
|
|
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 |
|
|
1610
|
+
|
|
1611
|
+
Runtimes: `e2e/bun`, `e2e/node`, `e2e/deno`, `e2e/cf-workers`.
|
|
1612
|
+
|
|
1613
|
+
### Live Cloudflare proof
|
|
1614
|
+
|
|
1615
|
+
Same kitchen app on workerd: https://honey-cf-e2e.lovro-zagar5.workers.dev
|
|
1616
|
+
|
|
1617
|
+
See `e2e/cf-workers/README.md`. Redeploy with `bun run deploy:e2e:cf`. Live soak: `bun run test:live:cf`.
|
|
1618
|
+
|
|
1619
|
+
### Bench
|
|
1620
|
+
|
|
1621
|
+
`bench/` compares Honey, Hono, Elysia, Express, and Nest on Bun and Node (naked + Zod). Bombardier, 10s / 100 connections, localhost.
|
|
1622
|
+
|
|
1623
|
+
```bash
|
|
1624
|
+
bun run --filter @honey/bench build:all
|
|
1625
|
+
bun run --filter @honey/bench bench:bun
|
|
1626
|
+
bun run --filter @honey/bench bench:node
|
|
1627
|
+
```
|
|
1628
|
+
|
|
1629
|
+
Numbers and bundle sizes: [`bench/RESULTS.md`](https://github.com/lovrozagar/honey/blob/main/bench/RESULTS.md).
|
|
1630
|
+
|
|
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`.
|
|
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.
|
|
46
1636
|
|
|
47
|
-
|
|
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.
|
|
48
1638
|
|
|
49
1639
|
## License
|
|
50
1640
|
|