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 +2 -0
- package/dist/src/app.js +329 -87
- package/dist/src/runtime.d.ts +21 -81
- package/dist/src/runtime.js +0 -165
- package/package.json +4 -4
- package/src/runtime.ts +21 -194
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
|
|
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
|
|
378
|
-
*
|
|
379
|
-
*
|
|
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
|
|
382
|
-
if (
|
|
383
|
-
refuse.unvalidatableMediaType(route,
|
|
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
|
|
422
|
-
* it validated under
|
|
423
|
-
* the
|
|
424
|
-
*
|
|
425
|
-
*
|
|
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
|
-
|
|
608
|
+
body = { branches: validation.byType, identifier, optional: route.optionalBody === true };
|
|
429
609
|
}
|
|
430
|
-
return [{ route, names, validators,
|
|
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
|
-
|
|
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
|
-
*
|
|
688
|
-
*
|
|
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
|
-
*
|
|
693
|
-
*
|
|
694
|
-
*
|
|
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
|
|
697
|
-
*
|
|
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
|
|
944
|
+
const bodyMiddleware = entry.body === undefined
|
|
700
945
|
? []
|
|
701
946
|
: [
|
|
702
|
-
`\t\
|
|
703
|
-
...entry.
|
|
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
|
|
711
|
-
// the body twice and reject every media type but one.
|
|
712
|
-
|
|
713
|
-
.
|
|
714
|
-
|
|
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
|
|
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 =
|
|
1003
|
-
|
|
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};` : ""}
|
|
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
|
|
package/dist/src/runtime.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Context, Env, Input, MiddlewareHandler } from "hono";
|
|
2
|
-
import type {
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
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
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
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
|
package/dist/src/runtime.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
* `
|
|
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
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
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
|