typespec-http-zod 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,24 +1,14 @@
1
1
  # typespec-http-zod
2
2
 
3
- Zod validators and framework-free contract types from a TypeSpec HTTP service — agreeing, keyword for
4
- keyword, with the OpenAPI document [`@typespec/openapi3`](https://typespec.io) publishes from the same
5
- source.
3
+ Generate Zod validators and framework-free TypeScript types from a TypeSpec HTTP service.
6
4
 
7
- It is a **library and an emitter**. Use the emitter to generate validators for any server — Express,
8
- Fastify, Elysia, a Workers fetch handler, a typed client — or use the API to build an emitter of your
9
- own. [`typespec-hono`](https://github.com/bison-digital/typespec-hono) is the second kind, and is
10
- where the API is proved.
5
+ The emitted validators agree, keyword for keyword, with the OpenAPI document
6
+ [`@typespec/openapi3`](https://typespec.io) publishes from the same source. Use them with Express,
7
+ Fastify, Elysia, a Workers fetch handler, a typed client, or a test suite. No server framework is
8
+ required.
11
9
 
12
- ## The governing rule
13
-
14
- **If the document cannot say it, the validator must not enforce it.**
15
-
16
- A validator that checks something the published contract never states is a rule no caller reading that
17
- contract can see. This package ships **zero decorators** for exactly that reason — four once existed,
18
- and every one let a spec state something `@typespec/openapi3` could not publish — and asserts the
19
- class is empty rather than trusting it. Where openapi3 decides something, its rule is read from its
20
- source and copied, refusals included: the same spec has to be representable by both emitters or by
21
- neither, or a differential between them means nothing.
10
+ It is also a library. [`typespec-hono`](https://github.com/bison-digital/typespec-hono) is an emitter
11
+ built on its API.
22
12
 
23
13
  ## Install
24
14
 
@@ -26,28 +16,58 @@ neither, or a differential between them means nothing.
26
16
  npm install typespec-http-zod
27
17
  ```
28
18
 
29
- ⚠️ **A regular dependency, not a dev one, if your application calls `armFor`.** The emitter itself
30
- runs at build time, and the generated files import only `import type { ResponseArm }` — a type, which
31
- erases. But [`./runtime`](#answering-a-request) also exports `armFor`, a **function**, and it exists
32
- precisely so applications do not re-derive OpenAPI's response precedence by hand. An application that
33
- calls it and installed this package with `--save-dev` typechecks, builds, and runs in development,
34
- then fails on deploy:
19
+ Install it as a regular dependency if your application calls `armFor`. If you use only the emitted
20
+ validators, a dev dependency is enough. See [installing](docs/guides.md#installing).
21
+
22
+ Peer dependencies: `@typespec/compiler`, `@typespec/http`, `@typespec/openapi`, and `zod`.
23
+ `@typespec/versioning` and `@typespec/streams` are optional.
24
+
25
+ ## Quick start
35
26
 
36
27
  ```
37
- Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'typespec-http-zod'
38
- imported from dist/server.js
28
+ main.tsp your API definition
29
+ tspconfig.yaml which emitters to run
30
+ src/
31
+ generated/ written by `tsp compile`, never edited by hand
32
+ schemas.gen.ts
33
+ index.ts your code
39
34
  ```
40
35
 
41
- Measured in a fresh project installed from a `pnpm pack` tarball: identical build output, `pnpm
42
- install --prod`, **exit 1**; the same bytes with the package in `dependencies`, **exit 0**. If you use
43
- only the emitted validators and never import `armFor`, `--save-dev` is sufficient.
36
+ ### `main.tsp`
37
+
38
+ ```tsp
39
+ import "@typespec/http";
40
+
41
+ using Http;
42
+
43
+ @service(#{ title: "Widget API" })
44
+ namespace WidgetApi;
45
+
46
+ model Widget {
47
+ id: string;
48
+
49
+ @minLength(1)
50
+ name: string;
51
+
52
+ @minValue(0)
53
+ quantity: int32;
54
+ }
55
+
56
+ @error
57
+ model NotFound {
58
+ @statusCode statusCode: 404;
59
+ code: string;
60
+ message: string;
61
+ }
62
+
63
+ @route("/widgets/{widgetId}")
64
+ @get
65
+ op readWidget(@path widgetId: int32): Widget | NotFound;
66
+ ```
44
67
 
45
- Peers: `@typespec/compiler`, `@typespec/http`, `@typespec/openapi`, and `zod` (the generated output
46
- imports it). `@typespec/versioning` and `@typespec/streams` are **optional** — they are resolved
47
- behind a guarded `import()`, so a spec declaring neither needs neither.
68
+ ### `tspconfig.yaml`
48
69
 
49
70
  ```yaml
50
- # tspconfig.yaml
51
71
  emit:
52
72
  - typespec-http-zod
53
73
  options:
@@ -55,70 +75,41 @@ options:
55
75
  emitter-output-dir: "{project-root}/src/generated"
56
76
  ```
57
77
 
58
- ## What it emits
78
+ Then run `tsp compile .`.
59
79
 
60
- ⚠️ **Only `schemas.gen.ts` is emitted by the configuration above.** The other three are written only
61
- when `contracts-output-dir` names somewhere to put them — they are the framework-free contract types
62
- and the assertions that pair them against the validators, and a consumer that wants validators alone
63
- should not have them appear uninvited. Add it to get all four:
80
+ ### `src/generated/schemas.gen.ts` (generated)
64
81
 
65
- ```yaml
66
- # tspconfig.yaml
67
- emit:
68
- - typespec-http-zod
69
- options:
70
- typespec-http-zod:
71
- emitter-output-dir: "{project-root}/src/generated"
72
- contracts-output-dir: "{project-root}/src/generated"
82
+ ```ts
83
+ export const widgetSchema = z.object({
84
+ id: z.string(),
85
+ name: z.string().min(1),
86
+ quantity: z.number().int().min(0),
87
+ });
88
+
89
+ export const notFoundSchema = z.object({
90
+ code: z.string(),
91
+ message: z.string(),
92
+ });
93
+
94
+ export const readWidgetPath = z.object({
95
+ widgetId: z.preprocess(decodeNumber, z.number().int()),
96
+ });
97
+
98
+ export const readWidgetResponses = [
99
+ { status: 200, schema: widgetSchema },
100
+ { status: 404, schema: notFoundSchema },
101
+ ] satisfies readonly ResponseArm[];
73
102
  ```
74
103
 
75
- | file | what it is | emitted when |
76
- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- |
77
- | `schemas.gen.ts` | every validator: the component schemas, **and** each operation's path, query, header, body and response validators, plus the arms it may answer with | always |
78
- | `requests.gen.ts` | framework-free contract types — plain TypeScript, no imports, so they cross layers that must not see a validation library | `contracts-output-dir` is set |
79
- | `vocabularies.gen.ts` | each enum's members as a runtime tuple, because a generated type cannot be iterated | `contracts-output-dir` is set |
80
- | `wire-contract.gen.ts` | assertions pairing the emitted Zod against those types, so the two walks cannot disagree silently | that **and** `contracts-package` |
81
-
82
- Per operation you get `readWidgetPath`, `readWidgetQuery`, `readWidgetHeader`, `readWidgetBody`,
83
- `readWidgetResponse` and `readWidgetResponses` — named from **the operation id the document
84
- publishes**, which is not always the bare operation name.
85
-
86
- ⚠️ **An operation inside a namespace is named after the whole id.** `op readWidget` inside
87
- `namespace Widgets` has operation id `Widgets_readWidget`, so the identifiers are
88
- `Widgets_readWidgetPath` and so on. Namespaces are the idiomatic way to group routes, so this is the
89
- common case rather than the exception — read the emitted file for the names rather than assuming
90
- them.
91
-
92
- ⚠️ **A validator that is already a component gets no second name.** `readWidgetResponse` is emitted
93
- only when the response shape needs a name of its own; where the operation answers with a declared
94
- model the arm refers to that model's schema directly — `widgetSchema`, not `readWidgetResponse`. The
95
- same holds for a request body: `op createWidget(@body body: WidgetCreate)` is validated by
96
- `widgetCreateSchema`. The mapping from an operation to the schema that validates it is published
97
- through the [API](#building-an-emitter-on-this) as `EmittedService.schemaNames`; from the emitted file
98
- alone, an operation with no `…Body` const is one whose body is a named component.
99
-
100
- ## Options
101
-
102
- | option | what it does |
103
- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
104
- | `seal-object-schemas` | whether a model closed in the spec REJECTS an undeclared property rather than stripping it. ⚠️ **Set this to whatever `@typespec/openapi3`'s option of the same name is set to** — the two emitters answer the same question about the same models and neither can read the other's configuration. Defaults to `false`, which is openapi3's default. |
105
- | `contracts-output-dir` | where the framework-free contract types go, when they should not sit beside the validators. Omitted, they are not emitted. |
106
- | `contracts-package` | the specifier the emitted Zod imports shared types from. **No default** — it once defaulted to one repository's package name, so a consumer who configured nothing got validators importing from a package they had never heard of. With none named, enums are emitted inline and the output depends on nothing. |
107
- | `key-vocabularies` | models whose PROPERTY NAMES are also emitted as a runtime tuple. For a closed key set that is a contract fact but cannot be a `Record` key type. A name matching no model is reported, not ignored. |
108
- | `runtime-module` | where the generated files import `ResponseArm` from. Defaults to `typespec-http-zod/runtime`. |
109
- | `services` | per-`@service` overrides, keyed by namespace name, for a spec that publishes more than one surface. |
110
-
111
- ## Answering a request
112
-
113
- No server framework appears below, because none is needed: the emitted validators are plain Zod and
114
- `./runtime` is two exports. Express, Fastify, Elysia or a bare `fetch` handler each supply the
115
- `params`/`query`/`body` this reads.
104
+ Path and query values arrive as strings. The emitted validator decodes `"1"` to `1` before the
105
+ schema runs, so hand the raw values straight in.
106
+
107
+ ### `src/index.ts`
116
108
 
117
109
  ```ts
118
110
  import { armFor, type ResponseArm } from "typespec-http-zod/runtime";
119
- import { Widgets_readWidgetPath, Widgets_readWidgetResponses } from "./generated/schemas.gen.js";
111
+ import { readWidgetPath, readWidgetResponses } from "./generated/schemas.gen.js";
120
112
 
121
- /** Answer a status with a body the document says that status carries. */
122
113
  function respond(arms: readonly ResponseArm[], status: number, body: unknown) {
123
114
  const arm = armFor(arms, status);
124
115
  if (arm === undefined) throw new Error(`no declared arm for ${status}`);
@@ -126,143 +117,35 @@ function respond(arms: readonly ResponseArm[], status: number, body: unknown) {
126
117
  }
127
118
 
128
119
  export function readWidget(params: Record<string, unknown>, widget: unknown | undefined) {
129
- const parsed = Widgets_readWidgetPath.safeParse(params);
120
+ const parsed = readWidgetPath.safeParse(params);
130
121
  if (!parsed.success) return { status: 400, body: parsed.error.issues };
122
+
131
123
  return widget === undefined
132
- ? respond(Widgets_readWidgetResponses, 404, { code: "not_found", message: "no such widget" })
133
- : respond(Widgets_readWidgetResponses, 200, widget);
124
+ ? respond(readWidgetResponses, 404, { code: "not_found", message: "no such widget" })
125
+ : respond(readWidgetResponses, 200, widget);
134
126
  }
135
127
  ```
136
128
 
137
- **`armFor` is the whole reason `./runtime` exists.** An operation can declare `404`, `4XX` and
138
- `default` at once and all three describe a 404. OpenAPI settles it — an explicit code beats a range,
139
- and `default` is every status not otherwise listed — and a package that emits arms containing `4XX`
140
- and `default` without shipping the rule for reading them has shipped a footgun. Measured against the
141
- arms emitted for such an operation:
129
+ `armFor` resolves which arm governs a status, applying OpenAPI's precedence: an exact code first,
130
+ then a range such as `4XX`, then `default`.
142
131
 
143
- | answered | arm chosen | body validated against |
144
- | -------- | ---------- | ---------------------- |
145
- | `200` | `200` | the success model |
146
- | `404` | `404` | `NotFound` |
147
- | `429` | `4XX` | `Throttled` |
148
- | `500` | `default` | `Unexpected` |
149
-
150
- ⚠️ **A path or query value is a string on the wire, and the emitted validators already know.** A
151
- parameter the document types as `integer` is emitted wrapped in a `z.preprocess` that decodes `"1"`
152
- to `1` — and **decodes rather than coerces**, so `?limit=` stays empty and fails the schema instead of
153
- arriving as `0`. A flattened list (`?tags=a,b,c`) is split on the delimiter the document's `style`
154
- implies before the array schema runs. Hand the raw values straight in; do not pre-parse them.
132
+ ## What it emits
155
133
 
156
- ## Building an emitter on this
134
+ | file | contents | emitted when |
135
+ | ---------------------- | --------------------------------------------------------------------------------------------------------- | ----------------------------- |
136
+ | `schemas.gen.ts` | component schemas, per-operation path, query, header, body and response validators, and the response arms | always |
137
+ | `requests.gen.ts` | framework-free TypeScript types, with no imports | `contracts-output-dir` is set |
138
+ | `vocabularies.gen.ts` | each enum's members as a runtime tuple | `contracts-output-dir` is set |
139
+ | `wire-contract.gen.ts` | assertions pairing the emitted Zod against those types | that and `contracts-package` |
157
140
 
158
- The exported API — `emitHttpZod`, `EmittedService`, `EmittedRoute`, `collectRoutes` and the rest — is
159
- what an emitter for another framework calls. `emitHttpZod` returns one `EmittedService` per
160
- `@service`, and `EmittedService.schemaNames` maps each operation id to the identifiers its validators
161
- were declared under, so a wrapping emitter imports names rather than agreeing about them.
141
+ Identifiers are named from the operation id the document publishes. An operation inside a namespace
142
+ carries the whole id, so `op readWidget` in `namespace Widgets` becomes `Widgets_readWidgetPath`.
162
143
 
163
- ```ts
164
- const services = await emitHttpZod(context, { defaultRuntimeModule: "my-emitter/runtime" });
165
- for (const service of services) {
166
- for (const route of service.routes) {
167
- const names = service.schemaNames.get(route.operationId);
168
- // names.path / names.query / names.header / names.body / names.response / names.responses
169
- }
170
- }
171
- ```
144
+ ## Docs
172
145
 
173
- ⚠️ **`defaultRuntimeModule` is a default, not an override** — a consumer's `runtime-module` still
174
- wins. It exists so a wrapping emitter whose generated files import more than `ResponseArm` can point
175
- them at its own module.
176
-
177
- ## What it refuses, and why
178
-
179
- Every refusal is a **diagnostic**, not a thrown error: it points at the offending declaration, carries
180
- a code you can search for, and does not stop the walk — so one compile names every problem rather
181
- than the first.
182
-
183
- | code | why |
184
- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
185
- | `unsupported-scalar` | a scalar with no known base and no `@encode`. Give it `extends`, or encode it. |
186
- | `unsupported-type` | a construct with no runtime representation — `never` is the reachable case. |
187
- | `unsupported-default` | a default value that is neither a scalar nor an empty collection. |
188
- | `empty-union` | a union with no representable variants has nothing to validate against. |
189
- | `circular-model` | a cycle closing through a union variant or dictionary value, neither of which is a property a getter can sit on. Ordinary recursion through a model property is fully supported. |
190
- | `unknown-key-vocabulary` | a `key-vocabularies` entry naming no model. Reported because the failure mode is silence: a missing vocabulary looks exactly like an empty one. |
191
- | `unsupported-status-code-range` | OpenAPI keys a range as `1XX`…`5XX`, so `@minValue(494) @maxValue(499)` has nowhere to go. ⚠️ **Refused because `@typespec/openapi3` refuses it**, with the same rule copied from its source. A range covering a bucket exactly — `@minValue(400) @maxValue(499)` — is supported. |
192
- | `undeclared-discriminator` | ⚠️ **Upstream, and not something you can fix with an emitter option.** `@discriminated(#{envelope: "none"})` puts the discriminator inside each variant on the wire, and openapi3 emits `oneOf` with a `discriminator` keyword while never adding that property to the variant schema — which OpenAPI 3.1 forbids ("the `discriminator` field MUST be a required field"). Tracked as [`microsoft/typespec#7141`](https://github.com/microsoft/typespec/issues/7141), open and maintainer-filed. **It is avoidable in your spec**: declare the discriminator on the variant (`model Cat { kind: "cat"; … }`) and openapi3 publishes it as required, with no divergence and no refusal. |
193
-
194
- ## Known limits
195
-
196
- Stated here rather than left as numbers in a baseline file, because a number in a baseline is not a
197
- stated limitation once a package is published. Each is re-measured by the suite.
198
-
199
- - **`format` is deliberately not enforced, and that is now CHECKED rather than counted.** Under JSON
200
- Schema 2020-12 `format` is an annotation, not a validation keyword, so turning one into a check
201
- would enforce something the document does not assert. 136 annotations go unenforced, and
202
- `test/vocabulary.test.ts` refuses any format-derived Zod call as a class — so turning it on breaks a
203
- test rather than moving a number.
204
- - **4 negotiated response bodies cannot be attributed to a single arm.** OpenAPI lists one
205
- body per media type against members that each carry their own, so there is no one arm to compare
206
- them to. The status-to-body mapping is still graded for them.
207
- - **4 response bodies reduce to no readable kind on one side** — a stream, or a union. Counted, never
208
- silently passed.
209
- - **A `@head` operation gets validators here and cannot be served by every router.** That is a fact
210
- about the server, not about this package — see `typespec-hono`, which refuses it and says why.
211
- - **`int64` and `uint64` are validated as JavaScript numbers, so values above `2^53-1` are refused —
212
- and `@encode(string)` is the fix.** The document publishes `type: integer` with `format: int64`,
213
- which states no bound; the emitted `z.number().int()` refuses anything beyond
214
- `Number.MAX_SAFE_INTEGER`. That is a constraint the document does not state, and it is kept anyway,
215
- because it is imposed by the runtime rather than invented here — **above `2^53-1` an integer is no
216
- longer uniquely representable as a JavaScript number**, so a validator cannot certify that the value
217
- it holds is the value that was sent. Measured: `9007199254740993` reaches a handler as
218
- `9007199254740992`, and int64's maximum as `9223372036854776000`, both through `JSON.parse` before
219
- any validator runs. Accepting them would stamp "valid" on a number nobody sent, which is the one
220
- answer a validator must never give. `z.int64()` is not an alternative: it is `bigint`-based and
221
- `JSON.parse` never produces a `bigint`, so it would refuse every JSON body.
222
-
223
- ⚠️ **TypeSpec's own remedy works and costs one decorator.** `@encode(string) value: int64` emits
224
- `z.string()`, and openapi3 publishes `type: string` with `format: int64` — the two agree exactly, and
225
- the digits survive the wire intact. Use it for any 64-bit integer whose values can exceed `2^53-1`:
226
- identifiers, balances in minor units, timestamps in nanoseconds.
227
-
228
- - **The emitted output requires `zod`, not `zod/mini`.** The tree-shakeable variant has no chained
229
- methods at all — measured on 4.4.3, `typeof z.string().optional`, `.nullable` and `.min` are each
230
- `undefined` — and the emitted validators use `.optional()`, `.nullable()`, `.default()`, `.strict()`,
231
- `.loose()` and `.catchall()`. A browser or Workers consumer therefore pays for the full `zod` build.
232
- This is a property of the emitted spelling rather than of the schemas, so it is fixable; it is
233
- recorded here as a measured fact rather than a plan.
234
-
235
- ## How it is graded
236
-
237
- Nothing here is judged by fixtures we wrote alone.
238
-
239
- - **Conformance differential** — both emitters run from ONE compile of
240
- [`@typespec/http-specs`](https://www.npmjs.com/package/@typespec/http-specs), Microsoft's own
241
- scenario corpus, and the assertion is that the validators say what the document says. Components,
242
- request parameters, declared statuses and response bodies. **0 divergences.**
243
- - **Round-trip against APIs nobody here designed** — the OpenAPI Initiative's teaching example and the
244
- Swagger Petstore, converted to TypeSpec and compiled back out, then compared against the originals.
245
- **22 operations, none lost, no shape disagreements.** Every other suite measures this emitter
246
- against material that already knows about it; this one asks what a first adopter asks. The
247
- documents are vendored with digests the suite asserts, never fetched.
248
- - **Request bodies are compared, and were compared by nothing at all** — 21 by shape and 36 by kind. Parameters were graded and responses were graded; what a caller may SEND, the largest surface of most APIs, had no arm.
249
- - **Response bodies are compared, not counted** — 24 inline bodies by SHAPE and
250
- 44 non-object bodies by KIND. This surface was a bare number for a long time, and closing it
251
- immediately found three positions where the emitter required a string for a raw binary body the
252
- document says nothing about.
253
- - **`content-type` and `accept` are compared** against the `content` keys that state them — 77
254
- positions, no divergences. OpenAPI declares media types through `requestBody.content` and each
255
- response's `content` rather than as parameters, so the parameter arm sets these aside; a separate
256
- arm compares them where the document actually puts them.
257
- - **Depth fixtures** for what a protocol corpus does not contain: it declares **no constraints at
258
- all**, so `test/reference/constraints.tsp` exercises every constraint keyword once, on the type it
259
- is legal on.
260
- - **Every counting arm has a non-vacuity floor.** An arm that stops firing otherwise reports agreement
261
- about nothing, which has happened here and is why the floors exist.
262
- - **The emitted output is compiled** by the real compiler under `strict` and
263
- `exactOptionalPropertyTypes`, because "it emitted" is not "it works".
264
- - **The package is packed and its tarball inspected**, because `files` is a set of globs and a glob
265
- that stops matching is indistinguishable from one that matches nothing.
146
+ - [Guides](docs/guides.md): installing, validating a request, answering with the right body, content
147
+ types, and building an emitter on the API.
148
+ - [Reference](docs/reference.md): every option, every diagnostic, and the known limits.
266
149
 
267
150
  ## Licence
268
151
 
package/dist/src/api.d.ts CHANGED
@@ -2,13 +2,13 @@ import { type EmitContext, type Program, type Type } from "@typespec/compiler";
2
2
  import { type HttpOperation, type HttpService } from "@typespec/http";
3
3
  import { SchemaRegistry } from "./registry.js";
4
4
  /**
5
- * `typespec-http-zod` — Zod validators and framework-free contract types from a TypeSpec HTTP
5
+ * `typespec-http-zod` - Zod validators and framework-free contract types from a TypeSpec HTTP
6
6
  * service, agreeing with the OpenAPI document `@typespec/openapi3` publishes from the same source.
7
7
  *
8
8
  * **The governing rule: if the document cannot say it, the validator must not enforce it.** A
9
9
  * validator that checks something the published contract never states is a rule no caller reading
10
- * that contract can see. This package ships **zero decorators** for exactly that reason — four were
11
- * deleted, each of which let a spec state something openapi3 could not publish — and asserts the
10
+ * that contract can see. This package ships **zero decorators** for exactly that reason - four were
11
+ * deleted, each of which let a spec state something openapi3 could not publish - and asserts the
12
12
  * class is empty rather than trusting it. Where openapi3 decides something, its rule is read from its
13
13
  * source and copied, refusals included: the same spec has to be representable by both emitters or by
14
14
  * neither, or a differential between them means nothing.
@@ -17,9 +17,9 @@ import { SchemaRegistry } from "./registry.js";
17
17
  * self-describes as "highly experimental", and emits a Node.js `http` router. It also answers a
18
18
  * different question: this package emits no server at all. Validators are useful to Express, Fastify,
19
19
  * Elysia, a Workers fetch handler or a typed client, and a server generator is a thin consumer of
20
- * them — `typespec-hono` is one.
20
+ * them - `typespec-hono` is one.
21
21
  *
22
- * **Built on the stable 1.x surface only** — `$onEmit` plus `@typespec/http` — rather than on
22
+ * **Built on the stable 1.x surface only** - `$onEmit` plus `@typespec/http` - rather than on
23
23
  * `@typespec/emitter-framework` (pre-1.0) or `@typespec/asset-emitter`, which announces its own
24
24
  * replacement in its package description. Fewer moving parts under something that has to keep working.
25
25
  */
@@ -28,15 +28,15 @@ export { $lib } from "./lib.js";
28
28
  * The option contract, published so an emitter built on this library can **derive** its own rather
29
29
  * than restate it.
30
30
  *
31
- * ⚠️ **Two schemas that have to agree are two lists that will not.** A server generator adds its own
31
+ * **Two schemas that have to agree are two lists that will not.** A server generator adds its own
32
32
  * keys and forwards the rest; if it restates this one, an option added here is silently rejected as
33
33
  * unknown by the wrapper, or accepted and never passed on. Neither failure is visible in output.
34
34
  */
35
35
  export { EmitterOptionsSchema, type EmitterOptions } from "./lib.js";
36
36
  /**
37
- * How a property name is spelled as an object key — quoted only when it has to be.
37
+ * How a property name is spelled as an object key - quoted only when it has to be.
38
38
  *
39
- * ⚠️ **Published because a consumer emitting code beside these validators has to spell keys the
39
+ * **Published because a consumer emitting code beside these validators has to spell keys the
40
40
  * SAME way, and getting it wrong produces a file that does not parse.** An unquoted
41
41
  * `x-ms-test-header` is exactly that, and it happened: a corpus scenario declares one and the
42
42
  * emitted module stopped being loadable. This is not a style choice a second emitter may make
@@ -47,7 +47,7 @@ export { objectKey } from "./zod.js";
47
47
  * The success status a row registers.
48
48
  *
49
49
  * An operation declares exactly one success status. A range (`2XX`) or a wildcard is not a status a
50
- * handler can return, so it is not something this emitter can turn into a `respond()` call — an
50
+ * handler can return, so it is not something this emitter can turn into a `respond()` call - an
51
51
  * operation carrying only those is skipped rather than guessed at.
52
52
  */
53
53
  export declare function successStatusOf(operation: HttpOperation): number | undefined;
@@ -70,13 +70,13 @@ export type StatusKey = number | "default" | `${1 | 2 | 3 | 4 | 5}XX`;
70
70
  /**
71
71
  * The body every `@error` response in the service declares, when they all declare the same one.
72
72
  *
73
- * ⚠️ **The emitter does not know what a problem document is, and must not.** This is the general
74
- * capability — "a service may declare an error response; emit its validator so the runtime can check
75
- * what it sends" — which is what makes it reusable by any gateway built on this emitter rather than a
73
+ * **The emitter does not know what a problem document is, and must not.** This is the general
74
+ * capability - "a service may declare an error response; emit its validator so the runtime can check
75
+ * what it sends" - which is what makes it reusable by any gateway built on this emitter rather than a
76
76
  * CM-shaped special case. RFC 9457 happens to be what `cm-api` puts in that slot.
77
77
  *
78
- * ⚠️ **This used to be an INVARIANT the emitter enforced, and it is now merely an observation.** It
79
- * reported `mixed-error-bodies` — an error, aborting the compile — whenever a service declared two
78
+ * **This used to be an INVARIANT the emitter enforced, and it is now merely an observation.** It
79
+ * reported `mixed-error-bodies` - an error, aborting the compile - whenever a service declared two
80
80
  * catch-all bodies, or a specific status whose body neither was nor spread the catch-all one. That
81
81
  * was a real constraint while every failure arm carried this single schema; now each declared
82
82
  * response resolves its own, and a service answering `404` with `NotFoundError` and everything else
@@ -84,25 +84,25 @@ export type StatusKey = number | "default" | `${1 | 2 | 3 | 4 | 5}XX`;
84
84
  *
85
85
  * So the refusal is gone, and what remains is a convenience: the module-level `GENERATED_ERROR_SCHEMA`
86
86
  * the route table publishes, which exists precisely when the service has one such body. Where it does
87
- * not, the constant is not emitted — and a consumer importing it gets a missing export, named at the
87
+ * not, the constant is not emitted - and a consumer importing it gets a missing export, named at the
88
88
  * import, which is the honest report of "there is no such thing here".
89
89
  */
90
90
  export declare function errorBodyOf(program: Program, service: HttpService): Type | undefined;
91
91
  /**
92
92
  * Whether a body served under these media types is RAW BINARY rather than a JSON value.
93
93
  *
94
- * ⚠️ **The distinction decides whether a validator may say anything about it at all.** A `bytes`
94
+ * **The distinction decides whether a validator may say anything about it at all.** A `bytes`
95
95
  * body inside a JSON payload is base64 and the document publishes `type: "string"` with
96
96
  * `contentEncoding`; a `bytes` body served as `application/octet-stream` or `image/png` is the bytes
97
- * themselves, and `@typespec/openapi3` publishes a schema carrying only `contentMediaType` — which is
97
+ * themselves, and `@typespec/openapi3` publishes a schema carrying only `contentMediaType` - which is
98
98
  * an ANNOTATION under JSON Schema 2020-12, not an assertion. The document therefore states nothing
99
99
  * about the value, and a validator that requires a string enforces a rule no caller reading the
100
100
  * contract can see.
101
101
  *
102
- * ⚠️ **The same decision was already made for multipart and not for bodies**, which is how the two
102
+ * **The same decision was already made for multipart and not for bodies**, which is how the two
103
103
  * came to disagree: a binary PART is `z.unknown()` because openapi3 publishes `{}` for it, while a
104
104
  * binary BODY stayed `z.string()`. Found by grading response-body kinds, a surface that had been
105
- * counted and never compared — three positions in `encode/bytes`.
105
+ * counted and never compared - three positions in `encode/bytes`.
106
106
  */
107
107
  export declare function isRawBinaryMediaType(contentTypes: readonly string[]): boolean;
108
108
  export interface EmittedRoute {
@@ -111,35 +111,35 @@ export interface EmittedRoute {
111
111
  readonly path: string;
112
112
  readonly statusCode: number;
113
113
  readonly statusCodes: readonly number[];
114
- /** Media types the success body can be served as — the basis for `Accept` negotiation. */
114
+ /** Media types the success body can be served as - the basis for `Accept` negotiation. */
115
115
  readonly responseContentTypes: readonly string[];
116
116
  /**
117
117
  * Media types the request body may be SENT as.
118
118
  *
119
- * ⚠️ **This was missing entirely, and its absence was two live defects in the generated server.**
119
+ * **This was missing entirely, and its absence was two live defects in the generated server.**
120
120
  * The response side has carried `responseContentTypes` from the beginning; the request side carried
121
121
  * nothing, so a server built on this library had no way to ask what the incoming body actually is.
122
122
  * Both consequences were measured against a Petstore server under `wrangler dev`:
123
123
  *
124
124
  * - every request body was validated as JSON, so a `multipart/form-data` upload was handed to
125
- * `c.req.json()` — 17 such registrations in `payload/multipart` alone, and 11 of the 17 request
125
+ * `c.req.json()` - 17 such registrations in `payload/multipart` alone, and 11 of the 17 request
126
126
  * bodies in the Swagger Petstore are not JSON;
127
127
  * - a `bytes` body was read with `c.req.text()` whatever it was sent as, which UTF-8-decodes. An
128
128
  * 18-byte binary upload arrived at the handler as 23 code points with five bytes replaced by
129
- * U+FFFD — silently corrupted, answering **200**.
129
+ * U+FFFD - silently corrupted, answering **200**.
130
130
  *
131
131
  * A server emitter needs the document's own answer to "what is on the wire", and this is it.
132
- * What to DO about it — which validator target, which body reader — belongs to the emitter that
132
+ * What to DO about it - which validator target, which body reader - belongs to the emitter that
133
133
  * writes the server, exactly as `responseContentTypes` leaves negotiation to it.
134
134
  */
135
135
  readonly requestContentTypes: readonly string[];
136
136
  readonly summary: string | undefined;
137
137
  readonly requestSchema: string | undefined;
138
138
  /**
139
- * The parameters, split by WHERE they arrive — see `parameterSchemasOf`.
139
+ * The parameters, split by WHERE they arrive - see `parameterSchemasOf`.
140
140
  *
141
- * ⚠️ **A MERGED `paramsSchema` sat beside these and was deleted before 0.1.0 froze the surface.**
142
- * It was written on every route and read by nothing — not `nameRouteSchemas`, which declares from
141
+ * **A MERGED `paramsSchema` sat beside these and was deleted before 0.1.0 froze the surface.**
142
+ * It was written on every route and read by nothing - not `nameRouteSchemas`, which declares from
143
143
  * the split forms, and not the one consumer. Worse, it was computed by a second function under
144
144
  * different rules, and both wire fixes the split forms received had been applied only there: it
145
145
  * never decoded a path or query value, so `z.number().int()` met `"1"` and refused it, and it keyed
@@ -157,10 +157,10 @@ export interface EmittedRoute {
157
157
  readonly value: string;
158
158
  } | undefined;
159
159
  /**
160
- * `undefined` when the operation's success response carries no body — a `204`, or any `@delete`
160
+ * `undefined` when the operation's success response carries no body - a `204`, or any `@delete`
161
161
  * returning `void`.
162
162
  *
163
- * ⚠️ **This used to be required, and requiring it silently deleted routes.** `collectRoutes`
163
+ * **This used to be required, and requiring it silently deleted routes.** `collectRoutes`
164
164
  * skipped any operation `successBodyOf` could not answer for, with no diagnostic: measured across
165
165
  * `@typespec/http-specs`, the document declared 540 operations and this emitter mounted 256, with
166
166
  * 282 of the 284 missing ones bodyless. `payload/multipart` produced `GENERATED_ROUTES = []`
@@ -186,7 +186,7 @@ export interface EmittedRoute {
186
186
  /**
187
187
  * No caller is established, so the operation receives no `ServiceContext`.
188
188
  *
189
- * Whether a tenant exists follows from whether the request was authenticated — there is no user,
189
+ * Whether a tenant exists follows from whether the request was authenticated - there is no user,
190
190
  * account or membership to build one from otherwise. Reading it here is what lets a server
191
191
  * derive an operation's call shape rather than a hand-written table restating it per row.
192
192
  */
@@ -194,7 +194,7 @@ export interface EmittedRoute {
194
194
  /**
195
195
  * The OAuth scopes the operation requires, from `@useAuth`.
196
196
  *
197
- * ⚠️ **Per operation, never per surface.** A resource-wide list means either one scope for
197
+ * **Per operation, never per surface.** A resource-wide list means either one scope for
198
198
  * everything (a read-only grant could write) or the union of them (a read-only grant could not
199
199
  * read), and neither is a grant a human would recognise from the consent screen. Emitted into the
200
200
  * row so a server applies the gate the DOCUMENT publishes, rather than a hand-written
@@ -214,7 +214,7 @@ export declare function collectRoutes(program: Program, registry: SchemaRegistry
214
214
  /**
215
215
  * The identifiers a route's validators are declared under, by what the DOCUMENT calls each one.
216
216
  *
217
- * ⚠️ **Keyed on request locations, not on a router's validator targets.** `@hono/zod-validator`
217
+ * **Keyed on request locations, not on a router's validator targets.** `@hono/zod-validator`
218
218
  * spells these `"param"`, `"query"`, `"header"`, `"json"`; another framework spells them differently
219
219
  * or not at all. The location is the contract fact and belongs here; the spelling is the server
220
220
  * emitter's, and translating one to the other is a two-line map in the package that knows.
@@ -228,7 +228,7 @@ export interface RouteSchemaNames {
228
228
  readonly response: string | undefined;
229
229
  readonly alternateResponse: string | undefined;
230
230
  /**
231
- * The `readonly ResponseArm[]` const — what the operation may answer with, and with which body.
231
+ * The `readonly ResponseArm[]` const - what the operation may answer with, and with which body.
232
232
  *
233
233
  * Always present, because every operation declares at least its success status. An operation whose
234
234
  * every arm is bodyless still gets the const, carrying `schema: undefined` per arm, which is the
@@ -237,9 +237,9 @@ export interface RouteSchemaNames {
237
237
  readonly responses: string;
238
238
  }
239
239
  /**
240
- * One `@service`, emitted — and everything a server emitter needs to render a router over it.
240
+ * One `@service`, emitted - and everything a server emitter needs to render a router over it.
241
241
  *
242
- * ⚠️ **This is the shared seam, and it is deliberately narrow.** It carries no `SchemaRegistry`, no
242
+ * **This is the shared seam, and it is deliberately narrow.** It carries no `SchemaRegistry`, no
243
243
  * import collector, no error schema and no vocabulary map, because measurement said a server
244
244
  * generator reads none of them: the registry is never touched once the names are minted, the external
245
245
  * imports serve the very declarations that now live in `schemas.gen.ts`, the error schema fed a route
@@ -252,7 +252,7 @@ export interface EmittedService {
252
252
  readonly routes: readonly EmittedRoute[];
253
253
  /** The identifiers every per-operation validator was declared under, keyed by operation id. */
254
254
  readonly schemaNames: ReadonlyMap<string, RouteSchemaNames>;
255
- /** Where this service's artefacts were written — a server emitter writes its own beside them. */
255
+ /** Where this service's artefacts were written - a server emitter writes its own beside them. */
256
256
  readonly outputDir: string;
257
257
  /** Already resolved through the per-service overrides, so a caller re-reads nothing. */
258
258
  readonly options: ResolvedServiceOptions;
@@ -270,13 +270,13 @@ export declare const DEFAULT_RUNTIME_MODULE = "typespec-http-zod/runtime";
270
270
  /**
271
271
  * What a WRAPPING emitter may change about this one, without changing what a consumer may set.
272
272
  *
273
- * ⚠️ **`defaultRuntimeModule` is a default, not an override.** A consumer's `runtime-module` still
273
+ * **`defaultRuntimeModule` is a default, not an override.** A consumer's `runtime-module` still
274
274
  * wins; this only decides what `schemas.gen.ts` and any file a wrapper emits beside it import from
275
275
  * when the consumer says nothing.
276
276
  *
277
- * ⚠️ **It exists because this package's own default is wrong for a wrapper, and shipped that way.**
277
+ * **It exists because this package's own default is wrong for a wrapper, and shipped that way.**
278
278
  * `typespec-hono` emits `import type { AppEnv, Awaitable, Ctx, Result, RouteDeps }` and
279
- * `import { selectContentType }` from whatever this resolves to. None of those six is exported here —
279
+ * `import { selectContentType }` from whatever this resolves to. None of those six is exported here -
280
280
  * this module exports `ResponseArm` and `armFor`, and nothing else. Worse, `typespec-http-zod` is a
281
281
  * TRANSITIVE dependency of a `typespec-hono` consumer, so under a strict `node_modules` the specifier
282
282
  * does not resolve at all whatever it exports. Measured in a fresh project installed from tarballs:
@@ -293,7 +293,7 @@ export interface EmitHttpZodOptions {
293
293
  /**
294
294
  * Emit this library's artefacts for every `@service` in the program, and hand back what was emitted.
295
295
  *
296
- * ⚠️ **The whole orchestration lives here, once.** The nesting below is load-bearing — a walk that
296
+ * **The whole orchestration lives here, once.** The nesting below is load-bearing - a walk that
297
297
  * runs outside `collectExternalImports` throws, vocabularies must be collected from what the service
298
298
  * DECLARES rather than from what the Zod walk happens to touch, and the error body has to be
299
299
  * registered inside the walk or its validator never reaches `schemas.gen.ts`. An emitter built on this