typespec-hono 0.20.0 → 0.22.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
@@ -1,5 +1,5 @@
1
1
  import { renderSecurity } from "./security.js";
2
- import { isRawBinaryMediaType, objectKey, } from "typespec-http-zod";
2
+ import { isRawBinaryMediaType, jsDocComment, objectKey, } from "typespec-http-zod";
3
3
  /**
4
4
  * The header every emitted file carries.
5
5
  *
@@ -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
  /**
@@ -556,7 +758,7 @@ type Fields<T> = string extends keyof T ? ([T[string]] extends [never] ? unknown
556
758
  ? body
557
759
  : `${body} & ${envelopeType}`;
558
760
  const signature = `ctx: Ctx, input: ${input ?? EMPTY_INPUT}`;
559
- const doc = route.summary === undefined ? "" : `\t/** ${route.summary} */\n`;
761
+ const doc = jsDocComment(route.summary, "\t");
560
762
  return `${doc}\t${route.operationId}(${signature}): Awaitable<Result<${output}>>;`;
561
763
  });
562
764
  /**
@@ -568,7 +770,6 @@ type Fields<T> = string extends keyof T ? ([T[string]] extends [never] ? unknown
568
770
  * mentions a name is how this package lost the `byContentType` import: the call gained an argument
569
771
  * and the substring stopped matching, so a module referenced a function it no longer imported.
570
772
  */
571
- const mountsOptionalBody = entries.some((entry) => entry.optional !== undefined);
572
773
  const returnsAnything = entries.some((entry) => entry.names.response !== undefined);
573
774
  const declaredHelper = returnsAnything
574
775
  ? `/**
@@ -726,41 +927,36 @@ type Produced<T> = T extends (...args: never[]) => unknown
726
927
  * from that type -- measured, `hc<typeof app>` resolved the wrapped route's body to `unknown`.
727
928
  */
728
929
  /**
729
- * Where the document declares request media types needing different parsers, the parser is
730
- * chosen from the request's `Content-Type`. `byContentType` takes the body's schema and
731
- * `deps.invalid` and validates with them, so it stands in for that target's `zValidator`
732
- * 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.
733
932
  *
734
- * **It used to wrap pre-built `zValidator`s and publish under whichever one ran, and that
735
- * emitted code a consumer could not compile.** A plain `MiddlewareHandler` contributes no
736
- * `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
737
936
  * `TS2345: Argument of type '"json"' is not assignable to parameter of type '"header"'`, and
738
- * the handler's declared input type omitted the body entirely because the body was not among
739
- * 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.
740
943
  */
741
- const dispatch = entry.dispatched === undefined
944
+ const bodyMiddleware = entry.body === undefined
742
945
  ? []
743
946
  : [
744
- `\t\tbyContentType(${identifierOf(entry)}, deps.invalid, [`,
745
- ...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)}],`),
746
949
  "\t\t]),",
747
950
  ];
748
951
  const middleware = [
749
952
  ...(headOnly ? ["\t\theadOnly,"] : []),
750
953
  ...gate,
751
954
  ...validators
752
- // The dispatched body's own `zValidator` is `byContentType`; emitting both would parse
753
- // the body twice and reject every media type but one. An optional body is the same
754
- // story with `optionalBody`, which additionally lets a bodyless request through.
755
- .filter(([target]) => (entry.dispatched === undefined && entry.optional === undefined) ||
756
- target !== VALIDATOR_TARGET.body)
757
- .map(([target, name]) => `\t\tzValidator(${JSON.stringify(target)}, ${name}, deps.invalid),`),
758
- ...dispatch,
759
- ...(entry.optional === undefined
760
- ? []
761
- : [
762
- `\t\toptionalBody(${entry.optional.identifier}, deps.invalid, ${JSON.stringify(entry.optional.target)}),`,
763
- ]),
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,
764
960
  ];
765
961
  /**
766
962
  * Whether the operation requires a caller, and NOTHING else about the caller.
@@ -1021,7 +1217,6 @@ type Produced<T> = T extends (...args: never[]) => unknown
1021
1217
  * safe in a way these are not: an identifier absent from the text is genuinely not needed, so the
1022
1218
  * check cannot be wrong in the direction that breaks a build.
1023
1219
  */
1024
- const dispatchesBody = entries.some((entry) => entry.dispatched !== undefined);
1025
1220
  /**
1026
1221
  * **Imported only where a route actually validates something, like every other value import here.**
1027
1222
  *
@@ -1032,7 +1227,7 @@ type Produced<T> = T extends (...args: never[]) => unknown
1032
1227
  * `TS6133: 'zValidator' is declared but its value is never read`.
1033
1228
  *
1034
1229
  * Counted from the same filtered list the middleware is rendered from, so it cannot disagree with
1035
- * 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`.
1036
1231
  */
