typespec-hono 0.22.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.
@@ -8,8 +8,8 @@ import { type EmitContext } from "@typespec/compiler";
8
8
  * forced it to be a `dependency` and made `--save-dev` fail at deploy and nowhere earlier. Emitting
9
9
  * the runtime removes the choice rather than documenting it.
10
10
  *
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.
11
+ * **Always this module.** `runtime-module` used to replace it, and that made an application own a
12
+ * copy of generated logic - see the `runtime-module-removed` diagnostic.
13
13
  */
14
- export declare const DEFAULT_RUNTIME_MODULE = "./runtime.gen.js";
14
+ export { DEFAULT_RUNTIME_MODULE } from "./app.js";
15
15
  export declare function $onEmit(context: EmitContext): Promise<void>;
@@ -1,10 +1,9 @@
1
- import { emitFile, resolvePath } from "@typespec/compiler";
1
+ import { emitFile, NoTarget, resolvePath } from "@typespec/compiler";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { emitHttpZod } from "typespec-http-zod";
5
5
  import { renderApp } from "./app.js";
6
6
  import { resolveBasePath } from "./base-path.js";
7
- import { securityFor } from "./security.js";
8
7
  import { reportDiagnostic } from "./lib.js";
9
8
  /**
10
9
  * This emitter's entry point. **the whole of `typespec-http-zod`, plus one file**.
@@ -40,27 +39,6 @@ function operationFor(emitted, verb, path) {
40
39
  function targetFor(emitted, verb, path) {
41
40
  return operationFor(emitted, verb, path)?.operation ?? emitted.service.namespace;
42
41
  }
43
- /**
44
- * What the generated files import their runtime contract from when the consumer sets nothing.
45
- *
46
- * **THIS package's runtime, not the library's, and the distinction is the whole of a defect that
47
- * shipped.** `app.gen.ts` names `AppEnv`, `Awaitable`, `Ctx`, `Result`, `RouteDeps` and
48
- * `selectContentType`; every one of them is declared in `src/runtime.ts` here. The library's runtime
49
- * exports `ResponseArm` and `armFor` and nothing else, and it is a TRANSITIVE dependency of a
50
- * consumer of this package, so under a strict `node_modules` its specifier does not resolve from
51
- * consumer code at all.
52
- *
53
- * Pointing at `typespec-hono/runtime` fixes both halves at once, because this module RE-EXPORTS
54
- * `ResponseArm` and `armFor` (see `runtime.ts`), which is what `schemas.gen.ts` imports. One
55
- * specifier, present in the consumer's own dependency, carrying every name both generated files
56
- * reference.
57
- *
58
- * **Measured in a fresh project installed from `pnpm pack` tarballs, because no test could see it:**
59
- * every compile in both harnesses sets `runtime-module` explicitly, so the default branch was ungraded
60
- * across 240 tests. `tsp compile` succeeded with zero diagnostics and `tsc` then reported two
61
- * `TS2307`s (`Cannot find module 'typespec-http-zod/runtime'`) one in each generated file.
62
- * `test/adopter.test.ts` is the arm that now opens that branch.
63
- */
64
42
  /** The header the emitted runtime carries, matching every other generated file. */
