typespec-hono 0.1.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.
@@ -0,0 +1,47 @@
1
+ import { getServers } from "@typespec/http";
2
+ /**
3
+ * The path prefix the DOCUMENT says this service is served under.
4
+ *
5
+ * ⚠️ **Without this the two artefacts from one spec disagree, which is the thing this project exists
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
8
+ * generated router answered `/accounts`. Measured: every client generated from the document, and
9
+ * every "try it" button in a rendered document, 404s.
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.
14
+ */
15
+ /** The static path of a server URL, or `undefined` when it has none this can rely on. */
16
+ function pathOf(server) {
17
+ const url = server.url;
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
21
+ * the document publishes are already relative to whatever they choose. Mounting at the root is
22
+ * correct there, and warning would raise noise on the majority of real specs.
23
+ */
24
+ if (url.includes("{"))
25
+ return undefined;
26
+ // An absolute URL carries a host; only its path is a prefix.
27
+ const path = /^[a-z][a-z0-9+.-]*:\/\//i.test(url) ? (URL.parse(url)?.pathname ?? "/") : url;
28
+ const trimmed = path.replace(/\/+$/, "");
29
+ return trimmed === "" || trimmed === "/" ? undefined : trimmed;
30
+ }
31
+ /**
32
+ * Read the service's declared servers and decide what to mount under.
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.
38
+ */
39
+ export function resolveBasePath(program, namespace) {
40
+ 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() };
47
+ }
@@ -0,0 +1,24 @@
1
+ import { type EmitContext } from "@typespec/compiler";
2
+ /**
3
+ * What the generated files import their runtime contract from when the consumer sets nothing.
4
+ *
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.
11
+ *
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.
22
+ */
23
+ export declare const DEFAULT_RUNTIME_MODULE = "typespec-hono/runtime";
24
+ export declare function $onEmit(context: EmitContext): Promise<void>;
@@ -0,0 +1,108 @@
1
+ import { emitFile, resolvePath } from "@typespec/compiler";
2
+ import { emitHttpZod } from "typespec-http-zod";
3
+ import { renderApp } from "./app.js";
4
+ import { resolveBasePath } from "./base-path.js";
5
+ import { securityFor } from "./security.js";
6
+ import { reportDiagnostic } from "./lib.js";
7
+ /**
8
+ * This emitter's entry point — **the whole of `typespec-http-zod`, plus one file**.
9
+ *
10
+ * ⚠️ **A consumer lists ONE emitter, and this is why.** The validators and the server share a naming
11
+ * contract: `app.gen.ts` imports `readWidgetPath` and `readWidgetResponses` from `schemas.gen.js` by
12
+ * name. Two separate TypeSpec emitters would each get their own `$onEmit` and their own registry, and
13
+ * 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
15
+ * for.
16
+ *
17
+ * ⚠️ **It uses nothing `typespec-http-zod` does not export.** The package's `exports` map makes a deep
18
+ * import impossible, so this file is the proof that the published API is sufficient to build a server
19
+ * generator on. Anything it cannot do from here is a gap in that API, to be fixed there.
20
+ */
21
+ /**
22
+ * The HTTP operation an emitted route came from, keyed on verb and path.
23
+ *
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
27
+ * them never succeeded for an operation declared inside an `interface`, which is most of them.
28
+ *
29
+ * Two things rested on that lookup and both were silently wrong: every diagnostic pointed at the
30
+ * service namespace instead of the operation that caused it, and the security requirements resolved
31
+ * to none, so a scheme-gated route emitted no gate. Verb and path identify a route exactly, and are
32
+ * what both sides already agree on.
33
+ */
34
+ function operationFor(emitted, verb, path) {
35
+ return emitted.service.operations.find((candidate) => candidate.verb.toUpperCase() === verb.toUpperCase() && candidate.path === path);
36
+ }
37
+ /** The declaration a diagnostic should point at, falling back to the service when it cannot be found. */
38
+ function targetFor(emitted, verb, path) {
39
+ return operationFor(emitted, verb, path)?.operation ?? emitted.service.namespace;
40
+ }
41
+ /**
42
+ * What the generated files import their runtime contract from when the consumer sets nothing.
43
+ *
44
+ * ⚠️ **THIS package's runtime, not the library's, and the distinction is the whole of a defect that
45
+ * shipped.** `app.gen.ts` names `AppEnv`, `Awaitable`, `Ctx`, `Result`, `RouteDeps` and
46
+ * `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
48
+ * consumer of this package, so under a strict `node_modules` its specifier does not resolve from
49
+ * consumer code at all.
50
+ *
51
+ * 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
53
+ * specifier, present in the consumer's own dependency, carrying every name both generated files
54
+ * reference.
55
+ *
56
+ * ⚠️ **Measured in a fresh project installed from `pnpm pack` tarballs, because no test could see it:**
57
+ * every compile in both harnesses sets `runtime-module` explicitly, so the default branch was ungraded
58
+ * 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.
60
+ * `test/adopter.test.ts` is the arm that now opens that branch.
61
+ */
62
+ export const DEFAULT_RUNTIME_MODULE = "typespec-hono/runtime";
63
+ export async function $onEmit(context) {
64
+ for (const emitted of await emitHttpZod(context, {
65
+ defaultRuntimeModule: DEFAULT_RUNTIME_MODULE,
66
+ })) {
67
+ /**
68
+ * ⚠️ **The path the DOCUMENT says this service is served under.** An OpenAPI path is relative to
69
+ * its server, so `@server("/api/v1")` plus `/accounts` publishes `/api/v1/accounts`. Mounting at
70
+ * the root made every client generated from the document 404.
71
+ */
72
+ 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
+ await emitFile(context.program, {
81
+ path: resolvePath(emitted.outputDir, "app.gen.ts"),
82
+ content: renderApp(emitted, {
83
+ /**
84
+ * 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
86
+ * are still written. A path this router cannot express is not a reason to emit nothing.
87
+ */
88
+ unsupportedPathTemplate: (route, template, name) => {
89
+ reportDiagnostic(context.program, {
90
+ code: "unsupported-path-template",
91
+ format: { template, name },
92
+ target: targetFor(emitted, route.verb, route.path),
93
+ });
94
+ },
95
+ unroutableVerb: (route) => {
96
+ reportDiagnostic(context.program, {
97
+ code: "unroutable-verb",
98
+ format: { operationId: route.operationId, verb: route.verb },
99
+ target: targetFor(emitted, route.verb, route.path),
100
+ });
101
+ },
102
+ }, base.basePath, (verb, path) => {
103
+ const operation = operationFor(emitted, verb, path);
104
+ return operation === undefined ? [] : securityFor(context.program, operation);
105
+ }),
106
+ });
107
+ }
108
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The package's entry point: what a consumer may use, and the emitter written against it.
3
+ *
4
+ * ⚠️ **`typespec-http-zod` is re-exported deliberately.** This emitter runs the whole of it, so a
5
+ * consumer of the generated server is already a consumer of those validators and their types. Making
6
+ * them reach for a second package to name a schema this one caused to exist would be an accident of
7
+ * packaging showing through.
8
+ */
9
+ export * from "typespec-http-zod";
10
+ export { $lib } from "./lib.js";
11
+ export { $onEmit } from "./emitter.js";
12
+ export { renderApp, toHonoPath } from "./app.js";
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The package's entry point: what a consumer may use, and the emitter written against it.
3
+ *
4
+ * ⚠️ **`typespec-http-zod` is re-exported deliberately.** This emitter runs the whole of it, so a
5
+ * consumer of the generated server is already a consumer of those validators and their types. Making
6
+ * them reach for a second package to name a schema this one caused to exist would be an accident of
7
+ * packaging showing through.
8
+ */
9
+ export * from "typespec-http-zod";
10
+ export { $lib } from "./lib.js";
11
+ export { $onEmit } from "./emitter.js";
12
+ export { renderApp, toHonoPath } from "./app.js";
@@ -0,0 +1,71 @@
1
+ import { type CallableMessage, type DiagnosticReport, type JSONSchemaType, type Program, type TypeSpecLibrary } from "@typespec/compiler";
2
+ import { type EmitterOptions as HttpZodOptions } from "typespec-http-zod";
3
+ /**
4
+ * The library definition, kept in its own module so `tsp-index.ts` and the emitter can both reach it
5
+ * without importing each other.
6
+ */
7
+ /**
8
+ * Everything `typespec-http-zod` accepts, plus what a Hono server needs on top.
9
+ *
10
+ * ⚠️ **DERIVED, never restated, and the distinction is load-bearing.** This emitter runs the whole of
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,
13
+ * accepted and silently dropped, which produces output that is wrong in a way no test of either
14
+ * package would see. `test/options.test.ts` asserts the forwarding as a CLASS.
15
+ *
16
+ * There is currently nothing to add: `runtime-module` belongs to the library, because the library is
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
19
+ * derivation keeps the rest honest.
20
+ */
21
+ export type EmitterOptions = HttpZodOptions;
22
+ /**
23
+ * ⚠️ **A spread of the published schema, so a new key arrives for free.**
24
+ *
25
+ * `properties` is spread rather than re-listed for the same reason the type is aliased rather than
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.
28
+ */
29
+ declare const EmitterOptionsSchema: JSONSchemaType<EmitterOptions>;
30
+ /**
31
+ * ⚠️ **Spelled out rather than inferred, and every type in it imported through THIS package's own
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,
34
+ * two `.pnpm` paths differing only in which repository they sit under. TypeScript then had a choice
35
+ * of which copy to name in the emitted declarations and chose the other package's, producing five
36
+ * `TS2883`s whose message is exactly the problem: *"this is likely not portable"*.
37
+ *
38
+ * A published `.d.ts` naming a `node_modules/.pnpm/...` path is broken for everyone who installed
39
+ * differently. Naming these through the direct import pins them to the specifier a consumer resolves,
40
+ * whatever their tree looks like.
41
+ *
42
+ * ⚠️ **A consumer never hits this**, because a peer dependency is installed once and both packages
43
+ * share it. That is precisely why it is worth guarding against here rather than trusting: the failure
44
+ * exists only in the arrangement that BUILDS the package, and would ship silently.
45
+ */
46
+ type Diagnostics = {
47
+ "unroutable-verb": {
48
+ readonly default: CallableMessage<["operationId", "verb"]>;
49
+ };
50
+ "unsupported-path-template": {
51
+ readonly default: CallableMessage<["template", "name"]>;
52
+ };
53
+ "ambiguous-server-path": {
54
+ readonly default: CallableMessage<["paths"]>;
55
+ };
56
+ };
57
+ /**
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` —
60
+ * that is what a peer dependency does, and a consumer installing both gets one copy while a
61
+ * side-by-side checkout gets two. Inferring the type here makes the emitted `.d.ts` name a compiler
62
+ * 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"*.
64
+ *
65
+ * A published declaration file that names a `node_modules/.pnpm/...` path is broken for everyone who
66
+ * installed differently. Naming the type explicitly is what makes the declaration stand on its own,
67
+ * and this is exactly the class of defect a package built inside one workspace never has to face.
68
+ */
69
+ export declare const $lib: TypeSpecLibrary<Diagnostics, EmitterOptions>;
70
+ export declare const reportDiagnostic: <C extends keyof Diagnostics, M extends keyof Diagnostics[C]>(program: Program, diagnostic: DiagnosticReport<Diagnostics, C, M>) => void;
71
+ export { EmitterOptionsSchema };
@@ -0,0 +1,123 @@
1
+ import { createTypeSpecLibrary, paramMessage, } from "@typespec/compiler";
2
+ import { EmitterOptionsSchema as httpZodOptions, } from "typespec-http-zod";
3
+ /**
4
+ * ⚠️ **A spread of the published schema, so a new key arrives for free.**
5
+ *
6
+ * `properties` is spread rather than re-listed for the same reason the type is aliased rather than
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.
9
+ */
10
+ const EmitterOptionsSchema = {
11
+ ...httpZodOptions,
12
+ properties: { ...httpZodOptions.properties },
13
+ };
14
+ /**
15
+ * What this emitter refuses that the library does not.
16
+ *
17
+ * The library refuses what no OpenAPI document can state. Everything here is narrower: what no *Hono
18
+ * router* can mount. Two different questions, and keeping them in separate libraries is what stops a
19
+ * consumer who wants validators without a server inheriting a refusal about routing.
20
+ */
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
+ /**
81
+ * A path template this emitter will not translate into a route.
82
+ *
83
+ * ⚠️ **Refused rather than approximated, because the failure mode is a route that WORKS and is
84
+ * 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
86
+ * which matches, answers, and binds the wrong thing, which is strictly worse than one that 404s.
87
+ *
88
+ * ⚠️ **The refusal lands HERE and not in the library, which is the whole point of the split.** The
89
+ * validators for such an operation are correct and are still emitted: what a request body must
90
+ * look like does not depend on whether some particular router can express the path. Only the
91
+ * server is impossible.
92
+ *
93
+ * Plain names are translated, including the hyphens and dots that `\w` used to drop on the floor —
94
+ * a parameter carrying a hyphen was left alone entirely, so `@path("thing-id")` produced the
95
+ * literal route `/things/{thing-id}`: mounted, counted by every arm that counted routes, and
96
+ * reachable by nobody.
97
+ */
98
+ "unsupported-path-template": {
99
+ severity: "warning",
100
+ 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 '~'.`,
102
+ },
103
+ },
104
+ };
105
+ /**
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` —
108
+ * that is what a peer dependency does, and a consumer installing both gets one copy while a
109
+ * side-by-side checkout gets two. Inferring the type here makes the emitted `.d.ts` name a compiler
110
+ * 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"*.
112
+ *
113
+ * A published declaration file that names a `node_modules/.pnpm/...` path is broken for everyone who
114
+ * installed differently. Naming the type explicitly is what makes the declaration stand on its own,
115
+ * and this is exactly the class of defect a package built inside one workspace never has to face.
116
+ */
117
+ export const $lib = createTypeSpecLibrary({
118
+ name: "typespec-hono",
119
+ diagnostics,
120
+ emitter: { options: EmitterOptionsSchema },
121
+ });
122
+ export const reportDiagnostic = $lib.reportDiagnostic;
123
+ export { EmitterOptionsSchema };
@@ -0,0 +1,174 @@
1
+ import type { Context, Env, Input, MiddlewareHandler } from "hono";
2
+ import type { ResponseArm } from "typespec-http-zod/runtime";
3
+ /**
4
+ * ⚠️ **`ResponseArm` and `armFor` live in `typespec-http-zod` and are re-exported here.**
5
+ *
6
+ * The library is what emits the response arms — `schemas.gen.ts` declares them and annotates them
7
+ * with `satisfies readonly ResponseArm[]` — so the type belongs to the package that produces it, and
8
+ * the rule for reading an array containing `4XX` and `default` belongs beside it. A consumer serving
9
+ * those validators from Express needs both and should not depend on a Hono emitter to get them.
10
+ *
11
+ * Re-exported rather than merely available, so an application still has ONE runtime import and the
12
+ * `runtime-module` substitution keeps working unchanged: a module an app points that option at has to
13
+ * supply every name the generated files reference, and they reference these.
14
+ */
15
+ export { armFor, type ResponseArm } from "typespec-http-zod/runtime";
16
+ /**
17
+ * One acceptable combination of credentials, exactly as OpenAPI's `security` states it: scheme id to
18
+ * the scopes that scheme requires. Every entry in one requirement must be satisfied TOGETHER, and
19
+ * satisfying ANY ONE requirement authorises the caller.
20
+ *
21
+ * ⚠️ **Declared here rather than beside the code that derives it**, because `./runtime` is what a
22
+ * running server imports and must stay free of every build-time dependency — a packaging arm asserts
23
+ * it names no `@typespec/*` package at all. An application should not drag a compiler into its
24
+ * Worker to read one type.
25
+ */
26
+ export type SecurityRequirement = Readonly<Record<string, readonly string[]>>;
27
+ /**
28
+ * The contract between the GENERATED server and the app that mounts it.
29
+ *
30
+ * ⚠️ **This exists because "here is a data table, write your own router" is not a deliverable.**
31
+ * The emitter used to produce `GENERATED_ROUTES` — one array of plain objects — and every consumer
32
+ * had to hand-write a loop that interpreted it at run time. In this repository that loop is 220
33
+ * lines, it sits outside every oracle the emitter is judged by, and it carries a cast that exists
34
+ * *only* because iterating a homogeneous array throws away the per-operation types the emitter knew:
35
+ * `backend[operationId]` is a union of 104 differently-typed methods, so nothing about the call can
36
+ * be checked. Generating the server removes the loop, the cast, and the compensating type-level
37
+ * assertion invented to put the guarantee back.
38
+ *
39
+ * What is left for the app to supply is genuinely app-specific: how a request becomes a caller's
40
+ * context, and how a result becomes a response. Everything else — routing, validation, which
41
+ * validator applies to which target, what status each arm answers — is generated.
42
+ */
43
+ /**
44
+ * How an operation's return value is wrapped.
45
+ *
46
+ * Identity by default, so an operation may simply return its value. An app with a result envelope
47
+ * points `runtime-module` at its own module and re-declares this as, say, `ServiceResult<T>` — which
48
+ * is what keeps the generated `Operations` interface concretely typed end to end instead of falling
49
+ * back to `unknown` and reintroducing the cast this whole change exists to delete.
50
+ */
51
+ export type Result<T> = T;
52
+ /**
53
+ * The Hono environment the generated server mounts on, and the caller context its operations take.
54
+ *
55
+ * ⚠️ **Concrete on purpose.** Making `registerRoutes` generic over the environment does not work:
56
+ * Hono narrows `Context` per route and its conditional types cannot reduce
57
+ * `IfAnyThenEmptyObject<E extends Env ? …>` while `E` is an unbound parameter, so nothing the app
58
+ * supplies is ever assignable and every call site needs a cast. Naming the types here instead — an
59
+ * app points `runtime-module` at its own module and re-declares them — keeps every generated call
60
+ * site concrete and cast-free. Identity defaults, so an app with neither can ignore both.
61
+ */
62
+ export type AppEnv = Env;
63
+ export type Ctx = unknown;
64
+ /** Anything an operation may hand back: the value, or a promise of it. */
65
+ export type Awaitable<T> = T | Promise<T>;
66
+ /**
67
+ * Pick the media type to serve, per RFC 9110 §12.5.1.
68
+ *
69
+ * ⚠️ **In the runtime rather than in {@link RouteDeps}, deliberately.** The test for admitting
70
+ * anything to `deps` is *the generated code cannot proceed without an answer*, and this is not that:
71
+ * which media types an operation offers is a contract fact the emitter reads from the document, and
72
+ * how `Accept` selects among them is specified by the RFC. Both sides are derivable, so an app that
73
+ * had to supply this would be re-implementing the standard — and could get it wrong differently from
74
+ * everybody else.
75
+ *
76
+ * The rules that matter, and that a naive `includes()` gets wrong:
77
+ * - **absent or empty `Accept` means anything is acceptable** — serve the first offer;
78
+ * - **`q=0` is a REFUSAL**, not a weak preference, so a range scoring zero can never be chosen;
79
+ * - **specificity breaks ties before quality does**: `text/plain` beats `text/*` beats `*​/*` at
80
+ * equal `q`, which is why the score is a pair and not a number;
81
+ * - parameters after the media range (`;charset=utf-8`) are not part of the match.
82
+ *
83
+ * Returns `undefined` when nothing offered is acceptable — the caller answers 406, and the
84
+ * difference between "no preference" and "no acceptable option" is exactly what that turns on.
85
+ */
86
+ export declare function selectContentType(accept: string | undefined, offered: readonly string[]): string | undefined;
87
+ /**
88
+ * What the app provides. One object, passed once, rather than a module the generated file imports by
89
+ * path — a generated server that hard-codes `../../backend.js` is only usable by the project it was
90
+ * generated in, and this one has to be usable by any.
91
+ *
92
+ * ⚠️ **The hooks are generic over Hono's path and input parameters, deliberately.** Hono narrows
93
+ * `Context` per route — by the literal path, and by whatever the validators on that route produced —
94
+ * so a hook typed against a single `Context<E>` is not assignable at any real call site. Making the
95
+ * hooks generic lets the app write functions that ignore both, without a cast anywhere.
96
+ *
97
+ * ⚠️ **`E` and `C` are PARAMETERS, and they have to be.** The defaults keep the bare `RouteDeps` the
98
+ * generated server writes working for an app that substitutes nothing. An app that substitutes
99
+ * anything binds them once — `export type RouteDeps = BaseRouteDeps<AppEnv, Ctx>` in the module it
100
+ * points `runtime-module` at — and every hook is then typed against its own environment and its own
101
+ * caller context.
102
+ *
103
+ * Re-exporting this interface unparameterised instead does not work, and the reason is not obvious:
104
+ * **Hono's `Context` is INVARIANT in its environment**, because `Context.set` takes `E` as an
105
+ * argument. So `Context<AppEnv, …>` is not assignable to `Context<Env, …>` however plain the
106
+ * substituted environment is, and every generated `deps.*` call site fails. Separately, `context`
107
+ * would keep returning the identity `Ctx` — `unknown` — which the app's own handlers then reject.
108
+ * Measured before this was parameterised: **19 errors on a four-operation service.**
109
+ */
110
+ export interface RouteDeps<E extends Env = AppEnv, C = Ctx> {
111
+ /**
112
+ * The gate the DOCUMENT publishes, as middleware.
113
+ *
114
+ * ⚠️ **Which scopes an operation demands is a contract fact; how a token is verified is not.**
115
+ * `@useAuth(OAuth2Auth<…>)` reaches OpenAPI as `security` per operation, so the requirement is
116
+ * generated and this implements the check — the same split as `context` and `respond`. Emitted
117
+ * only where the operation declares scopes, which is why an internal surface with none is
118
+ * unaffected.
119
+ *
120
+ * Its absence was a real defect for one commit: the generated server carried **zero** references
121
+ * to scopes while the document published eleven, so a surface mounted with its gate silently
122
+ * dropped.
123
+ *
124
+ * ⚠️ **It receives the document's REQUIREMENTS, not a flat list of scopes, and that is the second
125
+ * half of the same defect.** `@useAuth(BearerAuth)` publishes `security: [{ "BearerAuth": [] }]` —
126
+ * no scopes — so a scopes-only gate was emitted for OAuth2 and for nothing else. Bearer, api-key
127
+ * and basic, which is most services, carried no gate at all and rested entirely on `context`
128
+ * returning null. An app whose `context` read a cookie would serve a route the document says needs
129
+ * a bearer token.
130
+ *
131
+ * Satisfying ANY ONE requirement authorises the caller, and every scheme WITHIN a requirement must
132
+ * be satisfied together — which is exactly what an array of OpenAPI `security` objects means.
133
+ */
134
+ readonly authorize: (requirements: readonly SecurityRequirement[]) => MiddlewareHandler<E>;
135
+ /**
136
+ * The caller's context, or `null` when there is none to establish.
137
+ *
138
+ * `authentication` is what the DOCUMENT says, and only that: `"none"` where the operation
139
+ * declares `@useAuth(NoAuth)` — `security: []` in OpenAPI — and `"required"` otherwise. Deciding
140
+ * it at generation time is the point: the gate the document publishes is the gate that runs.
141
+ *
142
+ * ⚠️ **It used to be `"none" | "account" | "resource"`, and the last two were an invention.** They
143
+ * were chosen by whether the path had parameters, which no OpenAPI keyword expresses and which
144
+ * merely happened to fit the first consumer. A generated server enforcing a rule derived from
145
+ * nothing published is the defect class this emitter exists to remove, so it is gone. An app that
146
+ * needs the distinction can read the request, which is the one thing it definitely has.
147
+ */
148
+ readonly context: <P extends string, I extends Input>(c: Context<E, P, I>, authentication: "none" | "required") => C | null;
149
+ /** The response when `context` returns `null`. */
150
+ readonly noContext: <P extends string, I extends Input>(c: Context<E, P, I>) => Response;
151
+ /**
152
+ * The response when the caller's `Accept` matches nothing the operation offers — a 406.
153
+ *
154
+ * Emitted only on routes where the document declares more than one media type for a status, so
155
+ * a service without content negotiation never sees it. Same shape of hook as {@link noContext}
156
+ * and admitted on the same test: the status and the `offered` list are contract facts the
157
+ * generated code already has, but the body they are reported in is the app's envelope, and it
158
+ * cannot proceed without one. {@link selectContentType} does the choosing; this reports failure.
159
+ */
160
+ readonly notAcceptable: <P extends string, I extends Input>(c: Context<E, P, I>, offered: readonly string[]) => Response;
161
+ /**
162
+ * Passed straight to `zValidator`'s hook. Returning `undefined` lets a successful validation
163
+ * through; returning a `Response` is how a rejection becomes the status this API promises rather
164
+ * than the middleware's default.
165
+ */
166
+ readonly invalid: <P extends string, I extends Input>(result: {
167
+ readonly success: boolean;
168
+ }, c: Context<E, P, I>) => Response | undefined;
169
+ /**
170
+ * Turn an operation's result into a response, checked against the schema the document publishes
171
+ * for the arm that applies. A bodyless success is an arm whose `schema` is `undefined`.
172
+ */
173
+ readonly respond: <P extends string, I extends Input>(c: Context<E, P, I>, arms: readonly ResponseArm[], result: unknown) => Awaitable<Response>;
174
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * ⚠️ **`ResponseArm` and `armFor` live in `typespec-http-zod` and are re-exported here.**
3
+ *
4
+ * The library is what emits the response arms — `schemas.gen.ts` declares them and annotates them
5
+ * with `satisfies readonly ResponseArm[]` — so the type belongs to the package that produces it, and
6
+ * the rule for reading an array containing `4XX` and `default` belongs beside it. A consumer serving
7
+ * those validators from Express needs both and should not depend on a Hono emitter to get them.
8
+ *
9
+ * Re-exported rather than merely available, so an application still has ONE runtime import and the
10
+ * `runtime-module` substitution keeps working unchanged: a module an app points that option at has to
11
+ * supply every name the generated files reference, and they reference these.
12
+ */
13
+ export { armFor } from "typespec-http-zod/runtime";
14
+ /**
15
+ * Pick the media type to serve, per RFC 9110 §12.5.1.
16
+ *
17
+ * ⚠️ **In the runtime rather than in {@link RouteDeps}, deliberately.** The test for admitting
18
+ * anything to `deps` is *the generated code cannot proceed without an answer*, and this is not that:
19
+ * which media types an operation offers is a contract fact the emitter reads from the document, and
20
+ * how `Accept` selects among them is specified by the RFC. Both sides are derivable, so an app that
21
+ * had to supply this would be re-implementing the standard — and could get it wrong differently from
22
+ * everybody else.
23
+ *
24
+ * The rules that matter, and that a naive `includes()` gets wrong:
25
+ * - **absent or empty `Accept` means anything is acceptable** — serve the first offer;
26
+ * - **`q=0` is a REFUSAL**, not a weak preference, so a range scoring zero can never be chosen;
27
+ * - **specificity breaks ties before quality does**: `text/plain` beats `text/*` beats `*​/*` at
28
+ * equal `q`, which is why the score is a pair and not a number;
29
+ * - parameters after the media range (`;charset=utf-8`) are not part of the match.
30
+ *
31
+ * Returns `undefined` when nothing offered is acceptable — the caller answers 406, and the
32
+ * difference between "no preference" and "no acceptable option" is exactly what that turns on.
33
+ */
34
+ export function selectContentType(accept, offered) {
35
+ if (offered.length === 0)
36
+ return undefined;
37
+ const header = accept?.trim();
38
+ if (header === undefined || header === "")
39
+ return offered[0];
40
+ const ranges = header.split(",").map((entry) => {
41
+ const [range = "", ...parameters] = entry.split(";").map((part) => part.trim());
42
+ const quality = parameters
43
+ .map((parameter) => /^q=(?<value>[\d.]+)$/i.exec(parameter)?.groups?.value)
44
+ .find((value) => value !== undefined);
45
+ return { range: range.toLowerCase(), q: quality === undefined ? 1 : Number(quality) };
46
+ });
47
+ let best;
48
+ for (const type of offered) {
49
+ const [group] = type.toLowerCase().split("/");
50
+ for (const { range, q } of ranges) {
51
+ // `q=0` is "I will not accept this", so it never becomes a candidate.
52
+ if (!Number.isFinite(q) || q <= 0)
53
+ continue;
54
+ const specificity = range === type.toLowerCase() ? 2 : range === `${group}/*` ? 1 : range === "*/*" ? 0 : -1;
55
+ if (specificity < 0)
56
+ continue;
57
+ if (best === undefined || q > best.q || (q === best.q && specificity > best.specificity)) {
58
+ best = { type, q, specificity };
59
+ }
60
+ }
61
+ }
62
+ return best?.type;
63
+ }