1037
1232
  /**
1038
1233
  * **`z` is only ever reached through `z.infer`**, which appears where an operation has an input
@@ -1040,9 +1235,14 @@ type Produced<T> = T extends (...args: never[]) => unknown
1040
1235
  * neither, so the import was written and never used:
1041
1236
  * `TS6133: 'z' is declared but its value is never read`. Same shape as the `zValidator` one above,
1042
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.
1043
1241
  */
1044
- const usesZod = entries.some((entry) => inputTypeOf(entry) !== undefined || entry.names.response !== undefined);
1045
- 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);
1046
1246
  const runtimeModule = JSON.stringify(emitted.options.runtimeModule);
1047
1247
  /**
1048
1248
  * **One base sub-app, mounted with `app.route()`. Hono's own nesting, not a rewritten path on
@@ -1054,8 +1254,8 @@ type Produced<T> = T extends (...args: never[]) => unknown
1054
1254
  const usesBasePath = basePaths.length > 0;
1055
1255
  const needsHonoValue = subApps.size > 0 || usesBasePath;
1056
1256
  return `${generatedBanner(emitted.options.regenerateHint)}
1057
- ${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";'}
1058
- ${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};` : ""}
1059
1259
  ${imports}
1060
1260
  /**
1061
1261
  * One method per operation, each concretely typed from the schemas it validates against.
@@ -1063,7 +1263,7 @@ ${imports}
1063
1263
  * There is no cast anywhere in this file, and no dynamic lookup: the generated call sites name the
1064
1264
  * method, so an implementation whose input or output does not match the contract fails to compile.
1065
1265
  */
