typespec-hono 0.21.0 → 0.23.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,266 @@
1
- import { renderSecurity } from "./security.js";
2
- import { isRawBinaryMediaType, objectKey, } from "typespec-http-zod";
1
+ import { isRawBinaryMediaType, jsDocComment, objectKey, } from "typespec-http-zod";
2
+ /**
3
+ * Hono's status codes, group by group, exactly as `hono/utils/http-status` declares them.
4
+ *
5
+ * **The generated route needs the LITERALS, not only the type.** A range arm is served from a
6
+ * `switch` on the result's status, and TypeScript narrows the result to one member only through a
7
+ * `case` label per literal: a type guard narrows the status and leaves the result a union, and
8
+ * merging the range into `default` pairs every body with every status. Both measured on hono 4.13.1.
9
+ *
10
+ * `test/status-codes.test.ts` holds these equal to Hono's own unions, so a status Hono adds fails a
11
+ * test rather than silently falling out of a range.
12
+ */
13
+ export const STATUS_GROUPS = {
14
+ 1: { type: "InfoStatusCode", codes: [100, 101, 102, 103] },
15
+ 2: { type: "SuccessStatusCode", codes: [200, 201, 202, 203, 204, 205, 206, 207, 208, 226] },
16
+ 3: { type: "RedirectStatusCode", codes: [300, 301, 302, 303, 304, 305, 306, 307, 308] },
17
+ 4: {
18
+ type: "ClientErrorStatusCode",
19
+ codes: [
20
+ 400, 401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 414, 415, 416, 417, 418,
21
+ 421, 422, 423, 424, 425, 426, 428, 429, 431, 451,
22
+ ],
23
+ },
24
+ 5: {
25
+ type: "ServerErrorStatusCode",
26
+ codes: [500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511],
27
+ },
28
+ };
29
+ /**
30
+ * Where every generated file imports its runtime from: the copy this package writes beside them.
31
+ *
32
+ * Declared here, where the import is rendered, and re-exported by the emitter that writes the file.
33
+ */
34
+ export const DEFAULT_RUNTIME_MODULE = "./runtime.gen.js";
35
+ /** Hono's `ContentlessStatusCode`: a status that cannot carry a body, so `c.json` refuses it. */
36
+ export const CONTENTLESS_STATUS_CODES = [101, 204, 205, 304];
37
+ /** A media type whose body is a JSON value, served with `c.json`. The library's own rule. */
38
+ function isJsonMediaType(type) {
39
+ return /^application\/(json|.*\+json)$/.test(type);
40
+ }
41
+ function bodyServingOf(member) {
42
+ const { response, mediaType } = member;
43
+ if (response.schema === undefined || mediaType === undefined)
44
+ return "none";
45
+ if (response.streamed)
46
+ return "stream";
47
+ if (response.binary)
48
+ return "binary";
49
+ if (isJsonMediaType(mediaType))
50
+ return "json";
51
+ /**
52
+ * **A string body under a non-JSON media type IS the text**, so it is validated and served as is.
53
+ * Any other body under one - a model as `application/xml` - has no serialisation this emitter can
54
+ * derive, so the handler supplies the text and it is served unvalidated, which the
55
+ * `unvalidated-response-media-type` warning says out loud.
56
+ */
57
+ return response.textual ? "text" : "unvalidated";
58
+ }
59
+ function membersOf(entry) {
60
+ return entry.route.responses.flatMap((response, index) => {
61
+ const schemaName = entry.names.arms[index]?.schema;
62
+ if (response.contentTypes.length === 0) {
63
+ return [{ response, schemaName, mediaType: undefined, choosesMediaType: false }];
64
+ }
65
+ /**
66
+ * The handler names the media type where the status offers several, and where the document names
67
+ * only a wildcard range, which is not a type a response can be sent as.
68
+ */
69
+ const choosesMediaType = response.contentTypes.length > 1 || response.contentTypes.some(isMediaRange);
70
+ return response.contentTypes.map((mediaType) => ({
71
+ response,
72
+ schemaName,
73
+ mediaType,
74
+ choosesMediaType,
75
+ }));
76
+ });
77
+ }
78
+ /** A media range such as `image/*`, rather than a type a response can be sent as. */
79
+ function isMediaRange(mediaType) {
80
+ return mediaType.endsWith("/*");
81
+ }
82
+ /**
83
+ * The type of the `contentType` a handler names for a member.
84
+ *
85
+ * **A range is typed as the types inside it**: `image/*` is `` `image/${string}` ``, so `text/html`
86
+ * under it does not compile, and TypeScript still narrows a literal member away from it. Only the
87
+ * full range is `string`.
88
+ */
89
+ function contentTypeTypeOf(mediaType) {
90
+ if (!isMediaRange(mediaType))
91
+ return JSON.stringify(mediaType);
92
+ if (mediaType === "*/*")
93
+ return "string";
94
+ return `\`${mediaType.slice(0, -1)}\${string}\``;
95
+ }
96
+ /** The Hono group a status key's codes belong to. */
97
+ function groupOf(status) {
98
+ const digit = typeof status === "number" ? Math.floor(status / 100) : Number(String(status)[0]);
99
+ return digit >= 1 && digit <= 5 ? STATUS_GROUPS[digit] : undefined;
100
+ }
101
+ /**
102
+ * The codes a status key answers with, as `case` labels, or `"default"` for the catch-all.
103
+ *
104
+ * **A range excludes what is declared more precisely**, because OpenAPI resolves an exact code before
105
+ * its range and `armFor` does the same: a `404` beside a `4XX` is the `404`'s. A bodied range also
106
+ * excludes the statuses that cannot carry a body.
107
+ */
108
+ function caseLabelsOf(response, route) {
109
+ const { status } = response;
110
+ if (status === "default")
111
+ return "default";
112
+ if (typeof status === "number")
113
+ return [status];
114
+ const exact = new Set(route.responses.flatMap((candidate) => typeof candidate.status === "number" ? [candidate.status] : []));
115
+ const contentless = new Set(response.schema === undefined ? [] : CONTENTLESS_STATUS_CODES);
116
+ return (groupOf(status)?.codes ?? []).filter((code) => !exact.has(code) && !contentless.has(code));
117
+ }
118
+ /**
119
+ * Serve a handler's `result` for every response the operation declares, as statements at `depth`.
120
+ *
121
+ * **One `case` per status literal, and one Hono call per declared response.** `c.json(body, 404)` is
122
+ * what gives `hc` a typed body for a 404, so the route serves each status through its own call rather
123
+ * than through one call over the union, which measured as a single endpoint pairing every body with
124
+ * every status.
125
+ *
126
+ * **Every served body is the PARSED body.** `servedBody` checks it against the schema the document
127
+ * publishes for that status and throws `ResponseContractError` when it does not match, failures
128
+ * included, and what is sent is the parse result - so a field the schema does not declare does not
129
+ * reach the wire.
130
+ */
131
+ function servingLines(entry, depth, uses) {
132
+ const indent = "\t".repeat(depth);
133
+ const operationId = JSON.stringify(entry.route.operationId);
134
+ const members = membersOf(entry);
135
+ const lines = [`${indent}switch (result.status) {`];
136
+ let servesDefault = false;
137
+ for (const response of entry.route.responses) {
138
+ const labels = caseLabelsOf(response, entry.route);
139
+ if (labels !== "default" && labels.length === 0)
140
+ continue;
141
+ if (labels === "default") {
142
+ servesDefault = true;
143
+ lines.push(`${indent}\tdefault: {`);
144
+ }
145
+ else {
146
+ lines.push(...labels.map((code) => `${indent}\tcase ${code}:`));
147
+ lines[lines.length - 1] = `${lines.at(-1) ?? ""} {`;
148
+ }
149
+ const status = labels !== "default" && labels.length === 1 ? String(labels[0]) : "result.status";
150
+ const own = members.filter((member) => member.response === response);
151
+ if (!own.some((member) => member.choosesMediaType)) {
152
+ for (const member of own) {
153
+ lines.push(`${indent}\t\t${serveCall(member, status, operationId, uses)}`);
154
+ }
155
+ }
156
+ else {
157
+ /**
158
+ * **Exact types by `case`, then ranges by `mediaTypeWithin`, then a throw.** A `case "image/*"`
159
+ * would compare the range's own spelling and never match the `image/png` the handler is typed
160
+ * to name. Every `case` returns, so after the switch TypeScript has already narrowed the
161
+ * result to the range members, whose bodies are served the same way.
162
+ */
163
+ const exact = own.filter((member) => member.mediaType !== undefined && !isMediaRange(member.mediaType));
164
+ if (exact.length > 0) {
165
+ lines.push(`${indent}\t\tswitch (result.contentType) {`);
166
+ for (const member of exact) {
167
+ lines.push(`${indent}\t\t\tcase ${JSON.stringify(member.mediaType)}:`);
168
+ lines.push(`${indent}\t\t\t\t${serveCall(member, status, operationId, uses)}`);
169
+ }
170
+ lines.push(`${indent}\t\t}`);
171
+ }
172
+ for (const member of own) {
173
+ if (member.mediaType === undefined || !isMediaRange(member.mediaType))
174
+ continue;
175
+ uses.runtime.add("mediaTypeWithin");
176
+ lines.push(`${indent}\t\tif (mediaTypeWithin(result.contentType, ${JSON.stringify(member.mediaType)})) ${serveCall(member, status, operationId, uses)}`);
177
+ }
178
+ uses.runtime.add("UndeclaredStatusError");
179
+ lines.push(`${indent}\t\tthrow new UndeclaredStatusError(${operationId}, result);`);
180
+ }
181
+ lines.push(`${indent}\t}`);
182
+ }
183
+ if (!servesDefault) {
184
+ /**
185
+ * **Unreachable from a typed handler**: every declared status has a `case`, so `result` is
186
+ * `never` here. A cast, or untyped data from a service binding, is what arrives - and serving
187
+ * it would publish a status the document does not declare.
188
+ */
189
+ uses.runtime.add("UndeclaredStatusError");
190
+ lines.push(`${indent}\tdefault:`);
191
+ lines.push(`${indent}\t\tthrow new UndeclaredStatusError(${operationId}, result);`);
192
+ }
193
+ lines.push(`${indent}}`);
194
+ return lines;
195
+ }
196
+ /** The one Hono call that serves one member, as a `return` statement. */
197
+ function serveCall(member, status, operationId, uses) {
198
+ const { response, mediaType } = member;
199
+ const serving = bodyServingOf(member);
200
+ const headers = [];
201
+ /**
202
+ * **`Content-Type` wherever `c.json` would not already say it.** `c.json` writes
203
+ * `application/json`; a `+json` type such as `application/problem+json` has to be stated, and
204
+ * `c.body` states nothing. The key is spelled `Content-Type` because Hono merges its own default
205
+ * under exactly that key, so a lowercase spelling would send both.
206
+ */
207
+ if (mediaType !== undefined && serving !== "none" && mediaType !== "application/json") {
208
+ const value = member.choosesMediaType || mediaType.includes("*")
209
+ ? "result.contentType"
210
+ : JSON.stringify(mediaType);
211
+ headers.push(`"Content-Type": ${value}`);
212
+ }
213
+ const allOptional = response.headers.every((header) => header.optional);
214
+ for (const header of response.headers) {
215
+ const key = JSON.stringify(header.name);
216
+ headers.push(`${key}: result.headers${allOptional ? "?." : ""}[${key}]`);
217
+ }
218
+ if (headers.length > 0)
219
+ uses.runtime.add("headersOf");
220
+ const headersArgument = headers.length === 0 ? "" : `, headersOf({ ${headers.join(", ")} })`;
221
+ const validated = () => {
222
+ uses.runtime.add("servedBody");
223
+ return `servedBody(${member.schemaName ?? "undefined"}, result.body, ${operationId}, ${status})`;
224
+ };
225
+ switch (serving) {
226
+ case "none":
227
+ return `return c.body(null, ${status}${headersArgument});`;
228
+ case "json":
229
+ return `return c.json(${validated()}, ${status}${headersArgument});`;
230
+ case "text":
231
+ return `return c.body(${validated()}, ${status}${headersArgument});`;
232
+ default:
233
+ return `return c.body(result.body, ${status}${headersArgument});`;
234
+ }
235
+ }
236
+ /**
237
+ * A member's status as a TypeScript type: disjoint from every other member's, by construction.
238
+ *
239
+ * **Disjoint is the property that makes a wrong body a compile error.** Were `default` allowed to
240
+ * overlap a declared status, `{ status: 404, body: <the default arm's body> }` would type-check
241
+ * through the `default` member; measured, and the generated switch stopped compiling as well.
242
+ */
243
+ function statusTypeOf(response, route) {
244
+ const { status } = response;
245
+ if (typeof status === "number")
246
+ return String(status);
247
+ const exact = route.responses.flatMap((candidate) => typeof candidate.status === "number" ? [String(candidate.status)] : []);
248
+ if (status !== "default") {
249
+ const group = groupOf(status);
250
+ if (group === undefined)
251
+ return "never";
252
+ const excluded = [
253
+ ...exact.filter((code) => groupOf(Number(code)) === group),
254
+ ...(response.schema === undefined ? [] : ["ContentlessStatusCode"]),
255
+ ];
256
+ return excluded.length === 0 ? group.type : `Exclude<${group.type}, ${excluded.join(" | ")}>`;
257
+ }
258
+ const ranges = route.responses.flatMap((candidate) => typeof candidate.status === "string" && candidate.status !== "default"
259
+ ? [groupOf(candidate.status)?.type ?? "never"]
260
+ : []);
261
+ const base = response.schema === undefined ? "StatusCode" : "ContentfulStatusCode";
262
+ return `Exclude<${base}, ${["InfoStatusCode", "UnofficialStatusCode", ...exact, ...ranges].join(" | ")}>`;
263
+ }
3
264
  /**
4
265
  * The header every emitted file carries.
5
266
  *
@@ -16,56 +277,80 @@ export function generatedBanner(hint) {
16
277
  }
17
278
  /** A parameter name Hono can carry verbatim. Measured against Hono, not assumed. */
