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.
@@ -1,5 +1,5 @@
1
1
  import type { Context, Env, Input, MiddlewareHandler } from "hono";
2
- import type { ZodType } from "zod";
2
+ import type { output, ZodError, ZodType } from "zod";
3
3
  /**
4
4
  * One arm of an operation's declared response set, as the document publishes it.
5
5
  *
@@ -13,39 +13,17 @@ export interface ResponseArm {
13
13
  readonly status: number | "default" | `${1 | 2 | 3 | 4 | 5}XX`;
14
14
  readonly schema: ZodType | undefined;
15
15
  /**
16
- * The media types this response offers, where the document names MORE than one.
17
- *
18
- * Absent where it names one, which is what an application already assumes, so "one type" and
19
- * "not carried" are the same state rather than two to tell apart. Present, it is the set to
20
- * negotiate against: `selectContentType` takes the caller's `Accept` and these.
16
+ * Every media type this response offers, including a single one. Absent where there is no body.
21
17
  */
22
18
  readonly contentTypes?: readonly string[];
23
19
  /**
24
- * The headers this response declares, as the document publishes them.
25
- *
26
- * **Two names, because two different things need them.** `name` is the WIRE name, which is what
27
- * the response sets; `property` is the name on the value the handler returned, which is where the
28
- * value is read from. `@header("x-correlation-id") correlationId: string` is `x-correlation-id`
29
- * on the wire and `correlationId` in the result, and they differ for any header with a hyphen.
30
- *
31
- * Absent where the response declares none.
20
+ * The headers this response declares, by the WIRE name the response sets. `optional` is the
21
+ * document's `required: false`. Absent where the response declares none.
32
22
  */
33
23
  readonly headers?: readonly {
34
24
  readonly name: string;
35
- readonly property: string;
25
+ readonly optional: boolean;
36
26
  }[];
37
- readonly when?: {
38
- readonly property: string;
39
- /**
40
- * **A number too, because a `@statusCode` union selects by the status itself.**
41
- *
42
- * `model Created { @statusCode statusCode: 200 | 201 }` names the property that chooses, and
43
- * its values are the statuses. The discriminator case carries a boolean or string literal off
44
- * the body instead; both are the same question - which arm did the handler mean - so both use
45
- * this one field.
46
- */
47
- readonly value: boolean | number | string;
48
- };
49
27
  }
50
28
  /**
51
29
  * The arm that applies to a status, preferring an exact code, then its `NXX` range, then `default`.
@@ -78,30 +56,38 @@ export type SecurityRequirement = Readonly<Record<string, readonly string[]>>;
78
56
  * assertion invented to put the guarantee back.
79
57
  *
80
58
  * What is left for the app to supply is genuinely app-specific: how a request becomes a caller's
81
- * context, and how a result becomes a response. Everything else, routing, validation, which
82
- * validator applies to which target, what status each arm answers, is generated.
59
+ * context, and what a refusal looks like. Everything else, routing, validation, which validator
60
+ * applies to which target, which statuses an operation may answer with and how each one is served,
61
+ * is generated.
83
62
  */
