typespec-hono 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -132,8 +132,20 @@ export interface Caller {
132
132
  }
133
133
 
134
134
  export const deps: RouteDeps<AppEnv, Caller> = {
135
+ // The requirements are the document's, verbatim: `[{ BearerAuth: [] }]`. Satisfying any ONE of
136
+ // them authorises; every scheme within one must be satisfied together. Waving a scheme through
137
+ // because you do not check it is how a gated route ends up ungated.
135
138
  authorize: (requirements) => async (c, next) => {
139
+ const bearer = /^Bearer\s+(\S+)$/i.exec(c.req.header("authorization") ?? "")?.[1];
140
+ const satisfied = requirements.some((requirement) =>
141
+ Object.entries(requirement).every(
142
+ ([scheme, scopes]) =>
143
+ scheme === "BearerAuth" && bearer !== undefined && scopes.length === 0,
144
+ ),
145
+ );
146
+ if (!satisfied) return c.json({ error: "unauthorized" }, 401);
136
147
  await next();
148
+ return undefined;
137
149
  },
138
150
  context: (c) => {
139
151
  const userId = c.req.header("x-user");
@@ -145,6 +157,11 @@ export const deps: RouteDeps<AppEnv, Caller> = {
145
157
  };
146
158
  ```
147
159
 
160
+ **`authorize` is the gate the document publishes, and an empty one is an open door.** A requirement
161
+ naming a scheme you do not check must not be satisfied: `[{}]`, which `@useAuth(NoAuth | X)` publishes
162
+ for its anonymous alternative, is the only requirement every caller satisfies, and it is satisfied by
163
+ `Object.entries({}).every(...)` being vacuously true rather than by a special case.
164
+
148
165
  `context` is told `"none"` (the operation needs nobody), `"optional"` (anonymous access is one
149
166
  alternative, so read a credential if one was presented) or `"required"`. Whatever it returns is what
150
167
  every handler receives as `ctx`. Your Hono environment - bindings
package/dist/src/app.d.ts CHANGED
@@ -74,6 +74,38 @@ export declare function generatedBanner(hint: string | undefined): string;
74
74
  * depends on whether one framework's router can express the path.
75
75
  */
76
76
  export declare function toHonoPath(segments: readonly EmittedPathSegment[], refuse: (template: string, name: string) => void): string;
77
+ /**
78
+ * Whether Hono's `RegExpRouter` can compile this route set, and the route it names if not.
79
+ *
80
+ * **Hono's own router, imported statically.** `hono` is a required peer - `runtime.ts` imports it and
81
+ * the generated server cannot run without it - so there is nothing to degrade gracefully around, and
82
+ * a guarded import would claim a resilience this package does not need. Running the real router is
83
+ * what stops the check drifting from a rule Hono never documented.
84
+ *
85
+ * **`match` as well as `add`.** `RegExpRouter` builds its expressions lazily, so a conflict added
86
+ * through `add` alone can go unreported until the first request. One `match` forces the build.
87
+ */
88
+ export declare function regExpRouterRefusal(routes: readonly (readonly [string, string])[]): {
89
+ path: string;
90
+ reason: string;
91
+ } | undefined;
92
+ /**
93
+ * **The `[method, path]` pairs a router is given for one service**, exactly as the emitted server
94
+ * registers them.
95
+ *
96
+ * One definition, used by the `regexp-router-unsupported` linter rule, so the rule judges the route
97
+ * set the generated server really mounts. Built from the same helpers the render uses - the verb a
98
+ * route registers under, `app.on` falling back to `GET` as Hono itself does, and `toHonoPath` on the
99
+ * route's segments - and prefixed with each base path the service is served under.
100
+ *
101
+ * **The full path, never the one relative to a sub-app.** The relative form is what the emitted
102
+ * text says and not what any router is given. Judging it reports conflicts between routes that are
103
+ * not siblings at all: measured on five fixtures, two were called unmountable that mount perfectly.
104
+ */
105
+ export declare function routerTableFor(routes: readonly {
106
+ readonly verb: string;
107
+ readonly pathSegments: readonly EmittedPathSegment[];
108
+ }[], basePaths: readonly string[]): (readonly [string, string])[];
77
109
  /** The one thing a Hono server cannot express, handed back rather than thrown. */
78
110
  export interface RenderRefusals {
79
111
  readonly unsupportedPathTemplate: (route: EmittedRoute, template: string, name: string) => void;
package/dist/src/app.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { RegExpRouter } from "hono/router/reg-exp-router";
1
2
  import { isRawBinaryMediaType, jsDocComment, objectKey, } from "typespec-http-zod";
2
3
  /**
3
4
  * Hono's status codes, group by group, exactly as `hono/utils/http-status` declares them.
@@ -59,8 +60,11 @@ function bodyServingOf(member) {
59
60
  function membersOf(entry) {
60
61
  return entry.route.responses.flatMap((response, index) => {
61
62
  const schemaName = entry.names.arms[index]?.schema;
63
+ const headersSchemaName = entry.names.arms[index]?.headers;
62
64
  if (response.contentTypes.length === 0) {
63
- return [{ response, schemaName, mediaType: undefined, choosesMediaType: false }];
65
+ return [
66
+ { response, schemaName, headersSchemaName, mediaType: undefined, choosesMediaType: false },
67
+ ];
64
68
  }
65
69
  /**
66
70
  * The handler names the media type where the status offers several, and where the document names
@@ -70,6 +74,7 @@ function membersOf(entry) {
70
74
  return response.contentTypes.map((mediaType) => ({
71
75
  response,
72
76
  schemaName,
77
+ headersSchemaName,
73
78
  mediaType,
74
79
  choosesMediaType,
75
80
  }));
@@ -215,9 +220,19 @@ function serveCall(member, status, operationId, uses) {
215
220
  const key = JSON.stringify(header.name);
216
221
  headers.push(`${key}: result.headers${allOptional ? "?." : ""}[${key}]`);
217
222
  }
223
+ /**
224
+ * **Declared headers are CHECKED, not only stringified**, which the body beside them always was.
225
+ * `headersOf` still serves a response whose only headers this emitter chose itself, such as the
226
+ * `Content-Type` a `c.body` call has to state.
227
+ */
228
+ const checksHeaders = response.headers.length > 0 && member.headersSchemaName !== undefined;
218
229
  if (headers.length > 0)
219
- uses.runtime.add("headersOf");
220
- const headersArgument = headers.length === 0 ? "" : `, headersOf({ ${headers.join(", ")} })`;
230
+ uses.runtime.add(checksHeaders ? "servedHeaders" : "headersOf");
231
+ const headersArgument = headers.length === 0
232
+ ? ""
233
+ : checksHeaders
234
+ ? `, servedHeaders(${member.headersSchemaName ?? "undefined"}, { ${headers.join(", ")} }, ${operationId}, ${status})`
235
+ : `, headersOf({ ${headers.join(", ")} })`;
221
236
  const validated = () => {
222
237
  uses.runtime.add("servedBody");
223
238
  return `servedBody(${member.schemaName ?? "undefined"}, result.body, ${operationId}, ${status})`;
@@ -379,14 +394,18 @@ const HONO_METHOD = {
379
394
  * A request location, as the DOCUMENT names it, mapped to the target `@hono/zod-validator` reads.
380
395
  *
381
396
  * **The two vocabularies are not the same, and only one of them is a contract fact.** OpenAPI says
382
- * `path`, `query`, `header` and a request body; zValidator says `param`, `query`, `header` and
383
- * `json`. The library publishes the first because that is what the document states; translating is
384
- * this package's job, and it is a map rather than a coincidence.
397
+ * `path`, `query`, `header`, `cookie` and a request body; zValidator says `param`, `query`,
398
+ * `header`, `cookie` and `json`. The library publishes the first because that is what the document
399
+ * states; translating is this package's job, and it is a map rather than a coincidence.
400
+ *
401
+ * `cookie` is the one location whose two spellings agree, and it is a first-class Hono target:
402
+ * `ValidationTargets` declares it and Hono's own validator reads it with `getCookie`.
385
403
  */
386
404
  const VALIDATOR_TARGET = {
387
405
  path: "param",
388
406
  query: "query",
389
407
  header: "header",
408
+ cookie: "cookie",
390
409
  body: "json",
391
410
  };
392
411
  /**
@@ -494,17 +513,22 @@ function bodyValidationFor(contentTypes, textual) {
494
513
  * the single permitted `.refine()` is a synchronous predicate on a multipart file part. So the async
495
514
  * path was buying nothing and costing on every request.
496
515
  *
497
- * Measured on an emitted five-property model, zod 4.5.2, 200k iterations:
516
+ * Measured on an emitted five-property model, zod 4.6.5, 200k iterations after a 20k warm-up:
498
517
  *
499
518
  * | call | ns/parse |
500
519
  * | --- | --- |
501
- * | `safeParseAsync` | 958 |
502
- * | `safeParse` | 371 |
520
+ * | `safeParseAsync` | 444 |
521
+ * | `safeParse` | 130 |
503
522
  *
504
- * **2.6x, on every parameter group of every request**, for a promise nothing awaited a result from.
523
+ * **3.4x, on every parameter group of every request**, for a promise nothing awaited a result from.
505
524
  * It also unblocks `z.compile()`, whose fast path is bypassed for any async parse - measured from
506
525
  * `zod/compile`'s own shim, which returns the uncompiled run for `ctx.async`, and confirmed on the
507
- * same fixture: compiled `safeParse` is 51 ns and compiled `safeParseAsync` is 923 ns.
526
+ * same fixture: compiled `safeParse` is 64 ns and compiled `safeParseAsync` is 489 ns.
527
+ *
528
+ * **Zod 4.6's `.validate()` does not apply here**, though it looks as though it should. It is 121 ns
529
+ * against 130 ns on the success path, and its advantage is on the FAILURE path, where it skips
530
+ * building a `ZodError`. This runtime needs that error: it is what `deps.invalid` receives and what
531
+ * `ResponseContractError` carries. The numbers on zod 4.5.2 were 958 and 371.
508
532
  *
509
533
  * `validationFunction` is `@hono/zod-validator`'s published option and its return type admits a
510
534
  * synchronous result, so this is the package's own seam rather than a way around it.
@@ -513,8 +537,40 @@ function bodyValidationFor(contentTypes, textual) {
513
537
  * synchronously, because `safeParse` THROWS on a schema that cannot - a claim about output this file
514
538
  * does not produce is exactly the kind that needs an arm rather than a comment.
515
539
  */
516
- const SYNC_PARSE = `/** Parse synchronously: nothing emitted here is async, and the async path costs 2.6x. */
517
- const SYNC = { validationFunction: (schema: z.ZodType, value: unknown) => schema.safeParse(value) };
540
+ const GUARDED_PARSE = `/**
541
+ * Parse, and treat input nested deeper than this runtime's stack as a refusal rather than a crash.
542
+ *
543
+ * **\`safeParse\` not throwing is the contract everything here rests on, and a deep enough value
544
+ * breaks it.** Zod recurses once per level, so a recursive model meets a \`RangeError\` that is not a
545
+ * validation failure and escapes to \`app.onError\` indistinguishable from a bug in the application's
546
+ * own code. Measured on \`workerd\`: a 40.5 KB body, about 1,925 levels, which Ajv on the published
547
+ * document calls VALID.
548
+ *
549
+ * It cannot be served, so it is refused - through \`deps.invalid\` like every other bad request, with
550
+ * an issue that says which limit was met rather than a generic failure. Anything that is not a
551
+ * \`RangeError\` is rethrown: a bug here must still look like one.
552
+ */
553
+ function parsed(schema: z.ZodType, value: unknown): z.ZodSafeParseResult<unknown> {
554
+ try {
555
+ return schema.safeParse(value);
556
+ } catch (error) {
557
+ if (!(error instanceof RangeError)) throw error;
558
+ return {
559
+ success: false,
560
+ error: new z.ZodError([
561
+ {
562
+ code: "custom",
563
+ path: [],
564
+ message: "input is nested too deeply for this runtime to validate",
565
+ },
566
+ ]),
567
+ };
568
+ }
569
+ }
570
+
571
+ `;
572
+ const SYNC_PARSE = `/** Parse synchronously: nothing emitted here is async, and the async path costs 3.4x. */
573
+ const SYNC = { validationFunction: (schema: z.ZodType, value: unknown) => parsed(schema, value) };
518
574
 
519
575
  `;
520
576
  const BODY_READER = `/** The two ways Hono can read a request body: \`c.req.json()\` and \`c.req.parseBody()\`. */
@@ -550,8 +606,8 @@ async function readBody(c: Context, target: BodyTarget): Promise<unknown> {
550
606
  * is not a body that validated.
551
607
  */
552
608
  function unreadableResult(schema: z.ZodType): { readonly success: boolean } {
553
- const parsed = schema.safeParse(undefined);
554
- return parsed.success ? { success: false } : parsed;
609
+ const result = parsed(schema, undefined);
610
+ return result.success ? { success: false } : result;
555
611
  }
556
612
 
557
613
  /**
@@ -562,13 +618,63 @@ function unreadableResult(schema: z.ZodType): { readonly success: boolean } {
562
618
  * generated. Parameters (\`; charset=utf-8\`, \`; boundary=...\`) are not part of the match, which
563
619
  * matters because a multipart request always carries a boundary.
564
620
  *
565
- * A \`Content-Type\` matching nothing declared falls through to the first branch, so the body simply
566
- * fails to parse and the app answers. No status is invented that the document does not describe.
621
+ * **A \`Content-Type\` matching nothing declared is REFUSED, and used to be parsed anyway.** It fell
622
+ * through to the first branch, and the comment here claimed the body would then "simply fail to
623
+ * parse" - which is not what happened: \`c.req.json()\` reads the bytes whatever the header says, so a
624
+ * route publishing \`application/json\` alone answered 200 to the same body sent as \`text/plain\`, as
625
+ * \`multipart/form-data\`, and with no \`Content-Type\` at all. The document says which media types an
626
+ * operation accepts, and a server that accepts others is not serving that document.
627
+ *
628
+ * \`undefined\` means "nothing declared covers this", and the caller refuses it through
629
+ * \`deps.invalid\` like any other bad request. No status is invented that the document does not
630
+ * describe, which is also what \`@typespec/http-server-js\` does with the identical case.
567
631
  */
568
- function bodyTarget(c: Context, branches: readonly (readonly [string, BodyTarget])[]): BodyTarget {
632
+ function bodyTarget(
633
+ c: Context,
634
+ branches: readonly (readonly [string, BodyTarget])[],
635
+ ): BodyTarget | undefined {
569
636
  const declared = (c.req.header("content-type") ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
570
- const matched = branches.find(([mediaType]) => mediaType.toLowerCase() === declared);
571
- return matched?.[1] ?? branches[0]?.[1] ?? "json";
637
+ if (declared === "") return undefined;
638
+ /**
639
+ * A declared media RANGE matches the types inside it, which a string comparison cannot do.
640
+ * \`@typespec/http\` reports a bare \`string\`-typed \`contentType\` as the wildcard range, and an author
641
+ * may write a subtype wildcard such as text-slash-star outright. Comparing the range TEXT refuses
642
+ * every real request, because no caller sends the range itself as its \`Content-Type\`. This is the
643
+ * request-side counterpart of \`mediaTypeWithin\` in the runtime module, inlined because the branch
644
+ * list is emitted here.
645
+ */
646
+ const covers = (range: string): boolean => {
647
+ if (range === "*/*") return true;
648
+ if (!range.endsWith("/*")) return false;
649
+ const slash = declared.indexOf("/");
650
+ return slash > 0 && declared.slice(0, slash) === range.slice(0, -2);
651
+ };
652
+ return branches.find(([mediaType]) => {
653
+ const lowered = mediaType.toLowerCase();
654
+ return lowered === declared || covers(lowered);
655
+ })?.[1];
656
+ }
657
+
658
+ /**
659
+ * The failure an undeclared media type produces, in the shape every other rejection has.
660
+ *
661
+ * Reported as an issue on the body rather than a bare \`{ success: false }\`, so an application's
662
+ * \`invalid\` hook can tell a caller WHICH thing was wrong - the same reason \`unreadableResult\` parses
663
+ * \`undefined\` rather than inventing a result.
664
+ */
665
+ function unsupportedMediaTypeResult(
666
+ branches: readonly (readonly [string, BodyTarget])[],
667
+ ): z.ZodSafeParseResult<unknown> {
668
+ return {
669
+ success: false,
670
+ error: new z.ZodError([
671
+ {
672
+ code: "custom",
673
+ path: ["content-type"],
674
+ message: \`this operation accepts \${branches.map(([type]) => type).join(", ")}\`,
675
+ },
676
+ ]),
677
+ };
572
678
  }
573
679
 
574
680
  /** The app's rejection hook, as this file needs to name it. */
@@ -610,9 +716,14 @@ function validateBody<E extends Env, S extends z.ZodType>(
610
716
  string,
611
717
  { in: { json: z.input<S> }; out: { json: z.output<S> } }
612
718
  > = async (ctx, proceed) => {
613
- const raw = await readBody(ctx, bodyTarget(ctx, branches));
719
+ const target = bodyTarget(ctx, branches);
720
+ const raw = target === undefined ? UNREADABLE : await readBody(ctx, target);
614
721
  const result =
615
- raw === UNREADABLE ? unreadableResult(schema) : schema.safeParse(raw);
722
+ target === undefined
723
+ ? unsupportedMediaTypeResult(branches)
724
+ : raw === UNREADABLE
725
+ ? unreadableResult(schema)
726
+ : parsed(schema, raw);
616
727
  const response = invalid(result, ctx);
617
728
  if (response !== undefined) return response;
618
729
  if (!result.success) return ctx.json(result, 400);
@@ -660,9 +771,14 @@ function validateOptionalBody<E extends Env, S extends z.ZodType>(
660
771
  await proceed();
661
772
  return undefined;
662
773
  }
663
- const raw = await readBody(ctx, bodyTarget(ctx, branches));
774
+ const target = bodyTarget(ctx, branches);
775
+ const raw = target === undefined ? UNREADABLE : await readBody(ctx, target);
664
776
  const result =
665
- raw === UNREADABLE ? unreadableResult(schema) : schema.safeParse(raw);
777
+ target === undefined
778
+ ? unsupportedMediaTypeResult(branches)
779
+ : raw === UNREADABLE
780
+ ? unreadableResult(schema)
781
+ : parsed(schema, raw);
666
782
  const response = invalid(result, ctx);
667
783
  if (response !== undefined) return response;
668
784
  if (!result.success) return ctx.json(result, 400);
@@ -847,6 +963,64 @@ function inputTypeOf(entry) {
847
963
  function capitaliseId(operationId) {
848
964
  return `${operationId.charAt(0).toUpperCase()}${operationId.slice(1)}`;
849
965
  }
966
+ /**
967
+ * Whether Hono's `RegExpRouter` can compile this route set, and the route it names if not.
968
+ *
969
+ * **Hono's own router, imported statically.** `hono` is a required peer - `runtime.ts` imports it and
970
+ * the generated server cannot run without it - so there is nothing to degrade gracefully around, and
971
+ * a guarded import would claim a resilience this package does not need. Running the real router is
972
+ * what stops the check drifting from a rule Hono never documented.
973
+ *
974
+ * **`match` as well as `add`.** `RegExpRouter` builds its expressions lazily, so a conflict added
975
+ * through `add` alone can go unreported until the first request. One `match` forces the build.
976
+ */
977
+ export function regExpRouterRefusal(routes) {
978
+ const first = routes[0];
979
+ if (first === undefined)
980
+ return undefined;
981
+ const router = new RegExpRouter();
982
+ const noop = () => undefined;
983
+ try {
984
+ for (const [method, path] of routes)
985
+ router.add(method, path, noop);
986
+ router.match("GET", first[1]);
987
+ return undefined;
988
+ }
989
+ catch (error) {
990
+ /**
991
+ * **The CONSTRUCTOR name, not `.name`.** Hono's `UnsupportedPathError` extends `Error` without
992
+ * setting `this.name`, so the inherited `.name` reads `"Error"` and says nothing. Its `message`
993
+ * is the offending path and is the whole of what it reports.
994
+ */
995
+ const thrown = error;
996
+ return {
997
+ path: thrown.message ?? "(unnamed)",
998
+ reason: thrown.constructor?.name ?? "the router refused it",
999
+ };
1000
+ }
1001
+ }
1002
+ /**
1003
+ * **The `[method, path]` pairs a router is given for one service**, exactly as the emitted server
1004
+ * registers them.
1005
+ *
1006
+ * One definition, used by the `regexp-router-unsupported` linter rule, so the rule judges the route
1007
+ * set the generated server really mounts. Built from the same helpers the render uses - the verb a
1008
+ * route registers under, `app.on` falling back to `GET` as Hono itself does, and `toHonoPath` on the
1009
+ * route's segments - and prefixed with each base path the service is served under.
1010
+ *
1011
+ * **The full path, never the one relative to a sub-app.** The relative form is what the emitted
1012
+ * text says and not what any router is given. Judging it reports conflicts between routes that are
1013
+ * not siblings at all: measured on five fixtures, two were called unmountable that mount perfectly.
1014
+ */
1015
+ export function routerTableFor(routes, basePaths) {
1016
+ const noRefusal = () => undefined;
1017
+ return routes.flatMap((route) => {
1018
+ const verb = registrationVerbOf(route.verb);
1019
+ const method = HONO_METHOD[verb] === undefined ? "GET" : verb;
1020
+ const path = toHonoPath(route.pathSegments, noRefusal);
1021
+ return (basePaths.length === 0 ? [""] : basePaths).map((prefix) => [method, `${prefix}${path}` || "/"]);
1022
+ });
1023
+ }
850
1024
  /**
851
1025
  * The resource a route belongs to (its first path segment) or `undefined` when it has none.
852
1026
  *
@@ -907,7 +1081,7 @@ basePaths = []) {
907
1081
  return [];
908
1082
  const validators = [];
909
1083
  let body;
910
- for (const location of ["path", "query", "header", "body"]) {
1084
+ for (const location of ["path", "query", "header", "cookie", "body"]) {
911
1085
  const identifier = names[location];
912
1086
  if (identifier === undefined)
913
1087
  continue;
@@ -1016,6 +1190,12 @@ type Fields<T> = string extends keyof T ? ([T[string]] extends [never] ? unknown
1016
1190
  */
1017
1191
  const validates = entries.some((entry) => entry.validators.filter(([target]) => entry.body === undefined || target !== VALIDATOR_TARGET.body).length > 0);
1018
1192
  const syncHelper = validates ? SYNC_PARSE : "";
1193
+ /**
1194
+ * `parsed` is used by `SYNC` and by both body middlewares, so it is emitted where either is, and
1195
+ * BEFORE them: a generated file is read top to bottom and a helper used above its declaration
1196
+ * reads as a mistake even where hoisting makes it legal.
1197
+ */
1198
+ const guardedParse = validates || mountsBody ? GUARDED_PARSE : "";
1019
1199
  /**
1020
1200
  * **One result type per operation: the union of every response its document declares.**
1021
1201
  *
@@ -1458,7 +1638,7 @@ type Produced<T> = T extends (...args: never[]) => unknown
1458
1638
  "\t\t\t},",
1459
1639
  "\t\t)",
1460
1640
  ].join("\n");
1461
- return { target, path: registeredPath, text, headOnly };
1641
+ return { target, path: registeredPath, mountedPath: path, method, text, headOnly };
1462
1642
  });
1463
1643
  /**
1464
1644
  * Every identifier this file names, imported from the module that declares it.
@@ -1491,8 +1671,10 @@ type Produced<T> = T extends (...args: never[]) => unknown
1491
1671
  entry.names.path,
1492
1672
  entry.names.query,
1493
1673
  entry.names.header,
1674
+ entry.names.cookie,
1494
1675
  entry.names.body,
1495
1676
  ...entry.names.arms.map((arm) => arm.schema),
1677
+ ...entry.names.arms.map((arm) => arm.headers),
1496
1678
  ].filter((name) => name !== undefined))),
1497
1679
  ]
1498
1680
  .filter((identifier) => mentioned.has(identifier))
@@ -1659,7 +1841,7 @@ type Produced<T> = T extends (...args: never[]) => unknown
1659
1841
  ${validates ? 'import { zValidator } from "@hono/zod-validator";\n' : ""}${needsHonoValue ? 'import { Hono } from "hono";\n' : ""}import type { ${honoTypes.join(", ")} } from "hono";
1660
1842
  ${statusTypes.length > 0 ? `import type { ${statusTypes.join(", ")} } from "hono/utils/http-status";\n` : ""}${usesZod ? 'import { z } from "zod";\n' : ""}import type { ${runtimeTypes.join(", ")} } from ${runtimeModule};${runtimeValues.length > 0 ? `\nimport { ${runtimeValues.join(", ")} } from ${runtimeModule};` : ""}
1661
1843
  ${imports}
1662
- ${syncHelper}${bodyHelper}${fieldsHelper}${declaredHelper}/**
1844
+ ${guardedParse}${syncHelper}${bodyHelper}${fieldsHelper}${declaredHelper}/**
1663
1845
  * What each operation may answer with: one member per response the document declares.
1664
1846
  *
1665
1847
  * A handler returns \`{ status, body, headers }\` for whichever response it means. The generated route
@@ -8,5 +8,6 @@
8
8
  */
9
9
  export * from "typespec-http-zod";
10
10
  export { $lib } from "./lib.js";
11
+ export { $linter } from "./linter.js";
11
12
  export { $onEmit } from "./emitter.js";
12
13
  export { renderApp, toHonoPath } from "./app.js";
package/dist/src/index.js CHANGED
@@ -8,5 +8,7 @@
8
8
  */
9
9
  export * from "typespec-http-zod";
10
10
  export { $lib } from "./lib.js";
11
+ // Explicit, so it shadows the linter `export *` would otherwise re-export from typespec-http-zod.
12
+ export { $linter } from "./linter.js";
11
13
  export { $onEmit } from "./emitter.js";
12
14
  export { renderApp, toHonoPath } from "./app.js";
@@ -0,0 +1,20 @@
1
+ import { type LinterDefinition } from "@typespec/compiler";
2
+ /**
3
+ * **The advisory checks, opt-in, as TypeSpec expects them.**
4
+ *
5
+ * Enabled from a consumer's `tspconfig.yaml` with `linter: { extends: ["typespec-hono/recommended"] }`.
6
+ *
7
+ * **`redos-prone-pattern` is registered here as well as in `typespec-http-zod`.** A consumer of this
8
+ * package lists only `typespec-hono`, and the library runs in-process, so it is never loaded as a
9
+ * TypeSpec library and cannot be named in a ruleset - under pnpm it is not even resolvable from the
10
+ * consumer's project. A rule's id is its registering library's name joined to the rule's, so here it
11
+ * is `typespec-hono/redos-prone-pattern`, and one ruleset turns on everything that applies.
12
+ */
13
+ /**
14
+ * Typed explicitly through this package's own `@typespec/compiler` specifier, for the reason
15
+ * `lib.ts` records: a side-by-side checkout resolves two copies of the compiler, and an INFERRED
16
+ * declaration type then names the other package's copy by a `.pnpm` path (`TS2883`, "likely not
17
+ * portable"). A published `.d.ts` naming such a path is broken for everyone who installed
18
+ * differently.
19
+ */
20
+ export declare const $linter: LinterDefinition;
@@ -0,0 +1,32 @@
1
+ import { defineLinter } from "@typespec/compiler";
2
+ import { redosPronePatternRule } from "typespec-http-zod";
3
+ import { regexpRouterUnsupportedRule } from "./rules/regexp-router-unsupported.rule.js";
4
+ /**
5
+ * **The advisory checks, opt-in, as TypeSpec expects them.**
6
+ *
7
+ * Enabled from a consumer's `tspconfig.yaml` with `linter: { extends: ["typespec-hono/recommended"] }`.
8
+ *
9
+ * **`redos-prone-pattern` is registered here as well as in `typespec-http-zod`.** A consumer of this
10
+ * package lists only `typespec-hono`, and the library runs in-process, so it is never loaded as a
11
+ * TypeSpec library and cannot be named in a ruleset - under pnpm it is not even resolvable from the
12
+ * consumer's project. A rule's id is its registering library's name joined to the rule's, so here it
13
+ * is `typespec-hono/redos-prone-pattern`, and one ruleset turns on everything that applies.
14
+ */
15
+ /**
16
+ * Typed explicitly through this package's own `@typespec/compiler` specifier, for the reason
17
+ * `lib.ts` records: a side-by-side checkout resolves two copies of the compiler, and an INFERRED
18
+ * declaration type then names the other package's copy by a `.pnpm` path (`TS2883`, "likely not
19
+ * portable"). A published `.d.ts` naming such a path is broken for everyone who installed
20
+ * differently.
21
+ */
22
+ export const $linter = defineLinter({
23
+ rules: [regexpRouterUnsupportedRule, redosPronePatternRule],
24
+ ruleSets: {
25
+ recommended: {
26
+ enable: {
27
+ [`typespec-hono/${regexpRouterUnsupportedRule.name}`]: true,
28
+ [`typespec-hono/${redosPronePatternRule.name}`]: true,
29
+ },
30
+ },
31
+ },
32
+ });
@@ -0,0 +1,32 @@
1
+ import { type CallableMessage, type LinterRuleDefinition } from "@typespec/compiler";
2
+ /**
3
+ * **A route set Hono's `RegExpRouter` refuses to compile.**
4
+ *
5
+ * **A linter rule, because TypeSpec's own definition puts it there.** A diagnostic says the program
6
+ * is not valid for this library; a linter says it "could be correct, but there might be room for
7
+ * improvements", and has to be enabled explicitly. Here the spec is valid, the document is correct,
8
+ * the emitted server is correct, and the default router serves it perfectly - `SmartRouter` tries
9
+ * `RegExpRouter` and falls back to `TrieRouter` for exactly this case. What can go wrong is a router
10
+ * choice the application makes, which this package cannot see.
11
+ *
12
+ * **Why that matters, measured.** Raised as an automatic warning it failed the build of a consumer
13
+ * that sets `warn-as-error: true` and constructs `new Hono()` - the default router, which holds its
14
+ * route set - over a problem that consumer does not have.
15
+ *
16
+ * **Why it is worth enabling.** `RegExpRouter` throws `UnsupportedPathError` for a static segment
17
+ * beside a path parameter at the same position - `/users/me` next to `/users/{id}`, the commonest
18
+ * shape in REST - inside `registerRoutes`, at module scope, so a Worker constructed with one never
19
+ * starts. The documentation once recommended it. Measured: one added route took a live worker from
20
+ * serving to connection-refused, from a compile that reported success.
21
+ *
22
+ * **Hono's real router is asked, not a reimplementation of its rule**, which is undocumented and
23
+ * version-specific, and the route set asked about is built by {@link routerTableFor} from the same
24
+ * pieces the render uses. The corpus oracle mounts every generated server under a bare
25
+ * `RegExpRouter` and fails if this rule's verdict disagrees in either direction.
26
+ *
27
+ * **Reported against the service**, because which routes `RegExpRouter` refuses is a property of the
28
+ * route SET: no single operation is the one at fault.
29
+ */
30
+ export declare const regexpRouterUnsupportedRule: LinterRuleDefinition<"regexp-router-unsupported", {
31
+ readonly default: CallableMessage<["path", "reason"]>;
32
+ }>;
@@ -0,0 +1,65 @@
1
+ import { createRule, paramMessage, } from "@typespec/compiler";
2
+ import { getAllHttpServices } from "@typespec/http";
3
+ import { routeTemplateOf } from "typespec-http-zod";
4
+ import { regExpRouterRefusal, routerTableFor } from "../app.js";
5
+ import { resolveBasePath } from "../base-path.js";
6
+ /**
7
+ * **A route set Hono's `RegExpRouter` refuses to compile.**
8
+ *
9
+ * **A linter rule, because TypeSpec's own definition puts it there.** A diagnostic says the program
10
+ * is not valid for this library; a linter says it "could be correct, but there might be room for
11
+ * improvements", and has to be enabled explicitly. Here the spec is valid, the document is correct,
12
+ * the emitted server is correct, and the default router serves it perfectly - `SmartRouter` tries
13
+ * `RegExpRouter` and falls back to `TrieRouter` for exactly this case. What can go wrong is a router
14
+ * choice the application makes, which this package cannot see.
15
+ *
16
+ * **Why that matters, measured.** Raised as an automatic warning it failed the build of a consumer
17
+ * that sets `warn-as-error: true` and constructs `new Hono()` - the default router, which holds its
18
+ * route set - over a problem that consumer does not have.
19
+ *
20
+ * **Why it is worth enabling.** `RegExpRouter` throws `UnsupportedPathError` for a static segment
21
+ * beside a path parameter at the same position - `/users/me` next to `/users/{id}`, the commonest
22
+ * shape in REST - inside `registerRoutes`, at module scope, so a Worker constructed with one never
23
+ * starts. The documentation once recommended it. Measured: one added route took a live worker from
24
+ * serving to connection-refused, from a compile that reported success.
25
+ *
26
+ * **Hono's real router is asked, not a reimplementation of its rule**, which is undocumented and
27
+ * version-specific, and the route set asked about is built by {@link routerTableFor} from the same
28
+ * pieces the render uses. The corpus oracle mounts every generated server under a bare
29
+ * `RegExpRouter` and fails if this rule's verdict disagrees in either direction.
30
+ *
31
+ * **Reported against the service**, because which routes `RegExpRouter` refuses is a property of the
32
+ * route SET: no single operation is the one at fault.
33
+ */
34
+ export const regexpRouterUnsupportedRule = createRule({
35
+ name: "regexp-router-unsupported",
36
+ severity: "warning",
37
+ description: "Flag a route set Hono's RegExpRouter cannot compile.",
38
+ messages: {
39
+ default: paramMessage `This service mounts '${"path"}', which Hono's 'RegExpRouter' cannot compile: ${"reason"}. A bare 'RegExpRouter' throws inside 'registerRoutes' at module scope, so the Worker would not start at all. Use 'new Hono()' for the default 'SmartRouter', or 'new Hono({ router: new PatternRouter() })'. Both mount this route set. The default is the one to reach for first: it settles on 'TrieRouter', which measures the same as 'RegExpRouter' in steady state, while 'PatternRouter' costs about five times more.`,
40
+ },
41
+ create(context) {
42
+ return {
43
+ root: (program) => {
44
+ // Its diagnostics are the emitter's to report, once, during emission.
45
+ const [services] = getAllHttpServices(program);
46
+ for (const service of services) {
47
+ const routes = service.operations.map((operation) => ({
48
+ verb: operation.verb.toUpperCase(),
49
+ pathSegments: routeTemplateOf(operation.uriTemplate, operation.parameters.parameters
50
+ .filter((parameter) => parameter.type === "path")
51
+ .map((parameter) => ({
52
+ name: parameter.name,
53
+ optional: parameter.param.optional,
54
+ reserved: parameter.type === "path" && parameter.allowReserved,
55
+ }))).segments,
56
+ }));
57
+ const refusal = regExpRouterRefusal(routerTableFor(routes, resolveBasePath(program, service.namespace).basePaths));
58
+ if (refusal !== undefined) {
59
+ context.reportDiagnostic({ format: refusal, target: service.namespace });
60
+ }
61
+ }
62
+ },
63
+ };
64
+ },
65
+ });
@@ -115,7 +115,7 @@ export type Awaitable<T> = T | Promise<T>;
115
115
  * **Specificity was implemented as a tie-break and that was wrong three ways at once**, all of them
