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.
- package/README.md +102 -62
- package/dist/src/app.d.ts +95 -43
- package/dist/src/app.js +845 -251
- package/dist/src/emitter.d.ts +3 -3
- package/dist/src/emitter.js +52 -45
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +2 -0
- package/dist/src/lib.d.ts +13 -4
- package/dist/src/lib.js +41 -1
- package/dist/src/linter.d.ts +20 -0
- package/dist/src/linter.js +32 -0
- package/dist/src/rules/regexp-router-unsupported.rule.d.ts +32 -0
- package/dist/src/rules/regexp-router-unsupported.rule.js +65 -0
- package/dist/src/runtime.d.ts +160 -80
- package/dist/src/runtime.js +152 -1
- package/package.json +16 -16
- package/src/runtime.ts +217 -85
- package/dist/src/security.d.ts +0 -29
- package/dist/src/security.js +0 -48
package/dist/src/runtime.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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,
|
|
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
|
|
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
|
|
82
|
-
*
|
|
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
|
-
*
|
|
64
|
+
* The Hono environment the generated server mounts on.
|
|
86
65
|
*
|
|
87
|
-
*
|
|
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
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
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
|
|
104
|
-
|
|
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
|
|
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
|
-
* **
|
|
146
|
+
* **A route whose template writes a query string, `/items?fixed=true{¶m}`, 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
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
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
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
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
|
-
* **`
|
|
185
|
-
*
|
|
186
|
-
*
|
|
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
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
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 =
|
|
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
|
|
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:
|
|
226
|
-
*
|
|
227
|
-
*
|
|
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
|
}
|
package/dist/src/runtime.js
CHANGED
|
@@ -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
|
|
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{¶m}`, 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.
|
|
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.
|
|
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.
|
|
54
|
-
"@typespec/events": "0.
|
|
55
|
-
"@typespec/http": "1.
|
|
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.
|
|
58
|
-
"@typespec/openapi3": "1.
|
|
59
|
-
"@typespec/rest": "0.
|
|
60
|
-
"@typespec/sse": "0.
|
|
61
|
-
"@typespec/streams": "0.
|
|
62
|
-
"@typespec/versioning": "0.
|
|
63
|
-
"@typespec/xml": "0.
|
|
64
|
-
"hono": "^4.
|
|
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
|
|
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.
|
|
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/",
|