typespec-hono 0.19.1 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -175,6 +175,8 @@ and exists only for the duration of a request.
175
175
  request bodies, streaming, observability
176
176
  - [Cloudflare Workers](docs/cloudflare-workers.md): which router to pick, and what the bundle costs
177
177
  - [Reference](docs/reference.md): every option, every diagnostic, and the known limits
178
+ - [Releasing](docs/releasing.md): rehearsing a two-package release against a local registry,
179
+ because the server resolves the library through npm and CI cannot verify a change spanning both
178
180
 
179
181
  ## Licence
180
182
 
package/dist/src/app.js CHANGED
@@ -160,6 +160,222 @@ function bodyValidationFor(contentTypes) {
160
160
  }
161
161
  return { byType, unparseable };
162
162
  }
163
+ /**
164
+ * The request-body middleware, emitted INTO the generated file rather than imported from the runtime
165
+ * module.
166
+ *
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
170
+ * used to be mounted by `zValidator`, which throws `HTTPException` on a body it cannot read - a
171
+ * `text/plain` 400 raised before `deps.invalid` is called, so an API whose document declares a JSON
172
+ * error envelope answered a shape its own contract forbids. Routing those through the runtime's
173
+ * `byContentType` fixes it in one line and makes that export mandatory for every substituting app:
174
+ * measured, 15 arms red, every one an app whose module has no such export.
175
+ *
176
+ * Emitting the middleware removes the choice. The runtime contract does not grow to close the gap -
177
+ * it SHRINKS by the two names that used to serve it, and the mechanism now lives in the file that
178
+ * uses it, where a reader can see what their route is mounted with.
179
+ *
180
+ * **One reader, one rejection, three positions.** Required, optional and dispatched bodies differ in
181
+ * exactly two ways - whether an absent body is permitted, and how many media types may be declared -
182
+ * and nothing else. `validateBody` covers required and dispatched, which are the same function with
183
+ * one branch or several. `validateOptionalBody` is separate only because its `Input` type differs:
184
+ * what it publishes may be `undefined`, and that cannot be a runtime argument.
185
+ *
186
+ * **`z.input` on the way in, `z.output` on the way out.** A `.default(...)` makes a property optional
187
+ * to a caller and guaranteed to a handler, and Hono's chain types those two positions separately, so
188
+ * saying `z.output` on both would require callers to send what the document says they may omit.
189
+ *
190
+ * Every rejection goes through `deps.invalid` first, and falls back to `zValidator`'s own answer when
191
+ * the hook declines - kept identical so a route that changed shape does not change how it refuses.
192
+ */
193
+ /**
194
+ * **Parameter validators parse SYNCHRONOUSLY, through `zValidator`'s own typed extension point.**
195
+ *
196
+ * `zValidator` calls `safeParseAsync` by default, and nothing this emitter writes is asynchronous:
197
+ * `vocabulary.test.ts` refuses every construct that could be - no `.transform()`, no `.pipe()`, and
198
+ * the single permitted `.refine()` is a synchronous predicate on a multipart file part. So the async
199
+ * path was buying nothing and costing on every request.
200
+ *
201
+ * Measured on an emitted five-property model, zod 4.5.2, 200k iterations:
202
+ *
203
+ * | call | ns/parse |
204
+ * | --- | --- |
205
+ * | `safeParseAsync` | 958 |
206
+ * | `safeParse` | 371 |
207
+ *
208
+ * **2.6x, on every parameter group of every request**, for a promise nothing awaited a result from.
209
+ * It also unblocks `z.compile()`, whose fast path is bypassed for any async parse - measured from
210
+ * `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.
212
+ *
213
+ * `validationFunction` is `@hono/zod-validator`'s published option and its return type admits a
214
+ * synchronous result, so this is the package's own seam rather than a way around it.
215
+ *
216
+ * `test/sync.test.ts` asserts that every schema emitted across the whole corpus really does parse
217
+ * synchronously, because `safeParse` THROWS on a schema that cannot - a claim about output this file
218
+ * does not produce is exactly the kind that needs an arm rather than a comment.
219
+ */
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) };
222
+
223
+ `;
224
+ const BODY_READER = `/** The two ways Hono can read a request body: \`c.req.json()\` and \`c.req.parseBody()\`. */
225
+ type BodyTarget = "json" | "form";
226
+
227
+ /**
228
+ * A body that is not what its content type claims.
229
+ *
230
+ * **BOTH readers can fail.** Hono's own validator catches in both branches and throws
231
+ * \`HTTPException\` - which is what escapes the app's envelope. Catching here is what lets the failure
232
+ * be reported like any other. Measured: an unreadable multipart body answered 500 while the JSON case
233
+ * answered 400, from the same missing \`try\`.
234
+ */
235
+ const UNREADABLE = Symbol("a body that is not what its content type claims");
236
+
237
+ async function readBody(c: Context, target: BodyTarget): Promise<unknown> {
238
+ try {
239
+ return target === "form" ? await c.req.parseBody({ all: true }) : await c.req.json();
240
+ } catch {
241
+ return UNREADABLE;
242
+ }
243
+ }
244
+
245
+ /**
246
+ * The failure an unreadable body produces, in the shape every other rejection has.
247
+ *
248
+ * Parsing \`undefined\` against the body's own schema is what makes a real Zod failure, so
249
+ * \`deps.invalid\` receives what it receives for a failed parse rather than a special case it has to
250
+ * know about. A schema that somehow accepts \`undefined\` still fails: a body that could not be read
251
+ * is not a body that validated.
252
+ */
253
+ function unreadableResult(schema: z.ZodType): { readonly success: boolean } {
254
+ const parsed = schema.safeParse(undefined);
255
+ return parsed.success ? { success: false } : parsed;
256
+ }
257
+
258
+ /**
259
+ * Which reader parses the media type the request actually carries.
260
+ *
261
+ * A route may declare several request media types needing different parsers, and which one applies is
262
+ * decided by the caller's \`Content-Type\` when the request arrives - not when this file was
263
+ * generated. Parameters (\`; charset=utf-8\`, \`; boundary=...\`) are not part of the match, which
264
+ * matters because a multipart request always carries a boundary.
265
+ *
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.
268
+ */
269
+ function bodyTarget(c: Context, branches: readonly (readonly [string, BodyTarget])[]): BodyTarget {
270
+ 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";
273
+ }
274
+
275
+ /** The app's rejection hook, as this file needs to name it. */
276
+ type BodyInvalid<E extends Env> = <P extends string, I extends Input>(
277
+ result: { readonly success: boolean },
278
+ c: Context<E, P, I>,
279
+ ) => Response | undefined;
280
+
281
+ `;
282
+ /**
283
+ * The required-body half, emitted only where a route declares one.
284
+ *
285
+ * **Split from the optional half because `noUnusedLocals` makes an unused declaration an error**, and
286
+ * a generated file has to pass the lint of whatever project it lands in. Three declarations have
287
+ * shipped that way already - `zValidator` and `z` imported unconditionally, and `Simplify<T>` written
288
+ * into a contracts file with nothing to flatten. Emitting both functions whenever any body exists
289
+ * made it four, in eight fixtures at once.
290
+ */
291
+ const REQUIRED_BODY_MIDDLEWARE = `/**
292
+ * Validate a REQUIRED request body, whatever media type it arrives as.
293
+ *
294
+ * The validated value is published under \`"json"\` whichever reader produced it: \`ValidationTargets\`
295
+ * is a closed union in Hono, so there is no name to coin for "the body", and using one slot is what
296
+ * makes every route the same shape downstream.
297
+ */
298
+ function validateBody<E extends Env, S extends z.ZodType>(
299
+ schema: S,
300
+ invalid: BodyInvalid<E>,
301
+ branches: readonly (readonly [string, BodyTarget])[],
302
+ ): MiddlewareHandler<E, string, { in: { json: z.input<S> }; out: { json: z.output<S> } }> {
303
+ return async (c, next) => {
304
+ /**
305
+ * Annotated rather than inlined, and that is what removes a CAST from this file. \`Context\` is
306
+ * invariant in its environment and in its \`Input\`, so passing the outer \`c\` to a hook typed
307
+ * against the route's own input needs either an annotation here or an \`as never\` there.
308
+ */
309
+ const parse: MiddlewareHandler<
310
+ E,
311
+ string,
312
+ { in: { json: z.input<S> }; out: { json: z.output<S> } }
313
+ > = async (ctx, proceed) => {
314
+ const raw = await readBody(ctx, bodyTarget(ctx, branches));
315
+ const result =
316
+ raw === UNREADABLE ? unreadableResult(schema) : schema.safeParse(raw);
317
+ const response = invalid(result, ctx);
318
+ if (response !== undefined) return response;
319
+ if (!result.success) return ctx.json(result, 400);
320
+ ctx.req.addValidatedData("json", ("data" in result ? result.data : undefined) ?? {});
321
+ await proceed();
322
+ return undefined;
323
+ };
324
+ return parse(c, next);
325
+ };
326
+ }
327
+
328
+ `;
329
+ /** The optional-body half. Emitted only where the document publishes `requestBody.required: false`. */
330
+ const OPTIONAL_BODY_MIDDLEWARE = `/**
331
+ * Validate a body the document says is OPTIONAL, and let a request carrying none through.
332
+ *
333
+ * \`requestBody.required: false\` means a request with no body is one the contract permits. Nothing is
334
+ * published when the body is absent, so \`c.req.valid("json")\` reads \`undefined\` and the handler is
335
+ * told the truth - which is why an optional body is a NAMED property on the input rather than merged
336
+ * into it. A body that is present but unreadable still fails, through the same hook.
337
+ */
338
+ function validateOptionalBody<E extends Env, S extends z.ZodType>(
339
+ schema: S,
340
+ invalid: BodyInvalid<E>,
341
+ branches: readonly (readonly [string, BodyTarget])[],
342
+ ): MiddlewareHandler<
343
+ E,
344
+ string,
345
+ { in: { json: z.input<S> | undefined }; out: { json: z.output<S> | undefined } }
346
+ > {
347
+ return async (c, next) => {
348
+ const parse: MiddlewareHandler<
349
+ E,
350
+ string,
351
+ { in: { json: z.input<S> | undefined }; out: { json: z.output<S> | undefined } }
352
+ > = async (ctx, proceed) => {
353
+ /**
354
+ * Both halves are load-bearing. The platform reports \`null\` for a request sent without a
355
+ * body, and a caller may instead send \`content-length: 0\`, which is a body of no bytes and
356
+ * reads the same way to anything downstream. Neither alone covers what arrives.
357
+ */
358
+ const absent =
359
+ ctx.req.raw.body === null || (ctx.req.header("content-length") ?? "").trim() === "0";
360
+ if (absent) {
361
+ await proceed();
362
+ return undefined;
363
+ }
364
+ const raw = await readBody(ctx, bodyTarget(ctx, branches));
365
+ const result =
366
+ raw === UNREADABLE ? unreadableResult(schema) : schema.safeParse(raw);
367
+ const response = invalid(result, ctx);
368
+ if (response !== undefined) return response;
369
+ if (!result.success) return ctx.json(result, 400);
370
+ ctx.req.addValidatedData("json", ("data" in result ? result.data : undefined) ?? {});
371
+ await proceed();
372
+ return undefined;
373
+ };
374
+ return parse(c, next);
375
+ };
376
+ }
377
+
378
+ `;
163
379
  /**
164
380
  * How an unparsed body reaches the handler, and as what.
165
381
  *
@@ -291,10 +507,6 @@ function inputTypeOf(entry) {
291
507
  function capitaliseId(operationId) {
292
508
  return `${operationId.charAt(0).toUpperCase()}${operationId.slice(1)}`;
293
509
  }
294
- /** The schema identifier a dispatched body validates against. */
295
- function identifierOf(entry) {
296
- return entry.dispatched?.identifier ?? "";
297
- }
298
510
  /**
299
511
  * The resource a route belongs to (its first path segment) or `undefined` when it has none.
300
512
  *
@@ -357,14 +569,13 @@ basePaths = [],
357
569
  */
358
570
  securityFor) {
359
571
  const mounted = emitted.routes.flatMap((route) => {
360
- let dispatched;
361
572
  const names = emitted.schemaNames.get(route.operationId);
362
573
  // The library declares a `Responses` const for every operation, so a missing entry is a bug in
363
574
  // this package's pairing rather than a spec the emitter chose not to serve.
364
575
  if (names === undefined)
365
576
  return [];
366
577
  const validators = [];
367
- let optional;
578
+ let body;
368
579
  for (const location of ["path", "query", "header", "body"]) {
369
580
  const identifier = names[location];
370
581
  if (identifier === undefined)
@@ -374,60 +585,29 @@ securityFor) {
374
585
  continue;
375
586
  }
376
587
  /**
377
- * The body is the one location whose parser the document does not fix at generation time.
378
- * Where it declares a single parseable media type this is one `zValidator`, unchanged. Where
379
- * it declares several, the choice belongs to the caller's `Content-Type` and is made then.
588
+ * **The body is the one location whose parser the document does not fix at generation
589
+ * time**, and it is the one whose rejection used to depend on which of three mountings it
590
+ * got. All three are now one emitted middleware; what the document decides is only the
591
+ * branches it is given and whether an absent body is permitted.
380
592
  */
381
- const body = bodyValidationFor(route.requestContentTypes);
382
- if (body.unparseable.length > 0)
383
- refuse.unvalidatableMediaType(route, body.unparseable);
384
- if (body.byType.length === 0)
385
- continue;
386
- const targets = [...new Set(body.byType.map(([, target]) => target))];
387
- if (targets.length === 1) {
388
- /**
389
- * **An optional body is mounted by `optionalBody`, not `zValidator`**, so a request
390
- * carrying none is let through instead of refused - see the runtime's docblock for the
391
- * 400 that produced. It publishes under the canonical body slot whichever parser read
392
- * it, exactly as `byContentType` does, so the handler reads it the same way everywhere.
393
- */
394
- if (route.optionalBody) {
395
- validators.push([VALIDATOR_TARGET.body, identifier]);
396
- optional = { target: targets[0], identifier };
397
- continue;
398
- }
399
- /**
400
- * **A required body keeps `zValidator`, and unifying it was tried and reverted.**
401
- *
402
- * `zValidator` raises `HTTPException` on a body that is not the JSON its content type
403
- * claims, BEFORE `deps.invalid` runs, so that 400 is `text/plain` and escapes the app's
404
- * error envelope. Routing every body through `byContentType` fixes it and is the obvious
405
- * move - one code path, one place the rejection is decided.
406
- *
407
- * **It is a breaking change to the RUNTIME CONTRACT, not just to emitted output.** An app
408
- * that points `runtime-module` at its own module would have to export `byContentType`,
409
- * which today it needs only if some operation declares several request media types - rare
410
- * enough that no consumer exports it. Measured: 15 arms red, every one of them an app
411
- * whose substituted module has no such export.
412
- *
413
- * The envelope gap is real and stays open for a required single-media-type body. Closing
414
- * it wants a form that adds no export to the runtime contract - most likely emitting the
415
- * middleware into `app.gen.ts` itself - and that is its own change, not a line here.
416
- */
417
- validators.push([targets[0], identifier]);
418
- continue;
593
+ const validation = bodyValidationFor(route.requestContentTypes);
594
+ if (validation.unparseable.length > 0) {
595
+ refuse.unvalidatableMediaType(route, validation.unparseable);
419
596
  }
597
+ if (validation.byType.length === 0)
598
+ continue;
420
599
  /**
421
- * **Recorded as an ordinary body validator as well**, because `byContentType` publishes what
422
- * it validated under the body target whichever parser produced it. So a dispatched route is
423
- * the same shape as every other one downstream: the handler's input type includes the body,
424
- * and the handler reads it with one `c.req.valid` like anywhere else. What differs is only
425
- * which middleware is emitted, below.
600
+ * **Recorded under the canonical body slot whatever media type arrives.** The middleware
601
+ * publishes what it validated under one target, so the handler reads it with a single
602
+ * `c.req.valid("json")` whether the body was JSON, a form, or chosen from the request's
603
+ * `Content-Type`. A required form body used to be published under `"form"` instead, which
604
+ * made it the one route shape that read differently downstream for no reason the document
605
+ * states.
426
606
  */
427
607
  validators.push([VALIDATOR_TARGET.body, identifier]);
428
- dispatched = { byType: body.byType, identifier };
608
+ body = { branches: validation.byType, identifier, optional: route.optionalBody === true };
429
609
  }
430
- return [{ route, names, validators, dispatched, optional }];
610
+ return [{ route, names, validators, body }];
431
611
  });
432
612
  const entries = mounted;
433
613
  /**
@@ -483,6 +663,28 @@ type Fields<T> = string extends keyof T ? ([T[string]] extends [never] ? unknown
483
663
 
484
664
  `