116
116
  * live in a published runtime until `test/negotiation.test.ts` was written. Scoring every matching
117
117
  * range and keeping the best `(q, specificity)` pair lets a permissive wildcard out-vote the precise
118
- * rule a caller wrote about that exact type - so `Accept: *​/*, application/json;q=0` was served
118
+ * rule a caller wrote about that exact type - so an `Accept` of any type at all followed by `application/json;q=0` was served
119
119
  * JSON, which is the one outcome an explicit refusal must never produce. The prose above stated the
120
120
  * right rules the whole time; nothing compared it to the code.
121
121
  *
@@ -165,7 +165,17 @@ export declare class ResponseContractError extends Error {
165
165
  readonly operationId: string;
166
166
  readonly status: number;
167
167
  readonly issues: ZodError["issues"];
168
- constructor(operationId: string, status: number, issues: ZodError["issues"]);
168
+ /**
169
+ * Which half of the response broke its contract. **Defaulted, so the three-argument form an
170
+ * application may already construct or match on keeps working.**
171
+ */
172
+ readonly part: "body" | "headers";
173
+ constructor(operationId: string, status: number, issues: ZodError["issues"],
174
+ /**
175
+ * Which half of the response broke its contract. **Defaulted, so the three-argument form an
176
+ * application may already construct or match on keeps working.**
177
+ */
178
+ part?: "body" | "headers");
169
179
  }
