typespec-hono 0.1.0 → 0.2.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.
@@ -1,16 +1,25 @@
1
1
  import type { Namespace, Program } from "@typespec/compiler";
2
2
  export interface BasePathResolution {
3
- /** The prefix to mount every route under, or `undefined` to mount at the root. */
4
- readonly basePath: string | undefined;
5
- /** The distinct paths found, when the document declares more than one and they disagree. */
6
- readonly ambiguous: readonly string[];
3
+ /**
4
+ * Every prefix the document publishes, in declaration order after de-duplication.
5
+ *
6
+ * Empty means the root, which is what a document with no `@server` -- or a templated one -- means.
7
+ * More than one is not a conflict to resolve: the document says the service answers at all of
8
+ * them, and Hono mounts one sub-app under several prefixes without duplicating a single route.
9
+ */
10
+ readonly basePaths: readonly string[];
7
11
  }
8
12
  /**
9
- * Read the service's declared servers and decide what to mount under.
13
+ * Read the service's declared servers and return every prefix it publishes.
10
14
  *
11
- * - no `@server`, or a templated one the root, which is what the document means;
12
- * - one static path, or several that agree → that path;
13
- * - several that DISAGREE the root, and the caller reports it. There is no answer that serves all
14
- * of them, and picking one would silently serve the wrong URLs for the others.
15
+ * - no `@server`, or a templated one, gives none, and the routes mount at the root;
16
+ * - one static path gives that path;
17
+ * - several give all of them, each mounted with its own `app.route()` over one shared sub-app.
18
+ *
19
+ * **Several servers used to be reported as ambiguous and mounted at the root.** That was wrong in
20
+ * the one way that matters: an OpenAPI path is relative to its server, so every caller following the
21
+ * document prefixed one of the declared paths and got a 404. The document was never ambiguous -- it
22
+ * says the service answers at all of them -- and Hono mounts one sub-app under as many prefixes as
23
+ * asked, so there was nothing to choose between in the first place.
15
24
  */
16
25
  export declare function resolveBasePath(program: Program, namespace: Namespace): BasePathResolution;
@@ -2,22 +2,22 @@ import { getServers } from "@typespec/http";
2
2
  /**
3
3
  * The path prefix the DOCUMENT says this service is served under.
4
4
  *
5
- * ⚠️ **Without this the two artefacts from one spec disagree, which is the thing this project exists
5
+ * **Without this the two artefacts from one spec disagree, which is the thing this project exists
6
6
  * to prevent.** `@server("/api/v1")` reaches OpenAPI as `servers: [{ url: "/api/v1" }]`, and an
7
- * OpenAPI path is relative to its server so the document says `/api/v1/accounts` while the
7
+ * OpenAPI path is relative to its server, so the document says `/api/v1/accounts` while the
8
8
  * generated router answered `/accounts`. Measured: every client generated from the document, and
9
9
  * every "try it" button in a rendered document, 404s.
10
10
  *
11
- * ⚠️ **A prefix is only taken where the document is unambiguous about it.** Guessing wrong is worse
12
- * than not applying one: a route mounted under the wrong prefix still matches, still answers, and
13
- * answers the wrong URL.
11
+ * **Every declared prefix is honoured, because the document publishes all of them.** A route
12
+ * mounted under only one of several still matches and still answers, and answers the wrong URL for
13
+ * every caller who followed one of the others.
14
14
  */
15
15
  /** The static path of a server URL, or `undefined` when it has none this can rely on. */