485
665
  : "";
666
+ /**
667
+ * **Which halves of the body middleware this file needs**, decided from the routes rather than from
668
+ * the rendered text - the rule the import block below states, and once broke by matching a
669
+ * substring that a new argument had moved.
670
+ *
671
+ * Each half is emitted only where something mounts it: `noUnusedLocals` makes an unused declaration
672
+ * an error, and a generated file has to pass the lint of whatever project it lands in.
673
+ */
674
+ const mountsRequiredBody = entries.some((entry) => entry.body?.optional === false);
675
+ const mountsOptionalBody = entries.some((entry) => entry.body?.optional === true);
676
+ const mountsBody = mountsRequiredBody || mountsOptionalBody;
677
+ const bodyHelper = mountsBody
678
+ ? `${BODY_READER}${mountsRequiredBody ? REQUIRED_BODY_MIDDLEWARE : ""}${mountsOptionalBody ? OPTIONAL_BODY_MIDDLEWARE : ""}`
679
+ : "";
680
+ /**
681
+ * **Whether any `zValidator` is actually emitted**, which is the same question the import block
682
+ * below asks - stated once, because `SYNC` is passed to every one of those calls and a second
683
+ * spelling of the rule is a second thing that can drift. A body's target is filtered out: it is
684
+ * mounted by the emitted middleware instead, so a route with only a body mounts no `zValidator`.
685
+ */
686
+ const validates = entries.some((entry) => entry.validators.filter(([target]) => entry.body === undefined || target !== VALIDATOR_TARGET.body).length > 0);
687
+ const syncHelper = validates ? SYNC_PARSE : "";
486
688
  const methods = entries.map((entry) => {
487
689
  const { route, names } = entry;
488
690
  /**
@@ -512,7 +714,49 @@ type Fields<T> = string extends keyof T ? ([T[string]] extends [never] ? unknown
512
714
  * here on its own, because this signature is derived from `z.infer` rather than from those
513
715
  * types - which is exactly the half-fix `test/openmodel/` exists to catch.
514
716
  */
515
- const output = names.response === undefined ? "void" : `Produced<z.infer<typeof ${names.response}>>`;
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}`;
516
760
  const signature = `ctx: Ctx, input: ${input ?? EMPTY_INPUT}`;
517
761
  const doc = route.summary === undefined ? "" : `\t/** ${route.summary} */\n`;
518
762
  return `${doc}\t${route.operationId}(${signature}): Awaitable<Result<${output}>>;`;
@@ -526,7 +770,6 @@ type Fields<T> = string extends keyof T ? ([T[string]] extends [never] ? unknown
526
770
  * mentions a name is how this package lost the `byContentType` import: the call gained an argument
527
771
  * and the substring stopped matching, so a module referenced a function it no longer imported.
528
772
  */
529
- const mountsOptionalBody = entries.some((entry) => entry.optional !== undefined);
530
773
  const returnsAnything = entries.some((entry) => entry.names.response !== undefined);
531
774
  const declaredHelper = returnsAnything
532
775
  ? `/**
@@ -684,41 +927,36 @@ type Produced<T> = T extends (...args: never[]) => unknown
684
927
  * from that type -- measured, `hc<typeof app>` resolved the wrapped route's body to `unknown`.
685
928
  */
686
929
  /**
687
- * Where the document declares request media types needing different parsers, the parser is
688
- * chosen from the request's `Content-Type`. `byContentType` takes the body's schema and
689
- * `deps.invalid` and validates with them, so it stands in for that target's `zValidator`
690
- * rather than wrapping one, and publishes under the body target either way.
930
+ * The body's own middleware, emitted into this file rather than imported - see
931
+ * `BODY_MIDDLEWARE` for why the runtime module is the wrong place for it.
691
932
  *
692
- * **It used to wrap pre-built `zValidator`s and publish under whichever one ran, and that
693
- * emitted code a consumer could not compile.** A plain `MiddlewareHandler` contributes no
694
- * `Input` to Hono's chain, so `c.req.valid("json")` in the handler below was
933
+ * It stands IN FOR the body's `zValidator` rather than wrapping one, which is what lets it
934
+ * decide the rejection. A plain `MiddlewareHandler` contributes no `Input` to Hono's chain,
935
+ * so a wrapper made `c.req.valid("json")` in the handler below
695
936
  * `TS2345: Argument of type '"json"' is not assignable to parameter of type '"header"'`, and
696
- * the handler's declared input type omitted the body entirely because the body was not among
697
- * the route's validators. One published slot removes both.
937
+ * dropped the body from the handler's declared input type entirely. Publishing to one known
938
+ * slot removes both.
939
+ *
940
+ * The branches are always emitted, even the single one: a route declaring one media type is
941
+ * the same question with one answer, and a special case there is what let three mountings
942
+ * drift apart in the first place.
698
943
  */
699
- const dispatch = entry.dispatched === undefined
944
+ const bodyMiddleware = entry.body === undefined
700
945
  ? []
701
946
  : [
702
- `\t\tbyContentType(${identifierOf(entry)}, deps.invalid, [`,
703
- ...entry.dispatched.byType.map(([type, target]) => `\t\t\t[${JSON.stringify(type)}, ${JSON.stringify(target)}],`),
947
+ `\t\t${entry.body.optional ? "validateOptionalBody" : "validateBody"}(${entry.body.identifier}, deps.invalid, [`,
948
+ ...entry.body.branches.map(([type, target]) => `\t\t\t[${JSON.stringify(type)}, ${JSON.stringify(target)}],`),
704
949
  "\t\t]),",
705
950
  ];
706
951
  const middleware = [
707
952
  ...(headOnly ? ["\t\theadOnly,"] : []),
708
953
  ...gate,
709
954
  ...validators
710
- // The dispatched body's own `zValidator` is `byContentType`; emitting both would parse
711
- // the body twice and reject every media type but one. An optional body is the same
712
- // story with `optionalBody`, which additionally lets a bodyless request through.
713
- .filter(([target]) => (entry.dispatched === undefined && entry.optional === undefined) ||
714
- target !== VALIDATOR_TARGET.body)
715
- .map(([target, name]) => `\t\tzValidator(${JSON.stringify(target)}, ${name}, deps.invalid),`),
716
- ...dispatch,
717
- ...(entry.optional === undefined
718
- ? []
719
- : [
720
- `\t\toptionalBody(${entry.optional.identifier}, deps.invalid, ${JSON.stringify(entry.optional.target)}),`,
721
- ]),
955
+ // The body's validator IS the middleware above; emitting a `zValidator` beside it would
956
+ // parse the body twice and reject every media type but one.
957
+ .filter(([target]) => entry.body === undefined || target !== VALIDATOR_TARGET.body)
958
+ .map(([target, name]) => `\t\tzValidator(${JSON.stringify(target)}, ${name}, deps.invalid, SYNC),`),
959
+ ...bodyMiddleware,
722
960
  ];
723
961
  /**
724
962
  * Whether the operation requires a caller, and NOTHING else about the caller.
@@ -979,7 +1217,6 @@ type Produced<T> = T extends (...args: never[]) => unknown
979
1217
  * safe in a way these are not: an identifier absent from the text is genuinely not needed, so the
980
1218
  * check cannot be wrong in the direction that breaks a build.
981
1219
  */
982
- const dispatchesBody = entries.some((entry) => entry.dispatched !== undefined);
983
1220
  /**
984
1221
  * **Imported only where a route actually validates something, like every other value import here.**
985
1222
  *
@@ -990,7 +1227,7 @@ type Produced<T> = T extends (...args: never[]) => unknown
990
1227
  * `TS6133: 'zValidator' is declared but its value is never read`.
991
1228
  *
992
1229
  * Counted from the same filtered list the middleware is rendered from, so it cannot disagree with
993
- * what was emitted: a dispatched body's validator is `byContentType`, not a `zValidator`.
1230
+ * what was emitted: a body's validator is the middleware this file declares, not a `zValidator`.
994
1231
  */
995
1232
  /**
996
1233
  * **`z` is only ever reached through `z.infer`**, which appears where an operation has an input
@@ -998,9 +1235,14 @@ type Produced<T> = T extends (...args: never[]) => unknown
998
1235
  * neither, so the import was written and never used:
999
1236
  * `TS6133: 'z' is declared but its value is never read`. Same shape as the `zValidator` one above,
1000
1237
  * one step further along.
1238
+ *
1239
+ * The emitted body middleware names `z.input` and `z.output` as well, so a service whose only use
1240
+ * of Zod is a request body still needs it.
1001
1241
  */
1002
- const usesZod = entries.some((entry) => inputTypeOf(entry) !== undefined || entry.names.response !== undefined);
1003
- const validates = entries.some((entry) => entry.validators.filter(([target]) => entry.dispatched === undefined || target !== VALIDATOR_TARGET.body).length > 0);
1242
+ const usesZod = mountsBody ||
1243
+ // `SYNC` annotates its schema parameter `z.ZodType`, so the value import is load-bearing.
1244
+ validates ||
1245
+ entries.some((entry) => inputTypeOf(entry) !== undefined || entry.names.response !== undefined);
1004
1246
  const runtimeModule = JSON.stringify(emitted.options.runtimeModule);
1005
1247
  /**
1006
1248
  * **One base sub-app, mounted with `app.route()`. Hono's own nesting, not a rewritten path on
@@ -1012,8 +1254,8 @@ type Produced<T> = T extends (...args: never[]) => unknown
1012
1254
  const usesBasePath = basePaths.length > 0;
1013
1255
  const needsHonoValue = subApps.size > 0 || usesBasePath;
1014
1256
  return `${generatedBanner(emitted.options.regenerateHint)}
1015
- ${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";'}
1016
- ${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};` : ""}${dispatchesBody ? `\nimport { byContentType } from ${runtimeModule};` : ""}${mountsOptionalBody ? `\nimport { optionalBody } from ${runtimeModule};` : ""}
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};` : ""}
1017
1259
  ${imports}
1018
1260
  /**
1019
1261
  * One method per operation, each concretely typed from the schemas it validates against.
@@ -1021,7 +1263,7 @@ ${imports}
1021
1263
  * There is no cast anywhere in this file, and no dynamic lookup: the generated call sites name the
1022
1264
  * method, so an implementation whose input or output does not match the contract fails to compile.
1023
1265
  */
1024
- ${fieldsHelper}${declaredHelper}export interface Operations {
1266
+ ${syncHelper}${bodyHelper}${fieldsHelper}${declaredHelper}export interface Operations {
1025
1267
  ${methods.join("\n")}
1026
1268
  }
1027
1269
 
@@ -1,5 +1,5 @@
1
1
  import type { Context, Env, Input, MiddlewareHandler } from "hono";
2
- import type { input, output, ZodType } from "zod";
2
+ import type { ZodType } from "zod";
3
3
  /**
4
4
  * One arm of an operation's declared response set, as the document publishes it.
5
5
  *
@@ -36,7 +36,15 @@ export interface ResponseArm {
36
36
  }[];
37
37
  readonly when?: {
38
38
  readonly property: string;
39
- readonly value: boolean | string;
39
+ /**
40
+ * **A number too, because a `@statusCode` union selects by the status itself.**
41
+ *
42
+ * `model Created { @statusCode statusCode: 200 | 201 }` names the property that chooses, and
43
+ * its values are the statuses. The discriminator case carries a boolean or string literal off
44
+ * the body instead; both are the same question - which arm did the handler mean - so both use
45
+ * this one field.
46
+ */
47
+ readonly value: boolean | number | string;
40
48
  };
41
49
  }
