typespec-hono 0.18.1 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/src/app.js CHANGED
@@ -181,10 +181,31 @@ function bodyValidationFor(contentTypes) {
181
181
  * `isRawBinaryMediaType` is the library's own rule for telling them apart, already applied on the
182
182
  * response side; importing it rather than re-deriving it is what keeps the two halves from disagreeing
183
183
  * again.
184
+ *
185
+ * **A binary body is handed over as the UNREAD stream, not as an `ArrayBuffer`.** `arrayBuffer()`
186
+ * materialises the whole payload in the isolate, and a Worker gets 128 MB against a request-body
187
+ * limit of 100 MB, so an upload at the documented maximum cannot be served at all. The document
188
+ * publishes only `contentMediaType` for such a body - **nothing a validator can check** - so
189
+ * materialising it produced a value nothing validates, at the cost of the one thing a gateway
190
+ * actually wants to do with it: pipe it somewhere without touching it.
191
+ *
192
+ * Handing over the stream is strictly more capable. A handler wanting the bytes writes
193
+ * `await new Response(input.body).arrayBuffer()`; a handler wanting to stream into R2 pipes it. The
194
+ * reverse was not possible.
195
+ *
196
+ * **It costs no ambient dependency, which was assumed to be a trade and then measured.**
197
+ * `ReadableStream` needs `lib.dom`, `@cloudflare/workers-types` or `@types/node` - and the generated
198
+ * server has needed exactly the same lib since it was first emitted, because it names `Response`.
199
+ * Whatever a project already supplies to satisfy that declares `ReadableStream` too, so there is no
200
+ * project that could build the old output and cannot build this. `test/streambody/` asserts both
201
+ * halves; `test/compiles.test.ts` cannot, because it sets `target` without `lib` and TypeScript then
202
+ * defaults to the `.full` variant, which includes DOM.
203
+ *
204
+ * `null` because a request may carry no body at all, which is what the platform reports.
184
205
  */
185
206
  function rawBodyReaderFor(contentTypes) {
186
207
  return isRawBinaryMediaType(contentTypes)
187
- ? { call: "await c.req.arrayBuffer()", type: "ArrayBuffer" }
208
+ ? { call: "c.req.raw.body", type: "ReadableStream<Uint8Array> | null" }
188
209
  : { call: "await c.req.text()", type: "string" };
189
210
  }
190
211
  /**
@@ -195,6 +216,20 @@ function rawBodyReaderFor(contentTypes) {
195
216
  * deriving the type from the same consts is what makes the call site check itself. A restated
196
217
  * interface would be a second source of truth that drifts.
197
218
  */
219
+ /**
220
+ * The validators whose fields are INTERSECTED into the input, as opposed to named beside it.
221
+ *
222
+ * Shared with the decision to emit `Fields<>` at all, so "is this helper used" is answered by the
223
+ * same code that uses it rather than by searching the rendered output - an unused type declaration
224
+ * fails `noUnusedLocals`, which a generated file has to pass like any other.
225
+ */
226
+ function intersectedValidatorsOf(entry) {
227
+ const bodyProperty = entry.route.bodyProperty;
228
+ const bodyName = entry.names.body;
229
+ return entry.validators
230
+ .filter(([, name]) => bodyProperty === undefined || name !== bodyName)
231
+ .map(([, name]) => name);
232
+ }
198
233
  function inputTypeOf(entry) {
199
234
  /**
200
235
  * **A body the document says must not be flattened is NAMED, not intersected.**
@@ -215,11 +250,30 @@ function inputTypeOf(entry) {
215
250
  * identifier is the same fact whichever parser reads it.
216
251
  */
217
252
  const bodyName = entry.names.body;
218
- const parts = entry.validators
219
- .filter(([, name]) => bodyProperty === undefined || name !== bodyName)
220
- .map(([, name]) => `z.infer<typeof ${name}>`);
253
+ /**
254
+ * **`Fields<>` is what stops an EMPTY validator poisoning the intersection.**
255
+ *
256
+ * Zod 4 infers `Record<string, never>` for `z.object({})` - "no string key may exist" - which is
257
+ * a correct reading of an empty object on its own and lethal in an intersection: `{ id: string }
258
+ * & Record<string, never>` makes `id` into `string & never`, so the input type is uninhabitable
259
+ * and the generated call site fails `tsc` inside `app.gen.ts` itself. Measured on
260
+ * `model Empty {}` as an optional body: `TS2345: Argument of type '{ id: string; }' is not
261
+ * assignable to parameter of type '{ id: string; } & Record<string, never>'`.
262
+ *
263
+ * The emitted server not compiling is the worst shape available to this emitter, and no arm saw
264
+ * it because no fixture declared an empty model.
265
+ */
266
+ const parts = intersectedValidatorsOf(entry).map((name) => `Fields<z.infer<typeof ${name}>>`);
221
267
  if (bodyProperty !== undefined && bodyName !== undefined) {
222
- parts.push(`{ ${objectKey(bodyProperty)}: z.infer<typeof ${bodyName}> }`);
268
+ /**
269
+ * **Optional on the input when the document says the body is optional**, which is the whole
270
+ * reason such a body is named rather than merged: `c.req.valid(...)` reads `undefined` when
271
+ * none arrived, and a merge has no way to say that. Spelled `| undefined` as every other
272
+ * optional here is, because `exactOptionalPropertyTypes` is on.
273
+ */
274
+ const optionalMark = entry.route.optionalBody ? "?" : "";
275
+ const optionalValue = entry.route.optionalBody ? " | undefined" : "";
276
+ parts.push(`{ ${objectKey(bodyProperty)}${optionalMark}: z.infer<typeof ${bodyName}>${optionalValue} }`);
223
277
  }
224
278
  if (entry.route.rawBodyProperty !== undefined) {
225
279
  const reader = rawBodyReaderFor(entry.route.requestContentTypes);
@@ -310,6 +364,7 @@ securityFor) {
310
364
  if (names === undefined)
311
365
  return [];
312
366
  const validators = [];
367
+ let optional;
313
368
  for (const location of ["path", "query", "header", "body"]) {
314
369
  const identifier = names[location];
315
370
  if (identifier === undefined)
@@ -330,6 +385,35 @@ securityFor) {
330
385
  continue;
331
386
  const targets = [...new Set(body.byType.map(([, target]) => target))];
332
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
+ */
333
417
  validators.push([targets[0], identifier]);
334
418
  continue;
335
419
  }
@@ -343,7 +427,7 @@ securityFor) {
343
427
  validators.push([VALIDATOR_TARGET.body, identifier]);
344
428
  dispatched = { byType: body.byType, identifier };
345
429
  }
346
- return [{ route, names, validators, dispatched }];
430
+ return [{ route, names, validators, dispatched, optional }];
347
431
  });