65
43
  function generatedRuntimeBanner(hint) {
66
44
  const second = hint === undefined
@@ -68,7 +46,6 @@ function generatedRuntimeBanner(hint) {
68
46
  : `// Regenerate with: ${hint}`;
69
47
  return `// GENERATED by typespec-hono. DO NOT EDIT.
70
48
  ${second}
71
- // Point \`runtime-module\` at a module of your own to replace it.
72
49
  `;
73
50
  }
74
51
  /**
@@ -80,27 +57,49 @@ ${second}
80
57
  * forced it to be a `dependency` and made `--save-dev` fail at deploy and nowhere earlier. Emitting
81
58
  * the runtime removes the choice rather than documenting it.
82
59
  *
83
- * `typespec-hono/runtime` is still exported, for an application that points `runtime-module` at it
84
- * deliberately. It is no longer what the generated code reaches for by default.
60
+ * **Always this module.** `runtime-module` used to replace it, and that made an application own a
61
+ * copy of generated logic - see the `runtime-module-removed` diagnostic.
85
62
  */
86
- export const DEFAULT_RUNTIME_MODULE = "./runtime.gen.js";
63
+ export { DEFAULT_RUNTIME_MODULE } from "./app.js";
87
64
  /** Where this package's own runtime source lives, to be copied beside the generated code. */
88
65
  const RUNTIME_SOURCE = fileURLToPath(new URL("../../src/runtime.ts", import.meta.url));
66
+ /**
67
+ * The options with `runtime-module` refused and removed, at the top level and per service.
68
+ *
69
+ * **Removed, not only reported**, because the library validates nothing about a key it no longer
70
+ * declares, and a wrapper passing options through should not hand it one.
71
+ */
72
+ function withoutRuntimeModule(context) {
73
+ const options = context.options;
74
+ const refuse = (value) => {
75
+ reportDiagnostic(context.program, {
76
+ code: "runtime-module-removed",
77
+ format: { value: String(value) },
78
+ target: NoTarget,
79
+ });
80
+ };
81
+ const { "runtime-module": topLevel, services, ...rest } = options;
82
+ if (topLevel !== undefined)
83
+ refuse(topLevel);
84
+ const kept = services === undefined
85
+ ? undefined
86
+ : Object.fromEntries(Object.entries(services).map(([name, service]) => {
87
+ const { "runtime-module": perService, ...others } = service;
88
+ if (perService !== undefined)
89
+ refuse(perService);
90
+ return [name, others];
91
+ }));
92
+ return {
93
+ ...context,
94
+ options: kept === undefined ? rest : { ...rest, services: kept },
95
+ };
96
+ }
89
97
  export async function $onEmit(context) {
90
- for (const emitted of await emitHttpZod(context, {
91
- defaultRuntimeModule: DEFAULT_RUNTIME_MODULE,
92
- })) {
93
- /**
94
- * Written only when the generated code actually points at it. An application that sets
95
- * `runtime-module` has supplied its own module and must not have a file it did not ask for
96
- * appear in its output directory.
97
- */
98
- if (emitted.options.runtimeModule === DEFAULT_RUNTIME_MODULE) {
99
- await emitFile(context.program, {
100
- path: resolvePath(emitted.outputDir, "runtime.gen.ts"),
101
- content: `${generatedRuntimeBanner(emitted.options.regenerateHint)}${readFileSync(RUNTIME_SOURCE, "utf8")}`,
102
- });
103
- }
98
+ for (const emitted of await emitHttpZod(withoutRuntimeModule(context))) {
99
+ await emitFile(context.program, {
100
+ path: resolvePath(emitted.outputDir, "runtime.gen.ts"),
101
+ content: `${generatedRuntimeBanner(emitted.options.regenerateHint)}${readFileSync(RUNTIME_SOURCE, "utf8")}`,
102
+ });
104
103
  /**
105
104
  * **The path the DOCUMENT says this service is served under.** An OpenAPI path is relative to
106
105
  * its server, so `@server("/api/v1")` plus `/accounts` publishes `/api/v1/accounts`. Mounting at
@@ -136,6 +135,17 @@ export async function $onEmit(context) {
136
135
  target: targetFor(emitted, route.verb, route.path),
137
136
  });
138
137
  },
138
+ unvalidatedResponseMediaType: (route, status, types) => {
139
+ reportDiagnostic(context.program, {
140
+ code: "unvalidated-response-media-type",
141
+ format: {
142
+ operationId: route.operationId,
143
+ status: String(status),
144
+ types: types.join(", "),
145
+ },
146
+ target: targetFor(emitted, route.verb, route.path),
147
+ });
148
+ },
139
149
  unvalidatableMediaType: (route, types) => {
140
150
  reportDiagnostic(context.program, {
141
151
  code: "unvalidatable-media-type",
@@ -143,10 +153,7 @@ export async function $onEmit(context) {
143
153
  target: targetFor(emitted, route.verb, route.path),
144
154
  });
145
155
  },
146
- }, base.basePaths, (verb, path) => {
147
- const operation = operationFor(emitted, verb, path);
148
- return operation === undefined ? [] : securityFor(context.program, operation);
149
- }),
156
+ }, base.basePaths),
150
157
  });
151
158
  }
152
159
  }
@@ -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";
package/dist/src/lib.d.ts CHANGED
@@ -13,12 +13,14 @@ import { type EmitterOptions as HttpZodOptions } from "typespec-http-zod";
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
- * 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.
16
+ * **`runtime-module` is declared here only to be REFUSED** (`runtime-module-removed`). The library no
17
+ * longer has the option, and a schema without the key would report only "must NOT have additional
18
+ * properties", which tells a consumer upgrading from a version that documented the option neither why
19
+ * nor what to do instead.
20
20
  */
21
21
  export type EmitterOptions = HttpZodOptions & {
22
+ /** Refused. See `runtime-module-removed`. */
23
+ "runtime-module"?: string;
22
24
  /**
23
25
  * Per-service overrides this emitter adds on top of the library's.
24
26
  *
@@ -27,6 +29,7 @@ export type EmitterOptions = HttpZodOptions & {
27
29
  */
28
30
  services?: Record<string, {
29
31
  "emit-server"?: boolean;
32
+ "runtime-module"?: string;
30
33
  }>;
31
34
  };
32
35
  /**
@@ -54,12 +57,18 @@ declare const EmitterOptionsSchema: JSONSchemaType<EmitterOptions>;
54
57
  * exists only in the arrangement that BUILDS the package, and would ship silently.
55
58
  */
56
59
  type Diagnostics = {
60
+ "runtime-module-removed": {
61
+ readonly default: CallableMessage<["value"]>;
62
+ };
57
63
  "unsupported-path-template": {
58
64
  readonly default: CallableMessage<["template", "name"]>;
59
65
  };
60
66
  "unvalidatable-media-type": {
61
67
  readonly default: CallableMessage<["operationId", "types"]>;
62
68
  };
69
+ "unvalidated-response-media-type": {
70
+ readonly default: CallableMessage<["operationId", "status", "types"]>;
71
+ };
63
72
  };
64
73
  /**
65
74
  * **Annotated rather than inferred, and the reason is a packaging fact rather than a style
package/dist/src/lib.js CHANGED
@@ -8,9 +8,13 @@ import { EmitterOptionsSchema as httpZodOptions, } from "typespec-http-zod";
8
8
  *, never by copying the list.
9
9
  */
10
10
  const EmitterOptionsSchema = {
11
- ...httpZodOptions,
11
+ type: "object",
12
+ additionalProperties: false,
13
+ required: [],
12
14
  properties: {
13
15
  ...httpZodOptions.properties,
16
+ // Accepted by the schema so that setting it reaches `runtime-module-removed`.
17
+ "runtime-module": { type: "string", nullable: true },
14
18
  /**
15
19
  * **Spread from the library's own entry rather than restated**, so a per-service option added
16
20
  * there still validates here. Only `emit-server` is added.
@@ -23,6 +27,7 @@ const EmitterOptionsSchema = {
23
27
  properties: {
24
28
  ...httpZodOptions.properties.services.additionalProperties.properties,
25
29
  "emit-server": { type: "boolean", nullable: true },
30
+ "runtime-module": { type: "string", nullable: true },
26
31
  },
27
32
  required: [],
28
33
  },
@@ -73,6 +78,41 @@ const diagnostics = {
73
78
  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'.`,
74
79
  },
75
80
  },
81
+ /**
82
+ * `runtime-module` is set, and this emitter no longer substitutes its runtime.
83
+ *
84
+ * **The option made an application OWN a copy of generated logic**, because the generated server
85
+ * imported the application's types from the same module as content negotiation, the HEAD guard and
86
+ * arm selection. So every consumer that needed its own caller context or result shape copied those
87
+ * too, and the copies aged: one gateway ran a runtime eleven releases behind the emitter, carrying a
88
+ * negotiation defect long since fixed.
89
+ *
90
+ * Nothing the option was for needs it now. The caller context is inferred from `deps.context`, the
91
+ * Hono environment is an interface an application augments, and what a handler returns is the union
92
+ * of the responses the document declares. **An error rather than a warning**, because the option's
93
+ * value is ignored, and an emitter that silently ignores a setting produces output its author did
94
+ * not ask for.
95
+ */
96
+ "runtime-module-removed": {
97
+ severity: "error",
98
+ messages: {
99
+ default: paramMessage `'runtime-module' is set to '${"value"}', and typespec-hono no longer substitutes its runtime: 'runtime.gen.ts' is always emitted. Remove the option and delete the module it pointed at. The caller context is inferred from 'deps.context', the Hono environment is augmented with 'declare module "./runtime.gen.js" { interface AppEnv { ... } }', and a handler returns '{ status, body, headers }' for any response the document declares. See the Upgrading section of docs/guides.md.`,
100
+ },
101
+ },
102
+ /**
103
+ * A response the handler supplies as text, served without being checked against its schema.
104
+ *
105
+ * A model declared under a media type that is not JSON - a `Pet` as `application/xml` - has no
106
+ * serialisation this emitter can derive from the document, so the handler returns the text and the
107
+ * route serves it as is. Reported for the same reason as its request-side twin: a route should not
108
+ * look validated when it is not.
109
+ */
110
+ "unvalidated-response-media-type": {
111
+ severity: "warning",
112
+ messages: {
113
+ default: paramMessage `'${"operationId"}' answers ${"status"} as ${"types"}, which this emitter cannot serialise from the schema. The handler returns that body as text and it is served without being validated. Declare a JSON media type, or a string body, to have it checked.`,
114
+ },
115
+ },
76
116
  "unsupported-path-template": {
77
117
  severity: "warning",
78
118
  messages: {
@@ -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
+ });