typespec-hono 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bison Digital
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,333 @@
1
+ # typespec-hono
2
+
3
+ Generate a [Hono](https://hono.dev) server — and the Zod validators it enforces — from a TypeSpec HTTP
4
+ service, agreeing with the OpenAPI document [`@typespec/openapi3`](https://typespec.io) publishes from
5
+ the same source.
6
+
7
+ This package runs the whole of
8
+ [`typespec-http-zod`](https://github.com/bison-digital/typespec-http-zod) and adds one file. **A
9
+ consumer lists one emitter and gets five artefacts.**
10
+
11
+ ## Why one emitter and not two
12
+
13
+ The server and the validators share a naming contract: `app.gen.ts` imports `readWidgetPath` and
14
+ `readWidgetResponses` from `schemas.gen.js` **by name**. Two separate TypeSpec emitters would each get
15
+ their own `$onEmit` and their own registry, and would have to arrive at identical identifiers by
16
+ coincidence. Running the library from inside this one means it mints the names, writes them, and hands
17
+ them back — so the agreement is structural.
18
+
19
+ If you want the validators without a server, install `typespec-http-zod` alone. Nothing here is
20
+ required for that.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ npm install typespec-hono
26
+ ```
27
+
28
+ ⚠️ **A dependency, not a devDependency, and this is not a style preference.** `typespec-hono/runtime`
29
+ exports **runtime values** — `armFor`, and `selectContentType` where a route negotiates — and both the
30
+ generated server and your own `deps` import them. Under `--save-dev` everything looks fine: install,
31
+ typecheck and `wrangler dev` all pass. It fails at **deploy**, when the production install drops it:
32
+
33
+ ```
34
+ ✘ [ERROR] Could not resolve "typespec-hono/runtime"
35
+ import { armFor, type RouteDeps } from "typespec-hono/runtime";
36
+ ```
37
+
38
+ Measured: `pnpm install --prod` then `wrangler deploy --dry-run` → exit 1. The emitter half is
39
+ build-time, but the runtime half ships.
40
+
41
+ Peers: `hono`, `@hono/zod-validator`, `zod`, `@typespec/compiler`.
42
+
43
+ ```yaml
44
+ # tspconfig.yaml
45
+ emit:
46
+ - typespec-hono
47
+ options:
48
+ typespec-hono:
49
+ emitter-output-dir: "{project-root}/src/generated"
50
+ seal-object-schemas: true
51
+ ```
52
+
53
+ ## What it emits
54
+
55
+ `app.gen.ts`, plus everything `typespec-http-zod` emits — see its README for the other four.
56
+
57
+ Routes are grouped into a **sub-app per resource** and mounted with `app.route()`, which is what
58
+ [Hono's best-practices guide](https://hono.dev/docs/guides/best-practices) recommends for building a
59
+ larger application. Its other recommendation is honoured at the same time and matters more: handlers
60
+ are written **directly after the path definitions**, never lifted into Rails-style controllers, because
61
+ a handler in a separate file cannot infer its path parameters. A resource with one route gets no
62
+ sub-app — that is ceremony around a single line, and not what an author writes.
63
+
64
+ Measured before relying on it: `app.route(prefix, sub)` composes paths exactly, including a parameter
65
+ in the prefix, and the parent's `app.routes` reports the fully composed path — so every arm that
66
+ counts routes still counts them.
67
+
68
+ The server is **plain `Hono` and `@hono/zod-validator`**, deliberately not `@hono/zod-openapi`. That
69
+ package is the same validation plus a document generated FROM the code — spec-last, and a second
70
+ source of truth competing with the one openapi3 publishes from the spec. We want its validation, not
71
+ its documentation.
72
+
73
+ ```ts
74
+ import { Hono } from "hono";
75
+ import { registerRoutes } from "./generated/app.gen.js";
76
+
77
+ // ⚠️ Unannotated on purpose — annotating widens the value and disables the exhaustiveness check.
78
+ const handlersFor = (c) => backendFor(c.env);
79
+ const routes = registerRoutes(new Hono<AppEnv>(), handlersFor, deps);
80
+
81
+ export default routes;
82
+ ```
83
+
84
+ ### Hono RPC (`hc`) works, and the return value is why
85
+
86
+ `registerRoutes` **chains** its registrations and hands back the result, so Hono's RPC client gets a
87
+ fully typed surface derived from the same document:
88
+
89
+ ```ts
90
+ import { hc } from "hono/client";
91
+
92
+ const client = hc<typeof routes>("https://api.example.com");
93
+ const response = await client.widgets[":widget-id"].$get({
94
+ param: { "widget-id": "w-1" },
95
+ header: { "x-request-id": "r-1" },
96
+ });
97
+ ```
98
+
99
+ ⚠️ **Use the RETURNED value, not the instance you passed in.** `hc` reads the `Schema` type Hono
100
+ accumulates through the chain; the bare `new Hono()` still carries nothing. Measured before this
101
+ worked: `hc<typeof app>` resolved to `unknown` — not an empty client, an unusable one.
102
+
103
+ `handlersFor` is a **factory** rather than an object because in Workers a service binding lives on
104
+ `c.env` and exists only for the duration of a request — there is no module scope in which
105
+ `backendFor(env)` can be resolved.
106
+
107
+ ### The document's base path is honoured
108
+
109
+ `@server("/api/v1")` reaches OpenAPI as `servers: [{ url: "/api/v1" }]`, and **an OpenAPI path is
110
+ relative to its server** — so the document publishes `/api/v1/accounts`, not `/accounts`. The
111
+ generated routes are mounted under that prefix with a nested `app.route()`, so the server and the
112
+ document agree and a client generated from the document reaches it.
113
+
114
+ Where the document is ambiguous — several servers with disagreeing paths — routes are mounted at the
115
+ root and `ambiguous-server-path` says so. A templated server such as `@server("{endpoint}")` means the
116
+ caller supplies the whole origin, so the root is already correct and nothing is reported.
117
+
118
+ ### Authentication carries the scheme, not just "someone is here"
119
+
120
+ `@useAuth(BearerAuth)` publishes `security: [{ "BearerAuth": [] }]`, and `deps.authorize` receives
121
+ exactly that:
122
+
123
+ ```ts
124
+ deps.authorize([{ BearerAuth: [] }]) // one scheme, no scopes
125
+ deps.authorize([{ OAuth2Auth: ["widgets:read"] }]) // scopes, from the declared flows
126
+ deps.authorize([{ OAuth2Auth: [...] }, { BearerAuth: [] }]) // EITHER authorises
127
+ ```
128
+
129
+ Satisfying **any one** requirement authorises the caller; every scheme **within** one must be
130
+ satisfied together. That is what an array of OpenAPI `security` objects means, and a flat list of
131
+ scopes cannot express the difference.
132
+
133
+ ⚠️ **This used to carry scopes only**, so a gate was emitted for OAuth2 and for nothing else — bearer,
134
+ api-key and basic carried none at all and rested entirely on `deps.context` returning null. That
135
+ answers "is somebody here", not "did they satisfy the scheme the contract names": an application
136
+ reading a cookie would have served a route the document says needs a bearer token.
137
+
138
+ Which credentials satisfy a scheme is still yours — it could not be anything else. Which schemes an
139
+ operation accepts is a contract fact, and is now generated.
140
+
141
+ ## Middleware — register it BEFORE `registerRoutes`
142
+
143
+ ⚠️ **This is the one ordering rule, and getting it wrong fails silently.** Hono middleware applies
144
+ only to routes registered _after_ it, and `registerRoutes` registers everything at once. Middleware
145
+ added afterwards does not error — it simply never runs.
146
+
147
+ ```ts
148
+ const app = new Hono<AppEnv>();
149
+
150
+ app.use(cors()); // ✅ global
151
+ app.use("/widgets/*", rateLimit()); // ✅ per resource
152
+ app.use("/widgets/:widget-id", cache()); // ✅ per route
153
+
154
+ const routes = registerRoutes(app, handlersFor, deps); // ← everything above applies
155
+
156
+ app.use(cors()); // ❌ silently applies to nothing
157
+ ```
158
+
159
+ All three scopes are reachable and each is asserted by a real request in
160
+ `test/wire/middleware.test.ts`. Per-resource works through a prefix wildcard rather than a handle on
161
+ the sub-app: the sub-apps are `const`s inside `registerRoutes`, and `/widgets/*` is the equivalent —
162
+ it works because every route of a resource is mounted under that resource's prefix.
163
+
164
+ `app.onError` and `app.notFound` are **not** subject to this: they are app-level handlers rather than
165
+ route middleware, and may be registered in any order.
166
+
167
+ ## Cloudflare Workers — pick a router
168
+
169
+ You construct the `Hono` instance, so the router is your choice. **Make it deliberately.** Measured on
170
+ a generated **580-operation** service (58 resources × 10), three runs, on Hono 4.13.1:
171
+
172
+ | router | register | first request | 1000 requests |
173
+ | ------------------------------ | -------------- | ---------------- | ------------- |
174
+ | SmartRouter _(Hono's default)_ | 5.4–6.1 ms | **18.5–18.9 ms** | 24–26 ms |
175
+ | **RegExpRouter** | 2.6–4.4 ms | 2.2–2.6 ms | **16–18 ms** |
176
+ | LinearRouter | **1.1–1.2 ms** | **0.6 ms** | 66–70 ms |
177
+ | PatternRouter | 2.7–2.9 ms | 2.5 ms | 36–39 ms |
178
+
179
+ ⚠️ **The default is the worst choice here, and the reason is a cold-start cost you pay per isolate.**
180
+ SmartRouter picks a router by trying one, and that build happens on the **first request** rather than
181
+ at registration. At this scale that is ~18.8 ms of CPU — and the Workers **free plan allows 10 ms of
182
+ CPU per request**, so the first request into every new isolate can be killed with `exceededCpu`.
183
+
184
+ **Recommendation for a generated app: `RegExpRouter`.**
185
+
186
+ ```ts
187
+ import { Hono } from "hono";
188
+ import { RegExpRouter } from "hono/router/reg-exp-router";
189
+
190
+ const routes = registerRoutes(new Hono<AppEnv>({ router: new RegExpRouter() }), handlersFor, deps);
191
+ ```
192
+
193
+ Its reputation for slow registration does not bite here — it registered in under 5 ms and is fastest
194
+ in steady state. Choose `LinearRouter` instead only for a very low-traffic Worker where cold start
195
+ dominates: it is the cheapest to start and ~4× slower per request thereafter.
196
+
197
+ ### Bundle size is not the constraint
198
+
199
+ | | raw | gzip | share of the 3 MB free limit |
200
+ | -------------- | -------- | ------------- | ---------------------------- |
201
+ | 20 operations | 642 KiB | 100.8 KiB | 3.3% |
202
+ | 580 operations | 1008 KiB | **112.6 KiB** | **3.7%** |
203
+
204
+ 560 extra operations cost ~12 KiB gzipped — the baseline is Hono and Zod, not your API. Router choice
205
+ moves it by under 1 KiB, so do not choose a router for size. Limits are 3 MB gzipped on the free plan
206
+ and 10 MB on paid.
207
+
208
+ `registerRoutes` runs at module scope in 1.1–6.1 ms, comfortably inside the **1 second** startup
209
+ budget, despite the docs' warning that "generating or consuming a large schema at the top level is a
210
+ common cause of exceeding this limit".
211
+
212
+ Nothing here needs `nodejs_compat`: the generated server runs on `workerd` with no Node built-ins.
213
+
214
+ ## Streaming
215
+
216
+ A generated operation returns a **value**, not a `Response`, so a handler cannot hand back a
217
+ `ReadableStream`. Streaming happens in `deps.respond`, which may return any `Response`:
218
+
219
+ ```ts
220
+ const deps: RouteDeps = {
221
+ // ...
222
+ respond: (c, arms, result) =>
223
+ streamSSE(c, async (stream) => {
224
+ for await (const item of pageThrough(result)) {
225
+ await stream.writeSSE({ data: JSON.stringify(item) });
226
+ }
227
+ }),
228
+ };
229
+ ```
230
+
231
+ The validators are middleware, so they still run **before** anything is streamed — a request the
232
+ document forbids is refused with an ordinary response and never opens a stream. Both properties are
233
+ asserted by real requests in `test/wire/streaming.test.ts`.
234
+
235
+ Point `runtime-module` at your own module and re-declare `Result<T>` if you want the handler's return
236
+ type to carry the stream shape; that is the same seam the README describes for result envelopes.
237
+
238
+ ## Observability
239
+
240
+ Nothing here is Sentry-specific, and this package deliberately ships no instrumentation — but two
241
+ properties an APM needs are asserted rather than hoped for:
242
+
243
+ - **`c.req.routePath` yields the route pattern**, `/widgets/:widget-id`, not the concrete URL. That is
244
+ the span name you want; the URL would be a cardinality bomb. It survives being mounted through a
245
+ sub-app, which is not obvious.
246
+ - **A handler's `throw` reaches an app-level `onError`.** Nothing in the generated file swallows it —
247
+ `deps.respond` is only reached on success.
248
+
249
+ Both are pinned by real requests, so a change to how routes are grouped cannot quietly remove them.
250
+
251
+ ## Options
252
+
253
+ Every option `typespec-http-zod` accepts, forwarded — the schema is **derived** from that package's,
254
+ not restated, and a test asserts the forwarding as a class at both the top level and inside the
255
+ per-service overrides. See its README for `seal-object-schemas`, `contracts-output-dir`,
256
+ `contracts-package`, `key-vocabularies`, `runtime-module` and `services`.
257
+
258
+ `runtime-module` is the one you are most likely to set: point it at a module of your own that
259
+ re-declares `Result`, `Ctx`, `AppEnv` and `RouteDeps`, and every generated signature carries your
260
+ types instead of the identity defaults.
261
+
262
+ ## What it refuses, and why
263
+
264
+ Both refusals are about the target framework. `typespec-http-zod` emits correct validators for these
265
+ operations; only a Hono server cannot serve them.
266
+
267
+ | code | why |
268
+ | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
269
+ | `unroutable-verb` | ⚠️ **A `@head` operation.** Hono rewrites every HEAD request to GET _before_ matching — `hono-base.js` does it unconditionally at the top of `#dispatch` — so a route registered under HEAD is never reached: 404 where the path has no GET, dead code where it has one. Measured on Hono 4.13.1; `on("PURGE", …)` and `on("OPTIONS", …)` both work, so this is HEAD specifically. **The remedy is in your spec:** declare `@get`, and Hono answers HEAD from it with the body stripped, which is what RFC 9110 requires anyway. Hono's own [best-practices guide](https://hono.dev/docs/guides/best-practices) states the same rule. |
270
+ | `ambiguous-server-path` | The service declares several `@server` entries whose base paths disagree, so there is no single prefix to mount under. Routes are mounted at the **root** and this says so. An OpenAPI path is relative to its server, so callers following the document will prefix one of them — mount the returned app under the prefix you actually serve, or declare a single base path. |
271
+ | `unsupported-path-template` | a path parameter that is not a plain name. Hono reads a parameter up to the next `/`, so an RFC 6570 operator or modifier would become part of the name — and `*` would become Hono's wildcard, mounting a route that matches the wrong requests and answers them. Refused rather than approximated, because a route that works and is wrong is worse than one that fails. |
272
+
273
+ ### Refusals are warnings, and you choose whether they fail the build
274
+
275
+ A refusal is reported as a **warning**, so a compile that contains one still succeeds and still emits
276
+ everything else — including `@typespec/openapi3`'s document, if you run it.
277
+
278
+ ⚠️ **That last part is why.** A TypeSpec `error` sets `program.hasError()`, and openapi3 declines to
279
+ write anything when the program has errors — _including errors that are not its own_. With these
280
+ refusals as errors, one `@head` operation cost you your entire OpenAPI document, and whether it did
281
+ depended on the order emitters were listed in. Measured: `openapi/` went from one file to none.
282
+
283
+ It is also what the first-party rule says. openapi3 uses `warning` for exactly this shape —
284
+ `streams-not-supported`, `unsupported-auth` — meaning _"the spec is valid and this emitter cannot
285
+ express it"_, and reserves `error` for a spec that is wrong for any emitter. A `@head` operation is
286
+ valid TypeSpec that openapi3 renders perfectly; only Hono cannot route it.
287
+
288
+ **Nothing is lost.** The operation is still excluded from the server, still named, still carries its
289
+ remedy. If you want a refusal to fail your build, that is the compiler's switch and always was:
290
+
291
+ ```yaml
292
+ # tspconfig.yaml
293
+ warn-as-error: true
294
+ ```
295
+
296
+ ```
297
+ default -> exit 0, document written, operation excluded
298
+ warn-as-error -> exit 1, build fails, refusal reported as an error
299
+ ```
300
+
301
+ ## Known limits
302
+
303
+ - **Fifteen of the seventeen `@head` operations in `@typespec/http-specs` are refused**, and that is
304
+ the honest count rather than a defect: they have no sibling `GET`, so Hono cannot route them at all.
305
+ This emitter previously emitted them, and every route-counting arm called them mounted.
306
+ - **`app.on(method, …)` is reachable by no TypeSpec spec**, because `@typespec/http` declares six
307
+ verbs, five have Hono helpers, and the sixth is `@head`, which is refused. The branch is exercised
308
+ directly by `test/render.test.ts` rather than left untested or deleted — deleting it would make
309
+ `HONO_METHOD[verb]` `undefined` for any verb TypeSpec adds later, emitting `app.undefined(...)`
310
+ from a spec that compiles.
311
+
312
+ ## How it is graded
313
+
314
+ - **Route surface over the corpus** — 61 scenarios of `@typespec/http-specs`, with counts read from
315
+ `app.routes` after mounting the real `registerRoutes`, never from the emitted text. **577 declared,
316
+ 564 mounted, 13 refused**, and `mounted + refused === declared` is asserted, because
317
+ `mounted === declared` can be satisfied by a route nobody can reach — and was.
318
+ - **An application is compiled against it**, with `runtime-module` pointed at a module that
319
+ substitutes real types, and with no cast anywhere. A signature no application could satisfy passed
320
+ every other test for a long time because the suite that mounted it cast to `unknown`.
321
+ - **Equivalence against a hand-written Hono app** — `test/equivalence/reference-app.ts` follows the
322
+ pattern in Hono's own validation guide, written without reference to what this emitter produces.
323
+ Both apps serve the same API and answer thirteen identical exchanges identically, and both routing
324
+ tables are compared, because behaviour alone would miss two apps that happen to 404 together. ⚠️
325
+ **Its value is entirely in its independence** — adjusted to match our output, it would prove only
326
+ that we agree with ourselves, so when the two disagree the emitter is what changes.
327
+ - **Real requests through the real router** — the only thing that can see a validator-to-wire defect.
328
+ A flattened collection parameter once had the document saying `array`, the validator saying `array`,
329
+ and the server rejecting every conformant caller.
330
+
331
+ ## Licence
332
+
333
+ MIT
@@ -0,0 +1,61 @@
1
+ import { type SecurityRequirement } from "./security.js";
2
+ import { type EmittedRoute, type EmittedService } from "typespec-http-zod";
3
+ /**
4
+ * TypeSpec publishes `/widgets/{widget-id}`; Hono routes on `/widgets/:widget-id`.
5
+ *
6
+ * ⚠️ **This used to match `\w+`, so any parameter carrying a hyphen was left ALONE.**
7
+ * `@path("thing-id")` produced the literal route `/things/{thing-id}` — mounted, counted by every arm
8
+ * that counts routes, and reachable by nobody. It answered 404 to the only requests it was for. Hono
9
+ * handles `:thing-id` and `:x.y` perfectly well; the narrow character class was ours.
10
+ *
11
+ * A name that is not plain is REFUSED rather than approximated. Hono reads a parameter up to the next
12
+ * `/`, so an RFC 6570 modifier would survive into the name and `*` would become Hono's wildcard — a
13
+ * route that matches the wrong requests and answers them, which is worse than one that fails.
14
+ *
15
+ * ⚠️ **This runs at RENDER time, not during collection.** It used to run inside `collectRoutes`, which
16
+ * put one framework's spelling into the shared intermediate representation and refused the whole
17
+ * operation — validators included — over a template no router could mount. What a request body must
18
+ * look like does not depend on that.
19
+ */
20
+ export declare function toHonoPath(template: string, refuse: (template: string, name: string) => void): string;
21
+ /** The two things a Hono server cannot express, handed back rather than thrown. */
22
+ export interface RenderRefusals {
23
+ readonly unsupportedPathTemplate: (route: EmittedRoute, template: string, name: string) => void;
24
+ readonly unroutableVerb: (route: EmittedRoute) => void;
25
+ }
26
+ /**
27
+ * The generated Hono server.
28
+ *
29
+ * ⚠️ **This replaces a data table that a hand-written loop interpreted at run time.** The emitter knew,
30
+ * per operation, which validators applied and what the call looked like — and then flattened all of it
31
+ * into one homogeneous array, so the consumer had to recover it dynamically and could not. Emitting
32
+ * the call sites keeps that knowledge, and every one of them is monomorphic and checked.
33
+ *
34
+ * ⚠️ **Plain `Hono` and `@hono/zod-validator`, deliberately not `@hono/zod-openapi`.** The latter is
35
+ * the same validation plus a document generated FROM the code — spec-last, and a second source of
36
+ * truth competing with the one `@typespec/openapi3` publishes from the spec. We want its validation,
37
+ * not its documentation.
38
+ *
39
+ * ⚠️ **It declares no schema of its own.** Every validator this file names was declared by
40
+ * `typespec-http-zod` into `schemas.gen.ts` and is imported by name. That is what makes the two
41
+ * packages one emitter rather than two that must agree by coincidence — and it is why a consumer who
42
+ * wants the validators without a server can simply not install this one.
43
+ */
44
+ export declare function renderApp(emitted: EmittedService, refuse: RenderRefusals,
45
+ /**
46
+ * The path the DOCUMENT says this service is served under, when it says one unambiguously.
47
+ *
48
+ * ⚠️ **An OpenAPI path is relative to its server**, so `@server("/api/v1")` plus `/accounts` means
49
+ * the document publishes `/api/v1/accounts`. Mounting at the root made every client generated from
50
+ * the document, and every "try it" in a rendered document, 404.
51
+ */
52
+ basePath?: string,
53
+ /**
54
+ * What the DOCUMENT says a caller must satisfy, per operation id.
55
+ *
56
+ * ⚠️ **Resolved by the caller rather than read off `EmittedRoute`**, because which schemes an
57
+ * operation accepts is a fact about the HTTP program and not part of the validator IR the library
58
+ * publishes. Keeping it out of that IR is what stops a Hono concern leaking into a package whose
59
+ * audience is wider.
60
+ */
61
+ securityFor?: (verb: string, path: string) => readonly SecurityRequirement[]): string;