18
279
  const PLAIN_PATH_PARAMETER = /^[A-Za-z0-9_.~-]+$/;
280
+ /** Text matched literally inside a Hono `{...}` parameter pattern. */
281
+ function escapeRegExp(text) {
282
+ return text.replace(/[.*+?^$()|[\]\\]/g, "\\$&");
283
+ }
19
284
  /**
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.
41
- */
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.
285
+ * The Hono route for a route template, from the segments `typespec-http-zod` reads out of the
286
+ * operation's RFC 6570 `uriTemplate`.
287
+ *
288
+ * **This used to convert `route.path`, which has every operator stripped**, so `array{.param*}`,
289
+ * `array{;param}` and `optional{/name}` were mounted as `array:param` and `optional:name`, which Hono
290
+ * does not match at all. Measured by request: 31 of the URIs `@typespec/http-specs` `routes` and
291
+ * `parameters/path` declare answered 404 from a server generated from them.
292
+ *
293
+ * - A whole-segment expression is `:name`; a reserved or exploding `/` one crosses `/`, so it is
294
+ * `:name{.+}` (Hono's spelling of greedy); an optional last segment is `:name?`.
295
+ * - An expression written beside literal text, or with a label or matrix operator, is a pattern
296
+ * parameter matching the whole segment: `:param{array\.[^\x2F]*}`. The captured text is the
297
+ * segment, and the path validator undoes the expansion. `\x2F` rather than `/`, so the mounted
298
+ * path still splits into its segments on `/` for the ordering and sub-app rules below.
299
+ *
300
+ * **A name Hono cannot carry is REFUSED rather than approximated**, and so is a segment holding two
301
+ * expressions, which no segment router can split. The segment stays literal, so it matches nothing
302
+ * rather than the wrong thing. A space, `+` and `!` are the characters that fail; a hyphen, a dot and
303
+ * a tilde do not.
304
+ *
305
+ * **This runs at RENDER time, not during collection**, so what a request body must look like never
306
+ * depends on whether one framework's router can express the path.
54
307
  */
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
- */
308
+ export function toHonoPath(segments, refuse) {
309
+ const template = `/${segments
310
+ .map((segment) => segment.kind === "expression"
311
+ ? `${segment.prefix}{${segment.operator}${segment.parameter}${segment.explode ? "*" : ""}}${segment.suffix}`
312
+ : segment.text)
313
+ .join("/")}`;
314
+ const parts = segments.map((segment, index) => {
315
+ if (segment.kind === "literal")
316
+ return segment.text;
317
+ if (segment.kind === "unsupported") {
318
+ refuse(template, segment.text);
319
+ return segment.text;
320
+ }
321
+ const name = segment.parameter;
63
322
  if (!PLAIN_PATH_PARAMETER.test(name)) {
64
323
  refuse(template, name);
65
- return match;
324
+ return `{${name}}`;
325
+ }
326
+ const whole = segment.prefix === "" &&
327
+ segment.suffix === "" &&
328
+ segment.operator !== "." &&
329
+ segment.operator !== ";";
330
+ if (whole) {
331
+ if (segment.reserved || (segment.operator === "/" && segment.explode))
332
+ return `:${name}{.+}`;
333
+ return segment.optional && index === segments.length - 1 ? `:${name}?` : `:${name}`;
66
334
  }
67
- return reserved.has(name) ? `:${name}{.+}` : `:${name}`;
335
+ const leader = segment.operator === "." || segment.operator === ";" ? escapeRegExp(segment.operator) : "";
336
+ const body = segment.reserved ? ".*" : "[^\\x2F]*";
337
+ return `:${name}{${escapeRegExp(segment.prefix)}${leader}${body}${escapeRegExp(segment.suffix)}}`;
68
338
  });
339
+ return `/${parts.join("/")}`;
340
+ }
341
+ /**
342
+ * The document's `security` as a TypeScript literal, for the generated `authorize` call. `{}` is the
343
+ * anonymous alternative, written out so an `authorize` applying the documented rule (any one
344
+ * requirement, every scheme within it) admits a caller who presents nothing.
345
+ */
346
+ function renderSecurity(requirements) {
347
+ return `[${requirements
348
+ .map((requirement) => Object.keys(requirement).length === 0
349
+ ? "{}"
350
+ : `{ ${Object.entries(requirement)
351
+ .map(([scheme, scopes]) => `${JSON.stringify(scheme)}: [${scopes.map((scope) => JSON.stringify(scope)).join(", ")}]`)
352
+ .join(", ")} }`)
353
+ .join(", ")}]`;
69
354
  }
70
355
  /**
71
356
  * The verb Hono actually dispatches under.
@@ -120,12 +405,23 @@ const VALIDATOR_TARGET = {
120
405
  * justifies, so it keeps the existing behaviour rather than acquiring a new one on the way past. That
121
406
  * gap is real and is stated in the README rather than papered over here.
122
407
  */
123
- function targetForMediaType(type) {
408
+ function targetForMediaType(type, textual) {
124
409
  if (type === "application/x-www-form-urlencoded" || type.startsWith("multipart/"))
125
410
  return "form";
126
411
  // `application/json`, and the `+json` structured suffix RFC 6839 defines.
127
412
  if (type === "application/json" || type.endsWith("+json"))
128
413
  return VALIDATOR_TARGET.body;
414
+ /**
415
+ * **A body the document says IS text is read as text**, `@body body: string` under `text/plain`
416
+ * being the ordinary case. `requestTextual` is the library's answer about the TYPE, so the same
417
+ * media type carrying a model stays unparseable and keeps its warning: `text/plain` says how the
418
+ * value is framed, not what it is.
419
+ *
420
+ * Measured before this existed: such a route emitted no body middleware at all, so the body was
421
+ * never read and the handler was called with an empty input.
422
+ */
423
+ if (textual)
424
+ return "text";
129
425
  return undefined;
130
426
  }
131
427
  /**
@@ -146,13 +442,13 @@ function targetForMediaType(type) {
146
442
  * package should make. Naming them is what stops a consumer believing a route is validated when it
147
443
  * is not -- the previous behaviour said nothing and rejected them.
148
444
  */
149
- function bodyValidationFor(contentTypes) {
445
+ function bodyValidationFor(contentTypes, textual) {
150
446
  if (contentTypes.length === 0)
151
447
  return { byType: [["", VALIDATOR_TARGET.body]], unparseable: [] };
152
448
  const byType = [];
153
449
  const unparseable = [];
154
450
  for (const type of contentTypes) {
155
- const target = targetForMediaType(type);
451
+ const target = targetForMediaType(type, textual);
156
452
  if (target === undefined)
157
453
  unparseable.push(type);
158
454
  else
@@ -164,9 +460,9 @@ function bodyValidationFor(contentTypes) {
164
460
  * The request-body middleware, emitted INTO the generated file rather than imported from the runtime
165
461
  * module.
166
462
  *
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
463
+ * **Because the runtime module was a contract too, and it was the one that broke quietly.** An
464
+ * application could point `runtime-module` at a module of its own, and everything the generated file
465
+ * imported from there was something that application had to supply. A required, single-media-type body
170
466
  * used to be mounted by `zValidator`, which throws `HTTPException` on a body it cannot read - a
171
467
  * `text/plain` 400 raised before `deps.invalid` is called, so an API whose document declares a JSON
172
468
  * error envelope answered a shape its own contract forbids. Routing those through the runtime's
@@ -222,7 +518,7 @@ const SYNC = { validationFunction: (schema: z.ZodType, value: unknown) => schema
222
518
 
223
519
  `;
224
520
  const BODY_READER = `/** The two ways Hono can read a request body: \`c.req.json()\` and \`c.req.parseBody()\`. */
225
- type BodyTarget = "json" | "form";
521
+ type BodyTarget = "json" | "form" | "text";
226
522
 
227
523
  /**
228
524
  * A body that is not what its content type claims.
@@ -236,7 +532,10 @@ const UNREADABLE = Symbol("a body that is not what its content type claims");
236
532
 
237
533
  async function readBody(c: Context, target: BodyTarget): Promise<unknown> {
238
534
  try {
239
- return target === "form" ? await c.req.parseBody({ all: true }) : await c.req.json();
535
+ if (target === "form") return await c.req.parseBody({ all: true });
536
+ // A text body IS the text: it is validated as the string the caller sent, not parsed first.
537
+ if (target === "text") return await c.req.text();
538
+ return await c.req.json();
240
539
  } catch {
241
540
  return UNREADABLE;
242
541
  }
@@ -375,6 +674,47 @@ function validateOptionalBody<E extends Env, S extends z.ZodType>(
375
674
  };
376
675
  }
377
676
 
677
+ `;
678
+ /**
679
+ * The caller context, established in middleware and handed to the handler.
680
+ *
681
+ * **Middleware, because a refusal returned inside the final handler erases the route's typed
682
+ * responses** - see `registrations`. **A per-request `WeakMap`, because `c.set` cannot carry it
683
+ * without leaking into the application's types.** Setting a variable needs the route's environment to
684
+ * declare it, and Hono's `Context` is invariant in its environment through `set`: a generated
685
+ * extension made every \`deps\` hook the application wrote against its own environment unassignable,
686
+ * measured as \`TS2345\` at the application's call site. The map keys on the request's own
687
+ * \`Context\`, which Hono passes unchanged from middleware to handler, including through a mounted
688
+ * sub-app.
689
+ */
690
+ const CONTEXT_MIDDLEWARE = `\tconst contexts = new WeakMap<object, { readonly value: C }>();
691
+ const contextFor =
692
+ (authentication: "none" | "optional" | "required"): MiddlewareHandler<AppEnv> =>
693
+ async (c, next) => {
694
+ const ctx = deps.context(c, authentication);
695
+ if (ctx === null) return deps.noContext(c);
696
+ contexts.set(c, { value: ctx });
697
+ await next();
698
+ return undefined;
699
+ };
700
+ const contextOf = (c: object): C => {
701
+ const held = contexts.get(c);
702
+ if (held === undefined) throw new Error("typespec-hono: no caller context was established for this request");
703
+ return held.value;
704
+ };
705
+
706
+ `;
707
+ /** A caller accepting none of the media types a negotiated route offers is refused here. */
708
+ const ACCEPTABLE_MIDDLEWARE = `\tconst acceptable =
709
+ (offered: readonly string[]): MiddlewareHandler<AppEnv> =>
710
+ async (c, next) => {
711
+ if (selectContentType(c.req.header("accept"), offered) === undefined) {
712
+ return deps.notAcceptable(c, offered);
713
+ }
714
+ await next();
715
+ return undefined;
716
+ };
717
+
378
718
  `;
379
719
  /**
380
720
  * How an unparsed body reaches the handler, and as what.
@@ -558,16 +898,7 @@ export function renderApp(emitted, refuse,
558
898
  * the document publishes `/api/v1/accounts`. Mounting at the root made every client generated from
559
899
  * the document, and every "try it" in a rendered document, 404.
560
900
  */
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) {
901
+ basePaths = []) {
571
902
  const mounted = emitted.routes.flatMap((route) => {
572
903
  const names = emitted.schemaNames.get(route.operationId);
573
904
  // The library declares a `Responses` const for every operation, so a missing entry is a bug in
@@ -590,7 +921,7 @@ securityFor) {
590
921
  * got. All three are now one emitted middleware; what the document decides is only the
591
922
  * branches it is given and whether an absent body is permitted.
592
923
  */
593
- const validation = bodyValidationFor(route.requestContentTypes);
924
+ const validation = bodyValidationFor(route.requestContentTypes, route.requestTextual);
594
925
  if (validation.unparseable.length > 0) {
595
926
  refuse.unvalidatableMediaType(route, validation.unparseable);
596
927
  }
@@ -685,8 +1016,82 @@ type Fields<T> = string extends keyof T ? ([T[string]] extends [never] ? unknown
685
1016
  */
686
1017
  const validates = entries.some((entry) => entry.validators.filter(([target]) => entry.body === undefined || target !== VALIDATOR_TARGET.body).length > 0);
687
1018
  const syncHelper = validates ? SYNC_PARSE : "";
1019
+ /**
1020
+ * **One result type per operation: the union of every response its document declares.**
1021
+ *
1022
+ * A handler returns `{ status, body, headers }` for whichever response it means, success or
1023
+ * failure. That is `c.json(body, status, headers)` as data - what `@hono/zod-openapi` requires of a
1024
+ * handler as a union of typed responses - and the document's own Responses Object: keyed by status,
1025
+ * each with its body and its headers.
1026
+ *
1027
+ * **The return type used to be the SUCCESS body alone.** The arms named every failure the document
1028
+ * declares and no handler could return one, so failures were thrown past the generated code into
1029
+ * `onError`, where no declared status or body was checked. Nine of a real operation's ten arms were
1030
+ * unreachable that way.
1031
+ *
1032
+ * Data rather than a `Response`, so a handler stays transport-neutral: the same object can cross a
1033
+ * Workers service binding and serve `typespec-http-mcp`'s tools.
1034
+ */
1035
+ const resultTypes = entries.map((entry) => {
1036
+ for (const response of entry.route.responses) {
1037
+ const unvalidated = membersOf(entry)
1038
+ .filter((member) => member.response === response && bodyServingOf(member) === "unvalidated")
1039
+ .flatMap((member) => (member.mediaType === undefined ? [] : [member.mediaType]));
1040
+ if (unvalidated.length > 0) {
1041
+ refuse.unvalidatedResponseMediaType(entry.route, response.status, unvalidated);
1042
+ }
1043
+ }
1044
+ const members = membersOf(entry).map((member) => {
1045
+ const { response } = member;
1046
+ const fields = [`readonly status: ${statusTypeOf(response, entry.route)}`];
1047
+ if (member.choosesMediaType && member.mediaType !== undefined) {
1048
+ fields.push(`readonly contentType: ${contentTypeTypeOf(member.mediaType)}`);
1049
+ }
1050
+ /**
1051
+ * **What the handler SUPPLIES for this body, which is not always what the schema infers.** A
1052
+ * stream or raw binary body is handed to Hono unread, a model under a non-JSON media type is text
1053
+ * the handler serialised, and everything else is the producer's view of the schema.
1054
+ */
1055
+ switch (bodyServingOf(member)) {
1056
+ case "none":
1057
+ fields.push("readonly body?: undefined");
1058
+ break;
1059
+ case "stream":
1060
+ fields.push("readonly body: ReadableStream");
1061
+ break;
1062
+ case "binary":
1063
+ fields.push("readonly body: ReadableStream | Uint8Array<ArrayBuffer> | ArrayBuffer");
1064
+ break;
1065
+ case "unvalidated":
1066
+ fields.push("readonly body: string");
1067
+ break;
1068
+ default:
1069
+ fields.push(`readonly body: Produced<z.infer<typeof ${member.schemaName}>>`);
1070
+ }
1071
+ if (response.headers.length > 0) {
1072
+ /**
1073
+ * Keyed by the WIRE name, as the document publishes it. `headers` itself is optional only
1074
+ * when every header in it is, so a response declaring a required header cannot be returned
1075
+ * without one.
1076
+ */
1077
+ const every = response.headers.every((header) => header.optional);
1078
+ const rendered = response.headers
1079
+ .map((header) => header.optional
1080
+ ? `readonly ${objectKey(header.name)}?: ${header.type} | undefined`
1081
+ : `readonly ${objectKey(header.name)}: ${header.type}`)
1082
+ .join("; ");
1083
+ fields.push(`readonly headers${every ? "?" : ""}: { ${rendered} }`);
1084
+ }
1085
+ return `{ ${fields.join("; ")} }`;
1086
+ });
1087
+ const name = `${capitaliseId(entry.route.operationId)}Result`;
1088
+ const body = members.length <= 1
1089
+ ? ` ${members[0] ?? "never"}`
1090
+ : members.map((member) => `\n\t| ${member}`).join("");
1091
+ return `export type ${name} =${body};`;
1092
+ });
688
1093
  const methods = entries.map((entry) => {
689
- const { route, names } = entry;
1094
+ const { route } = entry;
690
1095
  /**
691
1096
  * A negotiated member's `accept` is not in its validator (the negotiation supplies it) but it
692
1097
  * IS in the operation's declared input, so the interface has to keep it. The literal is known
@@ -702,75 +1107,18 @@ type Fields<T> = string extends keyof T ? ([T[string]] extends [never] ? unknown
702
1107
  ? negotiated
703
1108
  : `${validated} & ${negotiated}`;
704
1109
  /**
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.
716
- */
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.
1110
+ * **A PROPERTY signature, not a method signature, and the difference is a check.** TypeScript
1111
+ * compares a method signature's parameters bivariantly, so a handler declaring a richer caller
1112
+ * context than `deps.context` produces compiled against the method form - measured - and failed
1113
+ * against this one.
730
1114
  */
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
- const doc = route.summary === undefined ? "" : `\t/** ${route.summary} */\n`;
762
- return `${doc}\t${route.operationId}(${signature}): Awaitable<Result<${output}>>;`;
1115
+ const doc = jsDocComment(route.summary, "\t");
1116
+ return `${doc}\treadonly ${objectKey(route.operationId)}: (ctx: C, input: ${input ?? EMPTY_INPUT}) => Awaitable<${capitaliseId(route.operationId)}Result>;`;
763
1117
  });
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);
1118
+ const returnsAnything = entries.some((entry) => membersOf(entry).some((member) => {
1119
+ const serving = bodyServingOf(member);
1120
+ return serving === "json" || serving === "text";
1121
+ }));
774
1122
  const declaredHelper = returnsAnything
775
1123
  ? `/**
776
1124
  * What a handler must SUPPLY, as opposed to what it receives.
@@ -828,7 +1176,7 @@ type Produced<T> = T extends (...args: never[]) => unknown
828
1176
 
829
1177
  `
830
1178
  : "";
831
- const aliases = entries.map((entry) => `export type ${capitaliseId(entry.route.operationId)}Handler = Operations[${JSON.stringify(entry.route.operationId)}];`);
1179
+ const aliases = entries.map((entry) => `export type ${capitaliseId(entry.route.operationId)}Handler<C = unknown> = Operations<C>[${JSON.stringify(entry.route.operationId)}];`);
832
1180
  /**
833
1181
  * **Which resources get a sub-app, and which routes stay on the root.**
834
1182
  *
@@ -871,6 +1219,7 @@ type Produced<T> = T extends (...args: never[]) => unknown
871
1219
  const slot = `${registrationVerbOf(route.verb)} ${route.path}`;
872
1220
  slots.set(slot, [...(slots.get(slot) ?? []), group]);
873
1221
  }
1222
+ const uses = { runtime: new Set() };
874
1223
  const registrations = [...slots.values()].map((groupsInSlot) => {
875
1224
  const headGroups = groupsInSlot.filter((g) => g[0].route.verb === "HEAD");
876
1225
  const plainGroups = groupsInSlot.filter((g) => g[0].route.verb !== "HEAD");
@@ -888,7 +1237,7 @@ type Produced<T> = T extends (...args: never[]) => unknown
888
1237
  /** A HEAD with no GET beside it: registered under GET, and guarded so only a HEAD reaches it. */
889
1238
  const headOnly = plainGroups.length === 0 && headGroups.length > 0;
890
1239
  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));
1240
+ const path = toHonoPath(route.pathSegments, (template, name) => refuse.unsupportedPathTemplate(route, template, name));
892
1241
  /**
893
1242
  * A route inside a sub-app is registered RELATIVE to the prefix it is mounted at.
894
1243
  *
@@ -914,8 +1263,21 @@ type Produced<T> = T extends (...args: never[]) => unknown
914
1263
  * all and rested entirely on `deps.context` returning null, which answers "is somebody here"
915
1264
  * rather than "did they satisfy the scheme the contract names".
916
1265
  */
917
- const requirements = securityFor?.(route.verb, route.path) ?? [];
918
- const gate = requirements.length === 0 ? [] : [`\t\tdeps.authorize(${renderSecurity(requirements)}),`];
1266
+ /**
1267
+ * Read from the route record, which carries the document's own `security` (`{}` for an
1268
+ * anonymous alternative) and whether a caller is needed at all. **This package kept a second
1269
+ * copy of that rule** in `security.ts`, because the library's once dropped `{}`; one copy now.
1270
+ */
1271
+ const gate = route.authentication === "none"
1272
+ ? []
1273
+ : [`\t\tdeps.authorize(${renderSecurity(route.security)}),`];
1274
+ /**
1275
+ * A query string written into the route itself identifies it as much as the path does, so a
1276
+ * request without it is not a request for this operation: 404, as any unrouted request gets.
1277
+ */
1278
+ const literalQueryGuard = route.literalQuery.length === 0
1279
+ ? []
1280
+ : [`\t\tliteralQuery(${JSON.stringify(route.literalQuery)}),`];
919
1281
  /**
920
1282
  * A HEAD operation with no GET beside it is registered under GET, because that is the only verb
921
1283
  * Hono dispatches. The guard keeps the registration honest: a real GET is not in the document,
@@ -948,8 +1310,33 @@ type Produced<T> = T extends (...args: never[]) => unknown
948
1310
  ...entry.body.branches.map(([type, target]) => `\t\t\t[${JSON.stringify(type)}, ${JSON.stringify(target)}],`),
949
1311
  "\t\t]),",
950
1312
  ];
1313
+ /**
1314
+ * Whether the operation requires a caller, and NOTHING else about the caller.
1315
+ *
1316
+ * **This used to pass `"account"` or `"resource"`, chosen by how many path parameters the
1317
+ * route had, and that was a rule no document states.** `@useAuth(NoAuth)` reaches OpenAPI as
1318
+ * `security: []`, so "does this need a caller" is a contract fact and is generated. "Is this
1319
+ * account-scoped or resource-scoped" is not: no OpenAPI keyword expresses it, and the
1320
+ * path-parameter heuristic happened to fit the first consumer.
1321
+ *
1322
+ * **Middleware, after the validators, and never inline in the handler.** Hono reads a route's
1323
+ * response types from its final handler, and one plain `Response` returned there collapses
1324
+ * every typed response `hc` would otherwise see - measured, `res.json()` fell back to
1325
+ * `unknown` for every status. A plain `Response` from middleware contributes nothing to those
1326
+ * types, so the refusal moves there and the handler returns typed responses only.
1327
+ */
1328
+ const authentication = route.authentication;
1329
+ /**
1330
+ * Several operations, one route: the caller's `Accept` chooses which one answers, and a caller
1331
+ * accepting none of them is refused in middleware for the same reason the context is.
1332
+ */
1333
+ const offers = group.length > 1
1334
+ ? group.flatMap((member) => member.route.responseContentTypes.map((contentType) => ({ contentType, member })))
1335
+ : [];
1336
+ const offered = `[${offers.map((offer) => JSON.stringify(offer.contentType)).join(", ")}]`;
951
1337
  const middleware = [
952
1338
  ...(headOnly ? ["\t\theadOnly,"] : []),
1339
+ ...literalQueryGuard,
953
1340
  ...gate,
954
1341
  ...validators
955
1342
  // The body's validator IS the middleware above; emitting a `zValidator` beside it would
@@ -957,43 +1344,12 @@ type Produced<T> = T extends (...args: never[]) => unknown
957
1344
  .filter(([target]) => entry.body === undefined || target !== VALIDATOR_TARGET.body)
958
1345
  .map(([target, name]) => `\t\tzValidator(${JSON.stringify(target)}, ${name}, deps.invalid, SYNC),`),
959
1346
  ...bodyMiddleware,
1347
+ `\t\tcontextFor(${JSON.stringify(authentication)}),`,
1348
+ ...(offers.length > 0 ? [`\t\tacceptable(${offered}),`] : []),
960
1349
  ];
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
1350
  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
1351
  // 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.
1352
+ // nothing extra to spread: the body middleware published it there whichever parser ran.
997
1353
  // Same rule as `inputTypeOf`: the body is identified by its schema, not by its target.
998
1354
  const bodyTarget = validators.find(([, name]) => name === entry.names.body)?.[0];
999
1355
  const pieces = validators.map(([target]) =>
@@ -1011,7 +1367,16 @@ type Produced<T> = T extends (...args: never[]) => unknown
1011
1367
  const reader = rawBodyReaderFor(route.requestContentTypes);
1012
1368
  pieces.push(`${objectKey(route.rawBodyProperty)}: ${reader.call}`);
1013
1369
  }
1014
- const invoke = (member) => {
1370
+ /**
1371
+ * Call one operation's handler and serve what it returns, as statements at `depth` tabs.
1372
+ *
1373
+ * **Broken across lines rather than emitted as one.** Generated code is read far more often
1374
+ * than it is written (in review, in a stack trace, in a diff) and a single call reached 219
1375
+ * characters on a real service, against the 60-to-80 of every example in Hono's own
1376
+ * documentation. A call with no input stays on one line.
1377
+ */
1378
+ const serve = (member, depth) => {
1379
+ const indent = "\t".repeat(depth);
1015
1380
  // The member's own `accept` literal, which its input type requires and which the shared
1016
1381
  // validator no longer supplies. We know it exactly: it is the branch we are in.
1017
1382
  const own = group.length > 1 && member.route.accept !== undefined
@@ -1020,42 +1385,20 @@ type Produced<T> = T extends (...args: never[]) => unknown
1020
1385
  ]
1021
1386
  : [];
1022
1387
  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
1388
+ const call = `handlersFor(c).${member.route.operationId}(contextOf(c), {`;
1389
+ const invocation = input.length === 0
1039
1390
  ? // An empty object, because every operation takes `(ctx, input)`. See `EMPTY_INPUT`.
1040
- `handlersFor(c).${member.route.operationId}(ctx, {})`
1041
- : [
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})`
1391
+ [`${indent}const result = await ${call}});`]
1048
1392
  : [
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");
1393
+ `${indent}const result = await ${call}`,
1394
+ ...input.map((piece) => `${indent}\t${piece},`),
1395
+ `${indent}});`,
1396
+ ];
1397
+ return [...invocation, ...servingLines(member, depth, uses)];
1055
1398
  };
1056
1399
  /**
1057
1400
  * 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.
1401
+ * registration serves both and each operation keeps its own handler and its own responses.
1059
1402
  * Hono strips the body on the HEAD branch itself.
1060
1403
  *
1061
1404
  * The HEAD branch is emitted FIRST because it is the narrower condition, and it returns, so the
@@ -1064,31 +1407,31 @@ type Produced<T> = T extends (...args: never[]) => unknown
1064
1407
  if (headBranch !== undefined) {
1065
1408
  const headEntry = headBranch[0];
1066
1409
  body.push(`\t\t\tif (c.req.method === "HEAD") {`);
1067
- body.push(`\t\t\t\treturn ${invoke(headEntry).replaceAll("\n", "\n\t")};`);
1410
+ body.push(...serve(headEntry, 4));
1068
1411
  body.push("\t\t\t}");
1069
1412
  }
1070
1413
  if (group.length === 1) {
1071
- body.push(`\t\t\treturn ${invoke(entry)};`);
1414
+ body.push(...serve(entry, 3));
1072
1415
  }
1073
1416
  else {
1074
1417
  /**
1075
- * Several operations, one route: the caller's `Accept` chooses which one answers.
1076
- *
1077
1418
  * 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
1419
+ * `selectContentType` applies RFC 9110 section 12.5.1 to them. It lives in the runtime rather
1420
+ * than in `deps` because both halves are derivable, and an app forced to supply it would be
1080
1421
  * re-implementing the standard.
1081
1422
  */
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
1423
  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
1424
  for (const offer of offers) {
1087
- body.push(`\t\t\tif (served === ${JSON.stringify(offer.contentType)}) return ${invoke(offer.member)};`);
1425
+ body.push(`\t\t\tif (served === ${JSON.stringify(offer.contentType)}) {`);
1426
+ body.push(...serve(offer.member, 4));
1427
+ body.push("\t\t\t}");
1088
1428
  }
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});`);
1429
+ /**
1430
+ * Unreachable: `acceptable` refused every request whose `Accept` matches none of these, and
1431
+ * `selectContentType` only returns a member of the list it was given. Thrown rather than
1432
+ * answered, because a plain `Response` here would erase every typed response on the route.
1433
+ */
1434
+ body.push(`\t\t\tthrow new Error(${JSON.stringify(`typespec-hono: no operation on ${route.verb} ${route.path} serves the negotiated media type`)});`);
1092
1435
  }
1093
1436
  /**
1094
1437
  * **`app.on` takes the METHOD first, and we were not passing one.** Only five verbs have a
@@ -1115,7 +1458,7 @@ type Produced<T> = T extends (...args: never[]) => unknown
1115
1458
  "\t\t\t},",
1116
1459
  "\t\t)",
1117
1460
  ].join("\n");
1118
- return { target, text, headOnly };
1461
+ return { target, path: registeredPath, text, headOnly };
1119
1462
  });
1120
1463
  /**
1121
1464
  * Every identifier this file names, imported from the module that declares it.
@@ -1136,7 +1479,12 @@ type Produced<T> = T extends (...args: never[]) => unknown
1136
1479
  * Splitting on the language's own identifier rule and testing membership has neither failure: no
1137
1480
  * metacharacter can be misread, and `Foo` cannot match `FooExtra`.
1138
1481
  */
1139
- const rendered = [...registrations.map((r) => r.text), ...methods, ...aliases].join("\n");
1482
+ const rendered = [
1483
+ ...registrations.map((r) => r.text),
1484
+ ...resultTypes,
1485
+ ...methods,
1486
+ ...aliases,
1487
+ ].join("\n");
1140
1488
  const mentioned = new Set(rendered.match(/[A-Za-z_$][A-Za-z0-9_$]*/g) ?? []);
1141
1489
  const referenced = [
1142
1490
  ...new Set(entries.flatMap((entry) => [
@@ -1144,8 +1492,7 @@ type Produced<T> = T extends (...args: never[]) => unknown
1144
1492
  entry.names.query,
1145
1493
  entry.names.header,
1146
1494
  entry.names.body,
1147
- entry.names.response,
1148
- entry.names.responses,
1495
+ ...entry.names.arms.map((arm) => arm.schema),
1149
1496
  ].filter((name) => name !== undefined))),
1150
1497
  ]
1151
1498
  .filter((identifier) => mentioned.has(identifier))
@@ -1173,6 +1520,27 @@ type Produced<T> = T extends (...args: never[]) => unknown
1173
1520
  * bottom". A rule a reordering could break. A single expression cannot be reordered wrongly: the
1174
1521
  * sub-app is complete at the point it is mounted because it is its own initialiser.
1175
1522
  */
1523
+ /**
1524
+ * **A concrete path is registered before a templated one it would otherwise lose to.**
1525
+ *
1526
+ * Hono runs the first registered handler that matches, so `GET /items/:id` registered before
1527
+ * `GET /items/plain` answered every request for `/items/plain` - measured, with the wrong handler's
1528
+ * body. OpenAPI's Paths Object states the opposite rule: "concrete (non-templated) paths would be
1529
+ * matched before their templated counterparts". Ordering the registrations is how a router that
1530
+ * matches in order applies it. Otherwise the document's own order is kept, because the sort is
1531
+ * stable.
1532
+ */
1533
+ registrations.sort((a, b) => {
1534
+ const left = a.path.split("/");
1535
+ const right = b.path.split("/");
1536
+ for (let index = 0; index < Math.min(left.length, right.length); index++) {
1537
+ const leftTemplated = (left[index] ?? "").startsWith(":");
1538
+ const rightTemplated = (right[index] ?? "").startsWith(":");
1539
+ if (leftTemplated !== rightTemplated)
1540
+ return leftTemplated ? 1 : -1;
1541
+ }
1542
+ return 0;
1543
+ });
1176
1544
  const byTarget = new Map();
1177
1545
  for (const registration of registrations) {
1178
1546
  byTarget.set(registration.target, [
@@ -1199,6 +1567,7 @@ type Produced<T> = T extends (...args: never[]) => unknown
1199
1567
  const negotiates = [...grouped.values()].some((group) => group.length > 1);
1200
1568
  // Same rule: imported only where a HEAD operation stands alone on its path.
1201
1569
  const guardsHead = registrations.some((registration) => registration.headOnly);
1570
+ const guardsQuery = emitted.routes.some((route) => route.literalQuery.length > 0);
1202
1571
  /**
1203
1572
  * **Which runtime imports to write is read from the DATA, never from the rendered text.**
1204
1573
  *
@@ -1242,8 +1611,9 @@ type Produced<T> = T extends (...args: never[]) => unknown
1242
1611
  const usesZod = mountsBody ||
1243
1612
  // `SYNC` annotates its schema parameter `z.ZodType`, so the value import is load-bearing.
1244
1613
  validates ||
1245
- entries.some((entry) => inputTypeOf(entry) !== undefined || entry.names.response !== undefined);
1246
- const runtimeModule = JSON.stringify(emitted.options.runtimeModule);
1614
+ entries.some((entry) => inputTypeOf(entry) !== undefined) ||
1615
+ returnsAnything;
1616
+ const runtimeModule = JSON.stringify(DEFAULT_RUNTIME_MODULE);
1247
1617
  /**
1248
1618
  * **One base sub-app, mounted with `app.route()`. Hono's own nesting, not a rewritten path on
1249
1619
  * every registration.** Prefixing each path individually would fight the resource grouping, and
@@ -1253,17 +1623,59 @@ type Produced<T> = T extends (...args: never[]) => unknown
1253
1623
  */
1254
1624
  const usesBasePath = basePaths.length > 0;
1255
1625
  const needsHonoValue = subApps.size > 0 || usesBasePath;
1626
+ /**
1627
+ * Hono's status-code types the result unions name, read from the rendered unions by TOKEN, the
1628
+ * same way the validator identifiers above are: an identifier absent from the text is genuinely
1629
+ * not needed, so the check cannot be wrong in the direction that breaks a build.
1630
+ */
1631
+ const renderedResults = new Set(resultTypes.join("\n").match(/[A-Za-z_$][A-Za-z0-9_$]*/g) ?? []);
1632
+ const statusTypes = [
1633
+ "ClientErrorStatusCode",
1634
+ "ContentfulStatusCode",
1635
+ "ContentlessStatusCode",
1636
+ "InfoStatusCode",
1637
+ "RedirectStatusCode",
1638
+ "ServerErrorStatusCode",
1639
+ "StatusCode",
1640
+ "SuccessStatusCode",
1641
+ "UnofficialStatusCode",
1642
+ ].filter((name) => renderedResults.has(name));
1643
+ const operates = entries.length > 0;
1644
+ const honoTypes = [
1645
+ "Context",
1646
+ ...(mountsBody ? ["Env"] : []),
1647
+ ...(needsHonoValue ? [] : ["Hono"]),
1648
+ "Input",
1649
+ ...(operates || mountsBody ? ["MiddlewareHandler"] : []),
1650
+ ];
1651
+ const runtimeValues = [
1652
+ ...uses.runtime,
1653
+ ...(negotiates ? ["selectContentType"] : []),
1654
+ ...(guardsHead ? ["headOnly"] : []),
1655
+ ...(guardsQuery ? ["literalQuery"] : []),
1656
+ ].toSorted();
1657
+ const runtimeTypes = ["AppEnv", ...(operates ? ["Awaitable"] : []), "RouteDeps"];
1256
1658
  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};` : ""}
1659
+ ${validates ? 'import { zValidator } from "@hono/zod-validator";\n' : ""}${needsHonoValue ? 'import { Hono } from "hono";\n' : ""}import type { ${honoTypes.join(", ")} } from "hono";
1660
+ ${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
1661
  ${imports}
1662
+ ${syncHelper}${bodyHelper}${fieldsHelper}${declaredHelper}/**
1663
+ * What each operation may answer with: one member per response the document declares.
1664
+ *
1665
+ * A handler returns \`{ status, body, headers }\` for whichever response it means. The generated route
1666
+ * serves it with the Hono call for that status, so \`hc\` sees a typed body per status, and a status
1667
+ * or body the document does not declare does not compile.
1668
+ */
1669
+ ${resultTypes.join("\n\n")}
1670
+
1260
1671
  /**
1261
- * One method per operation, each concretely typed from the schemas it validates against.
1672
+ * One handler per operation, each concretely typed from the schemas it validates against.
1262
1673
  *
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.
1674
+ * \`C\` is the caller context, inferred by \`registerRoutes\` from \`deps.context\`. There is no cast
1675
+ * anywhere in this file, and no dynamic lookup: the generated call sites name the handler, so an
1676
+ * implementation whose input or result does not match the contract fails to compile.
1265
1677
  */
1266
- ${syncHelper}${bodyHelper}${fieldsHelper}${declaredHelper}export interface Operations {
1678
+ export interface Operations<C = unknown> {
1267
1679
  ${methods.join("\n")}
1268
1680
  }
1269
1681
 
@@ -1304,12 +1716,12 @@ ${aliases.join("\n")}
1304
1716
  export type Exhaustive<T> = T & Record<Exclude<keyof T, keyof Operations>, never>;
1305
1717
 
1306
1718
  /** Mount every operation the service declares. */
1307
- export function registerRoutes<T extends Operations>(
1719
+ export function registerRoutes<C, T extends Operations<C>>(
1308
1720
  app: Hono<AppEnv>,
1309
1721
  handlersFor: <P extends string, I extends Input>(c: Context<AppEnv, P, I>) => Exhaustive<T>,
1310
- deps: RouteDeps,
1722
+ deps: RouteDeps<AppEnv, C>,
1311
1723
  ) {
1312
- ${subAppDeclarations}${usesBasePath
1724
+ ${operates ? CONTEXT_MIDDLEWARE : ""}${negotiates ? ACCEPTABLE_MIDDLEWARE : ""}${subAppDeclarations}${usesBasePath
1313
1725
  ? `\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
1726
  : `\treturn app\n${rootChain.join("\n")};`}
1315
1727
  }