84
63
  /**
85
- * How an operation's return value is wrapped.
64
+ * The Hono environment the generated server mounts on.
86
65
  *
87
- * Identity by default, so an operation may simply return its value. An app with a result envelope
88
- * points `runtime-module` at its own module and re-declares this as, say, `ServiceResult<T>`, which
89
- * is what keeps the generated `Operations` interface concretely typed end to end instead of falling
90
- * back to `unknown` and reintroducing the cast this whole change exists to delete.
91
- */
92
- export type Result<T> = T;
93
- /**
94
- * The Hono environment the generated server mounts on, and the caller context its operations take.
66
+ * **An interface, so an application AUGMENTS it rather than replacing this module.**
95
67
  *
96
- * **Concrete on purpose.** Making `registerRoutes` generic over the environment does not work:
97
- * Hono narrows `Context` per route and its conditional types cannot reduce
98
- * `IfAnyThenEmptyObject<E extends Env ? ...>` while `E` is an unbound parameter, so nothing the app
99
- * supplies is ever assignable and every call site needs a cast. Naming the types here instead, an
100
- * app points `runtime-module` at its own module and re-declares them, keeps every generated call
101
- * site concrete and cast-free. Identity defaults, so an app with neither can ignore both.
68
+ * ```ts
69
+ * declare module "./generated/runtime.gen.js" {
70
+ * interface AppEnv {
71
+ * Bindings: { BACKEND: Service<Backend> };
72
+ * Variables: { principal: Principal };
73
+ * }
74
+ * }
75
+ * ```
76
+ *
77
+ * That is the idiom Hono itself uses for `ContextVariableMap`, and it is what lets this module be
78
+ * emitted beside the generated code on every compile instead of being copied into an application and
79
+ * aged there. A copy was the only other way to name an environment: a gateway ran a runtime from
80
+ * `0.10.1` while the emitter reached `0.21.0`, carrying a content-negotiation defect fixed eleven
81
+ * releases earlier.
82
+ *
83
+ * **Concrete rather than a type parameter of `registerRoutes`, and that was measured twice.** Hono
84
+ * narrows `Context` per route, and its conditional types cannot reduce
85
+ * `IfAnyThenEmptyObject<E extends Env ? ...>` while `E` is an unbound parameter, so nothing an
86
+ * application supplies is ever assignable and every call site needs a cast. Confirmed again on hono
87
+ * 4.13.1 with TypeScript 7.0.2: three `TS2345`s on a three-route probe.
102
88
  */
103
- export type AppEnv = Env;
104
- export type Ctx = unknown;
89
+ export interface AppEnv extends Env {
90
+ }
105
91
  /** Anything an operation may hand back: the value, or a promise of it. */
106
92
  export type Awaitable<T> = T | Promise<T>;
107
93
  /**
@@ -129,7 +115,7 @@ export type Awaitable<T> = T | Promise<T>;
129
115
  * **Specificity was implemented as a tie-break and that was wrong three ways at once**, all of them
130
116
  * live in a published runtime until `test/negotiation.test.ts` was written. Scoring every matching
131
117
  * range and keeping the best `(q, specificity)` pair lets a permissive wildcard out-vote the precise
132
- * rule a caller wrote about that exact type - so `Accept: *​/*, application/json;q=0` was served
118
+ * rule a caller wrote about that exact type - so an `Accept` of any type at all followed by `application/json;q=0` was served
133
119
  * JSON, which is the one outcome an explicit refusal must never produce. The prose above stated the
134
120
  * right rules the whole time; nothing compared it to the code.
135
121
  *
@@ -157,20 +143,112 @@ export declare function selectContentType(accept: string | undefined, offered: r
157
143
  */
158
144
  export declare const headOnly: MiddlewareHandler;
159
145
  /**
160
- * **The request-body middleware used to live here, and it moved into `app.gen.ts`.**
146
+ * **A route whose template writes a query string, `/items?fixed=true{&param}`, is only that route when
147
+ * the request carries it.** A router matches paths, so the pairs are checked here, and a request
148
+ * without them gets the 404 any unrouted request gets, through whatever `app.notFound()` the
149
+ * application has set. In the runtime for the same reason as {@link headOnly}.
150
+ */
151
+ export declare const literalQuery: (pairs: readonly (readonly [string, string])[]) => MiddlewareHandler;
152
+ /**
153
+ * A response body that does not match the schema the document publishes for its status.
161
154
  *
162
- * `byContentType` and `optionalBody` were exported from this module and imported by the generated
163
- * server, which made them part of a SECOND contract this package has: what an application that
164
- * points `runtime-module` at a module of its own must export. That contract is easy to break without
165
- * noticing, and it was the reason a required single-media-type body kept `zValidator` - whose
166
- * `HTTPException` on an unreadable body is a `text/plain` 400 raised before `deps.invalid`, escaping
167
- * the app's error envelope. Routing those through `byContentType` closed the gap in one line and
168
- * made that export mandatory for every substituting app: measured, 15 arms red.
155
+ * **Thrown, so an application decides what a contract failure answers with in `app.onError`**,
156
+ * which is where Hono puts that decision. Before this existed every consumer made the same check in
157
+ * its own `respond` and answered differently - a 500, a 502, a 502 with the issues in the body, a 502
158
+ * with them redacted - and only one of them checked failure bodies at all.
169
159
  *
170
- * Emitting the middleware instead closes the gap and SHRINKS the runtime contract by these two
171
- * names. Nothing generated imports them, so keeping them here would leave a second implementation of
172
- * body reading that nothing exercises - which is how two copies of one rule drift apart.
160
+ * `issues` are Zod's, so they carry paths and codes. They also carry the offending VALUES; an
161
+ * application that logs them should decide whether its responses may contain anything it would not
162
+ * log.
173
163
  */
