typespec-hono 0.22.0 → 0.24.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
@@ -1,5 +1,281 @@
1
- import { renderSecurity } from "./security.js";
1
+ import { RegExpRouter } from "hono/router/reg-exp-router";
2
2
  import { isRawBinaryMediaType, jsDocComment, objectKey, } from "typespec-http-zod";
3
+ /**
4
+ * Hono's status codes, group by group, exactly as `hono/utils/http-status` declares them.
5
+ *
6
+ * **The generated route needs the LITERALS, not only the type.** A range arm is served from a
7
+ * `switch` on the result's status, and TypeScript narrows the result to one member only through a
8
+ * `case` label per literal: a type guard narrows the status and leaves the result a union, and
9
+ * merging the range into `default` pairs every body with every status. Both measured on hono 4.13.1.
10
+ *
11
+ * `test/status-codes.test.ts` holds these equal to Hono's own unions, so a status Hono adds fails a
12
+ * test rather than silently falling out of a range.
13
+ */
14
+ export const STATUS_GROUPS = {
15
+ 1: { type: "InfoStatusCode", codes: [100, 101, 102, 103] },
16
+ 2: { type: "SuccessStatusCode", codes: [200, 201, 202, 203, 204, 205, 206, 207, 208, 226] },
17
+ 3: { type: "RedirectStatusCode", codes: [300, 301, 302, 303, 304, 305, 306, 307, 308] },
18
+ 4: {
19
+ type: "ClientErrorStatusCode",
20
+ codes: [
21
+ 400, 401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 414, 415, 416, 417, 418,
22
+ 421, 422, 423, 424, 425, 426, 428, 429, 431, 451,
23
+ ],
24
+ },
25
+ 5: {
26
+ type: "ServerErrorStatusCode",
27
+ codes: [500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511],
28
+ },
29
+ };
30
+ /**
31
+ * Where every generated file imports its runtime from: the copy this package writes beside them.
32
+ *
33
+ * Declared here, where the import is rendered, and re-exported by the emitter that writes the file.
34
+ */
35
+ export const DEFAULT_RUNTIME_MODULE = "./runtime.gen.js";
36
+ /** Hono's `ContentlessStatusCode`: a status that cannot carry a body, so `c.json` refuses it. */
37
+ export const CONTENTLESS_STATUS_CODES = [101, 204, 205, 304];
38
+ /** A media type whose body is a JSON value, served with `c.json`. The library's own rule. */
39
+ function isJsonMediaType(type) {
40
+ return /^application\/(json|.*\+json)$/.test(type);
41
+ }
42
+ function bodyServingOf(member) {
43
+ const { response, mediaType } = member;
44
+ if (response.schema === undefined || mediaType === undefined)
45
+ return "none";
46
+ if (response.streamed)
47
+ return "stream";
48
+ if (response.binary)
49
+ return "binary";
50
+ if (isJsonMediaType(mediaType))
51
+ return "json";
52
+ /**
53
+ * **A string body under a non-JSON media type IS the text**, so it is validated and served as is.
54
+ * Any other body under one - a model as `application/xml` - has no serialisation this emitter can
55
+ * derive, so the handler supplies the text and it is served unvalidated, which the
56
+ * `unvalidated-response-media-type` warning says out loud.
57
+ */
58
+ return response.textual ? "text" : "unvalidated";
59
+ }
60
+ function membersOf(entry) {
61
+ return entry.route.responses.flatMap((response, index) => {
62
+ const schemaName = entry.names.arms[index]?.schema;
63
+ const headersSchemaName = entry.names.arms[index]?.headers;
64
+ if (response.contentTypes.length === 0) {
65
+ return [
66
+ { response, schemaName, headersSchemaName, mediaType: undefined, choosesMediaType: false },
67
+ ];
68
+ }
69
+ /**
70
+ * The handler names the media type where the status offers several, and where the document names
71
+ * only a wildcard range, which is not a type a response can be sent as.
72
+ */
73
+ const choosesMediaType = response.contentTypes.length > 1 || response.contentTypes.some(isMediaRange);
74
+ return response.contentTypes.map((mediaType) => ({
75
+ response,
76
+ schemaName,
77
+ headersSchemaName,
78
+ mediaType,
79
+ choosesMediaType,
80
+ }));
81
+ });
82
+ }
83
+ /** A media range such as `image/*`, rather than a type a response can be sent as. */
84
+ function isMediaRange(mediaType) {
85
+ return mediaType.endsWith("/*");
86
+ }
87
+ /**
88
+ * The type of the `contentType` a handler names for a member.
89
+ *
90
+ * **A range is typed as the types inside it**: `image/*` is `` `image/${string}` ``, so `text/html`
91
+ * under it does not compile, and TypeScript still narrows a literal member away from it. Only the
92
+ * full range is `string`.
93
+ */
94
+ function contentTypeTypeOf(mediaType) {
95
+ if (!isMediaRange(mediaType))
96
+ return JSON.stringify(mediaType);
97
+ if (mediaType === "*/*")
98
+ return "string";
99
+ return `\`${mediaType.slice(0, -1)}\${string}\``;
100
+ }
101
+ /** The Hono group a status key's codes belong to. */
102
+ function groupOf(status) {
103
+ const digit = typeof status === "number" ? Math.floor(status / 100) : Number(String(status)[0]);
104
+ return digit >= 1 && digit <= 5 ? STATUS_GROUPS[digit] : undefined;
105
+ }
106
+ /**
107
+ * The codes a status key answers with, as `case` labels, or `"default"` for the catch-all.
108
+ *
109
+ * **A range excludes what is declared more precisely**, because OpenAPI resolves an exact code before
110
+ * its range and `armFor` does the same: a `404` beside a `4XX` is the `404`'s. A bodied range also
111
+ * excludes the statuses that cannot carry a body.
112
+ */
113
+ function caseLabelsOf(response, route) {
114
+ const { status } = response;
115
+ if (status === "default")
116
+ return "default";
117
+ if (typeof status === "number")
118
+ return [status];
119
+ const exact = new Set(route.responses.flatMap((candidate) => typeof candidate.status === "number" ? [candidate.status] : []));
120
+ const contentless = new Set(response.schema === undefined ? [] : CONTENTLESS_STATUS_CODES);
121
+ return (groupOf(status)?.codes ?? []).filter((code) => !exact.has(code) && !contentless.has(code));
122
+ }
123
+ /**
124
+ * Serve a handler's `result` for every response the operation declares, as statements at `depth`.
125
+ *
126
+ * **One `case` per status literal, and one Hono call per declared response.** `c.json(body, 404)` is
127
+ * what gives `hc` a typed body for a 404, so the route serves each status through its own call rather
128
+ * than through one call over the union, which measured as a single endpoint pairing every body with
129
+ * every status.
130
+ *
131
+ * **Every served body is the PARSED body.** `servedBody` checks it against the schema the document
132
+ * publishes for that status and throws `ResponseContractError` when it does not match, failures
133
+ * included, and what is sent is the parse result - so a field the schema does not declare does not
134
+ * reach the wire.
135
+ */
136
+ function servingLines(entry, depth, uses) {
137
+ const indent = "\t".repeat(depth);
138
+ const operationId = JSON.stringify(entry.route.operationId);
139
+ const members = membersOf(entry);
140
+ const lines = [`${indent}switch (result.status) {`];
141
+ let servesDefault = false;
142
+ for (const response of entry.route.responses) {
143
+ const labels = caseLabelsOf(response, entry.route);
144
+ if (labels !== "default" && labels.length === 0)
145
+ continue;
146
+ if (labels === "default") {
147
+ servesDefault = true;
148
+ lines.push(`${indent}\tdefault: {`);
149
+ }
150
+ else {
151
+ lines.push(...labels.map((code) => `${indent}\tcase ${code}:`));
152
+ lines[lines.length - 1] = `${lines.at(-1) ?? ""} {`;
153
+ }
154
+ const status = labels !== "default" && labels.length === 1 ? String(labels[0]) : "result.status";
155
+ const own = members.filter((member) => member.response === response);
156
+ if (!own.some((member) => member.choosesMediaType)) {
157
+ for (const member of own) {
158
+ lines.push(`${indent}\t\t${serveCall(member, status, operationId, uses)}`);
159
+ }
160
+ }
161
+ else {
162
+ /**
163
+ * **Exact types by `case`, then ranges by `mediaTypeWithin`, then a throw.** A `case "image/*"`
164
+ * would compare the range's own spelling and never match the `image/png` the handler is typed
165
+ * to name. Every `case` returns, so after the switch TypeScript has already narrowed the
166
+ * result to the range members, whose bodies are served the same way.
167
+ */
168
+ const exact = own.filter((member) => member.mediaType !== undefined && !isMediaRange(member.mediaType));
169
+ if (exact.length > 0) {
170
+ lines.push(`${indent}\t\tswitch (result.contentType) {`);
171
+ for (const member of exact) {
172
+ lines.push(`${indent}\t\t\tcase ${JSON.stringify(member.mediaType)}:`);
173
+ lines.push(`${indent}\t\t\t\t${serveCall(member, status, operationId, uses)}`);
174
+ }
175
+ lines.push(`${indent}\t\t}`);
176
+ }
177
+ for (const member of own) {
178
+ if (member.mediaType === undefined || !isMediaRange(member.mediaType))
179
+ continue;
180
+ uses.runtime.add("mediaTypeWithin");
181
+ lines.push(`${indent}\t\tif (mediaTypeWithin(result.contentType, ${JSON.stringify(member.mediaType)})) ${serveCall(member, status, operationId, uses)}`);
182
+ }
183
+ uses.runtime.add("UndeclaredStatusError");
184
+ lines.push(`${indent}\t\tthrow new UndeclaredStatusError(${operationId}, result);`);
185
+ }
186
+ lines.push(`${indent}\t}`);
187
+ }
188
+ if (!servesDefault) {
189
+ /**
190
+ * **Unreachable from a typed handler**: every declared status has a `case`, so `result` is
191
+ * `never` here. A cast, or untyped data from a service binding, is what arrives - and serving
192
+ * it would publish a status the document does not declare.
193
+ */
194
+ uses.runtime.add("UndeclaredStatusError");
195
+ lines.push(`${indent}\tdefault:`);
196
+ lines.push(`${indent}\t\tthrow new UndeclaredStatusError(${operationId}, result);`);
197
+ }
198
+ lines.push(`${indent}}`);
199
+ return lines;
200
+ }
201
+ /** The one Hono call that serves one member, as a `return` statement. */
202
+ function serveCall(member, status, operationId, uses) {
203
+ const { response, mediaType } = member;
204
+ const serving = bodyServingOf(member);
205
+ const headers = [];
206
+ /**
207
+ * **`Content-Type` wherever `c.json` would not already say it.** `c.json` writes
208
+ * `application/json`; a `+json` type such as `application/problem+json` has to be stated, and
209
+ * `c.body` states nothing. The key is spelled `Content-Type` because Hono merges its own default
210
+ * under exactly that key, so a lowercase spelling would send both.
211
+ */
212
+ if (mediaType !== undefined && serving !== "none" && mediaType !== "application/json") {
213
+ const value = member.choosesMediaType || mediaType.includes("*")
214
+ ? "result.contentType"
215
+ : JSON.stringify(mediaType);
216
+ headers.push(`"Content-Type": ${value}`);
217
+ }
218
+ const allOptional = response.headers.every((header) => header.optional);
219
+ for (const header of response.headers) {
220
+ const key = JSON.stringify(header.name);
221
+ headers.push(`${key}: result.headers${allOptional ? "?." : ""}[${key}]`);
222
+ }
223
+ /**
224
+ * **Declared headers are CHECKED, not only stringified**, which the body beside them always was.
225
+ * `headersOf` still serves a response whose only headers this emitter chose itself, such as the
226
+ * `Content-Type` a `c.body` call has to state.
227
+ */
228
+ const checksHeaders = response.headers.length > 0 && member.headersSchemaName !== undefined;
229
+ if (headers.length > 0)
230
+ uses.runtime.add(checksHeaders ? "servedHeaders" : "headersOf");
231
+ const headersArgument = headers.length === 0
232
+ ? ""
233
+ : checksHeaders
234
+ ? `, servedHeaders(${member.headersSchemaName ?? "undefined"}, { ${headers.join(", ")} }, ${operationId}, ${status})`
235
+ : `, headersOf({ ${headers.join(", ")} })`;
236
+ const validated = () => {
237
+ uses.runtime.add("servedBody");
238
+ return `servedBody(${member.schemaName ?? "undefined"}, result.body, ${operationId}, ${status})`;
239
+ };
240
+ switch (serving) {
241
+ case "none":
242
+ return `return c.body(null, ${status}${headersArgument});`;
243
+ case "json":
244
+ return `return c.json(${validated()}, ${status}${headersArgument});`;
245
+ case "text":
246
+ return `return c.body(${validated()}, ${status}${headersArgument});`;
247
+ default:
248
+ return `return c.body(result.body, ${status}${headersArgument});`;
249
+ }
250
+ }
251
+ /**
252
+ * A member's status as a TypeScript type: disjoint from every other member's, by construction.
253
+ *
254
+ * **Disjoint is the property that makes a wrong body a compile error.** Were `default` allowed to
255
+ * overlap a declared status, `{ status: 404, body: <the default arm's body> }` would type-check
256
+ * through the `default` member; measured, and the generated switch stopped compiling as well.
257
+ */
258
+ function statusTypeOf(response, route) {
259
+ const { status } = response;
260
+ if (typeof status === "number")
261
+ return String(status);
262
+ const exact = route.responses.flatMap((candidate) => typeof candidate.status === "number" ? [String(candidate.status)] : []);
263
+ if (status !== "default") {
264
+ const group = groupOf(status);
265
+ if (group === undefined)
266
+ return "never";
267
+ const excluded = [
268
+ ...exact.filter((code) => groupOf(Number(code)) === group),
269
+ ...(response.schema === undefined ? [] : ["ContentlessStatusCode"]),
270
+ ];
271
+ return excluded.length === 0 ? group.type : `Exclude<${group.type}, ${excluded.join(" | ")}>`;
272
+ }
273
+ const ranges = route.responses.flatMap((candidate) => typeof candidate.status === "string" && candidate.status !== "default"
274
+ ? [groupOf(candidate.status)?.type ?? "never"]
275
+ : []);
276
+ const base = response.schema === undefined ? "StatusCode" : "ContentfulStatusCode";
277
+ return `Exclude<${base}, ${["InfoStatusCode", "UnofficialStatusCode", ...exact, ...ranges].join(" | ")}>`;
278
+ }
3
279
  /**
4
280
  * The header every emitted file carries.
5
281
  *
@@ -16,56 +292,80 @@ export function generatedBanner(hint) {
16
292
  }
17
293
  /** A parameter name Hono can carry verbatim. Measured against Hono, not assumed. */