16
16
  function pathOf(server) {
17
17
  const url = server.url;
18
18
  /**
19
- * ⚠️ **A templated URL is not a mismatch and must not warn.** `@server("{endpoint}")` which most
20
- * of `@typespec/http-specs` uses means the whole origin is supplied by the caller, so the paths
19
+ * **A templated URL is not a mismatch and must not warn.** `@server("{endpoint}")`, which most
20
+ * of `@typespec/http-specs` uses, means the whole origin is supplied by the caller, so the paths
21
21
  * the document publishes are already relative to whatever they choose. Mounting at the root is
22
22
  * correct there, and warning would raise noise on the majority of real specs.
23
23
  */
@@ -29,19 +29,21 @@ function pathOf(server) {
29
29
  return trimmed === "" || trimmed === "/" ? undefined : trimmed;
30
30
  }
31
31
  /**
32
- * Read the service's declared servers and decide what to mount under.
32
+ * Read the service's declared servers and return every prefix it publishes.
33
33
  *
34
- * - no `@server`, or a templated one the root, which is what the document means;
35
- * - one static path, or several that agree → that path;
36
- * - several that DISAGREE the root, and the caller reports it. There is no answer that serves all
37
- * of them, and picking one would silently serve the wrong URLs for the others.
34
+ * - no `@server`, or a templated one, gives none, and the routes mount at the root;
35
+ * - one static path gives that path;
36
+ * - several give all of them, each mounted with its own `app.route()` over one shared sub-app.
37
+ *
38
+ * **Several servers used to be reported as ambiguous and mounted at the root.** That was wrong in
39
+ * the one way that matters: an OpenAPI path is relative to its server, so every caller following the
40
+ * document prefixed one of the declared paths and got a 404. The document was never ambiguous -- it
41
+ * says the service answers at all of them -- and Hono mounts one sub-app under as many prefixes as
42
+ * asked, so there was nothing to choose between in the first place.
38
43
  */
39
44
  export function resolveBasePath(program, namespace) {
40
45
  const servers = getServers(program, namespace) ?? [];
41
- const paths = [...new Set(servers.map(pathOf).filter((path) => path !== undefined))];
42
- if (paths.length === 0)
43
- return { basePath: undefined, ambiguous: [] };
44
- if (paths.length === 1)
45
- return { basePath: paths[0], ambiguous: [] };
46
- return { basePath: undefined, ambiguous: paths.toSorted() };
46
+ return {
47
+ basePaths: [...new Set(servers.map(pathOf).filter((path) => path !== undefined))],
48
+ };
47
49
  }
@@ -1,24 +1,15 @@
1
1
  import { type EmitContext } from "@typespec/compiler";
2
2
  /**
3
- * What the generated files import their runtime contract from when the consumer sets nothing.
3
+ * The generated runtime lands beside the generated code, so nothing the emitter writes imports this
4
+ * package at run time.
4
5
  *
5
- * ⚠️ **THIS package's runtime, not the library's, and the distinction is the whole of a defect that
6
- * shipped.** `app.gen.ts` names `AppEnv`, `Awaitable`, `Ctx`, `Result`, `RouteDeps` and
7
- * `selectContentType`; every one of them is declared in `src/runtime.ts` here. The library's runtime
8
- * exports `ResponseArm` and `armFor` and nothing else and it is a TRANSITIVE dependency of a
9
- * consumer of this package, so under a strict `node_modules` its specifier does not resolve from
10
- * consumer code at all.
6
+ * An emitter is a build-time tool. `@typespec/openapi3` is a devDependency and its output imports
7
+ * nothing, which is the shape a consumer expects. This package shipped a runtime half instead, which
8
+ * forced it to be a `dependency` and made `--save-dev` fail at deploy and nowhere earlier. Emitting
9
+ * the runtime removes the choice rather than documenting it.
11
10
  *
12
- * Pointing at `typespec-hono/runtime` fixes both halves at once, because this module RE-EXPORTS
13
- * `ResponseArm` and `armFor` (see `runtime.ts`) which is what `schemas.gen.ts` imports. One
14
- * specifier, present in the consumer's own dependency, carrying every name both generated files
15
- * reference.
16
- *
17
- * ⚠️ **Measured in a fresh project installed from `pnpm pack` tarballs, because no test could see it:**
18
- * every compile in both harnesses sets `runtime-module` explicitly, so the default branch was ungraded
19
- * across 240 tests. `tsp compile` succeeded with zero diagnostics and `tsc` then reported two
20
- * `TS2307`s — `Cannot find module 'typespec-http-zod/runtime'` — one in each generated file.
21
- * `test/adopter.test.ts` is the arm that now opens that branch.
11
+ * `typespec-hono/runtime` is still exported, for an application that points `runtime-module` at it
12
+ * deliberately. It is no longer what the generated code reaches for by default.
22
13
  */
23
- export declare const DEFAULT_RUNTIME_MODULE = "typespec-hono/runtime";
14
+ export declare const DEFAULT_RUNTIME_MODULE = "./runtime.gen.js";
24
15
  export declare function $onEmit(context: EmitContext): Promise<void>;
@@ -1,29 +1,31 @@
1
1
  import { emitFile, resolvePath } from "@typespec/compiler";
2
+ import { readFileSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
2
4
  import { emitHttpZod } from "typespec-http-zod";
3
5
  import { renderApp } from "./app.js";
4
6
  import { resolveBasePath } from "./base-path.js";
5
7
  import { securityFor } from "./security.js";
6
8
  import { reportDiagnostic } from "./lib.js";
7
9
  /**
8
- * This emitter's entry point **the whole of `typespec-http-zod`, plus one file**.
10
+ * This emitter's entry point. **the whole of `typespec-http-zod`, plus one file**.
9
11
  *
10
- * ⚠️ **A consumer lists ONE emitter, and this is why.** The validators and the server share a naming
12
+ * **A consumer lists ONE emitter, and this is why.** The validators and the server share a naming
11
13
  * contract: `app.gen.ts` imports `readWidgetPath` and `readWidgetResponses` from `schemas.gen.js` by
12
14
  * name. Two separate TypeSpec emitters would each get their own `$onEmit` and their own registry, and
13
15
  * would have to arrive at identical identifiers by coincidence. Running the library here means it
14
- * mints the names, writes them, and hands them back so agreement is structural rather than hoped
16
+ * mints the names, writes them, and hands them back, so agreement is structural rather than hoped
15
17
  * for.
16
18
  *
17
- * ⚠️ **It uses nothing `typespec-http-zod` does not export.** The package's `exports` map makes a deep
19
+ * **It uses nothing `typespec-http-zod` does not export.** The package's `exports` map makes a deep
18
20
  * import impossible, so this file is the proof that the published API is sufficient to build a server
19
21
  * generator on. Anything it cannot do from here is a gap in that API, to be fixed there.
20
22
  */
21
23
  /**
22
24
  * The HTTP operation an emitted route came from, keyed on verb and path.
23
25
  *
24
- * ⚠️ **Keyed on the ROUTE, not on the name, and the name was wrong for every interface.**
25
- * `EmittedRoute.operationId` is the id the document publishes `Accounts_list`, with the interface
26
- * prefix `resolveOperationId` inserts while `operation.operation.name` is the bare `list`. Matching
26
+ * **Keyed on the ROUTE, not on the name, and the name was wrong for every interface.**
27
+ * `EmittedRoute.operationId` is the id the document publishes, `Accounts_list`, with the interface
28
+ * prefix `resolveOperationId` inserts, while `operation.operation.name` is the bare `list`. Matching
27
29
  * them never succeeded for an operation declared inside an `interface`, which is most of them.
28
30
  *
29
31
  * Two things rested on that lookup and both were silently wrong: every diagnostic pointed at the
@@ -41,48 +43,71 @@ function targetFor(emitted, verb, path) {
41
43
  /**
42
44
  * What the generated files import their runtime contract from when the consumer sets nothing.
43
45
  *
44
- * ⚠️ **THIS package's runtime, not the library's, and the distinction is the whole of a defect that
46
+ * **THIS package's runtime, not the library's, and the distinction is the whole of a defect that
45
47
  * shipped.** `app.gen.ts` names `AppEnv`, `Awaitable`, `Ctx`, `Result`, `RouteDeps` and
46
48
  * `selectContentType`; every one of them is declared in `src/runtime.ts` here. The library's runtime
47
- * exports `ResponseArm` and `armFor` and nothing else and it is a TRANSITIVE dependency of a
49
+ * exports `ResponseArm` and `armFor` and nothing else, and it is a TRANSITIVE dependency of a
48
50
  * consumer of this package, so under a strict `node_modules` its specifier does not resolve from
49
51
  * consumer code at all.
50
52
  *
51
53
  * Pointing at `typespec-hono/runtime` fixes both halves at once, because this module RE-EXPORTS
52
- * `ResponseArm` and `armFor` (see `runtime.ts`) which is what `schemas.gen.ts` imports. One
54
+ * `ResponseArm` and `armFor` (see `runtime.ts`), which is what `schemas.gen.ts` imports. One
53
55
  * specifier, present in the consumer's own dependency, carrying every name both generated files
54
56
  * reference.
55
57
  *
56
- * ⚠️ **Measured in a fresh project installed from `pnpm pack` tarballs, because no test could see it:**
58
+ * **Measured in a fresh project installed from `pnpm pack` tarballs, because no test could see it:**
57
59
  * every compile in both harnesses sets `runtime-module` explicitly, so the default branch was ungraded
58
60
  * across 240 tests. `tsp compile` succeeded with zero diagnostics and `tsc` then reported two
59
- * `TS2307`s `Cannot find module 'typespec-http-zod/runtime'` one in each generated file.
61
+ * `TS2307`s (`Cannot find module 'typespec-http-zod/runtime'`) one in each generated file.
60
62
  * `test/adopter.test.ts` is the arm that now opens that branch.
61
63
  */
62
- export const DEFAULT_RUNTIME_MODULE = "typespec-hono/runtime";
64
+ /** The header the emitted runtime carries, matching every other generated file. */
65
+ const GENERATED_RUNTIME_BANNER = `// GENERATED by typespec-hono. DO NOT EDIT.
66
+ // A copy of this package's runtime, emitted so the generated code imports no package at run time.
67
+ // Point \`runtime-module\` at a module of your own to replace it.
68
+ `;
69
+ /**
70
+ * The generated runtime lands beside the generated code, so nothing the emitter writes imports this
71
+ * package at run time.
72
+ *
73
+ * An emitter is a build-time tool. `@typespec/openapi3` is a devDependency and its output imports
74
+ * nothing, which is the shape a consumer expects. This package shipped a runtime half instead, which
75
+ * forced it to be a `dependency` and made `--save-dev` fail at deploy and nowhere earlier. Emitting
76
+ * the runtime removes the choice rather than documenting it.
77
+ *
78
+ * `typespec-hono/runtime` is still exported, for an application that points `runtime-module` at it
79
+ * deliberately. It is no longer what the generated code reaches for by default.
80
+ */
81
+ export const DEFAULT_RUNTIME_MODULE = "./runtime.gen.js";
82
+ /** Where this package's own runtime source lives, to be copied beside the generated code. */
83
+ const RUNTIME_SOURCE = fileURLToPath(new URL("../../src/runtime.ts", import.meta.url));
63
84
  export async function $onEmit(context) {
64
85
  for (const emitted of await emitHttpZod(context, {
65
86
  defaultRuntimeModule: DEFAULT_RUNTIME_MODULE,
66
87
  })) {
67
88
  /**
68
- * ⚠️ **The path the DOCUMENT says this service is served under.** An OpenAPI path is relative to
89
+ * Written only when the generated code actually points at it. An application that sets
90
+ * `runtime-module` has supplied its own module and must not have a file it did not ask for
91
+ * appear in its output directory.
92
+ */
93
+ if (emitted.options.runtimeModule === DEFAULT_RUNTIME_MODULE) {
94
+ await emitFile(context.program, {
95
+ path: resolvePath(emitted.outputDir, "runtime.gen.ts"),
96
+ content: `${GENERATED_RUNTIME_BANNER}${readFileSync(RUNTIME_SOURCE, "utf8")}`,
97
+ });
98
+ }
99
+ /**
100
+ * **The path the DOCUMENT says this service is served under.** An OpenAPI path is relative to
69
101
  * its server, so `@server("/api/v1")` plus `/accounts` publishes `/api/v1/accounts`. Mounting at
70
102
  * the root made every client generated from the document 404.
71
103
  */
72
104
  const base = resolveBasePath(context.program, emitted.service.namespace);
73
- if (base.ambiguous.length > 0) {
74
- reportDiagnostic(context.program, {
75
- code: "ambiguous-server-path",
76
- format: { paths: base.ambiguous.join(", ") },
77
- target: emitted.service.namespace,
78
- });
79
- }
80
105
  await emitFile(context.program, {
81
106
  path: resolvePath(emitted.outputDir, "app.gen.ts"),
82
107
  content: renderApp(emitted, {
83
108
  /**
84
109
  * Reported rather than thrown, so a spec with one unmountable path still names every
85
- * other problem in the same compile and so the validators for the rest of the service
110
+ * other problem in the same compile, and so the validators for the rest of the service
86
111
  * are still written. A path this router cannot express is not a reason to emit nothing.
87
112
  */
88
113
  unsupportedPathTemplate: (route, template, name) => {
@@ -92,14 +117,14 @@ export async function $onEmit(context) {
92
117
  target: targetFor(emitted, route.verb, route.path),
93
118
  });
94
119
  },
95
- unroutableVerb: (route) => {
120
+ unvalidatableMediaType: (route, types) => {
96
121
  reportDiagnostic(context.program, {
97
- code: "unroutable-verb",
98
- format: { operationId: route.operationId, verb: route.verb },
122
+ code: "unvalidatable-media-type",
123
+ format: { operationId: route.operationId, types: types.join(", ") },
99
124
  target: targetFor(emitted, route.verb, route.path),
100
125
  });
101
126
  },
102
- }, base.basePath, (verb, path) => {
127
+ }, base.basePaths, (verb, path) => {
103
128
  const operation = operationFor(emitted, verb, path);
104
129
  return operation === undefined ? [] : securityFor(context.program, operation);
105
130
  }),
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The package's entry point: what a consumer may use, and the emitter written against it.
3
3
  *
4
- * ⚠️ **`typespec-http-zod` is re-exported deliberately.** This emitter runs the whole of it, so a
4
+ * **`typespec-http-zod` is re-exported deliberately.** This emitter runs the whole of it, so a
5
5
  * consumer of the generated server is already a consumer of those validators and their types. Making
6
6
  * them reach for a second package to name a schema this one caused to exist would be an accident of
7
7
  * packaging showing through.
package/dist/src/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The package's entry point: what a consumer may use, and the emitter written against it.
3
3
  *
4
- * ⚠️ **`typespec-http-zod` is re-exported deliberately.** This emitter runs the whole of it, so a
4
+ * **`typespec-http-zod` is re-exported deliberately.** This emitter runs the whole of it, so a
5
5
  * consumer of the generated server is already a consumer of those validators and their types. Making
6
6
  * them reach for a second package to name a schema this one caused to exist would be an accident of
7
7
  * packaging showing through.
package/dist/src/lib.d.ts CHANGED
@@ -7,30 +7,30 @@ import { type EmitterOptions as HttpZodOptions } from "typespec-http-zod";
7
7
  /**
8
8
  * Everything `typespec-http-zod` accepts, plus what a Hono server needs on top.
9
9
  *
10
- * ⚠️ **DERIVED, never restated, and the distinction is load-bearing.** This emitter runs the whole of
10
+ * **DERIVED, never restated, and the distinction is load-bearing.** This emitter runs the whole of
11
11
  * `typespec-http-zod` and adds one file; every option that package accepts has to reach it. Written
12
- * as a second literal list, an option added there would be rejected here as unknown or worse,
12
+ * as a second literal list, an option added there would be rejected here as unknown, or worse,
13
13
  * accepted and silently dropped, which produces output that is wrong in a way no test of either
14
14
  * package would see. `test/options.test.ts` asserts the forwarding as a CLASS.
15
15
  *
16
16
  * There is currently nothing to add: `runtime-module` belongs to the library, because the library is
17
17
  * what emits the annotated response arms that need it. This type exists as the seam rather than
18
- * because it carries anything today the moment a Hono-only option appears it goes here, and the
18
+ * because it carries anything today. The moment a Hono-only option appears it goes here, and the
19
19
  * derivation keeps the rest honest.
20
20
  */
21
21
  export type EmitterOptions = HttpZodOptions;
22
22
  /**
23
- * ⚠️ **A spread of the published schema, so a new key arrives for free.**
23
+ * **A spread of the published schema, so a new key arrives for free.**
24
24
  *
25
25
  * `properties` is spread rather than re-listed for the same reason the type is aliased rather than
26
26
  * re-declared. If this ever needs a Hono-only key, it is added to a spread of `httpZodOptions.properties`
27
- * never by copying the list.
27
+ *, never by copying the list.
28
28
  */
29
29
  declare const EmitterOptionsSchema: JSONSchemaType<EmitterOptions>;
30
30
  /**
31
- * ⚠️ **Spelled out rather than inferred, and every type in it imported through THIS package's own
31
+ * **Spelled out rather than inferred, and every type in it imported through THIS package's own
32
32
  * specifier.** Both this package and `typespec-http-zod` declare `@typespec/compiler` as a peer, so a
33
- * side-by-side checkout resolves two physically distinct copies of the identical version measured,
33
+ * side-by-side checkout resolves two physically distinct copies of the identical version, measured,
34
34
  * two `.pnpm` paths differing only in which repository they sit under. TypeScript then had a choice
35
35
  * of which copy to name in the emitted declarations and chose the other package's, producing five
36
36
  * `TS2883`s whose message is exactly the problem: *"this is likely not portable"*.
@@ -39,28 +39,25 @@ declare const EmitterOptionsSchema: JSONSchemaType<EmitterOptions>;
39
39
  * differently. Naming these through the direct import pins them to the specifier a consumer resolves,
40
40
  * whatever their tree looks like.
41
41
  *
42
- * ⚠️ **A consumer never hits this**, because a peer dependency is installed once and both packages
42
+ * **A consumer never hits this**, because a peer dependency is installed once and both packages
43
43
  * share it. That is precisely why it is worth guarding against here rather than trusting: the failure
44
44
  * exists only in the arrangement that BUILDS the package, and would ship silently.
45
45
  */
46
46
  type Diagnostics = {
47
- "unroutable-verb": {
48
- readonly default: CallableMessage<["operationId", "verb"]>;
49
- };
50
47
  "unsupported-path-template": {
51
48
  readonly default: CallableMessage<["template", "name"]>;
52
49
  };
53
- "ambiguous-server-path": {
54
- readonly default: CallableMessage<["paths"]>;
50
+ "unvalidatable-media-type": {
51
+ readonly default: CallableMessage<["operationId", "types"]>;
55
52
  };
56
53
  };
57
54
  /**
58
- * ⚠️ **Annotated rather than inferred, and the reason is a packaging fact rather than a style
59
- * preference.** This package and `typespec-http-zod` each resolve their own `@typespec/compiler` —
55
+ * **Annotated rather than inferred, and the reason is a packaging fact rather than a style
56
+ * preference.** This package and `typespec-http-zod` each resolve their own `@typespec/compiler`,
60
57
  * that is what a peer dependency does, and a consumer installing both gets one copy while a
61
58
  * side-by-side checkout gets two. Inferring the type here makes the emitted `.d.ts` name a compiler
62
59
  * through a path that exists only in the tree it was built in: `TS2883`, five of them, and the
63
- * message says it outright *"this is likely not portable"*.
60
+ * message says it outright, *"this is likely not portable"*.
64
61
  *
65
62
  * A published declaration file that names a `node_modules/.pnpm/...` path is broken for everyone who
66
63
  * installed differently. Naming the type explicitly is what makes the declaration stand on its own,
package/dist/src/lib.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import { createTypeSpecLibrary, paramMessage, } from "@typespec/compiler";
2
2
  import { EmitterOptionsSchema as httpZodOptions, } from "typespec-http-zod";
3
3
  /**
4
- * ⚠️ **A spread of the published schema, so a new key arrives for free.**
4
+ * **A spread of the published schema, so a new key arrives for free.**
5
5
  *
6
6
  * `properties` is spread rather than re-listed for the same reason the type is aliased rather than
7
7
  * re-declared. If this ever needs a Hono-only key, it is added to a spread of `httpZodOptions.properties`
8
- * never by copying the list.
8
+ *, never by copying the list.
9
9
  */
10
10
  const EmitterOptionsSchema = {
11
11
  ...httpZodOptions,
@@ -19,96 +19,56 @@ const EmitterOptionsSchema = {
19
19
  * consumer who wants validators without a server inheriting a refusal about routing.
20
20
  */
21
21
  const diagnostics = {
22
- /**
23
- * An operation on a verb Hono cannot dispatch to — in practice, `HEAD`.
24
- *
25
- * ⚠️ **Hono rewrites every HEAD request to GET BEFORE routing, unconditionally.** Read from its own
26
- * source, `hono-base.js`:
27
- *
28
- * ```js
29
- * if (method === "HEAD") {
30
- * return (async () => new Response(null, await this.#dispatch(request, executionCtx, env, "GET")))();
31
- * }
32
- * ```
33
- *
34
- * So a route registered under `HEAD` is categorically unreachable. Measured on Hono 4.13.1: with a
35
- * HEAD route and no GET, a HEAD request answers **404**; with both, the GET handler answers and the
36
- * HEAD handler is dead code. `on("PURGE", …)` and `on("OPTIONS", …)` both work, so this is HEAD
37
- * specifically and not a limitation of `on`.
38
- *
39
- * ⚠️ **This emitter shipped exactly that dead code, and every route-counting arm called it mounted**
40
- * — including a differential written specifically to catch unreachable routes, because `app.routes`
41
- * lists a registration Hono will never dispatch to. Fifteen of the seventeen HEAD operations in
42
- * `@typespec/http-specs` have no sibling GET, so they were 404s that counted as present.
43
- *
44
- * **Refused rather than compensated for.** The workarounds are worse: registering the handler under
45
- * GET invents an operation the document does not declare, and guarding it on `c.req.method` is not
46
- * something a Hono author would write. The remedy is in the spec, and the message says it.
47
- *
48
- * ⚠️ **Hono's own best-practices guide says the same thing outright**, which this refusal predates:
49
- * *"Don't create dedicated `app.head()` handlers — they won't execute as HEAD requests are converted
50
- * before route matching."* The rule was derived here by reading `hono-base.js` and measuring; finding
51
- * it stated in the documentation afterwards is corroboration rather than the source.
52
- *
53
- * ⚠️ **A refusal about the TARGET FRAMEWORK, which is why it lives here.** `typespec-http-zod` emits
54
- * correct validators for these operations; only a Hono server cannot route them.
55
- */
56
- "unroutable-verb": {
57
- severity: "warning",
58
- messages: {
59
- default: paramMessage `'${"operationId"}' is declared on '${"verb"}', which Hono cannot dispatch to: it rewrites every HEAD request to GET before matching, so a route registered under HEAD is never reached — 404 where the path has no GET, and dead code where it has one. Declare the operation as '@get' instead; Hono answers HEAD from it automatically with the body stripped, which is what RFC 9110 requires.`,
60
- },
61
- },
62
- /**
63
- * The document declares several servers whose paths disagree, so there is no prefix to mount under.
64
- *
65
- * ⚠️ **Reported rather than resolved, because every resolution is wrong for somebody.** An OpenAPI
66
- * path is relative to its server, so `@server("/api/v1")` plus `/accounts` means the document
67
- * publishes `/api/v1/accounts`. Where one static path is declared this emitter mounts under it and
68
- * the two artefacts agree. Where several disagree there is no single answer: picking one serves
69
- * the wrong URL for every other, and a route mounted under a wrong prefix still matches and still
70
- * answers, which is worse than one that fails.
71
- *
72
- * Routes are mounted at the root in this case, which is at least predictable, and this says so.
73
- */
74
- "ambiguous-server-path": {
75
- severity: "warning",
76
- messages: {
77
- default: paramMessage `The service declares servers with different base paths (${"paths"}), so routes are mounted at the root. An OpenAPI path is relative to its server, so callers following the document will prefix one of these — mount the returned app under the prefix you serve, or declare a single base path.`,
78
- },
79
- },
80
22
  /**
81
23
  * A path template this emitter will not translate into a route.
82
24
  *
83
- * ⚠️ **Refused rather than approximated, because the failure mode is a route that WORKS and is
25
+ * **Refused rather than approximated, because the failure mode is a route that WORKS and is
84
26
  * wrong.** Hono reads `:name` up to the next `/`, so an RFC 6570 modifier survives into the
85
- * parameter name `{id*}` becomes `:id*`, where `*` is Hono's own wildcard. That mounts a route
27
+ * parameter name, `{id*}` becomes `:id*`, where `*` is Hono's own wildcard. That mounts a route
86
28
  * which matches, answers, and binds the wrong thing, which is strictly worse than one that 404s.
87
29
  *
88
- * ⚠️ **The refusal lands HERE and not in the library, which is the whole point of the split.** The
30
+ * **The refusal lands HERE and not in the library, which is the whole point of the split.** The
89
31
  * validators for such an operation are correct and are still emitted: what a request body must
90
32
  * look like does not depend on whether some particular router can express the path. Only the
91
33
  * server is impossible.
92
34
  *
93
- * Plain names are translated, including the hyphens and dots that `\w` used to drop on the floor
35
+ * Plain names are translated, including the hyphens and dots that `\w` used to drop on the floor,
94
36
  * a parameter carrying a hyphen was left alone entirely, so `@path("thing-id")` produced the
95
37
  * literal route `/things/{thing-id}`: mounted, counted by every arm that counted routes, and
96
38
  * reachable by nobody.
97
39
  */
40
+ /**
41
+ * The document declares a request media type no `zValidator` target can parse.
42
+ *
43
+ * Reported rather than silently mis-parsed. This emitter used to hand every unrecognised media type
44
+ * to `zValidator("json", ...)`, which reads `c.req.json()`, so an `application/xml` body was
45
+ * rejected with a 400 and nothing said why. Naming it is what stops a consumer believing a route is
46
+ * validated when it is not.
47
+ *
48
+ * There is no Hono parser and no Zod representation for XML, and adding an XML dependency to a Zod
49
+ * emitter is not a trade this package should make. Where the same route also declares a media type
50
+ * that CAN be parsed, that one is still validated, chosen from the request's `Content-Type`.
51
+ */
52
+ "unvalidatable-media-type": {
53
+ severity: "warning",
54
+ messages: {
55
+ default: paramMessage `'${"operationId"}' declares request media types this emitter cannot validate (${"types"}). Requests carrying them are refused rather than validated. Declare a media type with a parser -- JSON, multipart or urlencoded -- or handle the body yourself with '@body body: bytes'.`,
56
+ },
57
+ },
98
58
  "unsupported-path-template": {
99
59
  severity: "warning",
100
60
  messages: {
101
- default: paramMessage `'${"template"}' is not a path template this emitter can mount: the parameter '${"name"}' is not a plain name. Hono reads a parameter up to the next '/', so an RFC 6570 operator or modifier would become part of the name or, for '*', a wildcard and the route would match the wrong requests rather than fail. Name the parameter with letters, digits, '_', '-', '.' or '~'.`,
61
+ default: paramMessage `'${"template"}' is not a path template this emitter can mount: the parameter '${"name"}' is not a plain name. Hono reads a parameter up to the next '/', so an RFC 6570 operator or modifier would become part of the name (or, for '*', a wildcard) and the route would match the wrong requests rather than fail. Name the parameter with letters, digits, '_', '-', '.' or '~'.`,
102
62
  },
103
63
  },
104
64
  };
105
65
  /**
106
- * ⚠️ **Annotated rather than inferred, and the reason is a packaging fact rather than a style
107
- * preference.** This package and `typespec-http-zod` each resolve their own `@typespec/compiler` —
66
+ * **Annotated rather than inferred, and the reason is a packaging fact rather than a style
67
+ * preference.** This package and `typespec-http-zod` each resolve their own `@typespec/compiler`,
108
68
  * that is what a peer dependency does, and a consumer installing both gets one copy while a
109
69
  * side-by-side checkout gets two. Inferring the type here makes the emitted `.d.ts` name a compiler
110
70
  * through a path that exists only in the tree it was built in: `TS2883`, five of them, and the
111
- * message says it outright *"this is likely not portable"*.
71
+ * message says it outright, *"this is likely not portable"*.
112
72
  *
113
73
  * A published declaration file that names a `node_modules/.pnpm/...` path is broken for everyone who
114
74
  * installed differently. Naming the type explicitly is what makes the declaration stand on its own,