164
+ export declare class ResponseContractError extends Error {
165
+ readonly operationId: string;
166
+ readonly status: number;
167
+ readonly issues: ZodError["issues"];
168
+ /**
169
+ * Which half of the response broke its contract. **Defaulted, so the three-argument form an
170
+ * application may already construct or match on keeps working.**
171
+ */
172
+ readonly part: "body" | "headers";
173
+ constructor(operationId: string, status: number, issues: ZodError["issues"],
174
+ /**
175
+ * Which half of the response broke its contract. **Defaulted, so the three-argument form an
176
+ * application may already construct or match on keeps working.**
177
+ */
178
+ part?: "body" | "headers");
179
+ }
180
+ /**
181
+ * A handler answered with a status its operation does not declare.
182
+ *
183
+ * **Unreachable from a typed handler.** The generated `Operations` interface types every result as
184
+ * the union of the declared statuses, so a literal outside it does not compile. This is what a CAST
185
+ * produces, or a result that crossed a boundary the type system cannot see into, such as untyped
186
+ * data from a service binding. Thrown rather than served, because serving it would publish a status
187
+ * the contract does not state.
188
+ */
189
+ export declare class UndeclaredStatusError extends Error {
190
+ readonly operationId: string;
191
+ readonly result: unknown;
192
+ constructor(operationId: string, result: unknown);
193
+ }
194
+ /**
195
+ * The body a response SERVES: what the handler returned, parsed against the schema the document
196
+ * publishes for that status.
197
+ *
198
+ * **What is served is the PARSED value, not the one the handler returned.** A schema that strips
199
+ * undeclared keys therefore strips them from the wire too, which is how an internal field such as a
200
+ * tenant id is kept out of a response the document does not publish it in. Seven of the nine
201
+ * consumer surfaces surveyed relied on exactly that, each in its own hand-written `respond`.
202
+ *
203
+ * Synchronous, because nothing this emitter writes is asynchronous - `test/sync.test.ts` asserts it
204
+ * over the whole corpus - and the asynchronous path costs 2.6x per parse.
205
+ */
206
+ export declare function servedBody<S extends ZodType>(schema: S, value: unknown, operationId: string, status: number): output<S>;
207
+ /**
208
+ * Declared response headers, as the strings a response carries.
209
+ *
210
+ * An optional header the handler did not supply is omitted rather than sent as `"undefined"`, and a
211
+ * typed value - a `retry-after` declared `int32` - is written as its text. Hono's `HeaderRecord`
212
+ * refuses `undefined`, so dropping it here is also what keeps the generated call sites free of a
213
+ * conditional per header.
214
+ */
215
+ export declare function headersOf(declared: Readonly<Record<string, unknown>>): Record<string, string>;
216
+ /**
217
+ * Declared response headers, CHECKED against the schemas the document publishes for them.
218
+ *
219
+ * **The body beside them was always checked and these never were.** `servedBody` parses every
220
+ * response body against its status's schema and throws `ResponseContractError`; a declared header
221
+ * went through `headersOf`, which is `String(value)` and nothing else. Measured on a generated
222
+ * server: a header the document constrains to `^[A-Za-z0-9-]+$` was served as
223
+ * `not a valid id"; injected=1` with a 200, while a body breaking its own schema on the same route
224
+ * threw. That asymmetry is the whole of what this removes.
225
+ *
226
+ * **Not a response-splitting hole, and worth saying so.** `new Response` refuses a header value
227
+ * containing CR or LF with a `TypeError` on both `workerd` and undici, so the runtime already stops
228
+ * the injection. What it does not stop is a response that disagrees with its own document, which a
229
+ * client generated from that document is entitled to rely on.
230
+ *
231
+ * **Undefined values are dropped BEFORE the check, not after.** An optional header the handler did
232
+ * not supply is absent rather than explicitly `undefined`, which is what the emitted schema's
233
+ * `.exactOptional()` means, and it is also what Hono's `HeaderRecord` requires. The check then runs
234
+ * on the values as the handler produced them - a `retry-after` declared `int32` is still a number
235
+ * here - because checking the text would check a different thing than the document describes.
236
+ *
237
+ * **What is returned is every header, including ones the schema does not mention.** A response
238
+ * carries headers the document does not declare, the `Content-Type` this emitter sets among them,
239
+ * and those are not a contract violation. The schema is a plain object schema, so it ignores them.
240
+ */
241
+ export declare function servedHeaders(schema: ZodType, declared: Readonly<Record<string, unknown>>, operationId: string, status: number): Record<string, string>;
242
+ /**
243
+ * Whether a media type a handler answers with lies inside a range the document offers.
244
+ *
245
+ * **A range is not a type a response can be sent as**, so where a status offers `image/*` the handler
246
+ * names the concrete type, and its result type already refuses one outside the range. This is what a
247
+ * CAST reaches, or data from a service binding: `text/html` under `image/*`, or `image/*` itself,
248
+ * would otherwise be served with a `Content-Type` the document does not permit. Type names compare
249
+ * case-insensitively (RFC 9110 section 8.3.1), and parameters such as `charset` are allowed.
250
+ */
251
+ export declare function mediaTypeWithin(served: string, range: string): boolean;
174
252
  /**
175
253
  * What the app provides. One object, passed once, rather than a module the generated file imports by
176
254
  * path. A generated server that hard-codes `../../backend.js` is only usable by the project it was
@@ -181,26 +259,25 @@ export declare const headOnly: MiddlewareHandler;
181
259
  * so a hook typed against a single `Context<E>` is not assignable at any real call site. Making the
182
260
  * hooks generic lets the app write functions that ignore both, without a cast anywhere.
183
261
  *
184
- * **`E` and `C` are PARAMETERS, and they have to be.** The defaults keep the bare `RouteDeps` the
185
- * generated server writes working for an app that substitutes nothing. An app that substitutes
186
- * anything binds them once, `export type RouteDeps = BaseRouteDeps<AppEnv, Ctx>` in the module it
187
- * points `runtime-module` at, and every hook is then typed against its own environment and its own
188
- * caller context.
262
+ * **`C` is the caller context, and `registerRoutes` INFERS it from `context`.** An application that
263
+ * returns a `Caller` from `context` has handlers typed `(ctx: Caller, input)`, with nothing to
264
+ * declare. It used to be a type an application re-declared in a substituted copy of this module.
189
265
  *
190
- * Re-exporting this interface unparameterised instead does not work, and the reason is not obvious:
191
- * **Hono's `Context` is INVARIANT in its environment**, because `Context.set` takes `E` as an
192
- * argument. So `Context<AppEnv, ...>` is not assignable to `Context<Env, ...>` however plain the
193
- * substituted environment is, and every generated `deps.*` call site fails. Separately, `context`
194
- * would keep returning the identity `Ctx` (`unknown`) which the app's own handlers then reject.
195
- * Measured before this was parameterised: **19 errors on a four-operation service.**
266
+ * **Nothing here renders a successful response, and that is the point.** Which statuses an operation
267
+ * may answer with, which body each one carries and how it is serialised are all things the document
268
+ * states, so the generated route does them. A `respond` hook used to be handed every arm and the
269
+ * handler's result, and every application re-implemented the choice by hand: five status-mapping
270
+ * tables across the consumers surveyed, none consulting the arms, one sending a declared 404 as 400.
271
+ * What remains are the refusals that happen before a handler runs, whose envelope only the
272
+ * application knows.
196
273
  */