42
50
  /**
@@ -149,88 +157,20 @@ export declare function selectContentType(accept: string | undefined, offered: r
149
157
  */
150
158
  export declare const headOnly: MiddlewareHandler;
151
159
  /**
152
- * The `zValidator` targets a request BODY can be read from. Hono extracts `"json"` with
153
- * `c.req.json()` and `"form"` with `c.req.parseBody()`, and those are the only two that read a body.
154
- */
155
- export type BodyTarget = "json" | "form";
156
- /**
157
- * Apply the validator that parses the media type the request actually carries.
158
- *
159
- * A route may declare several request media types needing different parsers -- `addPet` in the
160
- * Swagger Petstore accepts JSON, XML and urlencoded on one path. `zValidator`'s target is fixed when
161
- * the server is generated; which parser applies is decided by the caller's `Content-Type` when the
162
- * request arrives. Those are different times, and only the second one has the answer.
163
- *
164
- * **Before this, one target was chosen for the whole route and everything else was rejected.** A
165
- * form-encoded body to a route declaring JSON first was handed to `c.req.json()` and answered 400,
166
- * with no diagnostic anywhere. The status looked like the caller's fault and was not.
167
- *
168
- * Parameters after the media type (`; charset=utf-8`, `; boundary=...`) are not part of the match,
169
- * which matters because a multipart request always carries a boundary.
160
+ * **The request-body middleware used to live here, and it moved into `app.gen.ts`.**
170
161
  *
171
- * A `Content-Type` matching nothing declared falls through to the first validator, which reproduces
172
- * the previous behaviour exactly for that case: the body fails to parse and `deps.invalid` answers.
173
- * No status is invented here that the document does not describe.
162
+ * `byContentType` and `optionalBody` were exported from this module and imported by the generated
163
+ * server, which made them part of a SECOND contract this package has: what an application that
164
+ * points `runtime-module` at a module of its own must export. That contract is easy to break without
165
+ * noticing, and it was the reason a required single-media-type body kept `zValidator` - whose
166
+ * `HTTPException` on an unreadable body is a `text/plain` 400 raised before `deps.invalid`, escaping
167
+ * the app's error envelope. Routing those through `byContentType` closed the gap in one line and
168
+ * made that export mandatory for every substituting app: measured, 15 arms red.
174
169
  *
175
- * **The validated body is published under `"json"` whichever parser produced it**, and that is what
176
- * makes a dispatched route the same shape as every other one downstream. `ValidationTargets` is a
177
- * closed union in Hono, so there is no seventh name to coin for "the body"; `"json"` is already the
178
- * slot this emitter reads a body from, and using it here means the handler spreads one
179
- * `c.req.valid("json")` exactly as it does for a route declaring a single media type.
180
- *
181
- * **The alternative was a middleware that publishes under whichever target ran**, and it was worse
182
- * in two ways that both shipped. `byContentType` was a bare `MiddlewareHandler`, which contributes no
183
- * `Input` to the chain, so the generated `c.req.valid("json")` did not compile at all
184
- * (`TS2345: Argument of type '"json"' is not assignable to parameter of type '"header"'`), and the
185
- * handler's declared input type omitted the body entirely because the body was not among the route's
186
- * ordinary validators. Publishing to one known slot removes both, rather than typing around them.
187
- */
188
- export declare function byContentType<E extends Env, S extends ZodType>(schema: S, invalid: <P extends string, I extends Input>(result: {
189
- readonly success: boolean;
190
- }, c: Context<E, P, I>) => Response | undefined,
191
- /**
192
- * **A NON-EMPTY list, stated in the type.** The fallback below reads the first branch, and
193
- * `branches[0]` on a plain array is `T | undefined`, which was silenced with a cast. A tuple says
194
- * the same thing the emitter already guarantees - this middleware is only ever emitted for a route
195
- * declaring at least one request media type - and removes the cast rather than typing around it.
170
+ * Emitting the middleware instead closes the gap and SHRINKS the runtime contract by these two
171
+ * names. Nothing generated imports them, so keeping them here would leave a second implementation of
172
+ * body reading that nothing exercises - which is how two copies of one rule drift apart.
196
173
  */
197
- branches: readonly [
198
- readonly [mediaType: string, target: BodyTarget],
199
- ...(readonly [mediaType: string, target: BodyTarget])[]
200
- ]): MiddlewareHandler<E, string, {
201
- in: {
202
- json: input<S>;
203
- };
204
- out: {
205
- json: output<S>;
206
- };
207
- }>;
208
- /**
209
- * Validate a body the document says is OPTIONAL, and let a request carrying none through.
210
- *
211
- * **`requestBody.required: false` means a request with no body is one the contract permits**, and a
212
- * plain `zValidator` refused it. Measured against @hono/zod-validator 0.9.0 and hono 4.12.26: a
213
- * `POST` with `content-type: application/json` and no body answered **400 `Malformed JSON in request
214
- * body`** as `text/plain` - raised before the `invalid` hook, so outside the app's error envelope
215
- * entirely. A service refusing what its own document allows, in a shape that document forbids.
216
- *
217
- * **Nothing is published when the body is absent**, so `c.req.valid(...)` reads `undefined` and the
218
- * handler is told the truth. That is why an optional body is a NAMED property on the input rather
219
- * than merged into it: a merge has no way to say "these are here only sometimes" without making
220
- * every one of them optional, which is a weaker and different claim about the body that IS sent.
221
- *
222
- * A body that is present but unreadable still fails, through `invalid`, so the app's envelope holds.
223
- */
224
- export declare function optionalBody<E extends Env, S extends ZodType>(schema: S, invalid: <P extends string, I extends Input>(result: {
225
- readonly success: boolean;
226
- }, c: Context<E, P, I>) => Response | undefined, target: BodyTarget): MiddlewareHandler<E, string, {
227
- in: {
228
- json: input<S> | undefined;
229
- };
230
- out: {
231
- json: output<S> | undefined;
232
- };
233
- }>;
234
174
  /**
235
175
  * What the app provides. One object, passed once, rather than a module the generated file imports by
236
176
  * path. A generated server that hard-codes `../../backend.js` is only usable by the project it was
@@ -103,168 +103,3 @@ export function selectContentType(accept, offered) {
103
103
  * type. Measured: `hc<typeof app>` resolved a wrapped route's body to `unknown`.
104
104
  */