18
294
  const PLAIN_PATH_PARAMETER = /^[A-Za-z0-9_.~-]+$/;
295
+ /** Text matched literally inside a Hono `{...}` parameter pattern. */
296
+ function escapeRegExp(text) {
297
+ return text.replace(/[.*+?^$()|[\]\\]/g, "\\$&");
298
+ }
19
299
  /**
20
- * TypeSpec publishes `/widgets/{widget-id}`; Hono routes on `/widgets/:widget-id`.
21
- *
22
- * **This used to match `\w+`, so any parameter carrying a hyphen was left ALONE.**
23
- * `@path("thing-id")` produced the literal route `/things/{thing-id}`, mounted, counted by every arm
24
- * that counts routes, and reachable by nobody. It answered 404 to the only requests it was for. Hono
25
- * handles `:thing-id` and `:x.y` perfectly well; the narrow character class was ours.
26
- *
27
- * A name that is not plain is REFUSED rather than approximated, and the route stays at the literal
28
- * template so it matches nothing rather than matching the wrong thing.
29
- *
30
- * **This is about the NAME, not about RFC 6570 operators.** An earlier version of this comment
31
- * claimed `{+path}` or `{tag*}` would survive into the name and that `*` would become Hono's
32
- * wildcard. Measured, that is false: `@typespec/http` resolves the operator before this emitter sees
33
- * the path, and `@typespec/openapi3` strips it from the published document too, so both artefacts say
34
- * `/files{path}` and agree. What actually reaches here is a wire name from `@path("...")`, and the
35
- * forms that fail are a space, `+` and `!`. `*` is rejected by `@typespec/http` before it arrives.
36
- *
37
- * **This runs at RENDER time, not during collection.** It used to run inside `collectRoutes`, which
38
- * put one framework's spelling into the shared intermediate representation and refused the whole
39
- * operation (validators included) over a template no router could mount. What a request body must
40
- * look like does not depend on that.
300
+ * The Hono route for a route template, from the segments `typespec-http-zod` reads out of the
301
+ * operation's RFC 6570 `uriTemplate`.
302
+ *
303
+ * **This used to convert `route.path`, which has every operator stripped**, so `array{.param*}`,
304
+ * `array{;param}` and `optional{/name}` were mounted as `array:param` and `optional:name`, which Hono
305
+ * does not match at all. Measured by request: 31 of the URIs `@typespec/http-specs` `routes` and
306
+ * `parameters/path` declare answered 404 from a server generated from them.
307
+ *
308
+ * - A whole-segment expression is `:name`; a reserved or exploding `/` one crosses `/`, so it is
309
+ * `:name{.+}` (Hono's spelling of greedy); an optional last segment is `:name?`.
310
+ * - An expression written beside literal text, or with a label or matrix operator, is a pattern
311
+ * parameter matching the whole segment: `:param{array\.[^\x2F]*}`. The captured text is the
312
+ * segment, and the path validator undoes the expansion. `\x2F` rather than `/`, so the mounted
313
+ * path still splits into its segments on `/` for the ordering and sub-app rules below.
314
+ *
315
+ * **A name Hono cannot carry is REFUSED rather than approximated**, and so is a segment holding two
316
+ * expressions, which no segment router can split. The segment stays literal, so it matches nothing
317
+ * rather than the wrong thing. A space, `+` and `!` are the characters that fail; a hyphen, a dot and
318
+ * a tilde do not.
319
+ *
320
+ * **This runs at RENDER time, not during collection**, so what a request body must look like never
321
+ * depends on whether one framework's router can express the path.
41
322
  */