197
- export interface RouteDeps<E extends Env = AppEnv, C = Ctx> {
274
+ export interface RouteDeps<E extends Env = AppEnv, C = unknown> {
198
275
  /**
199
276
  * The gate the DOCUMENT publishes, as middleware.
200
277
  *
201
278
  * **Which scopes an operation demands is a contract fact; how a token is verified is not.**
202
279
  * `@useAuth(OAuth2Auth<...>)` reaches OpenAPI as `security` per operation, so the requirement is
203
- * generated and this implements the check. The same split as `context` and `respond`. Emitted
280
+ * generated and this implements the check. The same split as `context`. Emitted
204
281
  * only where the operation declares scopes, which is why an internal surface with none is
205
282
  * unaffected.
206
283
  *
@@ -222,9 +299,17 @@ export interface RouteDeps<E extends Env = AppEnv, C = Ctx> {
222
299
  /**
223
300
  * The caller's context, or `null` when there is none to establish.
224
301
  *
225
- * `authentication` is what the DOCUMENT says, and only that: `"none"` where the operation
226
- * declares `@useAuth(NoAuth)` (`security: []` in OpenAPI) and `"required"` otherwise. Deciding
227
- * it at generation time is the point: the gate the document publishes is the gate that runs.
302
+ * `authentication` is what the DOCUMENT says, and only that:
303
+ *
304
+ * - `"none"`: no requirement asks for anything (`@useAuth(NoAuth)`, or no authentication at all);
305
+ * - `"optional"`: an anonymous alternative sits beside a real one (`NoAuth | BearerAuth`), so
306
+ * `authorize` has admitted this caller either way and a presented credential should still be
307
+ * read;
308
+ * - `"required"`: every alternative asks for something.
309
+ *
310
+ * **`"optional"` is new, and without it the middle case was reported as `"none"`**, so a caller
311
+ * with a valid token on an optional route was never established as a caller. Deciding it at
312
+ * generation time is the point: the gate the document publishes is the gate that runs.
228
313
  *
229
314
  * **It used to be `"none" | "account" | "resource"`, and the last two were an invention.** They
230
315
  * were chosen by whether the path had parameters, which no OpenAPI keyword expresses and which
@@ -232,7 +317,7 @@ export interface RouteDeps<E extends Env = AppEnv, C = Ctx> {
232
317
  * nothing published is the defect class this emitter exists to remove, so it is gone. An app that
233
318
  * needs the distinction can read the request, which is the one thing it definitely has.
234
319
  */
235
- readonly context: <P extends string, I extends Input>(c: Context<E, P, I>, authentication: "none" | "required") => C | null;
320
+ readonly context: <P extends string, I extends Input>(c: Context<E, P, I>, authentication: "none" | "optional" | "required") => C | null;
236
321
  /** The response when `context` returns `null`. */
237
322
  readonly noContext: <P extends string, I extends Input>(c: Context<E, P, I>) => Response;
238
323
  /**
@@ -253,9 +338,4 @@ export interface RouteDeps<E extends Env = AppEnv, C = Ctx> {
253
338
  readonly invalid: <P extends string, I extends Input>(result: {
254
339
  readonly success: boolean;
255
340
  }, c: Context<E, P, I>) => Response | undefined;
256
- /**
257
- * Turn an operation's result into a response, checked against the schema the document publishes
258
- * for the arm that applies. A bodyless success is an arm whose `schema` is `undefined`.
259
- */
260
- readonly respond: <P extends string, I extends Input>(c: Context<E, P, I>, arms: readonly ResponseArm[], result: unknown) => Awaitable<Response>;
261
341
  }
@@ -34,7 +34,7 @@ export function armFor(arms, status) {
34
34
  * **Specificity was implemented as a tie-break and that was wrong three ways at once**, all of them
35
35
  * live in a published runtime until `test/negotiation.test.ts` was written. Scoring every matching
36
36
  * range and keeping the best `(q, specificity)` pair lets a permissive wildcard out-vote the precise
37
- * rule a caller wrote about that exact type - so `Accept: *​/*, application/json;q=0` was served
37
+ * rule a caller wrote about that exact type - so an `Accept` of any type at all followed by `application/json;q=0` was served
38
38
  * JSON, which is the one outcome an explicit refusal must never produce. The prose above stated the
39
39
  * right rules the whole time; nothing compared it to the code.
40
40
  *
@@ -103,3 +103,154 @@ export function selectContentType(accept, offered) {
103
103
  * type. Measured: `hc<typeof app>` resolved a wrapped route's body to `unknown`.
104
104
  */
105
105
  export const headOnly = async (c, next) => c.req.method === "HEAD" ? next() : c.notFound();
106
+ /**
107
+ * **A route whose template writes a query string, `/items?fixed=true{&param}`, is only that route when
108
+ * the request carries it.** A router matches paths, so the pairs are checked here, and a request
109
+ * without them gets the 404 any unrouted request gets, through whatever `app.notFound()` the
110
+ * application has set. In the runtime for the same reason as {@link headOnly}.
111
+ */
112
+ export const literalQuery = (pairs) => async (c, next) => pairs.every(([name, value]) => c.req.query(name) === value) ? next() : c.notFound();
113
+ /**
114
+ * A response body that does not match the schema the document publishes for its status.
115
+ *
116
+ * **Thrown, so an application decides what a contract failure answers with in `app.onError`**,
117
+ * which is where Hono puts that decision. Before this existed every consumer made the same check in
118
+ * its own `respond` and answered differently - a 500, a 502, a 502 with the issues in the body, a 502
119
+ * with them redacted - and only one of them checked failure bodies at all.
120
+ *
121
+ * `issues` are Zod's, so they carry paths and codes. They also carry the offending VALUES; an
122
+ * application that logs them should decide whether its responses may contain anything it would not
123
+ * log.
124
+ */
125
+ export class ResponseContractError extends Error {
126
+ operationId;
127
+ status;
128
+ issues;
129
+ part;
130
+ constructor(operationId, status, issues,
131
+ /**
132
+ * Which half of the response broke its contract. **Defaulted, so the three-argument form an
133
+ * application may already construct or match on keeps working.**
134
+ */
135
+ part = "body") {
136
+ super(`${operationId} answered ${status} with ${part === "body" ? "a body" : "headers"} its document does not permit`);
137
+ this.operationId = operationId;
138
+ this.status = status;
139
+ this.issues = issues;
140
+ this.part = part;
141
+ this.name = "ResponseContractError";
142
+ }
143
+ }
144
+ /**
145
+ * A handler answered with a status its operation does not declare.
146
+ *
147
+ * **Unreachable from a typed handler.** The generated `Operations` interface types every result as
148
+ * the union of the declared statuses, so a literal outside it does not compile. This is what a CAST
149
+ * produces, or a result that crossed a boundary the type system cannot see into, such as untyped
150
+ * data from a service binding. Thrown rather than served, because serving it would publish a status
151
+ * the contract does not state.
152
+ */
153
+ export class UndeclaredStatusError extends Error {
154
+ operationId;
155
+ result;
156
+ constructor(operationId, result) {
157
+ const status = typeof result === "object" && result !== null && "status" in result
158
+ ? String(result.status)
159
+ : "no status";
160
+ super(`${operationId} answered ${status}, which its document does not declare`);
161
+ this.operationId = operationId;
162
+ this.result = result;
163
+ this.name = "UndeclaredStatusError";
164
+ }
165
+ }
166
+ /**
167
+ * The body a response SERVES: what the handler returned, parsed against the schema the document
168
+ * publishes for that status.
169
+ *
170
+ * **What is served is the PARSED value, not the one the handler returned.** A schema that strips
171
+ * undeclared keys therefore strips them from the wire too, which is how an internal field such as a
172
+ * tenant id is kept out of a response the document does not publish it in. Seven of the nine
173
+ * consumer surfaces surveyed relied on exactly that, each in its own hand-written `respond`.
174
+ *
175
+ * Synchronous, because nothing this emitter writes is asynchronous - `test/sync.test.ts` asserts it
176
+ * over the whole corpus - and the asynchronous path costs 2.6x per parse.
177
+ */
178
+ export function servedBody(schema, value, operationId, status) {
179
+ const parsed = schema.safeParse(value);
180
+ if (!parsed.success)
181
+ throw new ResponseContractError(operationId, status, parsed.error.issues);
182
+ return parsed.data;
183
+ }
184
+ /**
185
+ * Declared response headers, as the strings a response carries.
186
+ *
187
+ * An optional header the handler did not supply is omitted rather than sent as `"undefined"`, and a
188
+ * typed value - a `retry-after` declared `int32` - is written as its text. Hono's `HeaderRecord`
189
+ * refuses `undefined`, so dropping it here is also what keeps the generated call sites free of a
190
+ * conditional per header.
191
+ */
192
+ export function headersOf(declared) {
193
+ const headers = {};
194
+ for (const [name, value] of Object.entries(declared)) {
195
+ if (value !== undefined)
196
+ headers[name] = String(value);
197
+ }
198
+ return headers;
199
+ }
200
+ /**
201
+ * Declared response headers, CHECKED against the schemas the document publishes for them.
202
+ *
203
+ * **The body beside them was always checked and these never were.** `servedBody` parses every
204
+ * response body against its status's schema and throws `ResponseContractError`; a declared header
205
+ * went through `headersOf`, which is `String(value)` and nothing else. Measured on a generated
206
+ * server: a header the document constrains to `^[A-Za-z0-9-]+$` was served as
207
+ * `not a valid id"; injected=1` with a 200, while a body breaking its own schema on the same route
208
+ * threw. That asymmetry is the whole of what this removes.
209
+ *
210
+ * **Not a response-splitting hole, and worth saying so.** `new Response` refuses a header value
211
+ * containing CR or LF with a `TypeError` on both `workerd` and undici, so the runtime already stops
212
+ * the injection. What it does not stop is a response that disagrees with its own document, which a
213
+ * client generated from that document is entitled to rely on.
214
+ *
215
+ * **Undefined values are dropped BEFORE the check, not after.** An optional header the handler did
216
+ * not supply is absent rather than explicitly `undefined`, which is what the emitted schema's
217
+ * `.exactOptional()` means, and it is also what Hono's `HeaderRecord` requires. The check then runs
218
+ * on the values as the handler produced them - a `retry-after` declared `int32` is still a number
219
+ * here - because checking the text would check a different thing than the document describes.
220
+ *
221
+ * **What is returned is every header, including ones the schema does not mention.** A response
222
+ * carries headers the document does not declare, the `Content-Type` this emitter sets among them,
223
+ * and those are not a contract violation. The schema is a plain object schema, so it ignores them.
224
+ */
225
+ export function servedHeaders(schema, declared, operationId, status) {
226
+ const present = {};
227
+ for (const [name, value] of Object.entries(declared)) {
228
+ if (value !== undefined)
229
+ present[name] = value;
230
+ }
231
+ const parsed = schema.safeParse(present);
232
+ if (!parsed.success) {
233
+ throw new ResponseContractError(operationId, status, parsed.error.issues, "headers");
234
+ }
235
+ return headersOf(present);
236
+ }
237
+ /** RFC 9110 `token`, less `*`, which names a range rather than a type. */
238
+ const MEDIA_TOKEN = "[!#$%&'+.^_`|~0-9A-Za-z-]+";
239
+ const SERVED_MEDIA_TYPE = new RegExp(`^(${MEDIA_TOKEN})/${MEDIA_TOKEN}\\s*(;.*)?$`);
240
+ /**
241
+ * Whether a media type a handler answers with lies inside a range the document offers.
242
+ *
243
+ * **A range is not a type a response can be sent as**, so where a status offers `image/*` the handler
244
+ * names the concrete type, and its result type already refuses one outside the range. This is what a
245
+ * CAST reaches, or data from a service binding: `text/html` under `image/*`, or `image/*` itself,
246
+ * would otherwise be served with a `Content-Type` the document does not permit. Type names compare
247
+ * case-insensitively (RFC 9110 section 8.3.1), and parameters such as `charset` are allowed.
248
+ */
249
+ export function mediaTypeWithin(served, range) {
250
+ const type = SERVED_MEDIA_TYPE.exec(served)?.[1];
251
+ if (type === undefined)
252
+ return false;
253
+ if (range === "*/*")
254
+ return true;
255
+ return range.endsWith("/*") && type.toLowerCase() === range.slice(0, -2).toLowerCase();
256
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typespec-hono",
3
- "version": "0.22.0",
3
+ "version": "0.24.0",
4
4
  "description": "TypeSpec emitter: generate a Hono server, and the Zod validators it enforces, from an HTTP service definition, agreeing with the OpenAPI document @typespec/openapi3 publishes from the same source.",
5
5
  "keywords": [
6
6
  "cloudflare-workers",
@@ -44,36 +44,36 @@
44
44
  "provenance": true
45
45
  },
46
46
  "dependencies": {
47
- "typespec-http-zod": "^0.25.0"
47
+ "typespec-http-zod": "^0.27.0"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@hono/zod-openapi": "^1.4.0",
51
51
  "@hono/zod-validator": "^0.9.0",
52
52
  "@types/node": "^26.0.0",
53
- "@typespec/compiler": "1.15.0",
54
- "@typespec/events": "0.85.0",
55
- "@typespec/http": "1.15.0",
53
+ "@typespec/compiler": "1.16.0",
54
+ "@typespec/events": "0.86.0",
55
+ "@typespec/http": "1.16.0",
56
56
  "@typespec/http-specs": "0.1.0-alpha.41",
57
- "@typespec/openapi": "1.15.0",
58
- "@typespec/openapi3": "1.15.0",
59
- "@typespec/rest": "0.85.0",
60
- "@typespec/sse": "0.85.0",
61
- "@typespec/streams": "0.85.0",
62
- "@typespec/versioning": "0.85.0",
63
- "@typespec/xml": "0.85.0",
64
- "hono": "^4.12.26",
57
+ "@typespec/openapi": "1.16.0",
58
+ "@typespec/openapi3": "1.16.0",
59
+ "@typespec/rest": "0.86.0",
60
+ "@typespec/sse": "0.86.0",
61
+ "@typespec/streams": "0.86.0",
62
+ "@typespec/versioning": "0.86.0",
63
+ "@typespec/xml": "0.86.0",
64
+ "hono": "^4.13.8",
65
65
  "oxfmt": "^0.63.0",
66
66
  "oxlint": "^1.78.0",
67
67
  "typescript": "~7.0.2",
68
68
  "typespec-hono": "link:.",
69
69
  "vitest": "^4.1.9",
70
- "zod": "^4.5.2"
70
+ "zod": "^4.6.5"
71
71
  },
72
72
  "peerDependencies": {
73
73
  "@hono/zod-validator": "^0.8.0 || ^0.9.0",
74
74
  "@typespec/compiler": "^1.15.0",
75
75
  "@typespec/http": "^1.15.0",
76
- "hono": "^4.12.0",
76
+ "hono": "^4.13.5",
77
77
  "zod": "^4.5.0"
78
78
  },
79
79
  "engines": {
@@ -81,7 +81,7 @@
81
81
  },
82
82
  "tspMain": "lib/main.tsp",
83
83
  "scripts": {
84
- "build": "tsc -p tsconfig.build.json",
84
+ "build": "node --eval \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.build.json",
85
85
  "test": "tsc -p tsconfig.build.json && vitest run",
86
86
  "typecheck": "tsc -p tsconfig.json",
87
87
  "lint": "oxlint src/ test/",