105
105
  export const headOnly = async (c, next) => c.req.method === "HEAD" ? next() : c.notFound();
106
- /**
107
- * The request body, read the way the target says to read it.
108
- *
109
- * The same two readers Hono's own `validator` uses, and the same rejection for a body that is not
110
- * the JSON it claims to be. `parseBody({ all: true })` builds the object Hono's `"form"` target
111
- * builds: a repeated key and a `key[]` name both become an array, everything else stays a string.
112
- */
113
- const UNREADABLE = Symbol("a body that is not what its content type claims");
114
- async function readBody(c, target) {
115
- if (target === "form")
116
- return c.req.parseBody({ all: true });
117
- try {
118
- return await c.req.json();
119
- }
120
- catch {
121
- return UNREADABLE;
122
- }
123
- }
124
- /**
125
- * The failure a malformed body produces, reported through the app's `invalid` hook like any other.
126
- *
127
- * **It used to `throw new HTTPException(400)`, which never reaches that hook.** The response was
128
- * `text/plain` with a bare message, so an API whose document declares a JSON error envelope answered
129
- * a shape its own contract forbids - and the app had no way to intervene, because the throw happened
130
- * before its code ran.
131
- *
132
- * Parsing `undefined` against the body's own schema is what produces a real Zod failure, so
133
- * `deps.invalid` receives the shape it receives for every other rejection rather than a special case
134
- * it has to know about. A schema that somehow accepts `undefined` still fails here: a body that
135
- * could not be read is not a body that validated.
136
- */
137
- async function unreadableResult(schema) {
138
- const parsed = await schema.safeParseAsync(undefined);
139
- return parsed.success ? { success: false } : parsed;
140
- }
141
- /**
142
- * The slot a validated request body is published under, whichever parser produced it.
143
- *
144
- * `ValidationTargets` is a closed union in Hono, so a body cannot be given a name of its own. This
145
- * is the slot the generated code already reads a body from when one media type is declared, so using
146
- * it for a dispatched body is what keeps the two cases identical downstream.
147
- */
148
- const BODY_TARGET = "json";
149
- /**
150
- * Apply the validator that parses the media type the request actually carries.
151
- *
152
- * A route may declare several request media types needing different parsers -- `addPet` in the
153
- * Swagger Petstore accepts JSON, XML and urlencoded on one path. `zValidator`'s target is fixed when
154
- * the server is generated; which parser applies is decided by the caller's `Content-Type` when the
155
- * request arrives. Those are different times, and only the second one has the answer.
156
- *
157
- * **Before this, one target was chosen for the whole route and everything else was rejected.** A
158
- * form-encoded body to a route declaring JSON first was handed to `c.req.json()` and answered 400,
159
- * with no diagnostic anywhere. The status looked like the caller's fault and was not.
160
- *
161
- * Parameters after the media type (`; charset=utf-8`, `; boundary=...`) are not part of the match,
162
- * which matters because a multipart request always carries a boundary.
163
- *
164
- * A `Content-Type` matching nothing declared falls through to the first validator, which reproduces
165
- * the previous behaviour exactly for that case: the body fails to parse and `deps.invalid` answers.
166
- * No status is invented here that the document does not describe.
167
- *
168
- * **The validated body is published under `"json"` whichever parser produced it**, and that is what
169
- * makes a dispatched route the same shape as every other one downstream. `ValidationTargets` is a
170
- * closed union in Hono, so there is no seventh name to coin for "the body"; `"json"` is already the
171
- * slot this emitter reads a body from, and using it here means the handler spreads one
172
- * `c.req.valid("json")` exactly as it does for a route declaring a single media type.
173
- *
174
- * **The alternative was a middleware that publishes under whichever target ran**, and it was worse
175
- * in two ways that both shipped. `byContentType` was a bare `MiddlewareHandler`, which contributes no
176
- * `Input` to the chain, so the generated `c.req.valid("json")` did not compile at all
177
- * (`TS2345: Argument of type '"json"' is not assignable to parameter of type '"header"'`), and the
178
- * handler's declared input type omitted the body entirely because the body was not among the route's
179
- * ordinary validators. Publishing to one known slot removes both, rather than typing around them.
180
- */
181
- export function byContentType(schema, invalid,
182
- /**
183
- * **A NON-EMPTY list, stated in the type.** The fallback below reads the first branch, and
184
- * `branches[0]` on a plain array is `T | undefined`, which was silenced with a cast. A tuple says
185
- * the same thing the emitter already guarantees - this middleware is only ever emitted for a route
186
- * declaring at least one request media type - and removes the cast rather than typing around it.
187
- */
188
- branches) {
189
- return async (c, next) => {
190
- const declared = (c.req.header("content-type") ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
191
- const matched = branches.find(([mediaType]) => mediaType.toLowerCase() === declared);
192
- const [, target] = matched ?? branches[0];
193
- /**
194
- * **Registered against the body slot whichever parser runs**, so the validated body is
195
- * published under one target and the handler reads it exactly as it reads a single-media-type
196
- * one. Hono's `validator` is what `@hono/zod-validator` is built on, so this is the same
197
- * extraction, the same `HTTPException` on malformed JSON, and the same default rejection.
198
- *
199
- * **Hono's own `validator` is deliberately not called here.** Its target fixes the reader at
200
- * registration time, and the whole point of this function is that the reader is chosen when the
201
- * request arrives. What it does instead is reproduce `validator`'s observable behaviour for both
202
- * targets: {@link readBody} performs the same two extractions and raises the same
203
- * `HTTPException` on malformed JSON, and the rejection below is `zValidator`'s own.
204
- *
205
- * Annotated rather than inlined because `Context` is invariant in its environment and in its
206
- * `Input`. Same reason `RouteDeps` is parameterised.
207
- */
208
- const parse = async (ctx, proceed) => {
209
- const raw = await readBody(ctx, target);
210
- const result = raw === UNREADABLE ? await unreadableResult(schema) : await schema.safeParseAsync(raw);
211
- const response = invalid(result, ctx);
212
- if (response !== undefined)
213
- return response;
214
- // `zValidator`'s own answer when a hook declines to, kept identical so a dispatched route
215
- // and a single-media-type one reject a bad body the same way.
216
- if (!result.success)
217
- return ctx.json(result, 400);
218
- ctx.req.addValidatedData(BODY_TARGET, ("data" in result ? result.data : undefined) ?? {});
219
- await proceed();
220
- return undefined;
221
- };
222
- return parse(c, next);
223
- };
224
- }
225
- /**
226
- * Validate a body the document says is OPTIONAL, and let a request carrying none through.
227
- *
228
- * **`requestBody.required: false` means a request with no body is one the contract permits**, and a
229
- * plain `zValidator` refused it. Measured against @hono/zod-validator 0.9.0 and hono 4.12.26: a
230
- * `POST` with `content-type: application/json` and no body answered **400 `Malformed JSON in request
231
- * body`** as `text/plain` - raised before the `invalid` hook, so outside the app's error envelope
232
- * entirely. A service refusing what its own document allows, in a shape that document forbids.
233
- *
234
- * **Nothing is published when the body is absent**, so `c.req.valid(...)` reads `undefined` and the
235
- * handler is told the truth. That is why an optional body is a NAMED property on the input rather
236
- * than merged into it: a merge has no way to say "these are here only sometimes" without making
237
- * every one of them optional, which is a weaker and different claim about the body that IS sent.
238
- *
239
- * A body that is present but unreadable still fails, through `invalid`, so the app's envelope holds.
240
- */
241
- export function optionalBody(schema, invalid, target) {
242
- return async (c, next) => {
243
- if (!hasBody(c)) {
244
- await next();
245
- return undefined;
246
- }
247
- const raw = await readBody(c, target);
248
- const result = raw === UNREADABLE ? await unreadableResult(schema) : await schema.safeParseAsync(raw);
249
- const response = invalid(result, c);
250
- if (response !== undefined)
251
- return response;
252
- if (!result.success)
253
- return c.json(result, 400);
254
- c.req.addValidatedData(BODY_TARGET, ("data" in result ? result.data : undefined) ?? {});
255
- await next();
256
- return undefined;
257
- };
258
- }
259
- /**
260
- * Whether the request carries a body at all.
261
- *
262
- * Both halves are load-bearing. The platform reports `null` for a request sent without one, and a
263
- * caller may instead send `content-length: 0`, which is a body of no bytes and reads the same way to
264
- * anything downstream. Neither alone covers what arrives.
265
- */
266
- function hasBody(c) {
267
- if (c.req.raw.body === null)
268
- return false;
269
- return (c.req.header("content-length") ?? "").trim() !== "0";
270
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typespec-hono",
3
- "version": "0.19.1",
3
+ "version": "0.21.0",
4
4
  "description": "TypeSpec emitter: generate a Hono server, and the Zod validators it enforces, from an HTTP service definition, agreeing with the OpenAPI document @typespec/openapi3 publishes from the same source.",
5
5
  "keywords": [
6
6
  "cloudflare-workers",
@@ -44,7 +44,7 @@
44
44
  "provenance": true
45
45
  },
46
46
  "dependencies": {
47
- "typespec-http-zod": "^0.21.0"
47
+ "typespec-http-zod": "^0.24.0"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@hono/zod-openapi": "^1.4.0",
@@ -67,14 +67,14 @@
67
67
  "typescript": "~7.0.2",
68
68
  "typespec-hono": "link:.",
69
69
  "vitest": "^4.1.9",
70
- "zod": "^4.4.3"
70
+ "zod": "^4.5.2"
71
71
  },
72
72
  "peerDependencies": {
73
73
  "@hono/zod-validator": "^0.8.0 || ^0.9.0",
74
74
  "@typespec/compiler": "^1.15.0",
75
75
  "@typespec/http": "^1.15.0",
76
76
  "hono": "^4.12.0",
77
- "zod": "^4.0.0"
77
+ "zod": "^4.5.0"
78
78
  },
79
79
  "engines": {
80
80
  "node": ">=22.0.0"
package/src/runtime.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Context, Env, Input, MiddlewareHandler } from "hono";
2
- import type { input, output, ZodType } from "zod";
2
+ import type { ZodType } from "zod";
3
3
 
4
4
  /**
5
5
  * One arm of an operation's declared response set, as the document publishes it.
@@ -34,7 +34,15 @@ export interface ResponseArm {
34
34
  readonly headers?: readonly { readonly name: string; readonly property: string }[];
35
35
  readonly when?: {
36
36
  readonly property: string;
37
- readonly value: boolean | string;
37
+ /**
38
+ * **A number too, because a `@statusCode` union selects by the status itself.**
39
+ *
40
+ * `model Created { @statusCode statusCode: 200 | 201 }` names the property that chooses, and
41
+ * its values are the statuses. The discriminator case carries a boolean or string literal off
42
+ * the body instead; both are the same question - which arm did the handler mean - so both use
43
+ * this one field.
44
+ */
45
+ readonly value: boolean | number | string;
38
46
  };
39
47
  }