348
432
  const entries = mounted;
349
433
  /**
@@ -376,6 +460,29 @@ securityFor) {
376
460
  * which is the opposite of the claim being made.
377
461
  */
378
462
  const EMPTY_INPUT = "Record<string, never>";
463
+ /**
464
+ * Emitted only where an operation INTERSECTS a validator's fields, which is what names it.
465
+ *
466
+ * Decided from the routes rather than by searching the rendered text - the mistake that once
467
+ * dropped the `byContentType` import when a call gained an argument.
468
+ */
469
+ const usesFields = entries.some((entry) => intersectedValidatorsOf(entry).length > 0);
470
+ const fieldsHelper = usesFields
471
+ ? `/**
472
+ * A validator's fields, or nothing at all when it declares none.
473
+ *
474
+ * **Zod 4 infers \`Record<string, never>\` for \`z.object({})\`.** That is a fair reading on its own -
475
+ * no string key may exist - and lethal in an intersection: \`{ id: string } & Record<string, never>\`
476
+ * makes \`id\` into \`string & never\`, so the input type is uninhabitable and the call site below
477
+ * cannot compile. An empty model contributes nothing to an operation's input, and \`unknown\` is the
478
+ * identity of \`&\`, so that is what it becomes.
479
+ *
480
+ * A DICTIONARY body is not this: \`Record<unknown>\` has a value type, so it survives untouched.
481
+ */
482
+ type Fields<T> = string extends keyof T ? ([T[string]] extends [never] ? unknown : T) : T;
483
+
484
+ `
485
+ : "";
379
486
  const methods = entries.map((entry) => {
380
487
  const { route, names } = entry;
381
488
  /**
@@ -419,6 +526,7 @@ securityFor) {
419
526
  * mentions a name is how this package lost the `byContentType` import: the call gained an argument
420
527
  * and the substring stopped matching, so a module referenced a function it no longer imported.
421
528
  */
529
+ const mountsOptionalBody = entries.some((entry) => entry.optional !== undefined);
422
530
  const returnsAnything = entries.some((entry) => entry.names.response !== undefined);
423
531
  const declaredHelper = returnsAnything
424
532
  ? `/**
@@ -600,10 +708,17 @@ type Produced<T> = T extends (...args: never[]) => unknown
600
708
  ...gate,
601
709
  ...validators
602
710
  // The dispatched body's own `zValidator` is `byContentType`; emitting both would parse
603
- // the body twice and reject every media type but one.
604
- .filter(([target]) => entry.dispatched === undefined || target !== VALIDATOR_TARGET.body)
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)
605
715
  .map(([target, name]) => `\t\tzValidator(${JSON.stringify(target)}, ${name}, deps.invalid),`),
606
716
  ...dispatch,
717
+ ...(entry.optional === undefined
718
+ ? []
719
+ : [
720
+ `\t\toptionalBody(${entry.optional.identifier}, deps.invalid, ${JSON.stringify(entry.optional.target)}),`,
721
+ ]),
607
722
  ];
608
723
  /**
609
724
  * Whether the operation requires a caller, and NOTHING else about the caller.
@@ -898,7 +1013,7 @@ type Produced<T> = T extends (...args: never[]) => unknown
898
1013
  const needsHonoValue = subApps.size > 0 || usesBasePath;
899
1014
  return `${generatedBanner(emitted.options.regenerateHint)}
900
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";'}
901
- ${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};` : ""}
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};` : ""}
902
1017
  ${imports}
903
1018
  /**
904
1019
  * One method per operation, each concretely typed from the schemas it validates against.
@@ -906,7 +1021,7 @@ ${imports}
906
1021
  * There is no cast anywhere in this file, and no dynamic lookup: the generated call sites name the
907
1022
  * method, so an implementation whose input or output does not match the contract fails to compile.
908
1023
  */
909
- ${declaredHelper}export interface Operations {
1024
+ ${fieldsHelper}${declaredHelper}export interface Operations {
910
1025
  ${methods.join("\n")}
911
1026
  }
912
1027
 
@@ -205,6 +205,32 @@ branches: readonly [
205
205
  json: output<S>;
206
206
  };
207
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
+ }>;
208
234
  /**
209
235
  * What the app provides. One object, passed once, rather than a module the generated file imports by
210
236
  * path. A generated server that hard-codes `../../backend.js` is only usable by the project it was
@@ -1,4 +1,3 @@
1
- import { HTTPException } from "hono/http-exception";
2
1
  /**
3
2
  * The arm that applies to a status, preferring an exact code, then its `NXX` range, then `default`.
4
3
  *
@@ -111,6 +110,7 @@ export const headOnly = async (c, next) => c.req.method === "HEAD" ? next() : c.
111
110
  * the JSON it claims to be. `parseBody({ all: true })` builds the object Hono's `"form"` target
112
111
  * builds: a repeated key and a `key[]` name both become an array, everything else stays a string.
113
112
  */
113
+ const UNREADABLE = Symbol("a body that is not what its content type claims");
114
114
  async function readBody(c, target) {
115
115
  if (target === "form")
116
116
  return c.req.parseBody({ all: true });
@@ -118,9 +118,26 @@ async function readBody(c, target) {
118
118
  return await c.req.json();
119
119
  }
120
120
  catch {
121
- throw new HTTPException(400, { message: "Malformed JSON in request body" });
121
+ return UNREADABLE;
122
122
  }
123
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
+ }
124
141
  /**
125
142
  * The slot a validated request body is published under, whichever parser produced it.
126
143
  *
@@ -189,7 +206,8 @@ branches) {
189
206
  * `Input`. Same reason `RouteDeps` is parameterised.
190
207
  */
191
208
  const parse = async (ctx, proceed) => {
192
- const result = await schema.safeParseAsync(await readBody(ctx, target));
209
+ const raw = await readBody(ctx, target);
210
+ const result = raw === UNREADABLE ? await unreadableResult(schema) : await schema.safeParseAsync(raw);
193
211
  const response = invalid(result, ctx);
194
212
  if (response !== undefined)
195
213
  return response;
@@ -197,10 +215,56 @@ branches) {
197
215
  // and a single-media-type one reject a bad body the same way.
198
216
  if (!result.success)
199
217
  return ctx.json(result, 400);
200
- ctx.req.addValidatedData(BODY_TARGET, result.data ?? {});
218
+ ctx.req.addValidatedData(BODY_TARGET, ("data" in result ? result.data : undefined) ?? {});
201
219
  await proceed();
202
220
  return undefined;
203
221
  };
204
222
  return parse(c, next);
205
223
  };