170
180
  /**
171
181
  * A handler answered with a status its operation does not declare.
@@ -203,6 +213,32 @@ export declare function servedBody<S extends ZodType>(schema: S, value: unknown,
203
213
  * conditional per header.
204
214
  */
205
215
  export declare function headersOf(declared: Readonly<Record<string, unknown>>): Record<string, string>;
216
+ /**
217
+ * Declared response headers, CHECKED against the schemas the document publishes for them.
218
+ *
219
+ * **The body beside them was always checked and these never were.** `servedBody` parses every
220
+ * response body against its status's schema and throws `ResponseContractError`; a declared header
221
+ * went through `headersOf`, which is `String(value)` and nothing else. Measured on a generated
222
+ * server: a header the document constrains to `^[A-Za-z0-9-]+$` was served as
223
+ * `not a valid id"; injected=1` with a 200, while a body breaking its own schema on the same route
224
+ * threw. That asymmetry is the whole of what this removes.
225
+ *
226
+ * **Not a response-splitting hole, and worth saying so.** `new Response` refuses a header value
227
+ * containing CR or LF with a `TypeError` on both `workerd` and undici, so the runtime already stops
228
+ * the injection. What it does not stop is a response that disagrees with its own document, which a
229
+ * client generated from that document is entitled to rely on.
230
+ *
231
+ * **Undefined values are dropped BEFORE the check, not after.** An optional header the handler did
232
+ * not supply is absent rather than explicitly `undefined`, which is what the emitted schema's
233
+ * `.exactOptional()` means, and it is also what Hono's `HeaderRecord` requires. The check then runs
234
+ * on the values as the handler produced them - a `retry-after` declared `int32` is still a number
235
+ * here - because checking the text would check a different thing than the document describes.
236
+ *
237
+ * **What is returned is every header, including ones the schema does not mention.** A response
238
+ * carries headers the document does not declare, the `Content-Type` this emitter sets among them,
239
+ * and those are not a contract violation. The schema is a plain object schema, so it ignores them.
240
+ */
241
+ export declare function servedHeaders(schema: ZodType, declared: Readonly<Record<string, unknown>>, operationId: string, status: number): Record<string, string>;
206
242
  /**
207
243
  * Whether a media type a handler answers with lies inside a range the document offers.
208
244
  *
@@ -34,7 +34,7 @@ export function armFor(arms, status) {
34
34
  * **Specificity was implemented as a tie-break and that was wrong three ways at once**, all of them
35
35
  * live in a published runtime until `test/negotiation.test.ts` was written. Scoring every matching
36
36
  * range and keeping the best `(q, specificity)` pair lets a permissive wildcard out-vote the precise
37
- * rule a caller wrote about that exact type - so `Accept: *​/*, application/json;q=0` was served
37
+ * rule a caller wrote about that exact type - so an `Accept` of any type at all followed by `application/json;q=0` was served
38
38
  * JSON, which is the one outcome an explicit refusal must never produce. The prose above stated the
39
39
  * right rules the whole time; nothing compared it to the code.
40
40
  *
@@ -126,11 +126,18 @@ export class ResponseContractError extends Error {
126
126
  operationId;
127
127
  status;
128
128
  issues;
129
- constructor(operationId, status, issues) {
130
- super(`${operationId} answered ${status} with a body its document does not permit`);
129
+ part;
130
+ constructor(operationId, status, issues,
131
+ /**
132
+ * Which half of the response broke its contract. **Defaulted, so the three-argument form an
133
+ * application may already construct or match on keeps working.**
134
+ */
135
+ part = "body") {
136
+ super(`${operationId} answered ${status} with ${part === "body" ? "a body" : "headers"} its document does not permit`);
131
137
  this.operationId = operationId;
132
138
  this.status = status;
133
139
  this.issues = issues;
140
+ this.part = part;
134
141
  this.name = "ResponseContractError";
135
142
  }