42
- export function toHonoPath(template, refuse,
43
- /**
44
- * Wire names the document says carry RFC 6570 reserved expansion, so their value may contain `/`.
45
- *
46
- * **A hierarchical identifier is ONE value, not several segments.** An Obsidian note is
47
- * `areas/health.md`; an S3 key and a GitHub file path are the same shape. A router that stops at
48
- * the first `/` binds `areas` and 404s the rest. Hono spells the greedy form `:name{.+}`.
49
- *
50
- * Read from `EmittedRoute.reservedPathParameters`, which the library resolves from `allowReserved`
51
- * on the parameter. **Never from the template**: the operator does not survive to `route.path`,
52
- * `@typespec/http` strips it, and it can also be set with no operator in the template at all, so
53
- * the template is a derived artefact rather than the source of truth.
54
- */
55
- reserved = new Set()) {
56
- return template.replace(/\{([^}]+)\}/g, (match, name) => {
57
- /**
58
- * **The name check comes FIRST and is unconditional.** A name Hono cannot carry is refused
59
- * whether or not it is reserved: greedy matching does not make a space or a `+` in a parameter
60
- * name expressible, and letting one through because another flag was set would mount a route
61
- * matching the wrong requests rather than one that fails.
62
- */
323
+ export function toHonoPath(segments, refuse) {
324
+ const template = `/${segments
325
+ .map((segment) => segment.kind === "expression"
326
+ ? `${segment.prefix}{${segment.operator}${segment.parameter}${segment.explode ? "*" : ""}}${segment.suffix}`
327
+ : segment.text)
328
+ .join("/")}`;
329
+ const parts = segments.map((segment, index) => {
330
+ if (segment.kind === "literal")
331
+ return segment.text;
332
+ if (segment.kind === "unsupported") {
333
+ refuse(template, segment.text);
334
+ return segment.text;
335
+ }
336
+ const name = segment.parameter;
63
337
  if (!PLAIN_PATH_PARAMETER.test(name)) {
64
338
  refuse(template, name);
65
- return match;
339
+ return `{${name}}`;
340
+ }
341
+ const whole = segment.prefix === "" &&
342
+ segment.suffix === "" &&
343
+ segment.operator !== "." &&
344
+ segment.operator !== ";";
345
+ if (whole) {
346
+ if (segment.reserved || (segment.operator === "/" && segment.explode))
347
+ return `:${name}{.+}`;
348
+ return segment.optional && index === segments.length - 1 ? `:${name}?` : `:${name}`;
66
349
  }
67
- return reserved.has(name) ? `:${name}{.+}` : `:${name}`;
350
+ const leader = segment.operator === "." || segment.operator === ";" ? escapeRegExp(segment.operator) : "";
351
+ const body = segment.reserved ? ".*" : "[^\\x2F]*";
352
+ return `:${name}{${escapeRegExp(segment.prefix)}${leader}${body}${escapeRegExp(segment.suffix)}}`;
68
353
  });
354
+ return `/${parts.join("/")}`;
355
+ }
356
+ /**
357
+ * The document's `security` as a TypeScript literal, for the generated `authorize` call. `{}` is the
358
+ * anonymous alternative, written out so an `authorize` applying the documented rule (any one
359
+ * requirement, every scheme within it) admits a caller who presents nothing.
360
+ */
361
+ function renderSecurity(requirements) {
362
+ return `[${requirements
363
+ .map((requirement) => Object.keys(requirement).length === 0
364
+ ? "{}"
365
+ : `{ ${Object.entries(requirement)
366
+ .map(([scheme, scopes]) => `${JSON.stringify(scheme)}: [${scopes.map((scope) => JSON.stringify(scope)).join(", ")}]`)
367
+ .join(", ")} }`)
368
+ .join(", ")}]`;
69
369
  }
70
370
  /**
71
371
  * The verb Hono actually dispatches under.
@@ -94,14 +394,18 @@ const HONO_METHOD = {
94
394
  * A request location, as the DOCUMENT names it, mapped to the target `@hono/zod-validator` reads.
95
395
  *
96
396
  * **The two vocabularies are not the same, and only one of them is a contract fact.** OpenAPI says
97
- * `path`, `query`, `header` and a request body; zValidator says `param`, `query`, `header` and
98
- * `json`. The library publishes the first because that is what the document states; translating is
99
- * this package's job, and it is a map rather than a coincidence.
397
+ * `path`, `query`, `header`, `cookie` and a request body; zValidator says `param`, `query`,
398
+ * `header`, `cookie` and `json`. The library publishes the first because that is what the document
399
+ * states; translating is this package's job, and it is a map rather than a coincidence.
400
+ *
401
+ * `cookie` is the one location whose two spellings agree, and it is a first-class Hono target:
402
+ * `ValidationTargets` declares it and Hono's own validator reads it with `getCookie`.
100
403
  */
101
404
  const VALIDATOR_TARGET = {
102
405
  path: "param",
103
406
  query: "query",
104
407
  header: "header",
408
+ cookie: "cookie",
105
409
  body: "json",
106
410
  };
107
411
  /**
@@ -120,12 +424,23 @@ const VALIDATOR_TARGET = {
120
424
  * justifies, so it keeps the existing behaviour rather than acquiring a new one on the way past. That
121
425
  * gap is real and is stated in the README rather than papered over here.
122
426
  */
123
- function targetForMediaType(type) {
427
+ function targetForMediaType(type, textual) {
124
428
  if (type === "application/x-www-form-urlencoded" || type.startsWith("multipart/"))
125
429
  return "form";
126
430
  // `application/json`, and the `+json` structured suffix RFC 6839 defines.
127
431
  if (type === "application/json" || type.endsWith("+json"))
128
432
  return VALIDATOR_TARGET.body;
433
+ /**
434
+ * **A body the document says IS text is read as text**, `@body body: string` under `text/plain`
435
+ * being the ordinary case. `requestTextual` is the library's answer about the TYPE, so the same
436
+ * media type carrying a model stays unparseable and keeps its warning: `text/plain` says how the
437
+ * value is framed, not what it is.
438
+ *
439
+ * Measured before this existed: such a route emitted no body middleware at all, so the body was
440
+ * never read and the handler was called with an empty input.
441
+ */
442
+ if (textual)
443
+ return "text";
129
444
  return undefined;
130
445
  }
131
446
  /**
@@ -146,13 +461,13 @@ function targetForMediaType(type) {
146
461
  * package should make. Naming them is what stops a consumer believing a route is validated when it
147
462
  * is not -- the previous behaviour said nothing and rejected them.
148
463
  */
149
- function bodyValidationFor(contentTypes) {
464
+ function bodyValidationFor(contentTypes, textual) {
150
465
  if (contentTypes.length === 0)
151
466
  return { byType: [["", VALIDATOR_TARGET.body]], unparseable: [] };
152
467
  const byType = [];
153
468
  const unparseable = [];
154
469
  for (const type of contentTypes) {
155
- const target = targetForMediaType(type);
470
+ const target = targetForMediaType(type, textual);
156
471
  if (target === undefined)
157
472
  unparseable.push(type);
158
473
  else
@@ -164,9 +479,9 @@ function bodyValidationFor(contentTypes) {
164
479
  * The request-body middleware, emitted INTO the generated file rather than imported from the runtime
165
480
  * module.
166
481
  *
167
- * **Because the runtime module is a contract too, and it is the one that breaks quietly.** An
168
- * application may point `runtime-module` at a module of its own, and everything the generated file
169
- * imports from there is something that application has to supply. A required, single-media-type body
482
+ * **Because the runtime module was a contract too, and it was the one that broke quietly.** An
483
+ * application could point `runtime-module` at a module of its own, and everything the generated file
484
+ * imported from there was something that application had to supply. A required, single-media-type body
170
485
  * used to be mounted by `zValidator`, which throws `HTTPException` on a body it cannot read - a
171
486
  * `text/plain` 400 raised before `deps.invalid` is called, so an API whose document declares a JSON
172
487
  * error envelope answered a shape its own contract forbids. Routing those through the runtime's
@@ -198,17 +513,22 @@ function bodyValidationFor(contentTypes) {
198
513
  * the single permitted `.refine()` is a synchronous predicate on a multipart file part. So the async
199
514
  * path was buying nothing and costing on every request.
200
515
  *
201
- * Measured on an emitted five-property model, zod 4.5.2, 200k iterations:
516
+ * Measured on an emitted five-property model, zod 4.6.5, 200k iterations after a 20k warm-up:
202
517
  *
203
518
  * | call | ns/parse |
204
519
  * | --- | --- |
205
- * | `safeParseAsync` | 958 |
206
- * | `safeParse` | 371 |
520
+ * | `safeParseAsync` | 444 |
521
+ * | `safeParse` | 130 |
207
522
  *
208
- * **2.6x, on every parameter group of every request**, for a promise nothing awaited a result from.
523
+ * **3.4x, on every parameter group of every request**, for a promise nothing awaited a result from.
209
524
  * It also unblocks `z.compile()`, whose fast path is bypassed for any async parse - measured from
210
525
  * `zod/compile`'s own shim, which returns the uncompiled run for `ctx.async`, and confirmed on the
211
- * same fixture: compiled `safeParse` is 51 ns and compiled `safeParseAsync` is 923 ns.
526
+ * same fixture: compiled `safeParse` is 64 ns and compiled `safeParseAsync` is 489 ns.
527
+ *
528
+ * **Zod 4.6's `.validate()` does not apply here**, though it looks as though it should. It is 121 ns
529
+ * against 130 ns on the success path, and its advantage is on the FAILURE path, where it skips
530
+ * building a `ZodError`. This runtime needs that error: it is what `deps.invalid` receives and what
531
+ * `ResponseContractError` carries. The numbers on zod 4.5.2 were 958 and 371.
212
532
  *
213
533
  * `validationFunction` is `@hono/zod-validator`'s published option and its return type admits a
214
534
  * synchronous result, so this is the package's own seam rather than a way around it.
@@ -217,12 +537,44 @@ function bodyValidationFor(contentTypes) {
217
537
  * synchronously, because `safeParse` THROWS on a schema that cannot - a claim about output this file
218
538
  * does not produce is exactly the kind that needs an arm rather than a comment.
219
539
  */
220
- const SYNC_PARSE = `/** Parse synchronously: nothing emitted here is async, and the async path costs 2.6x. */
221
- const SYNC = { validationFunction: (schema: z.ZodType, value: unknown) => schema.safeParse(value) };
540
+ const GUARDED_PARSE = `/**
541
+ * Parse, and treat input nested deeper than this runtime's stack as a refusal rather than a crash.
542
+ *
543
+ * **\`safeParse\` not throwing is the contract everything here rests on, and a deep enough value
544
+ * breaks it.** Zod recurses once per level, so a recursive model meets a \`RangeError\` that is not a
545
+ * validation failure and escapes to \`app.onError\` indistinguishable from a bug in the application's
546
+ * own code. Measured on \`workerd\`: a 40.5 KB body, about 1,925 levels, which Ajv on the published
547
+ * document calls VALID.
548
+ *
549
+ * It cannot be served, so it is refused - through \`deps.invalid\` like every other bad request, with
550
+ * an issue that says which limit was met rather than a generic failure. Anything that is not a
551
+ * \`RangeError\` is rethrown: a bug here must still look like one.
552
+ */
553
+ function parsed(schema: z.ZodType, value: unknown): z.ZodSafeParseResult<unknown> {
554
+ try {
555
+ return schema.safeParse(value);
556
+ } catch (error) {
557
+ if (!(error instanceof RangeError)) throw error;
558
+ return {
559
+ success: false,
560
+ error: new z.ZodError([
561
+ {
562
+ code: "custom",
563
+ path: [],
564
+ message: "input is nested too deeply for this runtime to validate",
565
+ },
566
+ ]),
567
+ };
568
+ }
569
+ }
570
+
571
+ `;
572
+ const SYNC_PARSE = `/** Parse synchronously: nothing emitted here is async, and the async path costs 3.4x. */
573
+ const SYNC = { validationFunction: (schema: z.ZodType, value: unknown) => parsed(schema, value) };
222
574
 
223
575
  `;
224
576
  const BODY_READER = `/** The two ways Hono can read a request body: \`c.req.json()\` and \`c.req.parseBody()\`. */
225
- type BodyTarget = "json" | "form";
577
+ type BodyTarget = "json" | "form" | "text";
226
578
 
227
579
  /**
228
580
  * A body that is not what its content type claims.
@@ -236,7 +588,10 @@ const UNREADABLE = Symbol("a body that is not what its content type claims");
236
588
 
237
589
  async function readBody(c: Context, target: BodyTarget): Promise<unknown> {
238
590
  try {
239
- return target === "form" ? await c.req.parseBody({ all: true }) : await c.req.json();
591
+ if (target === "form") return await c.req.parseBody({ all: true });
592
+ // A text body IS the text: it is validated as the string the caller sent, not parsed first.
593
+ if (target === "text") return await c.req.text();
594
+ return await c.req.json();
240
595
  } catch {
241
596
  return UNREADABLE;
242
597
  }
@@ -251,8 +606,8 @@ async function readBody(c: Context, target: BodyTarget): Promise<unknown> {
251
606
  * is not a body that validated.
252
607
  */
253
608
  function unreadableResult(schema: z.ZodType): { readonly success: boolean } {
254
- const parsed = schema.safeParse(undefined);
255
- return parsed.success ? { success: false } : parsed;
609
+ const result = parsed(schema, undefined);
610
+ return result.success ? { success: false } : result;
256
611
  }
257
612
 
258
613
  /**
@@ -263,13 +618,63 @@ function unreadableResult(schema: z.ZodType): { readonly success: boolean } {
263
618
  * generated. Parameters (\`; charset=utf-8\`, \`; boundary=...\`) are not part of the match, which
264
619
  * matters because a multipart request always carries a boundary.
265
620
  *
266
- * A \`Content-Type\` matching nothing declared falls through to the first branch, so the body simply
267
- * fails to parse and the app answers. No status is invented that the document does not describe.
621
+ * **A \`Content-Type\` matching nothing declared is REFUSED, and used to be parsed anyway.** It fell
622
+ * through to the first branch, and the comment here claimed the body would then "simply fail to
623
+ * parse" - which is not what happened: \`c.req.json()\` reads the bytes whatever the header says, so a
624
+ * route publishing \`application/json\` alone answered 200 to the same body sent as \`text/plain\`, as
625
+ * \`multipart/form-data\`, and with no \`Content-Type\` at all. The document says which media types an
626
+ * operation accepts, and a server that accepts others is not serving that document.
627
+ *
628
+ * \`undefined\` means "nothing declared covers this", and the caller refuses it through
629
+ * \`deps.invalid\` like any other bad request. No status is invented that the document does not
630
+ * describe, which is also what \`@typespec/http-server-js\` does with the identical case.
268
631
  */
269
- function bodyTarget(c: Context, branches: readonly (readonly [string, BodyTarget])[]): BodyTarget {
632
+ function bodyTarget(
633
+ c: Context,
634
+ branches: readonly (readonly [string, BodyTarget])[],
635
+ ): BodyTarget | undefined {
270
636
  const declared = (c.req.header("content-type") ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
271
- const matched = branches.find(([mediaType]) => mediaType.toLowerCase() === declared);
272
- return matched?.[1] ?? branches[0]?.[1] ?? "json";
637
+ if (declared === "") return undefined;
638
+ /**
639
+ * A declared media RANGE matches the types inside it, which a string comparison cannot do.
640
+ * \`@typespec/http\` reports a bare \`string\`-typed \`contentType\` as the wildcard range, and an author
641
+ * may write a subtype wildcard such as text-slash-star outright. Comparing the range TEXT refuses
642
+ * every real request, because no caller sends the range itself as its \`Content-Type\`. This is the
643
+ * request-side counterpart of \`mediaTypeWithin\` in the runtime module, inlined because the branch
644
+ * list is emitted here.
645
+ */
646
+ const covers = (range: string): boolean => {
647
+ if (range === "*/*") return true;
648
+ if (!range.endsWith("/*")) return false;
649
+ const slash = declared.indexOf("/");
650
+ return slash > 0 && declared.slice(0, slash) === range.slice(0, -2);
651
+ };
652
+ return branches.find(([mediaType]) => {
653
+ const lowered = mediaType.toLowerCase();
654
+ return lowered === declared || covers(lowered);
655
+ })?.[1];
656
+ }
657
+
658
+ /**
659
+ * The failure an undeclared media type produces, in the shape every other rejection has.
660
+ *
661
+ * Reported as an issue on the body rather than a bare \`{ success: false }\`, so an application's
662
+ * \`invalid\` hook can tell a caller WHICH thing was wrong - the same reason \`unreadableResult\` parses
663
+ * \`undefined\` rather than inventing a result.
664
+ */
665
+ function unsupportedMediaTypeResult(
666
+ branches: readonly (readonly [string, BodyTarget])[],
667
+ ): z.ZodSafeParseResult<unknown> {
668
+ return {
669
+ success: false,
670
+ error: new z.ZodError([
671
+ {
672
+ code: "custom",
673
+ path: ["content-type"],
674
+ message: \`this operation accepts \${branches.map(([type]) => type).join(", ")}\`,
675
+ },
676
+ ]),
677
+ };
273
678
  }
274
679
 
275
680
  /** The app's rejection hook, as this file needs to name it. */
@@ -311,9 +716,14 @@ function validateBody<E extends Env, S extends z.ZodType>(
311
716
  string,
312
717
  { in: { json: z.input<S> }; out: { json: z.output<S> } }
313
718
  > = async (ctx, proceed) => {
314
- const raw = await readBody(ctx, bodyTarget(ctx, branches));
719
+ const target = bodyTarget(ctx, branches);
720
+ const raw = target === undefined ? UNREADABLE : await readBody(ctx, target);
315
721
  const result =
316
- raw === UNREADABLE ? unreadableResult(schema) : schema.safeParse(raw);
722
+ target === undefined
723
+ ? unsupportedMediaTypeResult(branches)
724
+ : raw === UNREADABLE
725
+ ? unreadableResult(schema)
726
+ : parsed(schema, raw);
317
727
  const response = invalid(result, ctx);
318
728
  if (response !== undefined) return response;
319
729
  if (!result.success) return ctx.json(result, 400);
@@ -361,9 +771,14 @@ function validateOptionalBody<E extends Env, S extends z.ZodType>(
361
771
  await proceed();
362
772
  return undefined;
363
773
  }
364
- const raw = await readBody(ctx, bodyTarget(ctx, branches));
774
+ const target = bodyTarget(ctx, branches);
775
+ const raw = target === undefined ? UNREADABLE : await readBody(ctx, target);
365
776
  const result =
366
- raw === UNREADABLE ? unreadableResult(schema) : schema.safeParse(raw);
777
+ target === undefined
778
+ ? unsupportedMediaTypeResult(branches)
779
+ : raw === UNREADABLE
780
+ ? unreadableResult(schema)
781
+ : parsed(schema, raw);
367
782
  const response = invalid(result, ctx);
368
783
  if (response !== undefined) return response;
369
784
  if (!result.success) return ctx.json(result, 400);
@@ -375,6 +790,47 @@ function validateOptionalBody<E extends Env, S extends z.ZodType>(
375
790
  };
376
791
  }
377
792
 
793
+ `;
794
+ /**
795
+ * The caller context, established in middleware and handed to the handler.
796
+ *
797
+ * **Middleware, because a refusal returned inside the final handler erases the route's typed
798
+ * responses** - see `registrations`. **A per-request `WeakMap`, because `c.set` cannot carry it
799
+ * without leaking into the application's types.** Setting a variable needs the route's environment to
800
+ * declare it, and Hono's `Context` is invariant in its environment through `set`: a generated
801
+ * extension made every \`deps\` hook the application wrote against its own environment unassignable,
802
+ * measured as \`TS2345\` at the application's call site. The map keys on the request's own
803
+ * \`Context\`, which Hono passes unchanged from middleware to handler, including through a mounted
804
+ * sub-app.
805
+ */
806
+ const CONTEXT_MIDDLEWARE = `\tconst contexts = new WeakMap<object, { readonly value: C }>();
807
+ const contextFor =
808
+ (authentication: "none" | "optional" | "required"): MiddlewareHandler<AppEnv> =>
809
+ async (c, next) => {
810
+ const ctx = deps.context(c, authentication);
811
+ if (ctx === null) return deps.noContext(c);
812
+ contexts.set(c, { value: ctx });
813
+ await next();
814
+ return undefined;
815
+ };
816
+ const contextOf = (c: object): C => {
817
+ const held = contexts.get(c);
818
+ if (held === undefined) throw new Error("typespec-hono: no caller context was established for this request");
819
+ return held.value;
820
+ };
821
+
822
+ `;
823
+ /** A caller accepting none of the media types a negotiated route offers is refused here. */
824
+ const ACCEPTABLE_MIDDLEWARE = `\tconst acceptable =
825
+ (offered: readonly string[]): MiddlewareHandler<AppEnv> =>
826
+ async (c, next) => {
827
+ if (selectContentType(c.req.header("accept"), offered) === undefined) {
828
+ return deps.notAcceptable(c, offered);
829
+ }
830
+ await next();
831
+ return undefined;
832
+ };
833
+
378
834
  `;
379
835
  /**
380
836
  * How an unparsed body reaches the handler, and as what.
@@ -507,6 +963,64 @@ function inputTypeOf(entry) {
507
963
  function capitaliseId(operationId) {
508
964
  return `${operationId.charAt(0).toUpperCase()}${operationId.slice(1)}`;
509
965
  }
966
+ /**
967
+ * Whether Hono's `RegExpRouter` can compile this route set, and the route it names if not.
968
+ *
969
+ * **Hono's own router, imported statically.** `hono` is a required peer - `runtime.ts` imports it and
970
+ * the generated server cannot run without it - so there is nothing to degrade gracefully around, and
971
+ * a guarded import would claim a resilience this package does not need. Running the real router is
972
+ * what stops the check drifting from a rule Hono never documented.
973
+ *
974
+ * **`match` as well as `add`.** `RegExpRouter` builds its expressions lazily, so a conflict added
975
+ * through `add` alone can go unreported until the first request. One `match` forces the build.
976
+ */
977
+ export function regExpRouterRefusal(routes) {
978
+ const first = routes[0];
979
+ if (first === undefined)
980
+ return undefined;
981
+ const router = new RegExpRouter();
982
+ const noop = () => undefined;
983
+ try {
984
+ for (const [method, path] of routes)
985
+ router.add(method, path, noop);
986
+ router.match("GET", first[1]);
987
+ return undefined;
988
+ }
989
+ catch (error) {
990
+ /**
991
+ * **The CONSTRUCTOR name, not `.name`.** Hono's `UnsupportedPathError` extends `Error` without
992
+ * setting `this.name`, so the inherited `.name` reads `"Error"` and says nothing. Its `message`
993
+ * is the offending path and is the whole of what it reports.
994
+ */
995
+ const thrown = error;
996
+ return {
997
+ path: thrown.message ?? "(unnamed)",
998
+ reason: thrown.constructor?.name ?? "the router refused it",
999
+ };
1000
+ }
1001
+ }
1002
+ /**
1003
+ * **The `[method, path]` pairs a router is given for one service**, exactly as the emitted server
1004
+ * registers them.
1005
+ *
1006
+ * One definition, used by the `regexp-router-unsupported` linter rule, so the rule judges the route
1007
+ * set the generated server really mounts. Built from the same helpers the render uses - the verb a
1008
+ * route registers under, `app.on` falling back to `GET` as Hono itself does, and `toHonoPath` on the
1009
+ * route's segments - and prefixed with each base path the service is served under.
1010
+ *
1011
+ * **The full path, never the one relative to a sub-app.** The relative form is what the emitted
1012
+ * text says and not what any router is given. Judging it reports conflicts between routes that are
1013
+ * not siblings at all: measured on five fixtures, two were called unmountable that mount perfectly.
1014
+ */
1015
+ export function routerTableFor(routes, basePaths) {
1016
+ const noRefusal = () => undefined;
1017
+ return routes.flatMap((route) => {
1018
+ const verb = registrationVerbOf(route.verb);
1019
+ const method = HONO_METHOD[verb] === undefined ? "GET" : verb;
1020
+ const path = toHonoPath(route.pathSegments, noRefusal);
1021
+ return (basePaths.length === 0 ? [""] : basePaths).map((prefix) => [method, `${prefix}${path}` || "/"]);
1022
+ });
1023
+ }
510
1024
  /**
511
1025
  * The resource a route belongs to (its first path segment) or `undefined` when it has none.
512
1026
  *
@@ -558,16 +1072,7 @@ export function renderApp(emitted, refuse,
558
1072
  * the document publishes `/api/v1/accounts`. Mounting at the root made every client generated from
559
1073
  * the document, and every "try it" in a rendered document, 404.
560
1074
  */
561
- basePaths = [],
562
- /**
563
- * What the DOCUMENT says a caller must satisfy, per operation id.
564
- *
565
- * **Resolved by the caller rather than read off `EmittedRoute`**, because which schemes an
566
- * operation accepts is a fact about the HTTP program and not part of the validator IR the library
567
- * publishes. Keeping it out of that IR is what stops a Hono concern leaking into a package whose
568
- * audience is wider.
569
- */
570
- securityFor) {
1075
+ basePaths = []) {
571
1076
  const mounted = emitted.routes.flatMap((route) => {
572
1077
  const names = emitted.schemaNames.get(route.operationId);
573
1078
  // The library declares a `Responses` const for every operation, so a missing entry is a bug in
@@ -576,7 +1081,7 @@ securityFor) {
576
1081
  return [];
577
1082
  const validators = [];
578
1083
  let body;
579
- for (const location of ["path", "query", "header", "body"]) {
1084
+ for (const location of ["path", "query", "header", "cookie", "body"]) {
580
1085
  const identifier = names[location];
581
1086
  if (identifier === undefined)
582
1087
  continue;
@@ -590,7 +1095,7 @@ securityFor) {
590
1095
  * got. All three are now one emitted middleware; what the document decides is only the
591
1096
  * branches it is given and whether an absent body is permitted.
592
1097
  */
593
- const validation = bodyValidationFor(route.requestContentTypes);
1098
+ const validation = bodyValidationFor(route.requestContentTypes, route.requestTextual);
594
1099
  if (validation.unparseable.length > 0) {
595
1100
  refuse.unvalidatableMediaType(route, validation.unparseable);
596
1101
  }
@@ -685,8 +1190,88 @@ type Fields<T> = string extends keyof T ? ([T[string]] extends [never] ? unknown
685
1190
  */
686
1191
  const validates = entries.some((entry) => entry.validators.filter(([target]) => entry.body === undefined || target !== VALIDATOR_TARGET.body).length > 0);
687
1192
  const syncHelper = validates ? SYNC_PARSE : "";
1193
+ /**
1194
+ * `parsed` is used by `SYNC` and by both body middlewares, so it is emitted where either is, and
1195
+ * BEFORE them: a generated file is read top to bottom and a helper used above its declaration
1196
+ * reads as a mistake even where hoisting makes it legal.
1197
+ */
1198
+ const guardedParse = validates || mountsBody ? GUARDED_PARSE : "";
1199
+ /**
1200
+ * **One result type per operation: the union of every response its document declares.**
1201
+ *
1202
+ * A handler returns `{ status, body, headers }` for whichever response it means, success or
1203
+ * failure. That is `c.json(body, status, headers)` as data - what `@hono/zod-openapi` requires of a
1204
+ * handler as a union of typed responses - and the document's own Responses Object: keyed by status,
1205
+ * each with its body and its headers.
1206
+ *
1207
+ * **The return type used to be the SUCCESS body alone.** The arms named every failure the document
1208
+ * declares and no handler could return one, so failures were thrown past the generated code into
1209
+ * `onError`, where no declared status or body was checked. Nine of a real operation's ten arms were
1210
+ * unreachable that way.
1211
+ *
1212
+ * Data rather than a `Response`, so a handler stays transport-neutral: the same object can cross a
1213
+ * Workers service binding and serve `typespec-http-mcp`'s tools.
1214
+ */
1215
+ const resultTypes = entries.map((entry) => {
1216
+ for (const response of entry.route.responses) {
1217
+ const unvalidated = membersOf(entry)
1218
+ .filter((member) => member.response === response && bodyServingOf(member) === "unvalidated")
1219
+ .flatMap((member) => (member.mediaType === undefined ? [] : [member.mediaType]));
1220
+ if (unvalidated.length > 0) {
1221
+ refuse.unvalidatedResponseMediaType(entry.route, response.status, unvalidated);
1222
+ }
1223
+ }
1224
+ const members = membersOf(entry).map((member) => {
1225
+ const { response } = member;
1226
+ const fields = [`readonly status: ${statusTypeOf(response, entry.route)}`];
1227
+ if (member.choosesMediaType && member.mediaType !== undefined) {
1228
+ fields.push(`readonly contentType: ${contentTypeTypeOf(member.mediaType)}`);
1229
+ }
1230
+ /**
1231
+ * **What the handler SUPPLIES for this body, which is not always what the schema infers.** A
1232
+ * stream or raw binary body is handed to Hono unread, a model under a non-JSON media type is text
1233
+ * the handler serialised, and everything else is the producer's view of the schema.
1234
+ */
1235
+ switch (bodyServingOf(member)) {
1236
+ case "none":
1237
+ fields.push("readonly body?: undefined");
1238
+ break;
1239
+ case "stream":
1240
+ fields.push("readonly body: ReadableStream");
1241
+ break;
1242
+ case "binary":
1243
+ fields.push("readonly body: ReadableStream | Uint8Array<ArrayBuffer> | ArrayBuffer");
1244
+ break;
1245
+ case "unvalidated":
1246
+ fields.push("readonly body: string");
1247
+ break;
1248
+ default:
1249
+ fields.push(`readonly body: Produced<z.infer<typeof ${member.schemaName}>>`);
1250
+ }
1251
+ if (response.headers.length > 0) {
1252
+ /**
1253
+ * Keyed by the WIRE name, as the document publishes it. `headers` itself is optional only
1254
+ * when every header in it is, so a response declaring a required header cannot be returned
1255
+ * without one.
1256
+ */
1257
+ const every = response.headers.every((header) => header.optional);
1258
+ const rendered = response.headers
1259
+ .map((header) => header.optional
1260
+ ? `readonly ${objectKey(header.name)}?: ${header.type} | undefined`
1261
+ : `readonly ${objectKey(header.name)}: ${header.type}`)
1262
+ .join("; ");
1263
+ fields.push(`readonly headers${every ? "?" : ""}: { ${rendered} }`);
1264
+ }
1265
+ return `{ ${fields.join("; ")} }`;
1266
+ });
1267
+ const name = `${capitaliseId(entry.route.operationId)}Result`;
1268
+ const body = members.length <= 1
1269
+ ? ` ${members[0] ?? "never"}`
1270
+ : members.map((member) => `\n\t| ${member}`).join("");
1271
+ return `export type ${name} =${body};`;
1272
+ });
688
1273
  const methods = entries.map((entry) => {
689
- const { route, names } = entry;
1274
+ const { route } = entry;
690
1275
  /**
691
1276
  * A negotiated member's `accept` is not in its validator (the negotiation supplies it) but it
692
1277
  * IS in the operation's declared input, so the interface has to keep it. The literal is known
@@ -702,75 +1287,18 @@ type Fields<T> = string extends keyof T ? ([T[string]] extends [never] ? unknown
702
1287
  ? negotiated
703
1288
  : `${validated} & ${negotiated}`;
704
1289
  /**
705
- * **The RETURN type is the producer's view; the input type is left exactly as it arrives.**
706
- *
707
- * A handler receives whatever the validator let through, so an input carrying
708
- * `[key: string]: unknown`, `T | undefined` on an optional, and mutable arrays is an honest
709
- * description of the value in hand. Returning is the opposite direction: the handler supplies
710
- * something the application already holds, and each of those becomes an obligation rather than
711
- * a description. See `Produced` below for what that cost, measured.
712
- *
713
- * `typespec-http-zod` fixes the same three things on its contract types. None of it reaches
714
- * here on its own, because this signature is derived from `z.infer` rather than from those
715
- * types - which is exactly the half-fix `test/openmodel/` exists to catch.
1290
+ * **A PROPERTY signature, not a method signature, and the difference is a check.** TypeScript
1291
+ * compares a method signature's parameters bivariantly, so a handler declaring a richer caller
1292
+ * context than `deps.context` produces compiled against the method form - measured - and failed
1293
+ * against this one.
716
1294
  */
717
- /**
718
- * **The ENVELOPE a handler has to be able to say, beside the body it returns.**
719
- *
720
- * `@statusCode` and `@header` properties are stripped from the body schema - correctly, they
721
- * are not body - so a return type derived from that schema alone could not carry them. The arms
722
- * name them anyway: `{ headers: [{ property: "correlationId" }] }` tells `respond` to read a
723
- * property off the returned value, and `when: { property: "statusCode" }` tells it which arm
724
- * the handler meant. Measured before this existed, on `payload__head`:
725
- * `Awaitable<Result<void>>` against an arm naming two header properties, so the emitter
726
- * published an envelope contract nothing could satisfy.
727
- *
728
- * Only the SUCCESS statuses count. `responseHeaders` covers error responses too, and a header
729
- * declared on a 404 is the error body's business rather than something a handler returns.
730
- */
731
- const successStatuses = route.statusSelector?.statuses ?? [route.statusCode];
732
- const envelope = [];
733
- if (route.statusSelector !== undefined) {
734
- envelope.push(`${objectKey(route.statusSelector.property)}: ${route.statusSelector.statuses.join(" | ")}`);
735
- }
736
- const headerEntries = route.responseHeaders.filter((entry) => successStatuses.includes(entry.status));
737
- const declaredOn = new Map();
738
- for (const entry of headerEntries) {
739
- for (const header of entry.headers) {
740
- const seen = declaredOn.get(header.property);
741
- declaredOn.set(header.property, {
742
- count: (seen?.count ?? 0) + 1,
743
- type: header.type,
744
- });
745
- }
746
- }
747
- for (const [property, { count, type }] of declaredOn) {
748
- // Required only where EVERY success status declares it; otherwise the handler cannot know
749
- // which status it is answering with until it has chosen one.
750
- const optional = count === headerEntries.length && headerEntries.length === successStatuses.length;
751
- envelope.push(`${objectKey(property)}${optional ? "" : "?"}: ${type}`);
752
- }
753
- const envelopeType = envelope.length === 0 ? undefined : `{ ${envelope.join("; ")} }`;
754
- const body = names.response === undefined ? undefined : `Produced<z.infer<typeof ${names.response}>>`;
755
- const output = body === undefined
756
- ? (envelopeType ?? "void")
757
- : envelopeType === undefined
758
- ? body
759
- : `${body} & ${envelopeType}`;
760
- const signature = `ctx: Ctx, input: ${input ?? EMPTY_INPUT}`;
761
1295
  const doc = jsDocComment(route.summary, "\t");
762
- return `${doc}\t${route.operationId}(${signature}): Awaitable<Result<${output}>>;`;
1296
+ return `${doc}\treadonly ${objectKey(route.operationId)}: (ctx: C, input: ${input ?? EMPTY_INPUT}) => Awaitable<${capitaliseId(route.operationId)}Result>;`;
763
1297
  });
764
- /**
765
- * Emitted only when an operation actually returns something, because a generated file has to pass
766
- * `noUnusedLocals` like any other - the lint that has already failed this emitter twice over an
767
- * import written for a construct the service did not use.
768
- *
769
- * **Decided from the routes, not by searching the rendered text.** Asking whether the output
770
- * mentions a name is how this package lost the `byContentType` import: the call gained an argument
771
- * and the substring stopped matching, so a module referenced a function it no longer imported.
772
- */
773
- const returnsAnything = entries.some((entry) => entry.names.response !== undefined);
1298
+ const returnsAnything = entries.some((entry) => membersOf(entry).some((member) => {
1299
+ const serving = bodyServingOf(member);
1300
+ return serving === "json" || serving === "text";
1301
+ }));
774
1302
  const declaredHelper = returnsAnything
775
1303
  ? `/**
776
1304
  * What a handler must SUPPLY, as opposed to what it receives.
@@ -828,7 +1356,7 @@ type Produced<T> = T extends (...args: never[]) => unknown
828
1356
 
829
1357
  `
830
1358
  : "";
831
- const aliases = entries.map((entry) => `export type ${capitaliseId(entry.route.operationId)}Handler = Operations[${JSON.stringify(entry.route.operationId)}];`);
1359
+ const aliases = entries.map((entry) => `export type ${capitaliseId(entry.route.operationId)}Handler<C = unknown> = Operations<C>[${JSON.stringify(entry.route.operationId)}];`);
832
1360
  /**
833
1361
  * **Which resources get a sub-app, and which routes stay on the root.**
834
1362
  *
@@ -871,6 +1399,7 @@ type Produced<T> = T extends (...args: never[]) => unknown
871
1399
  const slot = `${registrationVerbOf(route.verb)} ${route.path}`;
872
1400
  slots.set(slot, [...(slots.get(slot) ?? []), group]);
873
1401
  }
1402
+ const uses = { runtime: new Set() };
874
1403
  const registrations = [...slots.values()].map((groupsInSlot) => {
875
1404
  const headGroups = groupsInSlot.filter((g) => g[0].route.verb === "HEAD");
876
1405
  const plainGroups = groupsInSlot.filter((g) => g[0].route.verb !== "HEAD");
@@ -888,7 +1417,7 @@ type Produced<T> = T extends (...args: never[]) => unknown
888
1417
  /** A HEAD with no GET beside it: registered under GET, and guarded so only a HEAD reaches it. */
889
1418
  const headOnly = plainGroups.length === 0 && headGroups.length > 0;
890
1419
  const method = HONO_METHOD[registrationVerbOf(route.verb)] ?? "on";
891
- const path = toHonoPath(route.path, (template, name) => refuse.unsupportedPathTemplate(route, template, name), new Set(route.reservedPathParameters));
1420
+ const path = toHonoPath(route.pathSegments, (template, name) => refuse.unsupportedPathTemplate(route, template, name));
892
1421
  /**
893
1422
  * A route inside a sub-app is registered RELATIVE to the prefix it is mounted at.
894
1423
  *
@@ -914,8 +1443,21 @@ type Produced<T> = T extends (...args: never[]) => unknown
914
1443
  * all and rested entirely on `deps.context` returning null, which answers "is somebody here"
915
1444
  * rather than "did they satisfy the scheme the contract names".
916
1445
  */
917
- const requirements = securityFor?.(route.verb, route.path) ?? [];
918
- const gate = requirements.length === 0 ? [] : [`\t\tdeps.authorize(${renderSecurity(requirements)}),`];
1446
+ /**
1447
+ * Read from the route record, which carries the document's own `security` (`{}` for an
1448
+ * anonymous alternative) and whether a caller is needed at all. **This package kept a second
1449
+ * copy of that rule** in `security.ts`, because the library's once dropped `{}`; one copy now.
1450
+ */
1451
+ const gate = route.authentication === "none"
1452
+ ? []
1453
+ : [`\t\tdeps.authorize(${renderSecurity(route.security)}),`];
1454
+ /**
1455
+ * A query string written into the route itself identifies it as much as the path does, so a
1456
+ * request without it is not a request for this operation: 404, as any unrouted request gets.
1457
+ */
1458
+ const literalQueryGuard = route.literalQuery.length === 0
1459
+ ? []
1460
+ : [`\t\tliteralQuery(${JSON.stringify(route.literalQuery)}),`];
919
1461
  /**
920
1462
  * A HEAD operation with no GET beside it is registered under GET, because that is the only verb
921
1463
  * Hono dispatches. The guard keeps the registration honest: a real GET is not in the document,
@@ -948,8 +1490,33 @@ type Produced<T> = T extends (...args: never[]) => unknown
948
1490
  ...entry.body.branches.map(([type, target]) => `\t\t\t[${JSON.stringify(type)}, ${JSON.stringify(target)}],`),
949
1491
  "\t\t]),",
950
1492
  ];
1493
+ /**
1494
+ * Whether the operation requires a caller, and NOTHING else about the caller.
1495
+ *
1496
+ * **This used to pass `"account"` or `"resource"`, chosen by how many path parameters the
1497
+ * route had, and that was a rule no document states.** `@useAuth(NoAuth)` reaches OpenAPI as
1498
+ * `security: []`, so "does this need a caller" is a contract fact and is generated. "Is this
1499
+ * account-scoped or resource-scoped" is not: no OpenAPI keyword expresses it, and the
1500
+ * path-parameter heuristic happened to fit the first consumer.
1501
+ *
1502
+ * **Middleware, after the validators, and never inline in the handler.** Hono reads a route's
1503
+ * response types from its final handler, and one plain `Response` returned there collapses
1504
+ * every typed response `hc` would otherwise see - measured, `res.json()` fell back to
1505
+ * `unknown` for every status. A plain `Response` from middleware contributes nothing to those
1506
+ * types, so the refusal moves there and the handler returns typed responses only.
1507
+ */
1508
+ const authentication = route.authentication;
1509
+ /**
1510
+ * Several operations, one route: the caller's `Accept` chooses which one answers, and a caller
1511
+ * accepting none of them is refused in middleware for the same reason the context is.
1512
+ */
1513
+ const offers = group.length > 1
1514
+ ? group.flatMap((member) => member.route.responseContentTypes.map((contentType) => ({ contentType, member })))
1515
+ : [];
1516
+ const offered = `[${offers.map((offer) => JSON.stringify(offer.contentType)).join(", ")}]`;
951
1517
  const middleware = [
952
1518
  ...(headOnly ? ["\t\theadOnly,"] : []),
1519
+ ...literalQueryGuard,
953
1520
  ...gate,
954
1521
  ...validators
955
1522
  // The body's validator IS the middleware above; emitting a `zValidator` beside it would
@@ -957,43 +1524,12 @@ type Produced<T> = T extends (...args: never[]) => unknown
957
1524
  .filter(([target]) => entry.body === undefined || target !== VALIDATOR_TARGET.body)
958
1525
  .map(([target, name]) => `\t\tzValidator(${JSON.stringify(target)}, ${name}, deps.invalid, SYNC),`),
959
1526
  ...bodyMiddleware,
1527
+ `\t\tcontextFor(${JSON.stringify(authentication)}),`,
1528
+ ...(offers.length > 0 ? [`\t\tacceptable(${offered}),`] : []),
960
1529
  ];
961
- /**
962
- * Whether the operation requires a caller, and NOTHING else about the caller.
963
- *
964
- * **This used to pass `"account"` or `"resource"`, chosen by how many path parameters the
965
- * route had, and that was a rule no document states.** `@useAuth(NoAuth)` reaches OpenAPI as
966
- * `security: []`, so "does this need a caller" is a contract fact and is generated. "Is this
967
- * account-scoped or resource-scoped" is not: no OpenAPI keyword expresses it, and the
968
- * path-parameter heuristic happened to fit the first consumer.
969
- */
970
1530
  const body = [];
971
- if (route.noAuth !== true) {
972
- body.push(`\t\t\tconst ctx = deps.context(c, "required");`);
973
- body.push("\t\t\tif (ctx === null) return deps.noContext(c);");
974
- }
975
- else {
976
- /**
977
- * **The same null check as an authenticated route, and it is what removes a CAST from
978
- * generated output.** This used to emit `deps.context(c, "none") as Ctx`, because one
979
- * signature returning `C | null` cannot express "this argument makes null impossible". A cast
980
- * in generated code is worse than one in hand-written code: nobody reviews it, and it
981
- * reappears on every compile.
982
- *
983
- * **Overloading `context` was tried and is worse.** It removes the cast from here and puts
984
- * one in every consumer's `deps`, because an overloaded property type stops contextually
985
- * typing a single implementation. Measured, the wiring consumer lost inference on every
986
- * hook. Trading a cast in generated code for a cast in hand-written code is the wrong
987
- * direction.
988
- *
989
- * Checking is also more honest than asserting: an app that returns null here has said it
990
- * could not build a context, and the old cast handed the handler that null typed as `Ctx`.
991
- */
992
- body.push(`\t\t\tconst ctx = deps.context(c, "none");`);
993
- body.push("\t\t\tif (ctx === null) return deps.noContext(c);");
994
- }
995
1531
  // A dispatched body is in `validators` under the body target like any other, so there is
996
- // nothing extra to spread: `byContentType` published it there whichever parser ran.
1532
+ // nothing extra to spread: the body middleware published it there whichever parser ran.
997
1533
  // Same rule as `inputTypeOf`: the body is identified by its schema, not by its target.
998
1534
  const bodyTarget = validators.find(([, name]) => name === entry.names.body)?.[0];
999
1535
  const pieces = validators.map(([target]) =>
@@ -1011,7 +1547,16 @@ type Produced<T> = T extends (...args: never[]) => unknown
1011
1547
  const reader = rawBodyReaderFor(route.requestContentTypes);
1012
1548
  pieces.push(`${objectKey(route.rawBodyProperty)}: ${reader.call}`);
1013
1549
  }
1014
- const invoke = (member) => {
1550
+ /**
1551
+ * Call one operation's handler and serve what it returns, as statements at `depth` tabs.
1552
+ *
1553
+ * **Broken across lines rather than emitted as one.** Generated code is read far more often
1554
+ * than it is written (in review, in a stack trace, in a diff) and a single call reached 219
1555
+ * characters on a real service, against the 60-to-80 of every example in Hono's own
1556
+ * documentation. A call with no input stays on one line.
1557
+ */
1558
+ const serve = (member, depth) => {
1559
+ const indent = "\t".repeat(depth);
1015
1560
  // The member's own `accept` literal, which its input type requires and which the shared
1016
1561
  // validator no longer supplies. We know it exactly: it is the branch we are in.
1017
1562
  const own = group.length > 1 && member.route.accept !== undefined
@@ -1020,42 +1565,20 @@ type Produced<T> = T extends (...args: never[]) => unknown
1020
1565
  ]
1021
1566
  : [];
1022
1567
  const input = [...pieces, ...own];
1023
- /**
1024
- * **Broken across lines rather than emitted as one.** Generated code is read far more often
1025
- * than it is written (in review, in a stack trace, in a diff) and a single call reached 219
1026
- * characters on a real service, against the 60-to-80 of every example in Hono's own
1027
- * documentation. Nothing about the behaviour changes; a reader's ability to see it does.
1028
- *
1029
- * A call with no input stays on one line, because wrapping it would add ceremony to something
1030
- * already short.
1031
- */
1032
- /**
1033
- * **Indented literally, because the surrounding `+1 tab` only reaches the FIRST physical
1034
- * line.** A multi-line fragment keeps whatever tabs it was written with, so the depths here
1035
- * are absolute: the `return` sits at four, its arguments at five, and the handler's input
1036
- * properties at six.
1037
- */
1038
- const call = input.length === 0
1568
+ const call = `handlersFor(c).${member.route.operationId}(contextOf(c), {`;
1569
+ const invocation = input.length === 0
1039
1570
  ? // An empty object, because every operation takes `(ctx, input)`. See `EMPTY_INPUT`.
1040
- `handlersFor(c).${member.route.operationId}(ctx, {})`
1571
+ [`${indent}const result = await ${call}});`]
1041
1572
  : [
1042
- `handlersFor(c).${member.route.operationId}(ctx, {`,
1043
- ...input.map((piece) => `\t\t\t\t\t\t${piece},`),
1044
- "\t\t\t\t\t})",
1045
- ].join("\n");
1046
- return input.length === 0
1047
- ? `deps.respond(c, ${member.names.responses}, await ${call})`
1048
- : [
1049
- `deps.respond(`,
1050
- `\t\t\t\t\tc,`,
1051
- `\t\t\t\t\t${member.names.responses},`,
1052
- `\t\t\t\t\tawait ${call},`,
1053
- "\t\t\t\t)",
1054
- ].join("\n");
1573
+ `${indent}const result = await ${call}`,
1574
+ ...input.map((piece) => `${indent}\t${piece},`),
1575
+ `${indent}});`,
1576
+ ];
1577
+ return [...invocation, ...servingLines(member, depth, uses)];
1055
1578
  };
1056
1579
  /**
1057
1580
  * Both verbs on one path: `c.req.method` still reads `HEAD` after Hono's rewrite, so one
1058
- * registration serves both and each operation keeps its own handler and its own response arms.
1581
+ * registration serves both and each operation keeps its own handler and its own responses.
1059
1582
  * Hono strips the body on the HEAD branch itself.
1060
1583
  *
1061
1584
  * The HEAD branch is emitted FIRST because it is the narrower condition, and it returns, so the
@@ -1064,31 +1587,31 @@ type Produced<T> = T extends (...args: never[]) => unknown
1064
1587
  if (headBranch !== undefined) {
1065
1588
  const headEntry = headBranch[0];
1066
1589
  body.push(`\t\t\tif (c.req.method === "HEAD") {`);
1067
- body.push(`\t\t\t\treturn ${invoke(headEntry).replaceAll("\n", "\n\t")};`);
1590
+ body.push(...serve(headEntry, 4));
1068
1591
  body.push("\t\t\t}");
1069
1592
  }
1070
1593
  if (group.length === 1) {
1071
- body.push(`\t\t\treturn ${invoke(entry)};`);
1594
+ body.push(...serve(entry, 3));
1072
1595
  }
1073
1596
  else {
1074
1597
  /**
1075
- * Several operations, one route: the caller's `Accept` chooses which one answers.
1076
- *
1077
1598
  * The offered list and which operation serves each type are both read from the document.
1078
- * `selectContentType` applies RFC 9110 section 12.5.1 to them. It lives in the runtime rather than
1079
- * in `deps` because both halves are derivable, and an app forced to supply it would be
1599
+ * `selectContentType` applies RFC 9110 section 12.5.1 to them. It lives in the runtime rather
1600
+ * than in `deps` because both halves are derivable, and an app forced to supply it would be
1080
1601
  * re-implementing the standard.
1081
1602
  */
1082
- const offers = group.flatMap((member) => member.route.responseContentTypes.map((contentType) => ({ contentType, member })));
1083
- const offered = `[${offers.map((offer) => JSON.stringify(offer.contentType)).join(", ")}]`;
1084
1603
  body.push(`\t\t\tconst served = selectContentType(c.req.header("accept"), ${offered});`);
1085
- body.push(`\t\t\tif (served === undefined) return deps.notAcceptable(c, ${offered});`);
1086
1604
  for (const offer of offers) {
1087
- body.push(`\t\t\tif (served === ${JSON.stringify(offer.contentType)}) return ${invoke(offer.member)};`);
1605
+ body.push(`\t\t\tif (served === ${JSON.stringify(offer.contentType)}) {`);
1606
+ body.push(...serve(offer.member, 4));
1607
+ body.push("\t\t\t}");
1088
1608
  }
1089
- // `selectContentType` only ever returns a member of the list it was given, so this is
1090
- // unreachable, and stating that is cheaper than a cast that would hide it if it were not.
1091
- body.push(`\t\t\treturn deps.notAcceptable(c, ${offered});`);
1609
+ /**
1610
+ * Unreachable: `acceptable` refused every request whose `Accept` matches none of these, and
1611
+ * `selectContentType` only returns a member of the list it was given. Thrown rather than
1612
+ * answered, because a plain `Response` here would erase every typed response on the route.
1613
+ */
1614
+ body.push(`\t\t\tthrow new Error(${JSON.stringify(`typespec-hono: no operation on ${route.verb} ${route.path} serves the negotiated media type`)});`);
1092
1615
  }
1093
1616
  /**
1094
1617
  * **`app.on` takes the METHOD first, and we were not passing one.** Only five verbs have a
@@ -1115,7 +1638,7 @@ type Produced<T> = T extends (...args: never[]) => unknown
1115
1638
  "\t\t\t},",
1116
1639
  "\t\t)",
1117
1640
  ].join("\n");
1118
- return { target, text, headOnly };
1641
+ return { target, path: registeredPath, mountedPath: path, method, text, headOnly };
1119
1642
  });
1120
1643
  /**
1121
1644
  * Every identifier this file names, imported from the module that declares it.
@@ -1136,16 +1659,22 @@ type Produced<T> = T extends (...args: never[]) => unknown
1136
1659
  * Splitting on the language's own identifier rule and testing membership has neither failure: no
1137
1660
  * metacharacter can be misread, and `Foo` cannot match `FooExtra`.
1138
1661
  */
1139
- const rendered = [...registrations.map((r) => r.text), ...methods, ...aliases].join("\n");
1662
+ const rendered = [
1663
+ ...registrations.map((r) => r.text),
1664
+ ...resultTypes,
1665
+ ...methods,
1666
+ ...aliases,
1667
+ ].join("\n");
1140
1668
  const mentioned = new Set(rendered.match(/[A-Za-z_$][A-Za-z0-9_$]*/g) ?? []);
1141
1669
  const referenced = [
1142
1670
  ...new Set(entries.flatMap((entry) => [
1143
1671
  entry.names.path,
1144
1672
  entry.names.query,
1145
1673
  entry.names.header,
1674
+ entry.names.cookie,
1146
1675
  entry.names.body,
1147
- entry.names.response,
1148
- entry.names.responses,
1676
+ ...entry.names.arms.map((arm) => arm.schema),
1677
+ ...entry.names.arms.map((arm) => arm.headers),
1149
1678
  ].filter((name) => name !== undefined))),
1150
1679
  ]
1151
1680
  .filter((identifier) => mentioned.has(identifier))
@@ -1173,6 +1702,27 @@ type Produced<T> = T extends (...args: never[]) => unknown
1173
1702
  * bottom". A rule a reordering could break. A single expression cannot be reordered wrongly: the
1174
1703
  * sub-app is complete at the point it is mounted because it is its own initialiser.
1175
1704
  */
1705
+ /**
1706
+ * **A concrete path is registered before a templated one it would otherwise lose to.**
1707
+ *
1708
+ * Hono runs the first registered handler that matches, so `GET /items/:id` registered before
1709
+ * `GET /items/plain` answered every request for `/items/plain` - measured, with the wrong handler's
1710
+ * body. OpenAPI's Paths Object states the opposite rule: "concrete (non-templated) paths would be
1711
+ * matched before their templated counterparts". Ordering the registrations is how a router that
1712
+ * matches in order applies it. Otherwise the document's own order is kept, because the sort is
1713
+ * stable.
1714
+ */
1715
+ registrations.sort((a, b) => {
1716
+ const left = a.path.split("/");
1717
+ const right = b.path.split("/");
1718
+ for (let index = 0; index < Math.min(left.length, right.length); index++) {
1719
+ const leftTemplated = (left[index] ?? "").startsWith(":");
1720
+ const rightTemplated = (right[index] ?? "").startsWith(":");
1721
+ if (leftTemplated !== rightTemplated)
1722
+ return leftTemplated ? 1 : -1;
1723
+ }
1724
+ return 0;
1725
+ });
1176
1726
  const byTarget = new Map();
1177
1727
  for (const registration of registrations) {
1178
1728
  byTarget.set(registration.target, [
@@ -1199,6 +1749,7 @@ type Produced<T> = T extends (...args: never[]) => unknown
1199
1749
  const negotiates = [...grouped.values()].some((group) => group.length > 1);
1200
1750
  // Same rule: imported only where a HEAD operation stands alone on its path.
1201
1751
  const guardsHead = registrations.some((registration) => registration.headOnly);
1752
+ const guardsQuery = emitted.routes.some((route) => route.literalQuery.length > 0);
1202
1753
  /**
1203
1754
  * **Which runtime imports to write is read from the DATA, never from the rendered text.**
1204
1755
  *
@@ -1242,8 +1793,9 @@ type Produced<T> = T extends (...args: never[]) => unknown
1242
1793
  const usesZod = mountsBody ||
1243
1794
  // `SYNC` annotates its schema parameter `z.ZodType`, so the value import is load-bearing.
1244
1795
  validates ||
1245
- entries.some((entry) => inputTypeOf(entry) !== undefined || entry.names.response !== undefined);
1246
- const runtimeModule = JSON.stringify(emitted.options.runtimeModule);
1796
+ entries.some((entry) => inputTypeOf(entry) !== undefined) ||
1797
+ returnsAnything;
1798
+ const runtimeModule = JSON.stringify(DEFAULT_RUNTIME_MODULE);
1247
1799
  /**
1248
1800
  * **One base sub-app, mounted with `app.route()`. Hono's own nesting, not a rewritten path on
1249
1801
  * every registration.** Prefixing each path individually would fight the resource grouping, and
@@ -1253,17 +1805,59 @@ type Produced<T> = T extends (...args: never[]) => unknown
1253
1805
  */
1254
1806
  const usesBasePath = basePaths.length > 0;
1255
1807
  const needsHonoValue = subApps.size > 0 || usesBasePath;
1808
+ /**
1809
+ * Hono's status-code types the result unions name, read from the rendered unions by TOKEN, the
1810
+ * same way the validator identifiers above are: an identifier absent from the text is genuinely
1811
+ * not needed, so the check cannot be wrong in the direction that breaks a build.
1812
+ */
1813
+ const renderedResults = new Set(resultTypes.join("\n").match(/[A-Za-z_$][A-Za-z0-9_$]*/g) ?? []);
1814
+ const statusTypes = [
1815
+ "ClientErrorStatusCode",
1816
+ "ContentfulStatusCode",
1817
+ "ContentlessStatusCode",
1818
+ "InfoStatusCode",
1819
+ "RedirectStatusCode",
1820
+ "ServerErrorStatusCode",
1821
+ "StatusCode",
1822
+ "SuccessStatusCode",
1823
+ "UnofficialStatusCode",
1824
+ ].filter((name) => renderedResults.has(name));
1825
+ const operates = entries.length > 0;
1826
+ const honoTypes = [
1827
+ "Context",
1828
+ ...(mountsBody ? ["Env"] : []),
1829
+ ...(needsHonoValue ? [] : ["Hono"]),
1830
+ "Input",
1831
+ ...(operates || mountsBody ? ["MiddlewareHandler"] : []),
1832
+ ];
1833
+ const runtimeValues = [
1834
+ ...uses.runtime,
1835
+ ...(negotiates ? ["selectContentType"] : []),
1836
+ ...(guardsHead ? ["headOnly"] : []),
1837
+ ...(guardsQuery ? ["literalQuery"] : []),
1838
+ ].toSorted();
1839
+ const runtimeTypes = ["AppEnv", ...(operates ? ["Awaitable"] : []), "RouteDeps"];
1256
1840
  return `${generatedBanner(emitted.options.regenerateHint)}
1257
- ${validates ? 'import { zValidator } from "@hono/zod-validator";\n' : ""}${needsHonoValue ? 'import { Hono } from "hono";\nimport type { Context, Input } from "hono";' : 'import type { Context, Hono, Input } from "hono";'}${mountsBody ? '\nimport type { Env, MiddlewareHandler } from "hono";' : ""}
1258
- ${usesZod ? 'import { z } from "zod";\n' : ""}import type { AppEnv, Awaitable, Ctx, Result, RouteDeps } from ${runtimeModule};${negotiates ? `\nimport { selectContentType } from ${runtimeModule};` : ""}${guardsHead ? `\nimport { headOnly } from ${runtimeModule};` : ""}
1841
+ ${validates ? 'import { zValidator } from "@hono/zod-validator";\n' : ""}${needsHonoValue ? 'import { Hono } from "hono";\n' : ""}import type { ${honoTypes.join(", ")} } from "hono";
1842
+ ${statusTypes.length > 0 ? `import type { ${statusTypes.join(", ")} } from "hono/utils/http-status";\n` : ""}${usesZod ? 'import { z } from "zod";\n' : ""}import type { ${runtimeTypes.join(", ")} } from ${runtimeModule};${runtimeValues.length > 0 ? `\nimport { ${runtimeValues.join(", ")} } from ${runtimeModule};` : ""}
1259
1843
  ${imports}
1844
+ ${guardedParse}${syncHelper}${bodyHelper}${fieldsHelper}${declaredHelper}/**
1845
+ * What each operation may answer with: one member per response the document declares.
1846
+ *
1847
+ * A handler returns \`{ status, body, headers }\` for whichever response it means. The generated route
1848
+ * serves it with the Hono call for that status, so \`hc\` sees a typed body per status, and a status
1849
+ * or body the document does not declare does not compile.
1850
+ */
1851
+ ${resultTypes.join("\n\n")}
1852
+
1260
1853
  /**
1261
- * One method per operation, each concretely typed from the schemas it validates against.
1854
+ * One handler per operation, each concretely typed from the schemas it validates against.
1262
1855
  *
1263
- * There is no cast anywhere in this file, and no dynamic lookup: the generated call sites name the
1264
- * method, so an implementation whose input or output does not match the contract fails to compile.
1856
+ * \`C\` is the caller context, inferred by \`registerRoutes\` from \`deps.context\`. There is no cast
1857
+ * anywhere in this file, and no dynamic lookup: the generated call sites name the handler, so an
1858
+ * implementation whose input or result does not match the contract fails to compile.
1265
1859
  */
1266
- ${syncHelper}${bodyHelper}${fieldsHelper}${declaredHelper}export interface Operations {
1860
+ export interface Operations<C = unknown> {
1267
1861
  ${methods.join("\n")}
1268
1862
  }
1269
1863
 
@@ -1304,12 +1898,12 @@ ${aliases.join("\n")}
1304
1898
  export type Exhaustive<T> = T & Record<Exclude<keyof T, keyof Operations>, never>;
1305
1899
 
1306
1900
  /** Mount every operation the service declares. */
1307
- export function registerRoutes<T extends Operations>(
1901
+ export function registerRoutes<C, T extends Operations<C>>(
1308
1902
  app: Hono<AppEnv>,
1309
1903
  handlersFor: <P extends string, I extends Input>(c: Context<AppEnv, P, I>) => Exhaustive<T>,
1310
- deps: RouteDeps,
1904
+ deps: RouteDeps<AppEnv, C>,
1311
1905
  ) {
1312
- ${subAppDeclarations}${usesBasePath
1906
+ ${operates ? CONTEXT_MIDDLEWARE : ""}${negotiates ? ACCEPTABLE_MIDDLEWARE : ""}${subAppDeclarations}${usesBasePath
1313
1907
  ? `\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("")};`
1314
1908
  : `\treturn app\n${rootChain.join("\n")};`}
1315
1909
  }