206
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.18.1",
3
+ "version": "0.19.1",
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.19.1"
47
+ "typespec-http-zod": "^0.21.0"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@hono/zod-openapi": "^1.4.0",
package/src/runtime.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { HTTPException } from "hono/http-exception";
2
1
  import type { Context, Env, Input, MiddlewareHandler } from "hono";
3
2
  import type { input, output, ZodType } from "zod";
4
3
 
@@ -219,15 +218,35 @@ export type BodyTarget = "json" | "form";
219
218
  * the JSON it claims to be. `parseBody({ all: true })` builds the object Hono's `"form"` target
220
219
  * builds: a repeated key and a `key[]` name both become an array, everything else stays a string.
221
220
  */
221
+ const UNREADABLE = Symbol("a body that is not what its content type claims");
222
+
222
223
  async function readBody(c: Context, target: BodyTarget): Promise<unknown> {
223
224
  if (target === "form") return c.req.parseBody({ all: true });
224
225
  try {
225
226
  return await c.req.json();
226
227
  } catch {
227
- throw new HTTPException(400, { message: "Malformed JSON in request body" });
228
+ return UNREADABLE;
228
229
  }
229
230
  }
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
+
231
250
  /**
232
251
  * The slot a validated request body is published under, whichever parser produced it.
233
252
  *
@@ -310,13 +329,15 @@ export function byContentType<E extends Env, S extends ZodType>(
310
329
  string,
311
330
  { in: { json: input<S> }; out: { json: output<S> } }
312
331
  > = async (ctx, proceed) => {
313
- const result = await schema.safeParseAsync(await readBody(ctx, target));
332
+ const raw = await readBody(ctx, target);
333
+ const result =
334
+ raw === UNREADABLE ? await unreadableResult(schema) : await schema.safeParseAsync(raw);
314
335
  const response = invalid(result, ctx);
315
336
  if (response !== undefined) return response;
316
337
  // `zValidator`'s own answer when a hook declines to, kept identical so a dispatched route
317
338
  // and a single-media-type one reject a bad body the same way.
318
339
  if (!result.success) return ctx.json(result, 400);
319
- ctx.req.addValidatedData(BODY_TARGET, result.data ?? {});
340
+ ctx.req.addValidatedData(BODY_TARGET, ("data" in result ? result.data : undefined) ?? {});
320
341
  await proceed();
321
342
  return undefined;
322
343
  };
@@ -324,6 +345,63 @@ export function byContentType<E extends Env, S extends ZodType>(
324
345
  };
325
346
  }
326
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
+
327
405
  /**
328
406
  * What the app provides. One object, passed once, rather than a module the generated file imports by
329
407
  * path. A generated server that hard-codes `../../backend.js` is only usable by the project it was