136
143
  }
@@ -190,6 +197,43 @@ export function headersOf(declared) {
190
197
  }
191
198
  return headers;
192
199
  }
200
+ /**
201
+ * Declared response headers, CHECKED against the schemas the document publishes for them.
202
+ *
203
+ * **The body beside them was always checked and these never were.** `servedBody` parses every
204
+ * response body against its status's schema and throws `ResponseContractError`; a declared header
205
+ * went through `headersOf`, which is `String(value)` and nothing else. Measured on a generated
206
+ * server: a header the document constrains to `^[A-Za-z0-9-]+$` was served as
207
+ * `not a valid id"; injected=1` with a 200, while a body breaking its own schema on the same route
208
+ * threw. That asymmetry is the whole of what this removes.
209
+ *
210
+ * **Not a response-splitting hole, and worth saying so.** `new Response` refuses a header value
211
+ * containing CR or LF with a `TypeError` on both `workerd` and undici, so the runtime already stops
212
+ * the injection. What it does not stop is a response that disagrees with its own document, which a
213
+ * client generated from that document is entitled to rely on.
214
+ *
215
+ * **Undefined values are dropped BEFORE the check, not after.** An optional header the handler did
216
+ * not supply is absent rather than explicitly `undefined`, which is what the emitted schema's
217
+ * `.exactOptional()` means, and it is also what Hono's `HeaderRecord` requires. The check then runs
218
+ * on the values as the handler produced them - a `retry-after` declared `int32` is still a number
219
+ * here - because checking the text would check a different thing than the document describes.
220
+ *
221
+ * **What is returned is every header, including ones the schema does not mention.** A response
222
+ * carries headers the document does not declare, the `Content-Type` this emitter sets among them,
223
+ * and those are not a contract violation. The schema is a plain object schema, so it ignores them.
224
+ */
225
+ export function servedHeaders(schema, declared, operationId, status) {
226
+ const present = {};
227
+ for (const [name, value] of Object.entries(declared)) {
228
+ if (value !== undefined)
229
+ present[name] = value;
230
+ }
231
+ const parsed = schema.safeParse(present);
232
+ if (!parsed.success) {
233
+ throw new ResponseContractError(operationId, status, parsed.error.issues, "headers");
234
+ }
235
+ return headersOf(present);
236
+ }
193
237
  /** RFC 9110 `token`, less `*`, which names a range rather than a type. */