40
48
 
@@ -206,201 +214,20 @@ export const headOnly: MiddlewareHandler = async (c, next) =>
206
214
  c.req.method === "HEAD" ? next() : c.notFound();
207
215
 
208
216
  /**
209
- * The `zValidator` targets a request BODY can be read from. Hono extracts `"json"` with
210
- * `c.req.json()` and `"form"` with `c.req.parseBody()`, and those are the only two that read a body.
211
- */
212
- export type BodyTarget = "json" | "form";
213
-
214
- /**
215
- * The request body, read the way the target says to read it.
216
- *
217
- * The same two readers Hono's own `validator` uses, and the same rejection for a body that is not
218
- * the JSON it claims to be. `parseBody({ all: true })` builds the object Hono's `"form"` target
219
- * builds: a repeated key and a `key[]` name both become an array, everything else stays a string.
220
- */
221
- const UNREADABLE = Symbol("a body that is not what its content type claims");
222
-
223
- async function readBody(c: Context, target: BodyTarget): Promise<unknown> {
224
- if (target === "form") return c.req.parseBody({ all: true });
225
- try {
226
- return await c.req.json();
227
- } catch {
228
- return UNREADABLE;
229
- }
230
- }
231
-
232
- /**
233
- * The failure a malformed body produces, reported through the app's `invalid` hook like any other.
234
- *
235
- * **It used to `throw new HTTPException(400)`, which never reaches that hook.** The response was
236
- * `text/plain` with a bare message, so an API whose document declares a JSON error envelope answered
237
- * a shape its own contract forbids - and the app had no way to intervene, because the throw happened
238
- * before its code ran.
239
- *
240
- * Parsing `undefined` against the body's own schema is what produces a real Zod failure, so
241
- * `deps.invalid` receives the shape it receives for every other rejection rather than a special case
242
- * it has to know about. A schema that somehow accepts `undefined` still fails here: a body that
243
- * could not be read is not a body that validated.
244
- */
245
- async function unreadableResult(schema: ZodType): Promise<{ readonly success: boolean }> {
246
- const parsed = await schema.safeParseAsync(undefined);
247
- return parsed.success ? { success: false } : parsed;
248
- }
249
-
250
- /**
251
- * The slot a validated request body is published under, whichever parser produced it.
252
- *
253
- * `ValidationTargets` is a closed union in Hono, so a body cannot be given a name of its own. This
254
- * is the slot the generated code already reads a body from when one media type is declared, so using
255
- * it for a dispatched body is what keeps the two cases identical downstream.
256
- */
257
- const BODY_TARGET = "json";
258
-
259
- /**
260
- * Apply the validator that parses the media type the request actually carries.
261
- *
262
- * A route may declare several request media types needing different parsers -- `addPet` in the
263
- * Swagger Petstore accepts JSON, XML and urlencoded on one path. `zValidator`'s target is fixed when
264
- * the server is generated; which parser applies is decided by the caller's `Content-Type` when the
265
- * request arrives. Those are different times, and only the second one has the answer.
266
- *
267
- * **Before this, one target was chosen for the whole route and everything else was rejected.** A
268
- * form-encoded body to a route declaring JSON first was handed to `c.req.json()` and answered 400,
269
- * with no diagnostic anywhere. The status looked like the caller's fault and was not.
270
- *
271
- * Parameters after the media type (`; charset=utf-8`, `; boundary=...`) are not part of the match,
272
- * which matters because a multipart request always carries a boundary.
273
- *
274
- * A `Content-Type` matching nothing declared falls through to the first validator, which reproduces
275
- * the previous behaviour exactly for that case: the body fails to parse and `deps.invalid` answers.
276
- * No status is invented here that the document does not describe.
217
+ * **The request-body middleware used to live here, and it moved into `app.gen.ts`.**
277
218
  *
278
- * **The validated body is published under `"json"` whichever parser produced it**, and that is what
279
- * makes a dispatched route the same shape as every other one downstream. `ValidationTargets` is a
280
- * closed union in Hono, so there is no seventh name to coin for "the body"; `"json"` is already the
281
- * slot this emitter reads a body from, and using it here means the handler spreads one
282
- * `c.req.valid("json")` exactly as it does for a route declaring a single media type.
219
+ * `byContentType` and `optionalBody` were exported from this module and imported by the generated
220
+ * server, which made them part of a SECOND contract this package has: what an application that
221
+ * points `runtime-module` at a module of its own must export. That contract is easy to break without
222
+ * noticing, and it was the reason a required single-media-type body kept `zValidator` - whose
223
+ * `HTTPException` on an unreadable body is a `text/plain` 400 raised before `deps.invalid`, escaping
224
+ * the app's error envelope. Routing those through `byContentType` closed the gap in one line and
225
+ * made that export mandatory for every substituting app: measured, 15 arms red.
283
226
  *
284
- * **The alternative was a middleware that publishes under whichever target ran**, and it was worse
285
- * in two ways that both shipped. `byContentType` was a bare `MiddlewareHandler`, which contributes no
286
- * `Input` to the chain, so the generated `c.req.valid("json")` did not compile at all
287
- * (`TS2345: Argument of type '"json"' is not assignable to parameter of type '"header"'`), and the
288
- * handler's declared input type omitted the body entirely because the body was not among the route's
289
- * ordinary validators. Publishing to one known slot removes both, rather than typing around them.
227
+ * Emitting the middleware instead closes the gap and SHRINKS the runtime contract by these two
228
+ * names. Nothing generated imports them, so keeping them here would leave a second implementation of
229
+ * body reading that nothing exercises - which is how two copies of one rule drift apart.
290
230
  */
