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/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 measured against Hono, not assumed. */
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
- * ⚠️ **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
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. Hono reads a parameter up to the next
18
- * `/`, so an RFC 6570 modifier would survive into the name and `*` would become Hono's wildcard — a
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
- * ⚠️ **This runs at RENDER time, not during collection.** It used to run inside `collectRoutes`, which
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 validators included over a template no router could mount. What a request body must
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
- * Verbs Hono cannot dispatch to, whatever they are registered with.
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
- * ⚠️ **`HEAD` is rewritten to `GET` before matching** — `hono-base.js` does it unconditionally at the
38
- * top of `#dispatch` so a route registered under it is never reached. See the `unroutable-verb`
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 UNROUTABLE_VERBS = new Set(["HEAD"]);
42
- /** `GET` → `get`. Hono's per-verb helpers are the idiom; `app.on` is the escape hatch. */
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
- * ⚠️ **The two vocabularies are not the same, and only one of them is a contract fact.** OpenAPI says
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
- * ⚠️ **This was `"json"` unconditionally, and that made every non-JSON body unservable.**
68
- * `zValidator("json", )` reads `c.req.json()`, so a `multipart/form-data` upload was parsed as 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** 5 urlencoded, 5 XML, 1 octet-stream.
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"` `c.req.parseBody()` handles the
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
- * ⚠️ **Anything else stays `"json"` deliberately.** A media type this function does not recognise is
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 bodyTargetFor(contentTypes) {
81
- /**
82
- * ⚠️ **EVERY, not some, and the difference is a regression I shipped for one measurement.**
83
- * `addPet` in the Swagger Petstore accepts `application/json`, `application/xml` **and**
84
- * `application/x-www-form-urlencoded`. Choosing `"form"` because one member is form-ish made
85
- * `zValidator` call `c.req.parseBody()` on JSON bodies, and a request that answered **200** before
86
- * the change answered **400** after it. Measured both ways against the same server.
87
- *
88
- * ⚠️ **A route offering several media types cannot be validated by one target at all**, because
89
- * which parser applies is decided by the caller's `Content-Type` at REQUEST time and `zValidator`
90
- * is chosen at generation time. So a mixed body keeps `"json"`: unchanged behaviour, and a stated
91
- * limitation rather than a guess that breaks the common case to fix the rare one. Only a body that
92
- * is form-encoded in every form it may take is unambiguous enough to switch.
93
- */
94
- const form = contentTypes.length > 0 &&
95
- contentTypes.every((type) => type.startsWith("multipart/") || type === "application/x-www-form-urlencoded");
96
- return form ? "form" : VALIDATOR_TARGET.body;
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
- * ⚠️ **This was always `c.req.text()` typed as `string`, and for a binary body that silently
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 the worst shape a defect can take.
146
+ * payload, no signal. The worst shape a defect can take.
112
147
  *
113
- * ⚠️ **`rawBodyProperty` conflated two different requirements, which is why one reader looked
114
- * sufficient.** Its docblock justifies `text()` by a webhook's MAC covering exactly what arrived true,
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
- * the `_` that `resolveOperationId` inserts is part of the published name.
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 its first path segment or `undefined` when it has none.
191
+ * The resource a route belongs to (its first path segment) or `undefined` when it has none.
153
192
  *
154
- * ⚠️ **A parameter is not a resource.** `/{id}/things` has no groupable head: mounting a sub-app at
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
- * ⚠️ **This replaces a data table that a hand-written loop interpreted at run time.** The emitter knew,
180
- * per operation, which validators applied and what the call looked like and then flattened all of it
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
- * ⚠️ **Plain `Hono` and `@hono/zod-validator`, deliberately not `@hono/zod-openapi`.** The latter is
185
- * the same validation plus a document generated FROM the code spec-last, and a second source of
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
- * ⚠️ **It declares no schema of its own.** Every validator this file names was declared by
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 and it is why a consumer who
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
- * The path the DOCUMENT says this service is served under, when it says one unambiguously.
235
+ * Every path the DOCUMENT says this service is served under. All of them are mounted.
197
236
  *
198
- * ⚠️ **An OpenAPI path is relative to its server**, so `@server("/api/v1")` plus `/accounts` means
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
- basePath,
241
+ basePaths = [],
203
242
  /**
204
243
  * What the DOCUMENT says a caller must satisfy, per operation id.
205
244
  *
206
- * ⚠️ **Resolved by the caller rather than read off `EmittedRoute`**, because which schemes an
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
- // The body's target depends on what the document says the wire carries; the rest are fixed.
233
- const target = location === "body" ? bodyTargetFor(route.requestContentTypes) : VALIDATOR_TARGET[location];
234
- validators.push([target, identifier]);
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
- * ⚠️ **One registration per verb+path, not per operation.**
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 so every operation after the first was dead code that looked
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 the negotiation supplies it but it
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
- * ⚠️ **Hono's own guidance, followed rather than guessed at.** Its best-practices page says to use
277
- * `app.route()` to build a larger application instead of Ruby-on-Rails-like controllers while
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
- * ⚠️ **Measured before relying on it:** `app.route(prefix, sub)` composes paths exactly, including a
283
- * parameter in the prefix, and the PARENT's `app.routes` reports the fully composed path so a
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
- const registrations = [...grouped.values()].map((group) => {
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
- const method = HONO_METHOD[route.verb] ?? "on";
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
- * ⚠️ **The collection route is `"/"`, never the empty string.** Measured: `sub.get("/")` under
311
- * `app.route("/widgets", sub)` answers `/widgets`, and `/widgets/` is a 404 so the composed
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
- * ⚠️ **Emitted whenever the document declares ANY security, not only when it declares scopes.**
327
- * `@useAuth(BearerAuth)` publishes `security: [{ "BearerAuth": [] }]` no scopes so a
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 and NOTHING else about the caller.
437
+ * Whether the operation requires a caller, and NOTHING else about the caller.
340
438
  *
341
- * ⚠️ **This used to pass `"account"` or `"resource"`, chosen by how many path parameters the
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
- * ⚠️ **The same null check as an authenticated route, and it is what removes a CAST from
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
- * ⚠️ **Overloading `context` was tried and is worse.** It removes the cast from here and puts
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 — measured, the wiring consumer lost inference on every
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 see `rawBodyReaderFor`.
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
- ? [`${objectKey(member.route.accept.name)}: ${JSON.stringify(member.route.accept.value)}`]
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
- * ⚠️ **Broken across lines rather than emitted as one.** Generated code is read far more often
389
- * than it is written in review, in a stack trace, in a diff and a single call reached 219
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
- * ⚠️ **Indented literally, because the surrounding `+1 tab` only reaches the FIRST physical
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 §12.5.1 to them it lives in the runtime rather than
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 and stating that is cheaper than a cast that would hide it if it were not.
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
- * ⚠️ **`app.on` takes the METHOD first, and we were not passing one.** Only five verbs have a
444
- * dedicated Hono method; everything else `HEAD`, `OPTIONS`, anything a spec invents fell
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
- * ⚠️ **Emitted as a CHAINED call fragment rather than a statement, and that is what makes Hono's
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 not from what is registered at run time. As
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 only the statements were.
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
- * ⚠️ **Derived from what the rendered text actually references, not from what was available.** An
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
- * ⚠️ **Mounted AFTER, deliberately.** `app.route()` copies the sub-app's routes into the parent at
496
- * the moment it is called, so mounting before the routes are registered mounts nothing the same
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
- * ⚠️ **One chained expression per app, because that is the only shape `hc` can read.**
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
- * ⚠️ **The ordering property that used to need a comment is now structural.** Mounting before the
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" a rule a reordering could break. A single expression cannot be reordered wrongly: the
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`** Hono's RPC client, which is one of
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
- * ⚠️ **One base sub-app, mounted with `app.route()` Hono's own nesting, not a rewritten path on
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 = basePath !== undefined && basePath !== "";
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
- * ⚠️ **Derived by indexed access, never restated.** An indexed access into Operations cannot drift
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
- * ⚠️ **There is deliberately NO exported HandlersFor alias to annotate the factory with.**
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 inference then carries the real shape into registerRoutes:
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
- * ⚠️ **Removal is the change the type system misses, and it is the one that matters most.** Adding an
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(basePath)}, basePathRoutes);`
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
  `;