@loopbacktools/server 0.0.0-stage → 0.4.1
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/LICENSE +21 -0
- package/README.md +26 -2
- package/dist/package.json +57 -0
- package/dist/src/domain/ask.d.ts +78 -0
- package/dist/src/domain/ask.js +161 -0
- package/dist/src/domain/errors.d.ts +70 -0
- package/dist/src/domain/errors.js +57 -0
- package/dist/src/generated/app.gen.d.ts +2311 -0
- package/dist/src/generated/app.gen.js +400 -0
- package/dist/src/generated/runtime.gen.d.ts +357 -0
- package/dist/src/generated/runtime.gen.js +298 -0
- package/dist/src/generated/schemas.gen.d.ts +6416 -0
- package/dist/src/generated/schemas.gen.js +306 -0
- package/dist/src/generated/wire-contract.gen.d.ts +77 -0
- package/dist/src/generated/wire-contract.gen.js +3 -0
- package/dist/src/index.d.ts +11 -0
- package/dist/src/index.js +8 -0
- package/dist/src/loopback.d.ts +27 -0
- package/dist/src/loopback.js +16 -0
- package/dist/src/space/arguments.d.ts +8 -0
- package/dist/src/space/arguments.js +52 -0
- package/dist/src/space/migrations.d.ts +10 -0
- package/dist/src/space/migrations.js +106 -0
- package/dist/src/space/object.d.ts +30 -0
- package/dist/src/space/object.js +169 -0
- package/dist/src/space/operations.d.ts +149 -0
- package/dist/src/space/operations.js +568 -0
- package/dist/src/space/read.d.ts +8 -0
- package/dist/src/space/read.js +33 -0
- package/dist/src/space/rpc.d.ts +61 -0
- package/dist/src/space/rpc.js +96 -0
- package/dist/src/space/services.d.ts +41 -0
- package/dist/src/space/services.js +58 -0
- package/dist/src/space/store.d.ts +87 -0
- package/dist/src/space/store.js +276 -0
- package/dist/src/version.d.ts +2 -0
- package/dist/src/version.js +3 -0
- package/dist/src/worker/env.d.ts +19 -0
- package/dist/src/worker/env.js +3 -0
- package/dist/src/worker/files.d.ts +21 -0
- package/dist/src/worker/files.js +42 -0
- package/dist/src/worker/gate.d.ts +55 -0
- package/dist/src/worker/gate.js +83 -0
- package/dist/src/worker/http.d.ts +2092 -0
- package/dist/src/worker/http.js +291 -0
- package/dist/src/worker/jwt.d.ts +49 -0
- package/dist/src/worker/jwt.js +142 -0
- package/dist/src/worker/mcp.d.ts +15 -0
- package/dist/src/worker/mcp.js +401 -0
- package/dist/src/worker/run.d.ts +45 -0
- package/dist/src/worker/run.js +71 -0
- package/dist/src/worker/spaces.d.ts +24 -0
- package/dist/src/worker/spaces.js +64 -0
- package/package.json +51 -3
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import type { Context, Env, Input, MiddlewareHandler } from "hono";
|
|
2
|
+
import type { output, ZodError, ZodType } from "zod";
|
|
3
|
+
/**
|
|
4
|
+
* One arm of an operation's declared response set, as the document publishes it.
|
|
5
|
+
*
|
|
6
|
+
* Declared here rather than imported from `typespec-http-zod`, so that this module and the copy the
|
|
7
|
+
* emitter writes beside the generated code are both free of any package import at run time. The two
|
|
8
|
+
* declarations are structurally identical, which is all TypeScript requires, and
|
|
9
|
+
* typespec-hono's runtime parity suite asserts that this `armFor` answers identically to the library's for
|
|
10
|
+
* every shape of arm list.
|
|
11
|
+
*/
|
|
12
|
+
export interface ResponseArm {
|
|
13
|
+
readonly status: number | "default" | `${1 | 2 | 3 | 4 | 5}XX`;
|
|
14
|
+
readonly schema: ZodType | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* Every media type this response offers, including a single one. Absent where there is no body.
|
|
17
|
+
*/
|
|
18
|
+
readonly contentTypes?: readonly string[];
|
|
19
|
+
/**
|
|
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.
|
|
22
|
+
*/
|
|
23
|
+
readonly headers?: readonly {
|
|
24
|
+
readonly name: string;
|
|
25
|
+
readonly optional: boolean;
|
|
26
|
+
}[];
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The arm that applies to a status, preferring an exact code, then its `NXX` range, then `default`.
|
|
30
|
+
*
|
|
31
|
+
* That order is the document's own: OpenAPI resolves a response the same way, so an application
|
|
32
|
+
* choosing an arm by hand would have to re-derive this and could get it wrong differently.
|
|
33
|
+
*/
|
|
34
|
+
export declare function armFor(arms: readonly ResponseArm[], status: number): ResponseArm | undefined;
|
|
35
|
+
/**
|
|
36
|
+
* One acceptable combination of credentials, exactly as OpenAPI's `security` states it: scheme id to
|
|
37
|
+
* the scopes that scheme requires. Every entry in one requirement must be satisfied TOGETHER, and
|
|
38
|
+
* satisfying ANY ONE requirement authorises the caller.
|
|
39
|
+
*
|
|
40
|
+
* **Declared here rather than beside the code that derives it**, because `./runtime` is what a
|
|
41
|
+
* running server imports and must stay free of every build-time dependency, a packaging arm asserts
|
|
42
|
+
* it names no `@typespec/*` package at all. An application should not drag a compiler into its
|
|
43
|
+
* Worker to read one type.
|
|
44
|
+
*/
|
|
45
|
+
export type SecurityRequirement = Readonly<Record<string, readonly string[]>>;
|
|
46
|
+
/**
|
|
47
|
+
* The contract between the GENERATED server and the app that mounts it.
|
|
48
|
+
*
|
|
49
|
+
* **This exists because "here is a data table, write your own router" is not a deliverable.**
|
|
50
|
+
* A table of routes leaves every application to hand-write the loop that interprets it at run time.
|
|
51
|
+
* That loop sits outside every oracle the emitter is judged by, and it needs a cast that exists
|
|
52
|
+
* *only* because iterating a homogeneous array throws away the per-operation types the emitter knew:
|
|
53
|
+
* `backend[operationId]` becomes a union of every operation's method, so nothing about the call can
|
|
54
|
+
* be checked. Generating the server removes the loop and the cast.
|
|
55
|
+
*
|
|
56
|
+
* What is left for the app to supply is genuinely app-specific: how a request becomes a caller's
|
|
57
|
+
* context, and what a refusal looks like. Everything else, routing, validation, which validator
|
|
58
|
+
* applies to which target, which statuses an operation may answer with and how each one is served,
|
|
59
|
+
* is generated.
|
|
60
|
+
*/
|
|
61
|
+
/**
|
|
62
|
+
* The Hono environment the generated server mounts on.
|
|
63
|
+
*
|
|
64
|
+
* **An interface, so an application AUGMENTS it rather than replacing this module.**
|
|
65
|
+
*
|
|
66
|
+
* ```ts
|
|
67
|
+
* declare module "./generated/runtime.gen.js" {
|
|
68
|
+
* interface AppEnv {
|
|
69
|
+
* Bindings: { BACKEND: Service<Backend> };
|
|
70
|
+
* Variables: { principal: Principal };
|
|
71
|
+
* }
|
|
72
|
+
* }
|
|
73
|
+
* ```
|
|
74
|
+
*
|
|
75
|
+
* That is the idiom Hono itself uses for `ContextVariableMap`, and it is what lets this module be
|
|
76
|
+
* emitted beside the generated code on every compile instead of being copied into an application and
|
|
77
|
+
* aged there. A copy is the only other way to name an environment, and a copy falls behind the
|
|
78
|
+
* emitter that generates the code beside it, keeping every defect fixed since it was taken.
|
|
79
|
+
*
|
|
80
|
+
* **Concrete rather than a type parameter of `registerRoutes`, and that was measured twice.** Hono
|
|
81
|
+
* narrows `Context` per route, and its conditional types cannot reduce
|
|
82
|
+
* `IfAnyThenEmptyObject<E extends Env ? ...>` while `E` is an unbound parameter, so nothing an
|
|
83
|
+
* application supplies is ever assignable and every call site needs a cast. Confirmed again on hono
|
|
84
|
+
* 4.13.1 with TypeScript 7.0.2: three `TS2345`s on a three-route probe.
|
|
85
|
+
*/
|
|
86
|
+
export interface AppEnv extends Env {
|
|
87
|
+
}
|
|
88
|
+
/** Anything an operation may hand back: the value, or a promise of it. */
|
|
89
|
+
export type Awaitable<T> = T | Promise<T>;
|
|
90
|
+
/**
|
|
91
|
+
* Pick the media type to serve, per RFC 9110 section 12.5.1.
|
|
92
|
+
*
|
|
93
|
+
* **In the runtime rather than in {@link RouteDeps}, deliberately.** The test for admitting
|
|
94
|
+
* anything to `deps` is *the generated code cannot proceed without an answer*, and this is not that:
|
|
95
|
+
* which media types an operation offers is a contract fact the emitter reads from the document, and
|
|
96
|
+
* how `Accept` selects among them is specified by the RFC. Both sides are derivable, so an app that
|
|
97
|
+
* had to supply this would be re-implementing the standard, and could get it wrong differently from
|
|
98
|
+
* everybody else.
|
|
99
|
+
*
|
|
100
|
+
* The rules that matter, and that a naive `includes()` gets wrong:
|
|
101
|
+
* - **absent or empty `Accept` means anything is acceptable**, serve the first offer;
|
|
102
|
+
* - **specificity SELECTS which rule applies, before quality is read at all.** For each offered
|
|
103
|
+
* type, the most specific range that matches it decides its quality: an exact type beats a
|
|
104
|
+
* subtype wildcard (`text/*`), which beats the fully wildcard range. The fully wildcard range is
|
|
105
|
+
* not written literally here because it would close this comment;
|
|
106
|
+
* - **`q=0` is a REFUSAL**, not a weak preference, so a type whose applicable rule scores zero is
|
|
107
|
+
* never chosen;
|
|
108
|
+
* - equal quality keeps the order the document offers, so nothing in the header displaces it;
|
|
109
|
+
* - a malformed `q` is IGNORED rather than read as zero - a typo should not turn into a 406;
|
|
110
|
+
* - parameters after the media range (`;charset=utf-8`) are not part of the match.
|
|
111
|
+
*
|
|
112
|
+
* **Specificity is not a tie-break.** Scoring every matching range and keeping the best
|
|
113
|
+
* `(q, specificity)` pair lets a permissive wildcard out-vote the precise rule a caller wrote about
|
|
114
|
+
* that exact type, so an `Accept` of any type at all followed by `application/json;q=0` would be
|
|
115
|
+
* served JSON, which is the one outcome an explicit refusal must never produce. typespec-hono's
|
|
116
|
+
* negotiation suite holds this function to the rules above.
|
|
117
|
+
*
|
|
118
|
+
* Returns `undefined` when nothing offered is acceptable. The caller answers 406, and the
|
|
119
|
+
* difference between "no preference" and "no acceptable option" is exactly what that turns on.
|
|
120
|
+
*/
|
|
121
|
+
export declare function selectContentType(accept: string | undefined, offered: readonly string[]): string | undefined;
|
|
122
|
+
/**
|
|
123
|
+
* Let only a real HEAD request through.
|
|
124
|
+
*
|
|
125
|
+
* Hono rewrites HEAD to GET before matching, so a `@head` operation has to be registered under GET
|
|
126
|
+
* to be reachable at all. Where the document declares no GET on that path, this keeps the
|
|
127
|
+
* registration honest: a GET gets the 404 it would have got if the route had never been registered,
|
|
128
|
+
* and only a HEAD reaches the validators and the handler. `c.req.method` still reads `HEAD` after the
|
|
129
|
+
* rewrite, which is what makes the distinction possible.
|
|
130
|
+
*
|
|
131
|
+
* In the runtime rather than in {@link RouteDeps} on the usual test: the generated code can proceed
|
|
132
|
+
* without asking the app anything. Which verbs the document declares is a contract fact, and the
|
|
133
|
+
* answer for a verb it does not declare is the same 404 any unrouted request already gets, through
|
|
134
|
+
* whatever `app.notFound()` the application has set.
|
|
135
|
+
*
|
|
136
|
+
* A plain middleware rather than `except()` from `hono/combine`, because `except` wraps the final
|
|
137
|
+
* handler and erases its response type, and Hono's RPC client derives its whole surface from that
|
|
138
|
+
* type. Measured: `hc<typeof app>` resolved a wrapped route's body to `unknown`.
|
|
139
|
+
*/
|
|
140
|
+
export declare const headOnly: MiddlewareHandler;
|
|
141
|
+
/**
|
|
142
|
+
* **A route whose template writes a query string, `/items?fixed=true{¶m}`, is only that route when
|
|
143
|
+
* the request carries it.** A router matches paths, so the pairs are checked here, and a request
|
|
144
|
+
* without them gets the 404 any unrouted request gets, through whatever `app.notFound()` the
|
|
145
|
+
* application has set. In the runtime for the same reason as {@link headOnly}.
|
|
146
|
+
*/
|
|
147
|
+
export declare const literalQuery: (pairs: readonly (readonly [string, string])[]) => MiddlewareHandler;
|
|
148
|
+
/**
|
|
149
|
+
* A response body that does not match the schema the document publishes for its status.
|
|
150
|
+
*
|
|
151
|
+
* **Thrown, so an application decides what a contract failure answers with in `app.onError`**,
|
|
152
|
+
* which is where Hono puts that decision: a 500, a 502, a 502 with the issues in the body or with
|
|
153
|
+
* them redacted. The check itself is the same for every application, and it covers failure bodies
|
|
154
|
+
* as well as successful ones.
|
|
155
|
+
*
|
|
156
|
+
* `issues` are Zod's, so they carry paths and codes. They also carry the offending VALUES; an
|
|
157
|
+
* application that logs them should decide whether its responses may contain anything it would not
|
|
158
|
+
* log.
|
|
159
|
+
*/
|
|
160
|
+
export declare class ResponseContractError extends Error {
|
|
161
|
+
readonly operationId: string;
|
|
162
|
+
readonly status: number;
|
|
163
|
+
readonly issues: ZodError["issues"];
|
|
164
|
+
/**
|
|
165
|
+
* Which half of the response broke its contract. **Defaulted, so the three-argument form an
|
|
166
|
+
* application may already construct or match on keeps working.**
|
|
167
|
+
*/
|
|
168
|
+
readonly part: "body" | "headers";
|
|
169
|
+
/**
|
|
170
|
+
* Fields declared and assigned rather than written as parameter properties, which are not
|
|
171
|
+
* erasable syntax: a project building with `erasableSyntaxOnly` could not compile this file.
|
|
172
|
+
*/
|
|
173
|
+
constructor(operationId: string, status: number, issues: ZodError["issues"], part?: "body" | "headers");
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* A handler answered with a status its operation does not declare.
|
|
177
|
+
*
|
|
178
|
+
* **Unreachable from a typed handler.** The generated `Operations` interface types every result as
|
|
179
|
+
* the union of the declared statuses, so a literal outside it does not compile. This is what a CAST
|
|
180
|
+
* produces, or a result that crossed a boundary the type system cannot see into, such as untyped
|
|
181
|
+
* data from a service binding. Thrown rather than served, because serving it would publish a status
|
|
182
|
+
* the contract does not state.
|
|
183
|
+
*/
|
|
184
|
+
export declare class UndeclaredStatusError extends Error {
|
|
185
|
+
readonly operationId: string;
|
|
186
|
+
readonly result: unknown;
|
|
187
|
+
constructor(operationId: string, result: unknown);
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* **A stream of bytes, as every platform's `ReadableStream<Uint8Array>` satisfies it.**
|
|
191
|
+
*
|
|
192
|
+
* The same structural type `typespec-http-zod` publishes in `requests.gen.ts`, so a backend whose
|
|
193
|
+
* result is typed by the framework-free contract can be returned from a handler as it is: a contract
|
|
194
|
+
* cannot name `ReadableStream` without a DOM or runtime library, so it states the reader every stream
|
|
195
|
+
* has. Declared here rather than imported because this file is copied beside the generated code, into
|
|
196
|
+
* a Worker where the emitter package is not installed; the corpus agreement test holds the two
|
|
197
|
+
* identical.
|
|
198
|
+
*/
|
|
199
|
+
export interface ByteStream {
|
|
200
|
+
getReader(): {
|
|
201
|
+
read(): Promise<{
|
|
202
|
+
readonly done: boolean;
|
|
203
|
+
readonly value?: Uint8Array | undefined;
|
|
204
|
+
}>;
|
|
205
|
+
releaseLock(): void;
|
|
206
|
+
cancel?(reason?: unknown): Promise<void>;
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* The bytes a binary or streamed response SERVES, as something `c.body` accepts.
|
|
211
|
+
*
|
|
212
|
+
* **A platform stream, a `Uint8Array` or an `ArrayBuffer` is served as it is** - a stream keeps the
|
|
213
|
+
* platform's own piping, which is the point of handing over a stream at all. Anything else satisfying
|
|
214
|
+
* `ByteStream` is pulled through a `ReadableStream`, one chunk per read, so it still streams rather
|
|
215
|
+
* than being read whole. Without this, `new Response(value)` stringifies an object it does not
|
|
216
|
+
* recognise, and the body served is `[object Object]` with a 200.
|
|
217
|
+
*/
|
|
218
|
+
export declare function servedBytes(body: ByteStream | Uint8Array<ArrayBuffer> | ArrayBuffer): ReadableStream<Uint8Array> | Uint8Array<ArrayBuffer> | ArrayBuffer;
|
|
219
|
+
/**
|
|
220
|
+
* The body a response SERVES: what the handler returned, parsed against the schema the document
|
|
221
|
+
* publishes for that status.
|
|
222
|
+
*
|
|
223
|
+
* **What is served is the PARSED value, not the one the handler returned.** A schema that strips
|
|
224
|
+
* undeclared keys therefore strips them from the wire too, which is how an internal field such as a
|
|
225
|
+
* password hash is kept out of a response the document does not publish it in.
|
|
226
|
+
*
|
|
227
|
+
* Synchronous, because nothing this emitter writes is asynchronous - typespec-hono's suite asserts it
|
|
228
|
+
* over the whole corpus - and the asynchronous path costs 2.6x per parse.
|
|
229
|
+
*/
|
|
230
|
+
export declare function servedBody<S extends ZodType>(schema: S, value: unknown, operationId: string, status: number): output<S>;
|
|
231
|
+
/**
|
|
232
|
+
* Declared response headers, as the strings a response carries.
|
|
233
|
+
*
|
|
234
|
+
* An optional header the handler did not supply is omitted rather than sent as `"undefined"`, and a
|
|
235
|
+
* typed value - a `retry-after` declared `int32` - is written as its text. Hono's `HeaderRecord`
|
|
236
|
+
* refuses `undefined`, so dropping it here is also what keeps the generated call sites free of a
|
|
237
|
+
* conditional per header.
|
|
238
|
+
*/
|
|
239
|
+
export declare function headersOf(declared: Readonly<Record<string, unknown>>): Record<string, string>;
|
|
240
|
+
/**
|
|
241
|
+
* Declared response headers, CHECKED against the schemas the document publishes for them.
|
|
242
|
+
*
|
|
243
|
+
* **Checked as the body beside them is.** `servedBody` parses every response body against its
|
|
244
|
+
* status's schema and throws `ResponseContractError`; `headersOf` alone is `String(value)` and
|
|
245
|
+
* nothing else, so a header the document constrains to `^[A-Za-z0-9-]+$` would be served as
|
|
246
|
+
* `not a valid id"; injected=1` with a 200, while a body breaking its own schema on the same route
|
|
247
|
+
* throws. That asymmetry is the whole of what this removes.
|
|
248
|
+
*
|
|
249
|
+
* **Not a response-splitting hole, and worth saying so.** `new Response` refuses a header value
|
|
250
|
+
* containing CR or LF with a `TypeError` on both `workerd` and undici, so the runtime already stops
|
|
251
|
+
* the injection. What it does not stop is a response that disagrees with its own document, which a
|
|
252
|
+
* client generated from that document is entitled to rely on.
|
|
253
|
+
*
|
|
254
|
+
* **Undefined values are dropped BEFORE the check, not after.** An optional header the handler did
|
|
255
|
+
* not supply is absent rather than explicitly `undefined`, which is what the emitted schema's
|
|
256
|
+
* `.exactOptional()` means, and it is also what Hono's `HeaderRecord` requires. The check then runs
|
|
257
|
+
* on the values as the handler produced them - a `retry-after` declared `int32` is still a number
|
|
258
|
+
* here - because checking the text would check a different thing than the document describes.
|
|
259
|
+
*
|
|
260
|
+
* **What is returned is every header, including ones the schema does not mention.** A response
|
|
261
|
+
* carries headers the document does not declare, the `Content-Type` this emitter sets among them,
|
|
262
|
+
* and those are not a contract violation. The schema is a plain object schema, so it ignores them.
|
|
263
|
+
*/
|
|
264
|
+
export declare function servedHeaders(schema: ZodType, declared: Readonly<Record<string, unknown>>, operationId: string, status: number): Record<string, string>;
|
|
265
|
+
/**
|
|
266
|
+
* Whether a media type a handler answers with lies inside a range the document offers.
|
|
267
|
+
*
|
|
268
|
+
* **A range is not a type a response can be sent as**, so where a status offers `image/*` the handler
|
|
269
|
+
* names the concrete type, and its result type already refuses one outside the range. This is what a
|
|
270
|
+
* CAST reaches, or data from a service binding: `text/html` under `image/*`, or `image/*` itself,
|
|
271
|
+
* would otherwise be served with a `Content-Type` the document does not permit. Type names compare
|
|
272
|
+
* case-insensitively (RFC 9110 section 8.3.1), and parameters such as `charset` are allowed.
|
|
273
|
+
*/
|
|
274
|
+
export declare function mediaTypeWithin(served: string, range: string): boolean;
|
|
275
|
+
/**
|
|
276
|
+
* What the app provides. One object, passed once, rather than a module the generated file imports by
|
|
277
|
+
* path. A generated server that hard-codes `../../backend.js` is only usable by the project it was
|
|
278
|
+
* generated in, and this one has to be usable by any.
|
|
279
|
+
*
|
|
280
|
+
* **The hooks are generic over Hono's path and input parameters, deliberately.** Hono narrows
|
|
281
|
+
* `Context` per route, by the literal path, and by whatever the validators on that route produced,
|
|
282
|
+
* so a hook typed against a single `Context<E>` is not assignable at any real call site. Making the
|
|
283
|
+
* hooks generic lets the app write functions that ignore both, without a cast anywhere.
|
|
284
|
+
*
|
|
285
|
+
* **`C` is the caller context, and `registerRoutes` INFERS it from `context`.** An application that
|
|
286
|
+
* returns a `Caller` from `context` has handlers typed `(ctx: Caller, input)`, with nothing to
|
|
287
|
+
* declare.
|
|
288
|
+
*
|
|
289
|
+
* **Nothing here renders a successful response, and that is the point.** Which statuses an operation
|
|
290
|
+
* may answer with, which body each one carries and how it is serialised are all things the document
|
|
291
|
+
* states, so the generated route does them. A hook handed every arm and the handler's result would
|
|
292
|
+
* leave every application to re-implement that choice by hand, in a status table nothing checks
|
|
293
|
+
* against the declared arms. What remains are the refusals that happen before a handler runs, whose
|
|
294
|
+
* envelope only the application knows.
|
|
295
|
+
*/
|
|
296
|
+
export interface RouteDeps<E extends Env = AppEnv, C = unknown> {
|
|
297
|
+
/**
|
|
298
|
+
* The gate the DOCUMENT publishes, as middleware.
|
|
299
|
+
*
|
|
300
|
+
* **Which scopes an operation demands is a contract fact; how a token is verified is not.**
|
|
301
|
+
* `@useAuth(OAuth2Auth<...>)` reaches OpenAPI as `security` per operation, so the requirement is
|
|
302
|
+
* generated and this implements the check. The same split as `context`. Emitted
|
|
303
|
+
* only where the operation declares scopes, which is why an internal surface with none is
|
|
304
|
+
* unaffected. Without it, a surface whose document declares scopes would mount with its gate
|
|
305
|
+
* silently dropped.
|
|
306
|
+
*
|
|
307
|
+
* **It receives the document's REQUIREMENTS, not a flat list of scopes.** `@useAuth(BearerAuth)`
|
|
308
|
+
* publishes `security: [{ "BearerAuth": [] }]` with no scopes, so a scopes-only gate would cover
|
|
309
|
+
* OAuth2 and nothing else. Bearer, api-key and basic, which is most services, would carry no gate
|
|
310
|
+
* at all and rest entirely on `context` returning null, and an app whose `context` read a cookie
|
|
311
|
+
* would serve a route the document says needs a bearer token.
|
|
312
|
+
*
|
|
313
|
+
* Satisfying ANY ONE requirement authorises the caller, and every scheme WITHIN a requirement must
|
|
314
|
+
* be satisfied together, which is exactly what an array of OpenAPI `security` objects means.
|
|
315
|
+
*/
|
|
316
|
+
readonly authorize: (requirements: readonly SecurityRequirement[]) => MiddlewareHandler<E>;
|
|
317
|
+
/**
|
|
318
|
+
* The caller's context, or `null` when there is none to establish.
|
|
319
|
+
*
|
|
320
|
+
* `authentication` is what the DOCUMENT says, and only that:
|
|
321
|
+
*
|
|
322
|
+
* - `"none"`: no requirement asks for anything (`@useAuth(NoAuth)`, or no authentication at all);
|
|
323
|
+
* - `"optional"`: an anonymous alternative sits beside a real one (`NoAuth | BearerAuth`), so
|
|
324
|
+
* `authorize` has admitted this caller either way and a presented credential should still be
|
|
325
|
+
* read;
|
|
326
|
+
* - `"required"`: every alternative asks for something.
|
|
327
|
+
*
|
|
328
|
+
* **Without `"optional"` the middle case would read as `"none"`**, so a caller with a valid token
|
|
329
|
+
* on an optional route would never be established as a caller. Deciding it at generation time is
|
|
330
|
+
* the point: the gate the document publishes is the gate that runs.
|
|
331
|
+
*
|
|
332
|
+
* **Nothing finer than the document states.** A generated server enforcing a rule derived from
|
|
333
|
+
* nothing published is the defect class this emitter exists to remove. An app that needs a finer
|
|
334
|
+
* distinction between callers can read the request, which is the one thing it definitely has.
|
|
335
|
+
*/
|
|
336
|
+
readonly context: <P extends string, I extends Input>(c: Context<E, P, I>, authentication: "none" | "optional" | "required") => C | null;
|
|
337
|
+
/** The response when `context` returns `null`. */
|
|
338
|
+
readonly noContext: <P extends string, I extends Input>(c: Context<E, P, I>) => Response;
|
|
339
|
+
/**
|
|
340
|
+
* The response when the caller's `Accept` matches nothing the operation offers, a 406.
|
|
341
|
+
*
|
|
342
|
+
* Emitted only on routes where the document declares more than one media type for a status, so
|
|
343
|
+
* a service without content negotiation never sees it. Same shape of hook as {@link noContext}
|
|
344
|
+
* and admitted on the same test: the status and the `offered` list are contract facts the
|
|
345
|
+
* generated code already has, but the body they are reported in is the app's envelope, and it
|
|
346
|
+
* cannot proceed without one. {@link selectContentType} does the choosing; this reports failure.
|
|
347
|
+
*/
|
|
348
|
+
readonly notAcceptable: <P extends string, I extends Input>(c: Context<E, P, I>, offered: readonly string[]) => Response;
|
|
349
|
+
/**
|
|
350
|
+
* Passed straight to `zValidator`'s hook. Returning `undefined` lets a successful validation
|
|
351
|
+
* through; returning a `Response` is how a rejection becomes the status this API promises rather
|
|
352
|
+
* than the middleware's default.
|
|
353
|
+
*/
|
|
354
|
+
readonly invalid: <P extends string, I extends Input>(result: {
|
|
355
|
+
readonly success: boolean;
|
|
356
|
+
}, c: Context<E, P, I>) => Response | undefined;
|
|
357
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The arm that applies to a status, preferring an exact code, then its `NXX` range, then `default`.
|
|
3
|
+
*
|
|
4
|
+
* That order is the document's own: OpenAPI resolves a response the same way, so an application
|
|
5
|
+
* choosing an arm by hand would have to re-derive this and could get it wrong differently.
|
|
6
|
+
*/
|
|
7
|
+
export function armFor(arms, status) {
|
|
8
|
+
return (arms.find((arm) => arm.status === status) ??
|
|
9
|
+
arms.find((arm) => arm.status === `${Math.floor(status / 100)}XX`) ??
|
|
10
|
+
arms.find((arm) => arm.status === "default"));
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Pick the media type to serve, per RFC 9110 section 12.5.1.
|
|
14
|
+
*
|
|
15
|
+
* **In the runtime rather than in {@link RouteDeps}, deliberately.** The test for admitting
|
|
16
|
+
* anything to `deps` is *the generated code cannot proceed without an answer*, and this is not that:
|
|
17
|
+
* which media types an operation offers is a contract fact the emitter reads from the document, and
|
|
18
|
+
* how `Accept` selects among them is specified by the RFC. Both sides are derivable, so an app that
|
|
19
|
+
* had to supply this would be re-implementing the standard, and could get it wrong differently from
|
|
20
|
+
* everybody else.
|
|
21
|
+
*
|
|
22
|
+
* The rules that matter, and that a naive `includes()` gets wrong:
|
|
23
|
+
* - **absent or empty `Accept` means anything is acceptable**, serve the first offer;
|
|
24
|
+
* - **specificity SELECTS which rule applies, before quality is read at all.** For each offered
|
|
25
|
+
* type, the most specific range that matches it decides its quality: an exact type beats a
|
|
26
|
+
* subtype wildcard (`text/*`), which beats the fully wildcard range. The fully wildcard range is
|
|
27
|
+
* not written literally here because it would close this comment;
|
|
28
|
+
* - **`q=0` is a REFUSAL**, not a weak preference, so a type whose applicable rule scores zero is
|
|
29
|
+
* never chosen;
|
|
30
|
+
* - equal quality keeps the order the document offers, so nothing in the header displaces it;
|
|
31
|
+
* - a malformed `q` is IGNORED rather than read as zero - a typo should not turn into a 406;
|
|
32
|
+
* - parameters after the media range (`;charset=utf-8`) are not part of the match.
|
|
33
|
+
*
|
|
34
|
+
* **Specificity is not a tie-break.** Scoring every matching range and keeping the best
|
|
35
|
+
* `(q, specificity)` pair lets a permissive wildcard out-vote the precise rule a caller wrote about
|
|
36
|
+
* that exact type, so an `Accept` of any type at all followed by `application/json;q=0` would be
|
|
37
|
+
* served JSON, which is the one outcome an explicit refusal must never produce. typespec-hono's
|
|
38
|
+
* negotiation suite holds this function to the rules above.
|
|
39
|
+
*
|
|
40
|
+
* Returns `undefined` when nothing offered is acceptable. The caller answers 406, and the
|
|
41
|
+
* difference between "no preference" and "no acceptable option" is exactly what that turns on.
|
|
42
|
+
*/
|
|
43
|
+
export function selectContentType(accept, offered) {
|
|
44
|
+
if (offered.length === 0)
|
|
45
|
+
return undefined;
|
|
46
|
+
const header = accept?.trim();
|
|
47
|
+
if (header === undefined || header === "")
|
|
48
|
+
return offered[0];
|
|
49
|
+
const ranges = header.split(",").map((entry) => {
|
|
50
|
+
const [range = "", ...parameters] = entry.split(";").map((part) => part.trim());
|
|
51
|
+
const quality = parameters
|
|
52
|
+
.map((parameter) => /^q=(?<value>[\d.]+)$/i.exec(parameter)?.groups?.["value"])
|
|
53
|
+
.find((value) => value !== undefined);
|
|
54
|
+
const q = quality === undefined ? 1 : Number(quality);
|
|
55
|
+
// A malformed `q` is treated as unstated. Reading `q=1.2.3` as zero would 406 a typo.
|
|
56
|
+
return { range: range.toLowerCase(), q: Number.isFinite(q) ? q : 1 };
|
|
57
|
+
});
|
|
58
|
+
let best;
|
|
59
|
+
for (const type of offered) {
|
|
60
|
+
// Compared by its essence, as the ranges are: a parameter on an offered type is not a type.
|
|
61
|
+
const lowered = (type.split(";")[0] ?? "").trim().toLowerCase();
|
|
62
|
+
const [group] = lowered.split("/");
|
|
63
|
+
/**
|
|
64
|
+
* **The most specific matching range decides this type's quality**, which is what makes an
|
|
65
|
+
* explicit `application/json;q=0` beat a wildcard that would otherwise accept it. Reading the
|
|
66
|
+
* best-scoring range instead lets a permissive rule override a precise one.
|
|
67
|
+
*/
|
|
68
|
+
let applicable;
|
|
69
|
+
for (const { range, q } of ranges) {
|
|
70
|
+
const specificity = range === lowered ? 2 : range === `${group}/*` ? 1 : range === "*/*" ? 0 : -1;
|
|
71
|
+
if (specificity < 0)
|
|
72
|
+
continue;
|
|
73
|
+
// Strictly greater, so two rules of equal specificity leave the first one in force.
|
|
74
|
+
if (applicable === undefined || specificity > applicable.specificity) {
|
|
75
|
+
applicable = { q, specificity };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// `q=0` is "I will not accept this", so a type its own rule scores zero is never a candidate.
|
|
79
|
+
if (applicable === undefined || applicable.q <= 0)
|
|
80
|
+
continue;
|
|
81
|
+
// Strictly greater, so equal quality keeps the order the document offers.
|
|
82
|
+
if (best === undefined || applicable.q > best.q)
|
|
83
|
+
best = { type, q: applicable.q };
|
|
84
|
+
}
|
|
85
|
+
return best?.type;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Let only a real HEAD request through.
|
|
89
|
+
*
|
|
90
|
+
* Hono rewrites HEAD to GET before matching, so a `@head` operation has to be registered under GET
|
|
91
|
+
* to be reachable at all. Where the document declares no GET on that path, this keeps the
|
|
92
|
+
* registration honest: a GET gets the 404 it would have got if the route had never been registered,
|
|
93
|
+
* and only a HEAD reaches the validators and the handler. `c.req.method` still reads `HEAD` after the
|
|
94
|
+
* rewrite, which is what makes the distinction possible.
|
|
95
|
+
*
|
|
96
|
+
* In the runtime rather than in {@link RouteDeps} on the usual test: the generated code can proceed
|
|
97
|
+
* without asking the app anything. Which verbs the document declares is a contract fact, and the
|
|
98
|
+
* answer for a verb it does not declare is the same 404 any unrouted request already gets, through
|
|
99
|
+
* whatever `app.notFound()` the application has set.
|
|
100
|
+
*
|
|
101
|
+
* A plain middleware rather than `except()` from `hono/combine`, because `except` wraps the final
|
|
102
|
+
* handler and erases its response type, and Hono's RPC client derives its whole surface from that
|
|
103
|
+
* type. Measured: `hc<typeof app>` resolved a wrapped route's body to `unknown`.
|
|
104
|
+
*/
|
|
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: a 500, a 502, a 502 with the issues in the body or with
|
|
118
|
+
* them redacted. The check itself is the same for every application, and it covers failure bodies
|
|
119
|
+
* as well as successful ones.
|
|
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
|
+
/**
|
|
130
|
+
* Which half of the response broke its contract. **Defaulted, so the three-argument form an
|
|
131
|
+
* application may already construct or match on keeps working.**
|
|
132
|
+
*/
|
|
133
|
+
part;
|
|
134
|
+
/**
|
|
135
|
+
* Fields declared and assigned rather than written as parameter properties, which are not
|
|
136
|
+
* erasable syntax: a project building with `erasableSyntaxOnly` could not compile this file.
|
|
137
|
+
*/
|
|
138
|
+
constructor(operationId, status, issues, part = "body") {
|
|
139
|
+
super(`${operationId} answered ${status} with ${part === "body" ? "a body" : "headers"} its document does not permit`);
|
|
140
|
+
this.name = "ResponseContractError";
|
|
141
|
+
this.operationId = operationId;
|
|
142
|
+
this.status = status;
|
|
143
|
+
this.issues = issues;
|
|
144
|
+
this.part = part;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* A handler answered with a status its operation does not declare.
|
|
149
|
+
*
|
|
150
|
+
* **Unreachable from a typed handler.** The generated `Operations` interface types every result as
|
|
151
|
+
* the union of the declared statuses, so a literal outside it does not compile. This is what a CAST
|
|
152
|
+
* produces, or a result that crossed a boundary the type system cannot see into, such as untyped
|
|
153
|
+
* data from a service binding. Thrown rather than served, because serving it would publish a status
|
|
154
|
+
* the contract does not state.
|
|
155
|
+
*/
|
|
156
|
+
export class UndeclaredStatusError extends Error {
|
|
157
|
+
operationId;
|
|
158
|
+
result;
|
|
159
|
+
constructor(operationId, result) {
|
|
160
|
+
const status = typeof result === "object" && result !== null && "status" in result
|
|
161
|
+
? String(result.status)
|
|
162
|
+
: "no status";
|
|
163
|
+
super(`${operationId} answered ${status}, which its document does not declare`);
|
|
164
|
+
this.operationId = operationId;
|
|
165
|
+
this.result = result;
|
|
166
|
+
this.name = "UndeclaredStatusError";
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* The bytes a binary or streamed response SERVES, as something `c.body` accepts.
|
|
171
|
+
*
|
|
172
|
+
* **A platform stream, a `Uint8Array` or an `ArrayBuffer` is served as it is** - a stream keeps the
|
|
173
|
+
* platform's own piping, which is the point of handing over a stream at all. Anything else satisfying
|
|
174
|
+
* `ByteStream` is pulled through a `ReadableStream`, one chunk per read, so it still streams rather
|
|
175
|
+
* than being read whole. Without this, `new Response(value)` stringifies an object it does not
|
|
176
|
+
* recognise, and the body served is `[object Object]` with a 200.
|
|
177
|
+
*/
|
|
178
|
+
export function servedBytes(body) {
|
|
179
|
+
if (body instanceof ReadableStream || body instanceof Uint8Array || body instanceof ArrayBuffer) {
|
|
180
|
+
return body;
|
|
181
|
+
}
|
|
182
|
+
const reader = body.getReader();
|
|
183
|
+
return new ReadableStream({
|
|
184
|
+
/**
|
|
185
|
+
* **Reads until there is a chunk to hand on.** A read may report none and not be done, which
|
|
186
|
+
* `ByteStream` admits; returning without enqueueing anything left the stream waiting for a
|
|
187
|
+
* pull nothing would make, so a server reading it as a `Response` never finished.
|
|
188
|
+
*/
|
|
189
|
+
async pull(controller) {
|
|
190
|
+
for (;;) {
|
|
191
|
+
const { done, value } = await reader.read();
|
|
192
|
+
if (done) {
|
|
193
|
+
reader.releaseLock();
|
|
194
|
+
controller.close();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (value !== undefined) {
|
|
198
|
+
controller.enqueue(value);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
/** The caller went away, so the producer is told: releasing the reader alone left it running. */
|
|
204
|
+
async cancel(reason) {
|
|
205
|
+
await reader.cancel?.(reason);
|
|
206
|
+
reader.releaseLock();
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* The body a response SERVES: what the handler returned, parsed against the schema the document
|
|
212
|
+
* publishes for that status.
|
|
213
|
+
*
|
|
214
|
+
* **What is served is the PARSED value, not the one the handler returned.** A schema that strips
|
|
215
|
+
* undeclared keys therefore strips them from the wire too, which is how an internal field such as a
|
|
216
|
+
* password hash is kept out of a response the document does not publish it in.
|
|
217
|
+
*
|
|
218
|
+
* Synchronous, because nothing this emitter writes is asynchronous - typespec-hono's suite asserts it
|
|
219
|
+
* over the whole corpus - and the asynchronous path costs 2.6x per parse.
|
|
220
|
+
*/
|
|
221
|
+
export function servedBody(schema, value, operationId, status) {
|
|
222
|
+
const parsed = schema.safeParse(value);
|
|
223
|
+
if (!parsed.success)
|
|
224
|
+
throw new ResponseContractError(operationId, status, parsed.error.issues);
|
|
225
|
+
return parsed.data;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Declared response headers, as the strings a response carries.
|
|
229
|
+
*
|
|
230
|
+
* An optional header the handler did not supply is omitted rather than sent as `"undefined"`, and a
|
|
231
|
+
* typed value - a `retry-after` declared `int32` - is written as its text. Hono's `HeaderRecord`
|
|
232
|
+
* refuses `undefined`, so dropping it here is also what keeps the generated call sites free of a
|
|
233
|
+
* conditional per header.
|
|
234
|
+
*/
|
|
235
|
+
export function headersOf(declared) {
|
|
236
|
+
const headers = {};
|
|
237
|
+
for (const [name, value] of Object.entries(declared)) {
|
|
238
|
+
if (value !== undefined)
|
|
239
|
+
headers[name] = String(value);
|
|
240
|
+
}
|
|
241
|
+
return headers;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Declared response headers, CHECKED against the schemas the document publishes for them.
|
|
245
|
+
*
|
|
246
|
+
* **Checked as the body beside them is.** `servedBody` parses every response body against its
|
|
247
|
+
* status's schema and throws `ResponseContractError`; `headersOf` alone is `String(value)` and
|
|
248
|
+
* nothing else, so a header the document constrains to `^[A-Za-z0-9-]+$` would be served as
|
|
249
|
+
* `not a valid id"; injected=1` with a 200, while a body breaking its own schema on the same route
|
|
250
|
+
* throws. That asymmetry is the whole of what this removes.
|
|
251
|
+
*
|
|
252
|
+
* **Not a response-splitting hole, and worth saying so.** `new Response` refuses a header value
|
|
253
|
+
* containing CR or LF with a `TypeError` on both `workerd` and undici, so the runtime already stops
|
|
254
|
+
* the injection. What it does not stop is a response that disagrees with its own document, which a
|
|
255
|
+
* client generated from that document is entitled to rely on.
|
|
256
|
+
*
|
|
257
|
+
* **Undefined values are dropped BEFORE the check, not after.** An optional header the handler did
|
|
258
|
+
* not supply is absent rather than explicitly `undefined`, which is what the emitted schema's
|
|
259
|
+
* `.exactOptional()` means, and it is also what Hono's `HeaderRecord` requires. The check then runs
|
|
260
|
+
* on the values as the handler produced them - a `retry-after` declared `int32` is still a number
|
|
261
|
+
* here - because checking the text would check a different thing than the document describes.
|
|
262
|
+
*
|
|
263
|
+
* **What is returned is every header, including ones the schema does not mention.** A response
|
|
264
|
+
* carries headers the document does not declare, the `Content-Type` this emitter sets among them,
|
|
265
|
+
* and those are not a contract violation. The schema is a plain object schema, so it ignores them.
|
|
266
|
+
*/
|
|
267
|
+
export function servedHeaders(schema, declared, operationId, status) {
|
|
268
|
+
const present = {};
|
|
269
|
+
for (const [name, value] of Object.entries(declared)) {
|
|
270
|
+
if (value !== undefined)
|
|
271
|
+
present[name] = value;
|
|
272
|
+
}
|
|
273
|
+
const parsed = schema.safeParse(present);
|
|
274
|
+
if (!parsed.success) {
|
|
275
|
+
throw new ResponseContractError(operationId, status, parsed.error.issues, "headers");
|
|
276
|
+
}
|
|
277
|
+
return headersOf(present);
|
|
278
|
+
}
|
|
279
|
+
/** RFC 9110 `token`, less `*`, which names a range rather than a type. */
|
|
280
|
+
const MEDIA_TOKEN = "[!#$%&'+.^_`|~0-9A-Za-z-]+";
|
|
281
|
+
const SERVED_MEDIA_TYPE = new RegExp(`^(${MEDIA_TOKEN})/${MEDIA_TOKEN}\\s*(;.*)?$`);
|
|
282
|
+
/**
|
|
283
|
+
* Whether a media type a handler answers with lies inside a range the document offers.
|
|
284
|
+
*
|
|
285
|
+
* **A range is not a type a response can be sent as**, so where a status offers `image/*` the handler
|
|
286
|
+
* names the concrete type, and its result type already refuses one outside the range. This is what a
|
|
287
|
+
* CAST reaches, or data from a service binding: `text/html` under `image/*`, or `image/*` itself,
|
|
288
|
+
* would otherwise be served with a `Content-Type` the document does not permit. Type names compare
|
|
289
|
+
* case-insensitively (RFC 9110 section 8.3.1), and parameters such as `charset` are allowed.
|
|
290
|
+
*/
|
|
291
|
+
export function mediaTypeWithin(served, range) {
|
|
292
|
+
const type = SERVED_MEDIA_TYPE.exec(served)?.[1];
|
|
293
|
+
if (type === undefined)
|
|
294
|
+
return false;
|
|
295
|
+
if (range === "*/*")
|
|
296
|
+
return true;
|
|
297
|
+
return range.endsWith("/*") && type.toLowerCase() === range.slice(0, -2).toLowerCase();
|
|
298
|
+
}
|