291
- export function byContentType<E extends Env, S extends ZodType>(
292
- schema: S,
293
- invalid: <P extends string, I extends Input>(
294
- result: { readonly success: boolean },
295
- c: Context<E, P, I>,
296
- ) => Response | undefined,
297
- /**
298
- * **A NON-EMPTY list, stated in the type.** The fallback below reads the first branch, and
299
- * `branches[0]` on a plain array is `T | undefined`, which was silenced with a cast. A tuple says
300
- * the same thing the emitter already guarantees - this middleware is only ever emitted for a route
301
- * declaring at least one request media type - and removes the cast rather than typing around it.
302
- */
303
- branches: readonly [
304
- readonly [mediaType: string, target: BodyTarget],
305
- ...(readonly [mediaType: string, target: BodyTarget])[],
306
- ],
307
- ): MiddlewareHandler<E, string, { in: { json: input<S> }; out: { json: output<S> } }> {
308
- return async (c, next) => {
309
- const declared = (c.req.header("content-type") ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
310
- const matched = branches.find(([mediaType]) => mediaType.toLowerCase() === declared);
311
- const [, target] = matched ?? branches[0];
312
- /**
313
- * **Registered against the body slot whichever parser runs**, so the validated body is
314
- * published under one target and the handler reads it exactly as it reads a single-media-type
315
- * one. Hono's `validator` is what `@hono/zod-validator` is built on, so this is the same
316
- * extraction, the same `HTTPException` on malformed JSON, and the same default rejection.
317
- *
318
- * **Hono's own `validator` is deliberately not called here.** Its target fixes the reader at
319
- * registration time, and the whole point of this function is that the reader is chosen when the
320
- * request arrives. What it does instead is reproduce `validator`'s observable behaviour for both
321
- * targets: {@link readBody} performs the same two extractions and raises the same
322
- * `HTTPException` on malformed JSON, and the rejection below is `zValidator`'s own.
323
- *
324
- * Annotated rather than inlined because `Context` is invariant in its environment and in its
325
- * `Input`. Same reason `RouteDeps` is parameterised.
326
- */
327
- const parse: MiddlewareHandler<
328
- E,
329
- string,
330
- { in: { json: input<S> }; out: { json: output<S> } }
331
- > = async (ctx, proceed) => {
332
- const raw = await readBody(ctx, target);
333
- const result =
334
- raw === UNREADABLE ? await unreadableResult(schema) : await schema.safeParseAsync(raw);
335
- const response = invalid(result, ctx);
336
- if (response !== undefined) return response;
337
- // `zValidator`'s own answer when a hook declines to, kept identical so a dispatched route
338
- // and a single-media-type one reject a bad body the same way.
339
- if (!result.success) return ctx.json(result, 400);
340
- ctx.req.addValidatedData(BODY_TARGET, ("data" in result ? result.data : undefined) ?? {});
341
- await proceed();
342
- return undefined;
343
- };
344
- return parse(c, next);
345
- };
346
- }
347
-
348
- /**
349
- * Validate a body the document says is OPTIONAL, and let a request carrying none through.
350
- *
351
- * **`requestBody.required: false` means a request with no body is one the contract permits**, and a
352
- * plain `zValidator` refused it. Measured against @hono/zod-validator 0.9.0 and hono 4.12.26: a
353
- * `POST` with `content-type: application/json` and no body answered **400 `Malformed JSON in request
354
- * body`** as `text/plain` - raised before the `invalid` hook, so outside the app's error envelope
355
- * entirely. A service refusing what its own document allows, in a shape that document forbids.
356
- *
357
- * **Nothing is published when the body is absent**, so `c.req.valid(...)` reads `undefined` and the
358
- * handler is told the truth. That is why an optional body is a NAMED property on the input rather
359
- * than merged into it: a merge has no way to say "these are here only sometimes" without making
360
- * every one of them optional, which is a weaker and different claim about the body that IS sent.
361
- *
362
- * A body that is present but unreadable still fails, through `invalid`, so the app's envelope holds.
363
- */
364
- export function optionalBody<E extends Env, S extends ZodType>(
365
- schema: S,
366
- invalid: <P extends string, I extends Input>(
367
- result: { readonly success: boolean },
368
- c: Context<E, P, I>,
369
- ) => Response | undefined,
370
- target: BodyTarget,
371
- ): MiddlewareHandler<
372
- E,
373
- string,
374
- { in: { json: input<S> | undefined }; out: { json: output<S> | undefined } }
375
- > {
376
- return async (c, next) => {
377
- if (!hasBody(c)) {
378
- await next();
379
- return undefined;
380
- }
381
- const raw = await readBody(c, target);
382
- const result =
383
- raw === UNREADABLE ? await unreadableResult(schema) : await schema.safeParseAsync(raw);
384
- const response = invalid(result, c as never);
385
- if (response !== undefined) return response;
386
- if (!result.success) return c.json(result, 400);
387
- c.req.addValidatedData(BODY_TARGET, ("data" in result ? result.data : undefined) ?? {});
388
- await next();
389
- return undefined;
390
- };
391
- }
392
-
393
- /**
394
- * Whether the request carries a body at all.
395
- *
396
- * Both halves are load-bearing. The platform reports `null` for a request sent without one, and a
397
- * caller may instead send `content-length: 0`, which is a body of no bytes and reads the same way to
398
- * anything downstream. Neither alone covers what arrives.
399
- */
400
- function hasBody(c: Context): boolean {
401
- if (c.req.raw.body === null) return false;
402
- return (c.req.header("content-length") ?? "").trim() !== "0";
403
- }
404
231
 
405
232
  /**
406
233
  * What the app provides. One object, passed once, rather than a module the generated file imports by