194
238
  const MEDIA_TOKEN = "[!#$%&'+.^_`|~0-9A-Za-z-]+";
195
239
  const SERVED_MEDIA_TYPE = new RegExp(`^(${MEDIA_TOKEN})/${MEDIA_TOKEN}\\s*(;.*)?$`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typespec-hono",
3
- "version": "0.23.0",
3
+ "version": "0.24.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.26.0"
47
+ "typespec-http-zod": "^0.27.0"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@hono/zod-openapi": "^1.4.0",
@@ -61,19 +61,19 @@
61
61
  "@typespec/streams": "0.86.0",
62
62
  "@typespec/versioning": "0.86.0",
63
63
  "@typespec/xml": "0.86.0",
64
- "hono": "^4.12.26",
64
+ "hono": "^4.13.8",
65
65
  "oxfmt": "^0.63.0",
66
66
  "oxlint": "^1.78.0",
67
67
  "typescript": "~7.0.2",
68
68
  "typespec-hono": "link:.",
69
69
  "vitest": "^4.1.9",
70
- "zod": "^4.5.2"
70
+ "zod": "^4.6.5"
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
- "hono": "^4.12.0",
76
+ "hono": "^4.13.5",
77
77
  "zod": "^4.5.0"
78
78
  },
79
79
  "engines": {
@@ -81,7 +81,7 @@
81
81
  },
