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 +21 -0
- package/README.md +333 -0
- package/dist/src/app.d.ts +61 -0
- package/dist/src/app.js +609 -0
- package/dist/src/base-path.d.ts +16 -0
- package/dist/src/base-path.js +47 -0
- package/dist/src/emitter.d.ts +24 -0
- package/dist/src/emitter.js +108 -0
- package/dist/src/index.d.ts +12 -0
- package/dist/src/index.js +12 -0
- package/dist/src/lib.d.ts +71 -0
- package/dist/src/lib.js +123 -0
- package/dist/src/runtime.d.ts +174 -0
- package/dist/src/runtime.js +63 -0
- package/dist/src/security.d.ts +29 -0
- package/dist/src/security.js +48 -0
- package/dist/src/tsp-index.d.ts +9 -0
- package/dist/src/tsp-index.js +9 -0
- package/lib/main.tsp +3 -0
- package/package.json +90 -0
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;
|