1066
- ${fieldsHelper}${declaredHelper}export interface Operations {
1266
+ ${syncHelper}${bodyHelper}${fieldsHelper}${declaredHelper}export interface Operations {
1067
1267
  ${methods.join("\n")}
1068
1268
  }
1069
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
  *
@@ -157,88 +157,20 @@ export declare function selectContentType(accept: string | undefined, offered: r
157
157
  */
158
158
  export declare const headOnly: MiddlewareHandler;
159
159
  /**
160
- * The `zValidator` targets a request BODY can be read from. Hono extracts `"json"` with
161
- * `c.req.json()` and `"form"` with `c.req.parseBody()`, and those are the only two that read a body.
162
- */
163
- export type BodyTarget = "json" | "form";
164
- /**
165
- * Apply the validator that parses the media type the request actually carries.
166
- *
167
- * A route may declare several request media types needing different parsers -- `addPet` in the
168
- * Swagger Petstore accepts JSON, XML and urlencoded on one path. `zValidator`'s target is fixed when
169
- * the server is generated; which parser applies is decided by the caller's `Content-Type` when the
170
- * request arrives. Those are different times, and only the second one has the answer.
171
- *
172
- * **Before this, one target was chosen for the whole route and everything else was rejected.** A
173
- * form-encoded body to a route declaring JSON first was handed to `c.req.json()` and answered 400,
174
- * with no diagnostic anywhere. The status looked like the caller's fault and was not.
175
- *
176
- * Parameters after the media type (`; charset=utf-8`, `; boundary=...`) are not part of the match,
177
- * 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`.**
178
161
  *
179
- * A `Content-Type` matching nothing declared falls through to the first validator, which reproduces
180
- * the previous behaviour exactly for that case: the body fails to parse and `deps.invalid` answers.
181
- * 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.
182
169
  *
183
- * **The validated body is published under `"json"` whichever parser produced it**, and that is what
184
- * makes a dispatched route the same shape as every other one downstream. `ValidationTargets` is a
185
- * closed union in Hono, so there is no seventh name to coin for "the body"; `"json"` is already the
186
- * slot this emitter reads a body from, and using it here means the handler spreads one
187
- * `c.req.valid("json")` exactly as it does for a route declaring a single media type.
188
- *
189
- * **The alternative was a middleware that publishes under whichever target ran**, and it was worse
190
- * in two ways that both shipped. `byContentType` was a bare `MiddlewareHandler`, which contributes no
191
- * `Input` to the chain, so the generated `c.req.valid("json")` did not compile at all
192
- * (`TS2345: Argument of type '"json"' is not assignable to parameter of type '"header"'`), and the
193
- * handler's declared input type omitted the body entirely because the body was not among the route's
194
- * ordinary validators. Publishing to one known slot removes both, rather than typing around them.
195
- */
196
- export declare function byContentType<E extends Env, S extends ZodType>(schema: S, invalid: <P extends string, I extends Input>(result: {
197
- readonly success: boolean;
198
- }, c: Context<E, P, I>) => Response | undefined,
199
- /**
200
- * **A NON-EMPTY list, stated in the type.** The fallback below reads the first branch, and
201
- * `branches[0]` on a plain array is `T | undefined`, which was silenced with a cast. A tuple says
202
- * the same thing the emitter already guarantees - this middleware is only ever emitted for a route
203
- * 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.
204
173
  */
205
- branches: readonly [
206
- readonly [mediaType: string, target: BodyTarget],
207
- ...(readonly [mediaType: string, target: BodyTarget])[]
208
- ]): MiddlewareHandler<E, string, {
209
- in: {
210
- json: input<S>;
211
- };
212
- out: {
213
- json: output<S>;
214
- };
215
- }>;
216
- /**
217
- * Validate a body the document says is OPTIONAL, and let a request carrying none through.
218
- *
219
- * **`requestBody.required: false` means a request with no body is one the contract permits**, and a
220
- * plain `zValidator` refused it. Measured against @hono/zod-validator 0.9.0 and hono 4.12.26: a
221
- * `POST` with `content-type: application/json` and no body answered **400 `Malformed JSON in request
222
- * body`** as `text/plain` - raised before the `invalid` hook, so outside the app's error envelope
223
- * entirely. A service refusing what its own document allows, in a shape that document forbids.
224
- *
225
- * **Nothing is published when the body is absent**, so `c.req.valid(...)` reads `undefined` and the
226
- * handler is told the truth. That is why an optional body is a NAMED property on the input rather
227
- * than merged into it: a merge has no way to say "these are here only sometimes" without making
228
- * every one of them optional, which is a weaker and different claim about the body that IS sent.
229
- *
230
- * A body that is present but unreadable still fails, through `invalid`, so the app's envelope holds.
231
- */
232
- export declare function optionalBody<E extends Env, S extends ZodType>(schema: S, invalid: <P extends string, I extends Input>(result: {
233
- readonly success: boolean;
234
- }, c: Context<E, P, I>) => Response | undefined, target: BodyTarget): MiddlewareHandler<E, string, {
235
- in: {
236
- json: input<S> | undefined;
237
- };
238
- out: {
239
- json: output<S> | undefined;
240
- };
241
- }>;
242
174
  /**
243
175
  * What the app provides. One object, passed once, rather than a module the generated file imports by
244
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.20.0",
3
+ "version": "0.22.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.22.0"
47
+ "typespec-http-zod": "^0.25.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.
@@ -214,201 +214,20 @@ export const headOnly: MiddlewareHandler = async (c, next) =>
214
214
  c.req.method === "HEAD" ? next() : c.notFound();
215
215
 
216
216
  /**
217
- * The `zValidator` targets a request BODY can be read from. Hono extracts `"json"` with
218
- * `c.req.json()` and `"form"` with `c.req.parseBody()`, and those are the only two that read a body.
219
- */
220
- export type BodyTarget = "json" | "form";
221
-
222
- /**
223
- * The request body, read the way the target says to read it.
224
- *
225
- * The same two readers Hono's own `validator` uses, and the same rejection for a body that is not
226
- * the JSON it claims to be. `parseBody({ all: true })` builds the object Hono's `"form"` target
227
- * builds: a repeated key and a `key[]` name both become an array, everything else stays a string.
228
- */
229
- const UNREADABLE = Symbol("a body that is not what its content type claims");
230
-
231
- async function readBody(c: Context, target: BodyTarget): Promise<unknown> {
232
- if (target === "form") return c.req.parseBody({ all: true });
233
- try {
234
- return await c.req.json();
235
- } catch {
236
- return UNREADABLE;
237
- }
238
- }
239
-
240
- /**
241
- * The failure a malformed body produces, reported through the app's `invalid` hook like any other.
242
- *
243
- * **It used to `throw new HTTPException(400)`, which never reaches that hook.** The response was
244
- * `text/plain` with a bare message, so an API whose document declares a JSON error envelope answered
245
- * a shape its own contract forbids - and the app had no way to intervene, because the throw happened
246
- * before its code ran.
247
- *
248
- * Parsing `undefined` against the body's own schema is what produces a real Zod failure, so
249
- * `deps.invalid` receives the shape it receives for every other rejection rather than a special case
250
- * it has to know about. A schema that somehow accepts `undefined` still fails here: a body that
251
- * could not be read is not a body that validated.
252
- */
253
- async function unreadableResult(schema: ZodType): Promise<{ readonly success: boolean }> {
254
- const parsed = await schema.safeParseAsync(undefined);
255
- return parsed.success ? { success: false } : parsed;
256
- }
257
-
258
- /**
259
- * The slot a validated request body is published under, whichever parser produced it.
260
- *
261
- * `ValidationTargets` is a closed union in Hono, so a body cannot be given a name of its own. This
262
- * is the slot the generated code already reads a body from when one media type is declared, so using
263
- * it for a dispatched body is what keeps the two cases identical downstream.
264
- */
265
- const BODY_TARGET = "json";
266
-
267
- /**
268
- * Apply the validator that parses the media type the request actually carries.
269
- *
270
- * A route may declare several request media types needing different parsers -- `addPet` in the
271
- * Swagger Petstore accepts JSON, XML and urlencoded on one path. `zValidator`'s target is fixed when
272
- * the server is generated; which parser applies is decided by the caller's `Content-Type` when the
273
- * request arrives. Those are different times, and only the second one has the answer.
274
- *
275
- * **Before this, one target was chosen for the whole route and everything else was rejected.** A
276
- * form-encoded body to a route declaring JSON first was handed to `c.req.json()` and answered 400,
277
- * with no diagnostic anywhere. The status looked like the caller's fault and was not.
278
- *
279
- * Parameters after the media type (`; charset=utf-8`, `; boundary=...`) are not part of the match,
280
- * which matters because a multipart request always carries a boundary.
281
- *
282
- * A `Content-Type` matching nothing declared falls through to the first validator, which reproduces
283
- * the previous behaviour exactly for that case: the body fails to parse and `deps.invalid` answers.
284
- * No status is invented here that the document does not describe.
285
- *
286
- * **The validated body is published under `"json"` whichever parser produced it**, and that is what
287
- * makes a dispatched route the same shape as every other one downstream. `ValidationTargets` is a
288
- * closed union in Hono, so there is no seventh name to coin for "the body"; `"json"` is already the
289
- * slot this emitter reads a body from, and using it here means the handler spreads one
290
- * `c.req.valid("json")` exactly as it does for a route declaring a single media type.
217
+ * **The request-body middleware used to live here, and it moved into `app.gen.ts`.**
291
218
  *
292
- * **The alternative was a middleware that publishes under whichever target ran**, and it was worse
293
- * in two ways that both shipped. `byContentType` was a bare `MiddlewareHandler`, which contributes no
294
- * `Input` to the chain, so the generated `c.req.valid("json")` did not compile at all
295
- * (`TS2345: Argument of type '"json"' is not assignable to parameter of type '"header"'`), and the
296
- * handler's declared input type omitted the body entirely because the body was not among the route's
297
- * ordinary validators. Publishing to one known slot removes both, rather than typing around them.
298
- */
299
- export function byContentType<E extends Env, S extends ZodType>(
300
- schema: S,
301
- invalid: <P extends string, I extends Input>(
302
- result: { readonly success: boolean },
303
- c: Context<E, P, I>,
304
- ) => Response | undefined,
305
- /**
306
- * **A NON-EMPTY list, stated in the type.** The fallback below reads the first branch, and
307
- * `branches[0]` on a plain array is `T | undefined`, which was silenced with a cast. A tuple says
308
- * the same thing the emitter already guarantees - this middleware is only ever emitted for a route
309
- * declaring at least one request media type - and removes the cast rather than typing around it.
310
- */
311
- branches: readonly [
312
- readonly [mediaType: string, target: BodyTarget],
313
- ...(readonly [mediaType: string, target: BodyTarget])[],
314
- ],
315
- ): MiddlewareHandler<E, string, { in: { json: input<S> }; out: { json: output<S> } }> {
316
- return async (c, next) => {
317
- const declared = (c.req.header("content-type") ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
318
- const matched = branches.find(([mediaType]) => mediaType.toLowerCase() === declared);
319
- const [, target] = matched ?? branches[0];
320
- /**
321
- * **Registered against the body slot whichever parser runs**, so the validated body is
322
- * published under one target and the handler reads it exactly as it reads a single-media-type
323
- * one. Hono's `validator` is what `@hono/zod-validator` is built on, so this is the same
324
- * extraction, the same `HTTPException` on malformed JSON, and the same default rejection.
325
- *
326
- * **Hono's own `validator` is deliberately not called here.** Its target fixes the reader at
327
- * registration time, and the whole point of this function is that the reader is chosen when the
328
- * request arrives. What it does instead is reproduce `validator`'s observable behaviour for both
329
- * targets: {@link readBody} performs the same two extractions and raises the same
330
- * `HTTPException` on malformed JSON, and the rejection below is `zValidator`'s own.
331
- *
332
- * Annotated rather than inlined because `Context` is invariant in its environment and in its
333
- * `Input`. Same reason `RouteDeps` is parameterised.
334
- */
335
- const parse: MiddlewareHandler<
336
- E,
337
- string,
338
- { in: { json: input<S> }; out: { json: output<S> } }
339
- > = async (ctx, proceed) => {
340
- const raw = await readBody(ctx, target);
341
- const result =
342
- raw === UNREADABLE ? await unreadableResult(schema) : await schema.safeParseAsync(raw);
343
- const response = invalid(result, ctx);
344
- if (response !== undefined) return response;
345
- // `zValidator`'s own answer when a hook declines to, kept identical so a dispatched route
346
- // and a single-media-type one reject a bad body the same way.
347
- if (!result.success) return ctx.json(result, 400);
348
- ctx.req.addValidatedData(BODY_TARGET, ("data" in result ? result.data : undefined) ?? {});
349
- await proceed();
350
- return undefined;
351
- };
352
- return parse(c, next);
353
- };
354
- }
355
-
356
- /**
357
- * Validate a body the document says is OPTIONAL, and let a request carrying none through.
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.
358
226
  *
359
- * **`requestBody.required: false` means a request with no body is one the contract permits**, and a
360
- * plain `zValidator` refused it. Measured against @hono/zod-validator 0.9.0 and hono 4.12.26: a
361
- * `POST` with `content-type: application/json` and no body answered **400 `Malformed JSON in request
362
- * body`** as `text/plain` - raised before the `invalid` hook, so outside the app's error envelope
363
- * entirely. A service refusing what its own document allows, in a shape that document forbids.
364
- *
365
- * **Nothing is published when the body is absent**, so `c.req.valid(...)` reads `undefined` and the
366
- * handler is told the truth. That is why an optional body is a NAMED property on the input rather
367
- * than merged into it: a merge has no way to say "these are here only sometimes" without making
368
- * every one of them optional, which is a weaker and different claim about the body that IS sent.
369
- *
370
- * A body that is present but unreadable still fails, through `invalid`, so the app's envelope holds.
371
- */
372
- export function optionalBody<E extends Env, S extends ZodType>(
373
- schema: S,
374
- invalid: <P extends string, I extends Input>(
375
- result: { readonly success: boolean },
376
- c: Context<E, P, I>,
377
- ) => Response | undefined,
378
- target: BodyTarget,
379
- ): MiddlewareHandler<
380
- E,
381
- string,
382
- { in: { json: input<S> | undefined }; out: { json: output<S> | undefined } }
383
- > {
384
- return async (c, next) => {
385
- if (!hasBody(c)) {
386
- await next();
387
- return undefined;
388
- }
389
- const raw = await readBody(c, target);
390
- const result =
391
- raw === UNREADABLE ? await unreadableResult(schema) : await schema.safeParseAsync(raw);
392
- const response = invalid(result, c as never);
393
- if (response !== undefined) return response;
394
- if (!result.success) return c.json(result, 400);
395
- c.req.addValidatedData(BODY_TARGET, ("data" in result ? result.data : undefined) ?? {});
396
- await next();
397
- return undefined;
398
- };
399
- }
400
-
401
- /**
402
- * Whether the request carries a body at all.
403
- *
404
- * Both halves are load-bearing. The platform reports `null` for a request sent without one, and a
405
- * caller may instead send `content-length: 0`, which is a body of no bytes and reads the same way to
406
- * anything downstream. Neither alone covers what arrives.
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.
407
230
  */
408
- function hasBody(c: Context): boolean {
409
- if (c.req.raw.body === null) return false;
410
- return (c.req.header("content-length") ?? "").trim() !== "0";
411
- }
412
231
 
413
232
  /**
414
233
  * What the app provides. One object, passed once, rather than a module the generated file imports by