typespec-hono 0.1.0 → 0.3.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 +113 -265
- package/dist/src/app.d.ts +25 -19
- package/dist/src/app.js +238 -113
- package/dist/src/base-path.d.ts +18 -9
- package/dist/src/base-path.js +20 -18
- package/dist/src/emitter.d.ts +9 -18
- package/dist/src/emitter.js +51 -26
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/lib.d.ts +13 -16
- package/dist/src/lib.js +28 -68
- package/dist/src/runtime.d.ts +102 -46
- package/dist/src/runtime.js +63 -17
- package/dist/src/security.d.ts +4 -4
- package/dist/src/security.js +2 -2
- package/dist/src/tsp-index.d.ts +1 -1
- package/dist/src/tsp-index.js +1 -1
- package/lib/main.tsp +1 -1
- package/package.json +6 -5
- package/src/runtime.ts +299 -0
package/dist/src/app.js
CHANGED
|
@@ -4,23 +4,29 @@ import { isRawBinaryMediaType, objectKey, } from "typespec-http-zod";
|
|
|
4
4
|
const GENERATED_BANNER = `// GENERATED by typespec-hono from the TypeSpec service definition. DO NOT EDIT.
|
|
5
5
|
// Recompile the spec that produced it; edits here are overwritten on the next run.
|
|
6
6
|
`;
|
|
7
|
-
/** A parameter name Hono can carry verbatim
|
|
7
|
+
/** A parameter name Hono can carry verbatim. Measured against Hono, not assumed. */
|
|
8
8
|
const PLAIN_PATH_PARAMETER = /^[A-Za-z0-9_.~-]+$/;
|
|
9
9
|
/**
|
|
10
10
|
* TypeSpec publishes `/widgets/{widget-id}`; Hono routes on `/widgets/:widget-id`.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
* `@path("thing-id")` produced the literal route `/things/{thing-id}
|
|
12
|
+
* **This used to match `\w+`, so any parameter carrying a hyphen was left ALONE.**
|
|
13
|
+
* `@path("thing-id")` produced the literal route `/things/{thing-id}`, mounted, counted by every arm
|
|
14
14
|
* that counts routes, and reachable by nobody. It answered 404 to the only requests it was for. Hono
|
|
15
15
|
* handles `:thing-id` and `:x.y` perfectly well; the narrow character class was ours.
|
|
16
16
|
*
|
|
17
|
-
* A name that is not plain is REFUSED rather than approximated
|
|
18
|
-
*
|
|
19
|
-
* route that matches the wrong requests and answers them, which is worse than one that fails.
|
|
17
|
+
* A name that is not plain is REFUSED rather than approximated, and the route stays at the literal
|
|
18
|
+
* template so it matches nothing rather than matching the wrong thing.
|
|
20
19
|
*
|
|
21
|
-
*
|
|
20
|
+
* **This is about the NAME, not about RFC 6570 operators.** An earlier version of this comment
|
|
21
|
+
* claimed `{+path}` or `{tag*}` would survive into the name and that `*` would become Hono's
|
|
22
|
+
* wildcard. Measured, that is false: `@typespec/http` resolves the operator before this emitter sees
|
|
23
|
+
* the path, and `@typespec/openapi3` strips it from the published document too, so both artefacts say
|
|
24
|
+
* `/files{path}` and agree. What actually reaches here is a wire name from `@path("...")`, and the
|
|
25
|
+
* forms that fail are a space, `+` and `!`. `*` is rejected by `@typespec/http` before it arrives.
|
|
26
|
+
*
|
|
27
|
+
* **This runs at RENDER time, not during collection.** It used to run inside `collectRoutes`, which
|
|
22
28
|
* put one framework's spelling into the shared intermediate representation and refused the whole
|
|
23
|
-
* operation
|
|
29
|
+
* operation (validators included) over a template no router could mount. What a request body must
|
|
24
30
|
* look like does not depend on that.
|
|
25
31
|
*/
|
|
26
32
|
export function toHonoPath(template, refuse) {
|
|
@@ -32,14 +38,21 @@ export function toHonoPath(template, refuse) {
|
|
|
32
38
|
});
|
|
33
39
|
}
|
|
34
40
|
/**
|
|
35
|
-
*
|
|
41
|
+
* The verb Hono actually dispatches under.
|
|
42
|
+
*
|
|
43
|
+
* `HEAD` is rewritten to `GET` before matching -- `hono-base.js` does it unconditionally at the top
|
|
44
|
+
* of `#dispatch` -- so a route registered under `HEAD` is never reached. Registering it under `GET`
|
|
45
|
+
* is what makes it reachable, and Hono strips the response body for a real HEAD request itself,
|
|
46
|
+
* which is what RFC 9110 requires.
|
|
36
47
|
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* diagnostic for the measurements.
|
|
48
|
+
* The original verb is still available to the handler: `c.req.method` reads `HEAD` on a HEAD
|
|
49
|
+
* request even after the rewrite, which is what lets one registration serve both.
|
|
40
50
|
*/
|
|
41
|
-
const
|
|
42
|
-
|
|
51
|
+
const REGISTRATION_VERB = { HEAD: "GET" };
|
|
52
|
+
function registrationVerbOf(verb) {
|
|
53
|
+
return REGISTRATION_VERB[verb] ?? verb;
|
|
54
|
+
}
|
|
55
|
+
/** `GET` -> `get`. Hono's per-verb helpers are the idiom; `app.on` is the escape hatch. */
|
|
43
56
|
const HONO_METHOD = {
|
|
44
57
|
GET: "get",
|
|
45
58
|
POST: "post",
|
|
@@ -50,7 +63,7 @@ const HONO_METHOD = {
|
|
|
50
63
|
/**
|
|
51
64
|
* A request location, as the DOCUMENT names it, mapped to the target `@hono/zod-validator` reads.
|
|
52
65
|
*
|
|
53
|
-
*
|
|
66
|
+
* **The two vocabularies are not the same, and only one of them is a contract fact.** OpenAPI says
|
|
54
67
|
* `path`, `query`, `header` and a request body; zValidator says `param`, `query`, `header` and
|
|
55
68
|
* `json`. The library publishes the first because that is what the document states; translating is
|
|
56
69
|
* this package's job, and it is a map rather than a coincidence.
|
|
@@ -64,41 +77,63 @@ const VALIDATOR_TARGET = {
|
|
|
64
77
|
/**
|
|
65
78
|
* The `@hono/zod-validator` target for a request body, decided by what the wire actually carries.
|
|
66
79
|
*
|
|
67
|
-
*
|
|
68
|
-
* `zValidator("json",
|
|
80
|
+
* **This was `"json"` unconditionally, and that made every non-JSON body unservable.**
|
|
81
|
+
* `zValidator("json", ...)` reads `c.req.json()`, so a `multipart/form-data` upload was parsed as JSON
|
|
69
82
|
* and refused. Measured: 17 such registrations in `payload/multipart` alone, and **11 of the 17
|
|
70
|
-
* request bodies in the Swagger Petstore are not JSON
|
|
83
|
+
* request bodies in the Swagger Petstore are not JSON**, 5 urlencoded, 5 XML, 1 octet-stream.
|
|
71
84
|
*
|
|
72
|
-
* Hono's own target for both multipart and urlencoded is `"form"
|
|
85
|
+
* Hono's own target for both multipart and urlencoded is `"form"`, `c.req.parseBody()` handles the
|
|
73
86
|
* two together, which is why one branch covers both.
|
|
74
87
|
*
|
|
75
|
-
*
|
|
88
|
+
* **Anything else stays `"json"` deliberately.** A media type this function does not recognise is
|
|
76
89
|
* not a licence to guess: `application/xml` has no Hono target and no Zod representation the document
|
|
77
90
|
* justifies, so it keeps the existing behaviour rather than acquiring a new one on the way past. That
|
|
78
91
|
* gap is real and is stated in the README rather than papered over here.
|
|
79
92
|
*/
|
|
80
|
-
function
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
93
|
+
function targetForMediaType(type) {
|
|
94
|
+
if (type === "application/x-www-form-urlencoded" || type.startsWith("multipart/"))
|
|
95
|
+
return "form";
|
|
96
|
+
// `application/json`, and the `+json` structured suffix RFC 6839 defines.
|
|
97
|
+
if (type === "application/json" || type.endsWith("+json"))
|
|
98
|
+
return VALIDATOR_TARGET.body;
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Which `zValidator` target parses each media type the request may carry.
|
|
103
|
+
*
|
|
104
|
+
* **This used to return ONE target for the whole route, and that was silently wrong for a mixed
|
|
105
|
+
* body.** Which parser applies is decided by the caller's `Content-Type` at REQUEST time;
|
|
106
|
+
* `zValidator`'s target is fixed at generation time. So a route declaring
|
|
107
|
+
* `application/json | application/xml | application/x-www-form-urlencoded` -- which is `addPet` in
|
|
108
|
+
* the Swagger Petstore, and 11 of its 17 request bodies are not JSON -- got `zValidator("json")` and
|
|
109
|
+
* rejected every form-encoded body with a 400, raising no diagnostic at all.
|
|
110
|
+
*
|
|
111
|
+
* The answer is to decide at request time, which is the only time the answer exists. Where the
|
|
112
|
+
* document declares one parseable type this still emits a single `zValidator` exactly as before.
|
|
113
|
+
*
|
|
114
|
+
* **`application/xml` and friends are reported, not guessed at.** There is no Hono parser and no
|
|
115
|
+
* Zod representation for XML, and adding an XML dependency to a Zod emitter is not a trade this
|
|
116
|
+
* package should make. Naming them is what stops a consumer believing a route is validated when it
|
|
117
|
+
* is not -- the previous behaviour said nothing and rejected them.
|
|
118
|
+
*/
|
|
119
|
+
function bodyValidationFor(contentTypes) {
|
|
120
|
+
if (contentTypes.length === 0)
|
|
121
|
+
return { byType: [["", VALIDATOR_TARGET.body]], unparseable: [] };
|
|
122
|
+
const byType = [];
|
|
123
|
+
const unparseable = [];
|
|
124
|
+
for (const type of contentTypes) {
|
|
125
|
+
const target = targetForMediaType(type);
|
|
126
|
+
if (target === undefined)
|
|
127
|
+
unparseable.push(type);
|
|
128
|
+
else
|
|
129
|
+
byType.push([type, target]);
|
|
130
|
+
}
|
|
131
|
+
return { byType, unparseable };
|
|
97
132
|
}
|
|
98
133
|
/**
|
|
99
134
|
* How an unparsed body reaches the handler, and as what.
|
|
100
135
|
*
|
|
101
|
-
*
|
|
136
|
+
* **This was always `c.req.text()` typed as `string`, and for a binary body that silently
|
|
102
137
|
* corrupts.** `text()` UTF-8-decodes, so every byte outside ASCII becomes U+FFFD. Measured against a
|
|
103
138
|
* Petstore `application/octet-stream` upload under `wrangler dev`:
|
|
104
139
|
*
|
|
@@ -108,10 +143,10 @@ function bodyTargetFor(contentTypes) {
|
|
|
108
143
|
* ```
|
|
109
144
|
*
|
|
110
145
|
* Five bytes destroyed, unrecoverably, and the request answered **200**. Success status, corrupt
|
|
111
|
-
* payload, no signal
|
|
146
|
+
* payload, no signal. The worst shape a defect can take.
|
|
112
147
|
*
|
|
113
|
-
*
|
|
114
|
-
* sufficient.** Its docblock justifies `text()` by a webhook's MAC covering exactly what arrived
|
|
148
|
+
* **`rawBodyProperty` conflated two different requirements, which is why one reader looked
|
|
149
|
+
* sufficient.** Its docblock justifies `text()` by a webhook's MAC covering exactly what arrived, true,
|
|
115
150
|
* and true only when what arrived is text. An upload is the other case, and it needs the bytes.
|
|
116
151
|
* `isRawBinaryMediaType` is the library's own rule for telling them apart, already applied on the
|
|
117
152
|
* response side; importing it rather than re-deriving it is what keeps the two halves from disagreeing
|
|
@@ -143,15 +178,19 @@ function inputTypeOf(entry) {
|
|
|
143
178
|
*
|
|
144
179
|
* Only the first character moves. An operation id is already the name the DOCUMENT publishes, and
|
|
145
180
|
* rewriting more of it would invent a second spelling of a contract fact for the sake of house style
|
|
146
|
-
|
|
181
|
+
*. The `_` that `resolveOperationId` inserts is part of the published name.
|
|
147
182
|
*/
|
|
148
183
|
function capitaliseId(operationId) {
|
|
149
184
|
return `${operationId.charAt(0).toUpperCase()}${operationId.slice(1)}`;
|
|
150
185
|
}
|
|
186
|
+
/** The schema identifier a dispatched body validates against. */
|
|
187
|
+
function identifierOf(entry) {
|
|
188
|
+
return entry.dispatched?.identifier ?? "";
|
|
189
|
+
}
|
|
151
190
|
/**
|
|
152
|
-
* The resource a route belongs to
|
|
191
|
+
* The resource a route belongs to (its first path segment) or `undefined` when it has none.
|
|
153
192
|
*
|
|
154
|
-
*
|
|
193
|
+
* **A parameter is not a resource.** `/{id}/things` has no groupable head: mounting a sub-app at
|
|
155
194
|
* `/:id` would make the parameter the resource name, which is not what the document says and not what
|
|
156
195
|
* anybody would write.
|
|
157
196
|
*/
|
|
@@ -176,49 +215,41 @@ function subAppNameOf(resource) {
|
|
|
176
215
|
/**
|
|
177
216
|
* The generated Hono server.
|
|
178
217
|
*
|
|
179
|
-
*
|
|
180
|
-
* per operation, which validators applied and what the call looked like
|
|
218
|
+
* **This replaces a data table that a hand-written loop interpreted at run time.** The emitter knew,
|
|
219
|
+
* per operation, which validators applied and what the call looked like, and then flattened all of it
|
|
181
220
|
* into one homogeneous array, so the consumer had to recover it dynamically and could not. Emitting
|
|
182
221
|
* the call sites keeps that knowledge, and every one of them is monomorphic and checked.
|
|
183
222
|
*
|
|
184
|
-
*
|
|
185
|
-
* the same validation plus a document generated FROM the code
|
|
223
|
+
* **Plain `Hono` and `@hono/zod-validator`, deliberately not `@hono/zod-openapi`.** The latter is
|
|
224
|
+
* the same validation plus a document generated FROM the code, spec-last, and a second source of
|
|
186
225
|
* truth competing with the one `@typespec/openapi3` publishes from the spec. We want its validation,
|
|
187
226
|
* not its documentation.
|
|
188
227
|
*
|
|
189
|
-
*
|
|
228
|
+
* **It declares no schema of its own.** Every validator this file names was declared by
|
|
190
229
|
* `typespec-http-zod` into `schemas.gen.ts` and is imported by name. That is what makes the two
|
|
191
|
-
* packages one emitter rather than two that must agree by coincidence
|
|
230
|
+
* packages one emitter rather than two that must agree by coincidence, and it is why a consumer who
|
|
192
231
|
* wants the validators without a server can simply not install this one.
|
|
193
232
|
*/
|
|
194
233
|
export function renderApp(emitted, refuse,
|
|
195
234
|
/**
|
|
196
|
-
*
|
|
235
|
+
* Every path the DOCUMENT says this service is served under. All of them are mounted.
|
|
197
236
|
*
|
|
198
|
-
*
|
|
237
|
+
* **An OpenAPI path is relative to its server**, so `@server("/api/v1")` plus `/accounts` means
|
|
199
238
|
* the document publishes `/api/v1/accounts`. Mounting at the root made every client generated from
|
|
200
239
|
* the document, and every "try it" in a rendered document, 404.
|
|
201
240
|
*/
|
|
202
|
-
|
|
241
|
+
basePaths = [],
|
|
203
242
|
/**
|
|
204
243
|
* What the DOCUMENT says a caller must satisfy, per operation id.
|
|
205
244
|
*
|
|
206
|
-
*
|
|
245
|
+
* **Resolved by the caller rather than read off `EmittedRoute`**, because which schemes an
|
|
207
246
|
* operation accepts is a fact about the HTTP program and not part of the validator IR the library
|
|
208
247
|
* publishes. Keeping it out of that IR is what stops a Hono concern leaking into a package whose
|
|
209
248
|
* audience is wider.
|
|
210
249
|
*/
|
|
211
250
|
securityFor) {
|
|
212
251
|
const entries = emitted.routes.flatMap((route) => {
|
|
213
|
-
|
|
214
|
-
* ⚠️ **Refused AND skipped, in that order.** Emitting the route anyway would put a registration
|
|
215
|
-
* in the file that `app.routes` lists and Hono never dispatches to — which is precisely how
|
|
216
|
-
* fifteen unreachable routes passed a differential written to catch unreachable routes.
|
|
217
|
-
*/
|
|
218
|
-
if (UNROUTABLE_VERBS.has(route.verb)) {
|
|
219
|
-
refuse.unroutableVerb(route);
|
|
220
|
-
return [];
|
|
221
|
-
}
|
|
252
|
+
let dispatched;
|
|
222
253
|
const names = emitted.schemaNames.get(route.operationId);
|
|
223
254
|
// The library declares a `Responses` const for every operation, so a missing entry is a bug in
|
|
224
255
|
// this package's pairing rather than a spec the emitter chose not to serve.
|
|
@@ -229,18 +260,35 @@ securityFor) {
|
|
|
229
260
|
const identifier = names[location];
|
|
230
261
|
if (identifier === undefined)
|
|
231
262
|
continue;
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
263
|
+
if (location !== "body") {
|
|
264
|
+
validators.push([VALIDATOR_TARGET[location], identifier]);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* The body is the one location whose parser the document does not fix at generation time.
|
|
269
|
+
* Where it declares a single parseable media type this is one `zValidator`, unchanged. Where
|
|
270
|
+
* it declares several, the choice belongs to the caller's `Content-Type` and is made then.
|
|
271
|
+
*/
|
|
272
|
+
const body = bodyValidationFor(route.requestContentTypes);
|
|
273
|
+
if (body.unparseable.length > 0)
|
|
274
|
+
refuse.unvalidatableMediaType(route, body.unparseable);
|
|
275
|
+
if (body.byType.length === 0)
|
|
276
|
+
continue;
|
|
277
|
+
const targets = [...new Set(body.byType.map(([, target]) => target))];
|
|
278
|
+
if (targets.length === 1) {
|
|
279
|
+
validators.push([targets[0], identifier]);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
dispatched = { byType: body.byType, identifier };
|
|
235
283
|
}
|
|
236
|
-
return [{ route, names, validators }];
|
|
284
|
+
return [{ route, names, validators, dispatched }];
|
|
237
285
|
});
|
|
238
286
|
/**
|
|
239
|
-
*
|
|
287
|
+
* **One registration per verb+path, not per operation.**
|
|
240
288
|
*
|
|
241
289
|
* TypeSpec models content negotiation as several operations on one route; OpenAPI models it as one
|
|
242
290
|
* path entry whose response lists every media type. This emitter used to register all of them, and
|
|
243
|
-
* Hono matches in registration order
|
|
291
|
+
* Hono matches in registration order, so every operation after the first was dead code that looked
|
|
244
292
|
* mounted, and the only symptom was an operation count two higher than the document's.
|
|
245
293
|
*/
|
|
246
294
|
const grouped = new Map();
|
|
@@ -251,7 +299,7 @@ securityFor) {
|
|
|
251
299
|
const methods = entries.map((entry) => {
|
|
252
300
|
const { route, names } = entry;
|
|
253
301
|
/**
|
|
254
|
-
* A negotiated member's `accept` is not in its validator
|
|
302
|
+
* A negotiated member's `accept` is not in its validator (the negotiation supplies it) but it
|
|
255
303
|
* IS in the operation's declared input, so the interface has to keep it. The literal is known
|
|
256
304
|
* exactly: it is the only value that reaches this member.
|
|
257
305
|
*/
|
|
@@ -273,14 +321,14 @@ securityFor) {
|
|
|
273
321
|
/**
|
|
274
322
|
* **Which resources get a sub-app, and which routes stay on the root.**
|
|
275
323
|
*
|
|
276
|
-
*
|
|
277
|
-
* `app.route()` to build a larger application instead of Ruby-on-Rails-like controllers
|
|
324
|
+
* **Hono's own guidance, followed rather than guessed at.** Its best-practices page says to use
|
|
325
|
+
* `app.route()` to build a larger application instead of Ruby-on-Rails-like controllers, while
|
|
278
326
|
* writing handlers *directly after the path definitions*, because a handler in a separate file
|
|
279
327
|
* cannot infer its path parameters. Both halves are honoured here: routes are grouped by resource,
|
|
280
328
|
* and every handler stays inline where `c.req.valid()` is typed.
|
|
281
329
|
*
|
|
282
|
-
*
|
|
283
|
-
* parameter in the prefix, and the PARENT's `app.routes` reports the fully composed path
|
|
330
|
+
* **Measured before relying on it:** `app.route(prefix, sub)` composes paths exactly, including a
|
|
331
|
+
* parameter in the prefix, and the PARENT's `app.routes` reports the fully composed path, so a
|
|
284
332
|
* route mounted through a sub-app is still countable, which is what every arm that counts routes
|
|
285
333
|
* depends on.
|
|
286
334
|
*
|
|
@@ -299,16 +347,42 @@ securityFor) {
|
|
|
299
347
|
if (slots.length > 1)
|
|
300
348
|
subApps.set(resource, subAppNameOf(resource));
|
|
301
349
|
}
|
|
302
|
-
|
|
350
|
+
/**
|
|
351
|
+
* Registration slots, which are not the same as negotiation groups.
|
|
352
|
+
*
|
|
353
|
+
* A HEAD operation shares GET's slot, because that is the only verb Hono will dispatch it under.
|
|
354
|
+
* So one slot can hold two groups -- the GET's and the HEAD's -- and the handler tells them apart
|
|
355
|
+
* with `c.req.method`. Everything else is one group per slot, exactly as before.
|
|
356
|
+
*/
|
|
357
|
+
const slots = new Map();
|
|
358
|
+
for (const group of grouped.values()) {
|
|
359
|
+
const { route } = group[0];
|
|
360
|
+
const slot = `${registrationVerbOf(route.verb)} ${route.path}`;
|
|
361
|
+
slots.set(slot, [...(slots.get(slot) ?? []), group]);
|
|
362
|
+
}
|
|
363
|
+
const registrations = [...slots.values()].map((groupsInSlot) => {
|
|
364
|
+
const headGroups = groupsInSlot.filter((g) => g[0].route.verb === "HEAD");
|
|
365
|
+
const plainGroups = groupsInSlot.filter((g) => g[0].route.verb !== "HEAD");
|
|
366
|
+
/**
|
|
367
|
+
* When a path declares both, the GET is the one whose validators the single registration
|
|
368
|
+
* carries, and the HEAD branch is served from the same request. RFC 9110 defines HEAD as
|
|
369
|
+
* identical to GET apart from the response body, so the two accepting different inputs is a
|
|
370
|
+
* contradiction in the document rather than something to reconcile here.
|
|
371
|
+
*/
|
|
372
|
+
const primary = (plainGroups[0] ?? headGroups[0]);
|
|
373
|
+
const headBranch = plainGroups.length > 0 ? headGroups[0] : undefined;
|
|
374
|
+
const group = primary;
|
|
303
375
|
const entry = group[0];
|
|
304
376
|
const { route, validators } = entry;
|
|
305
|
-
|
|
377
|
+
/** A HEAD with no GET beside it: registered under GET, and guarded so only a HEAD reaches it. */
|
|
378
|
+
const headOnly = plainGroups.length === 0 && headGroups.length > 0;
|
|
379
|
+
const method = HONO_METHOD[registrationVerbOf(route.verb)] ?? "on";
|
|
306
380
|
const path = toHonoPath(route.path, (template, name) => refuse.unsupportedPathTemplate(route, template, name));
|
|
307
381
|
/**
|
|
308
382
|
* A route inside a sub-app is registered RELATIVE to the prefix it is mounted at.
|
|
309
383
|
*
|
|
310
|
-
*
|
|
311
|
-
* `app.route("/widgets", sub)` answers `/widgets`, and `/widgets/` is a 404
|
|
384
|
+
* **The collection route is `"/"`, never the empty string.** Measured: `sub.get("/")` under
|
|
385
|
+
* `app.route("/widgets", sub)` answers `/widgets`, and `/widgets/` is a 404, so the composed
|
|
312
386
|
* path carries no trailing slash and the document's own path is what a caller reaches.
|
|
313
387
|
*/
|
|
314
388
|
const resource = resourceOf(path);
|
|
@@ -323,22 +397,46 @@ securityFor) {
|
|
|
323
397
|
* may not call the operation at all which payloads are well-formed.
|
|
324
398
|
*/
|
|
325
399
|
/**
|
|
326
|
-
*
|
|
327
|
-
* `@useAuth(BearerAuth)` publishes `security: [{ "BearerAuth": [] }]`
|
|
400
|
+
* **Emitted whenever the document declares ANY security, not only when it declares scopes.**
|
|
401
|
+
* `@useAuth(BearerAuth)` publishes `security: [{ "BearerAuth": [] }]` (no scopes) so a
|
|
328
402
|
* scopes-only gate covered OAuth2 and nothing else. Bearer, api-key and basic carried no gate at
|
|
329
403
|
* all and rested entirely on `deps.context` returning null, which answers "is somebody here"
|
|
330
404
|
* rather than "did they satisfy the scheme the contract names".
|
|
331
405
|
*/
|
|
332
406
|
const requirements = securityFor?.(route.verb, route.path) ?? [];
|
|
333
407
|
const gate = requirements.length === 0 ? [] : [`\t\tdeps.authorize(${renderSecurity(requirements)}),`];
|
|
408
|
+
/**
|
|
409
|
+
* A HEAD operation with no GET beside it is registered under GET, because that is the only verb
|
|
410
|
+
* Hono dispatches. The guard keeps the registration honest: a real GET is not in the document,
|
|
411
|
+
* so it gets the 404 it would have got before this route was registered at all, and only a HEAD
|
|
412
|
+
* reaches the validators and the handler.
|
|
413
|
+
*
|
|
414
|
+
* A plain middleware rather than `except()` from `hono/combine`, deliberately. `except` wraps the
|
|
415
|
+
* final handler, which erases its response type, and Hono's RPC client derives its entire surface
|
|
416
|
+
* from that type -- measured, `hc<typeof app>` resolved the wrapped route's body to `unknown`.
|
|
417
|
+
*/
|
|
418
|
+
/**
|
|
419
|
+
* Where the document declares request media types needing different parsers, the validator
|
|
420
|
+
* is chosen from the request's `Content-Type`. `byContentType` is a plain middleware, so the
|
|
421
|
+
* final handler is untouched and Hono's RPC client still derives its surface from it.
|
|
422
|
+
*/
|
|
423
|
+
const dispatch = entry.dispatched === undefined
|
|
424
|
+
? []
|
|
425
|
+
: [
|
|
426
|
+
"\t\tbyContentType([",
|
|
427
|
+
...entry.dispatched.byType.map(([type, target]) => `\t\t\t[${JSON.stringify(type)}, ${JSON.stringify(target)}, zValidator(${JSON.stringify(target)}, ${identifierOf(entry)}, deps.invalid)],`),
|
|
428
|
+
"\t\t]),",
|
|
429
|
+
];
|
|
334
430
|
const middleware = [
|
|
431
|
+
...(headOnly ? ["\t\theadOnly,"] : []),
|
|
335
432
|
...gate,
|
|
336
433
|
...validators.map(([target, name]) => `\t\tzValidator(${JSON.stringify(target)}, ${name}, deps.invalid),`),
|
|
434
|
+
...dispatch,
|
|
337
435
|
];
|
|
338
436
|
/**
|
|
339
|
-
* Whether the operation requires a caller
|
|
437
|
+
* Whether the operation requires a caller, and NOTHING else about the caller.
|
|
340
438
|
*
|
|
341
|
-
*
|
|
439
|
+
* **This used to pass `"account"` or `"resource"`, chosen by how many path parameters the
|
|
342
440
|
* route had, and that was a rule no document states.** `@useAuth(NoAuth)` reaches OpenAPI as
|
|
343
441
|
* `security: []`, so "does this need a caller" is a contract fact and is generated. "Is this
|
|
344
442
|
* account-scoped or resource-scoped" is not: no OpenAPI keyword expresses it, and the
|
|
@@ -351,15 +449,15 @@ securityFor) {
|
|
|
351
449
|
}
|
|
352
450
|
else {
|
|
353
451
|
/**
|
|
354
|
-
*
|
|
452
|
+
* **The same null check as an authenticated route, and it is what removes a CAST from
|
|
355
453
|
* generated output.** This used to emit `deps.context(c, "none") as Ctx`, because one
|
|
356
454
|
* signature returning `C | null` cannot express "this argument makes null impossible". A cast
|
|
357
455
|
* in generated code is worse than one in hand-written code: nobody reviews it, and it
|
|
358
456
|
* reappears on every compile.
|
|
359
457
|
*
|
|
360
|
-
*
|
|
458
|
+
* **Overloading `context` was tried and is worse.** It removes the cast from here and puts
|
|
361
459
|
* one in every consumer's `deps`, because an overloaded property type stops contextually
|
|
362
|
-
* typing a single implementation
|
|
460
|
+
* typing a single implementation. Measured, the wiring consumer lost inference on every
|
|
363
461
|
* hook. Trading a cast in generated code for a cast in hand-written code is the wrong
|
|
364
462
|
* direction.
|
|
365
463
|
*
|
|
@@ -370,10 +468,18 @@ securityFor) {
|
|
|
370
468
|
body.push("\t\t\tif (ctx === null) return deps.noContext(c);");
|
|
371
469
|
}
|
|
372
470
|
const pieces = validators.map(([target]) => `...c.req.valid(${JSON.stringify(target)})`);
|
|
471
|
+
/**
|
|
472
|
+
* Only one dispatched branch runs, and `zValidator` writes under its own target. Spreading every
|
|
473
|
+
* possible target is how the handler reads whichever one it was: an unset target reads
|
|
474
|
+
* `undefined`, and spreading `undefined` into an object literal contributes nothing.
|
|
475
|
+
*/
|
|
476
|
+
for (const target of new Set((entry.dispatched?.byType ?? []).map(([, name]) => name))) {
|
|
477
|
+
pieces.push(`...c.req.valid(${JSON.stringify(target)})`);
|
|
478
|
+
}
|
|
373
479
|
if (route.rawBodyProperty !== undefined) {
|
|
374
480
|
// The bytes ARE the contract: a signature covers exactly what arrived, so parsing and
|
|
375
481
|
// re-serialising would verify a different string than the sender signed. WHICH reader
|
|
376
|
-
// preserves them depends on the media type
|
|
482
|
+
// preserves them depends on the media type, see `rawBodyReaderFor`.
|
|
377
483
|
const reader = rawBodyReaderFor(route.requestContentTypes);
|
|
378
484
|
pieces.push(`${objectKey(route.rawBodyProperty)}: ${reader.call}`);
|
|
379
485
|
}
|
|
@@ -381,12 +487,14 @@ securityFor) {
|
|
|
381
487
|
// The member's own `accept` literal, which its input type requires and which the shared
|
|
382
488
|
// validator no longer supplies. We know it exactly: it is the branch we are in.
|
|
383
489
|
const own = group.length > 1 && member.route.accept !== undefined
|
|
384
|
-
? [
|
|
490
|
+
? [
|
|
491
|
+
`${objectKey(member.route.accept.name)}: ${JSON.stringify(member.route.accept.value)}`,
|
|
492
|
+
]
|
|
385
493
|
: [];
|
|
386
494
|
const input = [...pieces, ...own];
|
|
387
495
|
/**
|
|
388
|
-
*
|
|
389
|
-
* than it is written
|
|
496
|
+
* **Broken across lines rather than emitted as one.** Generated code is read far more often
|
|
497
|
+
* than it is written (in review, in a stack trace, in a diff) and a single call reached 219
|
|
390
498
|
* characters on a real service, against the 60-to-80 of every example in Hono's own
|
|
391
499
|
* documentation. Nothing about the behaviour changes; a reader's ability to see it does.
|
|
392
500
|
*
|
|
@@ -394,7 +502,7 @@ securityFor) {
|
|
|
394
502
|
* already short.
|
|
395
503
|
*/
|
|
396
504
|
/**
|
|
397
|
-
*
|
|
505
|
+
* **Indented literally, because the surrounding `+1 tab` only reaches the FIRST physical
|
|
398
506
|
* line.** A multi-line fragment keeps whatever tabs it was written with, so the depths here
|
|
399
507
|
* are absolute: the `return` sits at four, its arguments at five, and the handler's input
|
|
400
508
|
* properties at six.
|
|
@@ -416,6 +524,20 @@ securityFor) {
|
|
|
416
524
|
"\t\t\t\t)",
|
|
417
525
|
].join("\n");
|
|
418
526
|
};
|
|
527
|
+
/**
|
|
528
|
+
* Both verbs on one path: `c.req.method` still reads `HEAD` after Hono's rewrite, so one
|
|
529
|
+
* registration serves both and each operation keeps its own handler and its own response arms.
|
|
530
|
+
* Hono strips the body on the HEAD branch itself.
|
|
531
|
+
*
|
|
532
|
+
* The HEAD branch is emitted FIRST because it is the narrower condition, and it returns, so the
|
|
533
|
+
* GET path below is reached only by a real GET.
|
|
534
|
+
*/
|
|
535
|
+
if (headBranch !== undefined) {
|
|
536
|
+
const headEntry = headBranch[0];
|
|
537
|
+
body.push(`\t\t\tif (c.req.method === "HEAD") {`);
|
|
538
|
+
body.push(`\t\t\t\treturn ${invoke(headEntry).replaceAll("\n", "\n\t")};`);
|
|
539
|
+
body.push("\t\t\t}");
|
|
540
|
+
}
|
|
419
541
|
if (group.length === 1) {
|
|
420
542
|
body.push(`\t\t\treturn ${invoke(entry)};`);
|
|
421
543
|
}
|
|
@@ -424,7 +546,7 @@ securityFor) {
|
|
|
424
546
|
* Several operations, one route: the caller's `Accept` chooses which one answers.
|
|
425
547
|
*
|
|
426
548
|
* The offered list and which operation serves each type are both read from the document.
|
|
427
|
-
* `selectContentType` applies RFC 9110
|
|
549
|
+
* `selectContentType` applies RFC 9110 section 12.5.1 to them. It lives in the runtime rather than
|
|
428
550
|
* in `deps` because both halves are derivable, and an app forced to supply it would be
|
|
429
551
|
* re-implementing the standard.
|
|
430
552
|
*/
|
|
@@ -436,27 +558,27 @@ securityFor) {
|
|
|
436
558
|
body.push(`\t\t\tif (served === ${JSON.stringify(offer.contentType)}) return ${invoke(offer.member)};`);
|
|
437
559
|
}
|
|
438
560
|
// `selectContentType` only ever returns a member of the list it was given, so this is
|
|
439
|
-
// unreachable
|
|
561
|
+
// unreachable, and stating that is cheaper than a cast that would hide it if it were not.
|
|
440
562
|
body.push(`\t\t\treturn deps.notAcceptable(c, ${offered});`);
|
|
441
563
|
}
|
|
442
564
|
/**
|
|
443
|
-
*
|
|
444
|
-
* dedicated Hono method; everything else
|
|
565
|
+
* **`app.on` takes the METHOD first, and we were not passing one.** Only five verbs have a
|
|
566
|
+
* dedicated Hono method; everything else (`HEAD`, `OPTIONS`, anything a spec invents) fell
|
|
445
567
|
* through to `app.on(path, handler)`, which Hono reads as `on(method, path)`. The route was
|
|
446
568
|
* emitted, counted by every arm that counted rows, and mounted nowhere.
|
|
447
569
|
*/
|
|
448
570
|
/**
|
|
449
|
-
*
|
|
571
|
+
* **Emitted as a CHAINED call fragment rather than a statement, and that is what makes Hono's
|
|
450
572
|
* RPC client work at all.** `hc<typeof app>` derives its entire surface from the `Schema` type
|
|
451
|
-
* parameter Hono accumulates through chaining
|
|
573
|
+
* parameter Hono accumulates through chaining, not from what is registered at run time. As
|
|
452
574
|
* separate statements each call's type was discarded, `registerRoutes` returned `void`, and
|
|
453
575
|
* measured in a fresh project `hc<typeof app>` resolved to **`unknown`**: not an empty client, an
|
|
454
576
|
* unusable one. A hand-chained app with the same sub-app-per-resource shape supports it
|
|
455
|
-
* perfectly, so the shape was never the obstacle
|
|
577
|
+
* perfectly, so the shape was never the obstacle, only the statements were.
|
|
456
578
|
*/
|
|
457
579
|
const text = [
|
|
458
580
|
`\t\t.${method}(`,
|
|
459
|
-
...(method === "on" ? [`\t\t\t${JSON.stringify(route.verb)},`] : []),
|
|
581
|
+
...(method === "on" ? [`\t\t\t${JSON.stringify(registrationVerbOf(route.verb))},`] : []),
|
|
460
582
|
`\t\t\t${JSON.stringify(registeredPath)},`,
|
|
461
583
|
...middleware.map((line) => `\t${line}`),
|
|
462
584
|
"\t\t\tasync (c) => {",
|
|
@@ -469,7 +591,7 @@ securityFor) {
|
|
|
469
591
|
/**
|
|
470
592
|
* Every identifier this file names, imported from the module that declares it.
|
|
471
593
|
*
|
|
472
|
-
*
|
|
594
|
+
* **Derived from what the rendered text actually references, not from what was available.** An
|
|
473
595
|
* unused import fails the lint a generated file has to pass like any other, and a missing one is a
|
|
474
596
|
* file that does not compile. Both have happened.
|
|
475
597
|
*/
|
|
@@ -492,21 +614,21 @@ securityFor) {
|
|
|
492
614
|
/**
|
|
493
615
|
* One `Hono` per resource, declared before the routes and mounted after them.
|
|
494
616
|
*
|
|
495
|
-
*
|
|
496
|
-
* the moment it is called, so mounting before the routes are registered mounts nothing
|
|
617
|
+
* **Mounted AFTER, deliberately.** `app.route()` copies the sub-app's routes into the parent at
|
|
618
|
+
* the moment it is called, so mounting before the routes are registered mounts nothing, the same
|
|
497
619
|
* shape of defect as Hono middleware, which only applies to routes registered after it. Declaring
|
|
498
620
|
* at the top and mounting at the bottom is the arrangement that cannot be got wrong by reordering.
|
|
499
621
|
*/
|
|
500
622
|
/**
|
|
501
|
-
*
|
|
623
|
+
* **One chained expression per app, because that is the only shape `hc` can read.**
|
|
502
624
|
*
|
|
503
625
|
* Hono accumulates its `Schema` type through the chain, so a sub-app declared and then registered
|
|
504
626
|
* across separate statements throws every route's type away. Declaring it AS the chain keeps them,
|
|
505
627
|
* and mounting with a chained `.route()` carries them up into the parent.
|
|
506
628
|
*
|
|
507
|
-
*
|
|
629
|
+
* **The ordering property that used to need a comment is now structural.** Mounting before the
|
|
508
630
|
* routes were registered mounted nothing, and the fix was "declare at the top, mount at the
|
|
509
|
-
* bottom"
|
|
631
|
+
* bottom". A rule a reordering could break. A single expression cannot be reordered wrongly: the
|
|
510
632
|
* sub-app is complete at the point it is mounted because it is its own initialiser.
|
|
511
633
|
*/
|
|
512
634
|
const byTarget = new Map();
|
|
@@ -526,28 +648,31 @@ securityFor) {
|
|
|
526
648
|
* Everything that stays on the root, then every mount, as one returned chain.
|
|
527
649
|
*
|
|
528
650
|
* Returned rather than discarded: `registerRoutes` used to be `void`, and measured in a fresh
|
|
529
|
-
* project that made `hc<typeof app>` resolve to **`unknown
|
|
651
|
+
* project that made `hc<typeof app>` resolve to **`unknown`**. Hono's RPC client, which is one of
|
|
530
652
|
* the framework's headline features, was categorically unavailable to anything this emitter
|
|
531
653
|
* produced. Handing back the chained value costs nothing and restores it.
|
|
532
654
|
*/
|
|
533
655
|
const rootChain = [...(byTarget.get("app") ?? []), ...subAppMounts];
|
|
534
656
|
// Imported only where a route actually negotiates: an unused import fails the repo's own lint.
|
|
535
657
|
const negotiates = [...grouped.values()].some((group) => group.length > 1);
|
|
658
|
+
// Same rule: imported only where a HEAD operation stands alone on its path.
|
|
659
|
+
const guardsHead = registrations.some((registration) => registration.text.includes("headOnly,"));
|
|
660
|
+
const dispatchesBody = registrations.some((r) => r.text.includes("byContentType(["));
|
|
536
661
|
const runtimeModule = JSON.stringify(emitted.options.runtimeModule);
|
|
537
662
|
/**
|
|
538
|
-
*
|
|
663
|
+
* **One base sub-app, mounted with `app.route()`. Hono's own nesting, not a rewritten path on
|
|
539
664
|
* every registration.** Prefixing each path individually would fight the resource grouping, and
|
|
540
665
|
* would put the prefix in `hc`'s type surface as part of every route name rather than once. A
|
|
541
666
|
* nested `.route()` composes exactly, and the parent's `app.routes` still reports the fully
|
|
542
667
|
* composed path, which every arm that counts routes depends on.
|
|
543
668
|
*/
|
|
544
|
-
const usesBasePath =
|
|
669
|
+
const usesBasePath = basePaths.length > 0;
|
|
545
670
|
const needsHonoValue = subApps.size > 0 || usesBasePath;
|
|
546
671
|
return `${GENERATED_BANNER}
|
|
547
672
|
import { zValidator } from "@hono/zod-validator";
|
|
548
673
|
${needsHonoValue ? 'import { Hono } from "hono";\nimport type { Context, Input } from "hono";' : 'import type { Context, Hono, Input } from "hono";'}
|
|
549
674
|
import { z } from "zod";
|
|
550
|
-
import type { AppEnv, Awaitable, Ctx, Result, RouteDeps } from ${runtimeModule};${negotiates ? `\nimport { selectContentType } from ${runtimeModule};` : ""}
|
|
675
|
+
import type { AppEnv, Awaitable, Ctx, Result, RouteDeps } from ${runtimeModule};${negotiates ? `\nimport { selectContentType } from ${runtimeModule};` : ""}${guardsHead ? `\nimport { headOnly } from ${runtimeModule};` : ""}${dispatchesBody ? `\nimport { byContentType } from ${runtimeModule};` : ""}
|
|
551
676
|
${imports}
|
|
552
677
|
/**
|
|
553
678
|
* One method per operation, each concretely typed from the schemas it validates against.
|
|
@@ -562,21 +687,21 @@ ${methods.join("\n")}
|
|
|
562
687
|
/**
|
|
563
688
|
* One alias per operation, so a handler can live beside the module it belongs to.
|
|
564
689
|
*
|
|
565
|
-
*
|
|
690
|
+
* **Derived by indexed access, never restated.** An indexed access into Operations cannot drift
|
|
566
691
|
* from the interface; a second hand-written signature could, and a signature that silently disagrees
|
|
567
692
|
* with the contract is the whole failure mode this emitter exists to remove.
|
|
568
693
|
*/
|
|
569
694
|
${aliases.join("\n")}
|
|
570
695
|
|
|
571
696
|
/**
|
|
572
|
-
*
|
|
697
|
+
* **There is deliberately NO exported HandlersFor alias to annotate the factory with.**
|
|
573
698
|
*
|
|
574
699
|
* There was one, and it silently disabled the guard below. Annotating widens the value to
|
|
575
700
|
* Operations, so T infers as Operations, Exclude<keyof T, keyof Operations> is never, and the
|
|
576
701
|
* surplus-key constraint evaporates. Measured: a handler for an operation the spec no longer
|
|
577
702
|
* declares produced no error at all through the annotated form, and TS2345 without it.
|
|
578
703
|
*
|
|
579
|
-
* Write the factory unannotated
|
|
704
|
+
* Write the factory unannotated, inference then carries the real shape into registerRoutes:
|
|
580
705
|
*
|
|
581
706
|
* const handlersFor = (c) => backendFor(c.env);
|
|
582
707
|
* registerRoutes(app, handlersFor, deps);
|
|
@@ -585,7 +710,7 @@ ${aliases.join("\n")}
|
|
|
585
710
|
/**
|
|
586
711
|
* Refuses a handler set carrying an operation the spec no longer declares.
|
|
587
712
|
*
|
|
588
|
-
*
|
|
713
|
+
* **Removal is the change the type system misses, and it is the one that matters most.** Adding an
|
|
589
714
|
* operation is a missing property; changing one is a wrong signature; both fail loudly. Removing one
|
|
590
715
|
* leaves a handler that compiles forever against a route nobody mounts.
|
|
591
716
|
*
|
|
@@ -602,7 +727,7 @@ export function registerRoutes<T extends Operations>(
|
|
|
602
727
|
deps: RouteDeps,
|
|
603
728
|
) {
|
|
604
729
|
${subAppDeclarations}${usesBasePath
|
|
605
|
-
? `\tconst basePathRoutes = new Hono<AppEnv>()\n${rootChain.join("\n")};\n\n\treturn app.route(${JSON.stringify(
|
|
730
|
+
? `\tconst basePathRoutes = new Hono<AppEnv>()\n${rootChain.join("\n")};\n\n\treturn app${basePaths.map((prefix) => `\n\t\t.route(${JSON.stringify(prefix)}, basePathRoutes)`).join("")};`
|
|
606
731
|
: `\treturn app\n${rootChain.join("\n")};`}
|
|
607
732
|
}
|
|
608
733
|
`;
|