82
82
  "tspMain": "lib/main.tsp",
83
83
  "scripts": {
84
- "build": "tsc -p tsconfig.build.json",
84
+ "build": "node --eval \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.build.json",
85
85
  "test": "tsc -p tsconfig.build.json && vitest run",
86
86
  "typecheck": "tsc -p tsconfig.json",
87
87
  "lint": "oxlint src/ test/",
package/src/runtime.ts CHANGED
@@ -125,7 +125,7 @@ export type Awaitable<T> = T | Promise<T>;
125
125
  * **Specificity was implemented as a tie-break and that was wrong three ways at once**, all of them
126
126
  * live in a published runtime until `test/negotiation.test.ts` was written. Scoring every matching
127
127
  * range and keeping the best `(q, specificity)` pair lets a permissive wildcard out-vote the precise
128
- * rule a caller wrote about that exact type - so `Accept: *​/*, application/json;q=0` was served
128
+ * rule a caller wrote about that exact type - so an `Accept` of any type at all followed by `application/json;q=0` was served
129
129
  * JSON, which is the one outcome an explicit refusal must never produce. The prose above stated the
130
130
  * right rules the whole time; nothing compared it to the code.
131
131
  *
@@ -226,8 +226,15 @@ export class ResponseContractError extends Error {
226
226
  readonly operationId: string,
227
227
  readonly status: number,
228
228
  readonly issues: ZodError["issues"],
229
+ /**
230
+ * Which half of the response broke its contract. **Defaulted, so the three-argument form an
231
+ * application may already construct or match on keeps working.**
232
+ */
233
+ readonly part: "body" | "headers" = "body",
229
234
  ) {
230
- super(`${operationId} answered ${status} with a body its document does not permit`);
235
+ super(
236
+ `${operationId} answered ${status} with ${part === "body" ? "a body" : "headers"} its document does not permit`,
237
+ );
231
238
  this.name = "ResponseContractError";
232
239
  }
233
240
  }
@@ -294,6 +301,48 @@ export function headersOf(declared: Readonly<Record<string, unknown>>): Record<s
294
301
  return headers;
295
302
  }
296
303
 
304
+ /**
305
+ * Declared response headers, CHECKED against the schemas the document publishes for them.
306
+ *
307
+ * **The body beside them was always checked and these never were.** `servedBody` parses every
308
+ * response body against its status's schema and throws `ResponseContractError`; a declared header
309
+ * went through `headersOf`, which is `String(value)` and nothing else. Measured on a generated
310
+ * server: a header the document constrains to `^[A-Za-z0-9-]+$` was served as
311
+ * `not a valid id"; injected=1` with a 200, while a body breaking its own schema on the same route
312
+ * threw. That asymmetry is the whole of what this removes.
313
+ *
314
+ * **Not a response-splitting hole, and worth saying so.** `new Response` refuses a header value
315
+ * containing CR or LF with a `TypeError` on both `workerd` and undici, so the runtime already stops
316
+ * the injection. What it does not stop is a response that disagrees with its own document, which a
317
+ * client generated from that document is entitled to rely on.
318
+ *
319
+ * **Undefined values are dropped BEFORE the check, not after.** An optional header the handler did
320
+ * not supply is absent rather than explicitly `undefined`, which is what the emitted schema's
321
+ * `.exactOptional()` means, and it is also what Hono's `HeaderRecord` requires. The check then runs
322
+ * on the values as the handler produced them - a `retry-after` declared `int32` is still a number
323
+ * here - because checking the text would check a different thing than the document describes.
324
+ *
325
+ * **What is returned is every header, including ones the schema does not mention.** A response
326
+ * carries headers the document does not declare, the `Content-Type` this emitter sets among them,
327
+ * and those are not a contract violation. The schema is a plain object schema, so it ignores them.
328
+ */
329
+ export function servedHeaders(
330
+ schema: ZodType,
331
+ declared: Readonly<Record<string, unknown>>,
332
+ operationId: string,
333
+ status: number,
334
+ ): Record<string, string> {
335
+ const present: Record<string, unknown> = {};
336
+ for (const [name, value] of Object.entries(declared)) {
337
+ if (value !== undefined) present[name] = value;
338
+ }
339
+ const parsed = schema.safeParse(present);
340
+ if (!parsed.success) {
341
+ throw new ResponseContractError(operationId, status, parsed.error.issues, "headers");
342
+ }
343
+ return headersOf(present);
344
+ }
345
+
297
346
  /** RFC 9110 `token`, less `*`, which names a range rather than a type. */
298
347
  const MEDIA_TOKEN = "[!#$%&'+.^_`|~0-9A-Za-z-]+";
299
348
  const SERVED_MEDIA_TYPE = new RegExp(`^(${MEDIA_TOKEN})/${MEDIA_TOKEN}\\s*(;.*)?$`);