typespec-hono 0.22.0 → 0.23.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 +85 -62
- package/dist/src/app.d.ts +63 -43
- package/dist/src/app.js +641 -229
- package/dist/src/emitter.d.ts +3 -3
- package/dist/src/emitter.js +52 -45
- package/dist/src/lib.d.ts +13 -4
- package/dist/src/lib.js +41 -1
- package/dist/src/runtime.d.ts +123 -79
- package/dist/src/runtime.js +107 -0
- package/package.json +12 -12
- package/src/runtime.ts +167 -84
- package/dist/src/security.d.ts +0 -29
- package/dist/src/security.js +0 -48
package/README.md
CHANGED
|
@@ -58,10 +58,16 @@ model Widget {
|
|
|
58
58
|
name: string;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
@error
|
|
62
|
+
model NotFound {
|
|
63
|
+
@statusCode statusCode: 404;
|
|
64
|
+
message: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
61
67
|
@route("/widgets")
|
|
62
68
|
interface WidgetRoutes {
|
|
63
69
|
@get list(@query limit?: int32): Widget[];
|
|
64
|
-
@get read(@path id: string): Widget;
|
|
70
|
+
@get read(@path id: string): Widget | NotFound;
|
|
65
71
|
}
|
|
66
72
|
```
|
|
67
73
|
|
|
@@ -71,79 +77,108 @@ pnpm exec tsp compile .
|
|
|
71
77
|
|
|
72
78
|
### `src/index.ts`
|
|
73
79
|
|
|
74
|
-
|
|
75
|
-
|
|
80
|
+
A handler returns `{ status, body }` for any response its operation declares, the failures included.
|
|
81
|
+
`input` and every declared response are known from the spec, so a handler returning a status the spec
|
|
82
|
+
does not declare, or the wrong body for a status, does not compile:
|
|
76
83
|
|
|
77
84
|
```ts
|
|
78
85
|
import { Hono } from "hono";
|
|
79
|
-
import { registerRoutes } from "./generated/app.gen.js";
|
|
80
|
-
import { deps } from "./deps.js";
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
WidgetRoutes_list: (ctx, input) => widgets.slice(0, input.limit ?? 20),
|
|
84
|
-
WidgetRoutes_read: (ctx, input) =>
|
|
85
|
-
|
|
86
|
+
import { registerRoutes, type Operations } from "./generated/app.gen.js";
|
|
87
|
+
import { deps, type Caller } from "./deps.js";
|
|
88
|
+
|
|
89
|
+
const handlers = {
|
|
90
|
+
WidgetRoutes_list: (ctx, input) => ({ status: 200, body: widgets.slice(0, input.limit ?? 20) }),
|
|
91
|
+
WidgetRoutes_read: (ctx, input) => {
|
|
92
|
+
const widget = widgets.find((w) => w.id === input.id);
|
|
93
|
+
return widget === undefined
|
|
94
|
+
? { status: 404, body: { message: `no widget ${input.id}` } }
|
|
95
|
+
: { status: 200, body: widget };
|
|
96
|
+
},
|
|
97
|
+
} satisfies Operations<Caller>;
|
|
86
98
|
|
|
87
|
-
export default registerRoutes(new Hono(),
|
|
99
|
+
export default registerRoutes(new Hono(), () => handlers, deps);
|
|
88
100
|
```
|
|
89
101
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
102
|
+
`satisfies` keeps each `status` a literal, which is what selects the response it belongs to. Without
|
|
103
|
+
it, `status: 404` widens to `number`, which no declared response admits.
|
|
104
|
+
|
|
105
|
+
The generated route serves each response with the Hono call for it - `c.json(body, 404)` for the
|
|
106
|
+
failure above - after checking the body against the schema the document publishes for that status. So
|
|
107
|
+
Hono's RPC client narrows a response body by its status, and a body the document does not permit
|
|
108
|
+
reaches `app.onError` as a `ResponseContractError` rather than a caller.
|
|
109
|
+
|
|
110
|
+
Leave the factory passed to `registerRoutes` unannotated: annotating it widens the value and disables
|
|
111
|
+
the check that catches a handler for an operation the spec no longer declares. It is a factory rather
|
|
112
|
+
than an object because a Workers service binding lives on `c.env` and exists only for the duration of
|
|
113
|
+
a request.
|
|
93
114
|
|
|
94
115
|
### `src/deps.ts`
|
|
95
116
|
|
|
96
|
-
|
|
117
|
+
Five hooks, each answering something the spec does not contain:
|
|
97
118
|
|
|
98
|
-
| hook | the spec says
|
|
99
|
-
| --------------- |
|
|
100
|
-
| `authorize` | which schemes and scopes an operation needs
|
|
101
|
-
| `context` | whether a caller is required
|
|
102
|
-
| `noContext` |
|
|
103
|
-
| `notAcceptable` | which media types are offered
|
|
104
|
-
| `invalid` | the schema
|
|
105
|
-
| `respond` | every status arm and its schema | which arm this result is |
|
|
119
|
+
| hook | the spec says | you say |
|
|
120
|
+
| --------------- | ---------------------------------------------- | ------------------------------------ |
|
|
121
|
+
| `authorize` | which schemes and scopes an operation needs | whether this caller satisfies them |
|
|
122
|
+
| `context` | whether a caller is none, optional or required | who the caller is |
|
|
123
|
+
| `noContext` | | what to answer when there is not one |
|
|
124
|
+
| `notAcceptable` | which media types are offered | what to answer when none match |
|
|
125
|
+
| `invalid` | the schema | what a validation failure looks like |
|
|
106
126
|
|
|
107
127
|
```ts
|
|
108
|
-
import type { RouteDeps } from "./generated/runtime.gen.js";
|
|
128
|
+
import type { AppEnv, RouteDeps } from "./generated/runtime.gen.js";
|
|
109
129
|
|
|
110
|
-
export
|
|
130
|
+
export interface Caller {
|
|
131
|
+
readonly userId: string;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export const deps: RouteDeps<AppEnv, Caller> = {
|
|
111
135
|
authorize: (requirements) => async (c, next) => {
|
|
112
136
|
await next();
|
|
113
137
|
},
|
|
114
|
-
context: (c) =>
|
|
138
|
+
context: (c) => {
|
|
139
|
+
const userId = c.req.header("x-user");
|
|
140
|
+
return userId === undefined ? null : { userId };
|
|
141
|
+
},
|
|
115
142
|
noContext: (c) => c.json({ error: "unauthorized" }, 401),
|
|
116
143
|
notAcceptable: (c, offered) => c.json({ error: "not_acceptable", offered }, 406),
|
|
117
144
|
invalid: (result, c) => (result.success ? undefined : c.json({ error: "invalid" }, 400)),
|
|
118
|
-
respond: (c, arms, result) => c.json(result as never, 200),
|
|
119
145
|
};
|
|
120
146
|
```
|
|
121
147
|
|
|
122
|
-
|
|
123
|
-
|
|
148
|
+
`context` is told `"none"` (the operation needs nobody), `"optional"` (anonymous access is one
|
|
149
|
+
alternative, so read a credential if one was presented) or `"required"`. Whatever it returns is what
|
|
150
|
+
every handler receives as `ctx`. Your Hono environment - bindings
|
|
151
|
+
and variables - is an augmentation of the emitted `AppEnv`:
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
declare module "./generated/runtime.gen.js" {
|
|
155
|
+
interface AppEnv {
|
|
156
|
+
Bindings: { DB: D1Database };
|
|
157
|
+
Variables: { requestId: string };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Routing, request validation, the handler types and how each declared response is served come from the
|
|
163
|
+
spec. What is left is the four files above.
|
|
124
164
|
|
|
125
165
|
## What it emits
|
|
126
166
|
|
|
127
167
|
Into your output directory:
|
|
128
168
|
|
|
129
|
-
| file | what it is
|
|
130
|
-
| ---------------------- |
|
|
131
|
-
| `app.gen.ts` | the server: routes, validators, and the
|
|
132
|
-
| `runtime.gen.ts` | the types your `deps` implements against, and the helpers the server calls
|
|
133
|
-
| `schemas.gen.ts` | a Zod schema for every request and response, and the status arms each declares
|
|
134
|
-
| `vocabularies.gen.ts` | shared enums, where the spec declares them
|
|
135
|
-
| `requests.gen.ts` | request types, when `contracts-output-dir` is set
|
|
136
|
-
| `wire-contract.gen.ts` | assertions that the schemas and the types agree, with the same option
|
|
169
|
+
| file | what it is |
|
|
170
|
+
| ---------------------- | -------------------------------------------------------------------------------- |
|
|
171
|
+
| `app.gen.ts` | the server: routes, validators, what each operation may answer, and the handlers |
|
|
172
|
+
| `runtime.gen.ts` | the types your `deps` implements against, and the helpers the server calls |
|
|
173
|
+
| `schemas.gen.ts` | a Zod schema for every request and response, and the status arms each declares |
|
|
174
|
+
| `vocabularies.gen.ts` | shared enums, where the spec declares them |
|
|
175
|
+
| `requests.gen.ts` | request types, when `contracts-output-dir` is set |
|
|
176
|
+
| `wire-contract.gen.ts` | assertions that the schemas and the types agree, with the same option |
|
|
137
177
|
|
|
138
178
|
The last four are `typespec-http-zod`'s; see its README for what they contain.
|
|
139
179
|
|
|
140
|
-
Your own code imports from `
|
|
141
|
-
|
|
142
|
-
```ts
|
|
143
|
-
import type { Ctx, RouteDeps } from "./generated/runtime.gen.js";
|
|
144
|
-
```
|
|
145
|
-
|
|
146
|
-
Setting `runtime-module` replaces it with a module of your own, and it is then not written.
|
|
180
|
+
`runtime.gen.ts` is written on every compile. Your own code imports from it and augments `AppEnv` in
|
|
181
|
+
it, and nothing replaces it.
|
|
147
182
|
|
|
148
183
|
Routes are grouped into a sub-app per resource and mounted with `app.route()`, following
|
|
149
184
|
[Hono's best-practices guide](https://hono.dev/docs/guides/best-practices). Handlers are written
|
|
@@ -152,27 +187,15 @@ handler in another file cannot infer its path parameters. A resource with a sing
|
|
|
152
187
|
sub-app.
|
|
153
188
|
|
|
154
189
|
The output is plain `Hono` and `@hono/zod-validator`, not `@hono/zod-openapi`. That package generates
|
|
155
|
-
a document from the code, which would compete with the one openapi3 publishes from the spec.
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
import { registerRoutes } from "./generated/app.gen.js";
|
|
160
|
-
|
|
161
|
-
// Leave handlersFor unannotated. Annotating it widens the value and disables the
|
|
162
|
-
// exhaustiveness check that catches a handler for an operation the spec no longer declares.
|
|
163
|
-
const handlersFor = (c) => backendFor(c.env);
|
|
164
|
-
const routes = registerRoutes(new Hono<AppEnv>(), handlersFor, deps);
|
|
165
|
-
|
|
166
|
-
export default routes;
|
|
167
|
-
```
|
|
168
|
-
|
|
169
|
-
`handlersFor` is a factory rather than an object because a Workers service binding lives on `c.env`
|
|
170
|
-
and exists only for the duration of a request.
|
|
190
|
+
a document from the code, which would compete with the one openapi3 publishes from the spec. The
|
|
191
|
+
responses a route serves are the ones `@hono/zod-openapi` would require of it: across the conformance
|
|
192
|
+
corpus, what Hono's RPC client infers for 619 routes is compared with what `RouteConfigToTypedResponse`
|
|
193
|
+
derives from the published document.
|
|
171
194
|
|
|
172
195
|
## Docs
|
|
173
196
|
|
|
174
|
-
- [Guides](docs/guides.md): middleware, the RPC client, authentication, base
|
|
175
|
-
request bodies, streaming, observability
|
|
197
|
+
- [Guides](docs/guides.md): declared failures, middleware, the RPC client, authentication, base
|
|
198
|
+
paths, HEAD operations, request bodies, streaming, observability, upgrading
|
|
176
199
|
- [Cloudflare Workers](docs/cloudflare-workers.md): which router to pick, and what the bundle costs
|
|
177
200
|
- [Reference](docs/reference.md): every option, every diagnostic, and the known limits
|
|
178
201
|
- [Releasing](docs/releasing.md): rehearsing a two-package release against a local registry,
|
package/dist/src/app.d.ts
CHANGED
|
@@ -1,5 +1,45 @@
|
|
|
1
|
-
import { type
|
|
2
|
-
|
|
1
|
+
import { type EmittedPathSegment, type EmittedRoute, type EmittedService, type StatusKey } from "typespec-http-zod";
|
|
2
|
+
/**
|
|
3
|
+
* Hono's status codes, group by group, exactly as `hono/utils/http-status` declares them.
|
|
4
|
+
*
|
|
5
|
+
* **The generated route needs the LITERALS, not only the type.** A range arm is served from a
|
|
6
|
+
* `switch` on the result's status, and TypeScript narrows the result to one member only through a
|
|
7
|
+
* `case` label per literal: a type guard narrows the status and leaves the result a union, and
|
|
8
|
+
* merging the range into `default` pairs every body with every status. Both measured on hono 4.13.1.
|
|
9
|
+
*
|
|
10
|
+
* `test/status-codes.test.ts` holds these equal to Hono's own unions, so a status Hono adds fails a
|
|
11
|
+
* test rather than silently falling out of a range.
|
|
12
|
+
*/
|
|
13
|
+
export declare const STATUS_GROUPS: {
|
|
14
|
+
readonly 1: {
|
|
15
|
+
readonly type: "InfoStatusCode";
|
|
16
|
+
readonly codes: readonly [100, 101, 102, 103];
|
|
17
|
+
};
|
|
18
|
+
readonly 2: {
|
|
19
|
+
readonly type: "SuccessStatusCode";
|
|
20
|
+
readonly codes: readonly [200, 201, 202, 203, 204, 205, 206, 207, 208, 226];
|
|
21
|
+
};
|
|
22
|
+
readonly 3: {
|
|
23
|
+
readonly type: "RedirectStatusCode";
|
|
24
|
+
readonly codes: readonly [300, 301, 302, 303, 304, 305, 306, 307, 308];
|
|
25
|
+
};
|
|
26
|
+
readonly 4: {
|
|
27
|
+
readonly type: "ClientErrorStatusCode";
|
|
28
|
+
readonly codes: readonly [400, 401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 414, 415, 416, 417, 418, 421, 422, 423, 424, 425, 426, 428, 429, 431, 451];
|
|
29
|
+
};
|
|
30
|
+
readonly 5: {
|
|
31
|
+
readonly type: "ServerErrorStatusCode";
|
|
32
|
+
readonly codes: readonly [500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511];
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Where every generated file imports its runtime from: the copy this package writes beside them.
|
|
37
|
+
*
|
|
38
|
+
* Declared here, where the import is rendered, and re-exported by the emitter that writes the file.
|
|
39
|
+
*/
|
|
40
|
+
export declare const DEFAULT_RUNTIME_MODULE = "./runtime.gen.js";
|
|
41
|
+
/** Hono's `ContentlessStatusCode`: a status that cannot carry a body, so `c.json` refuses it. */
|
|
42
|
+
export declare const CONTENTLESS_STATUS_CODES: readonly [101, 204, 205, 304];
|
|
3
43
|
/**
|
|
4
44
|
* The header every emitted file carries.
|
|
5
45
|
*
|
|
@@ -10,46 +50,35 @@ import { type EmittedRoute, type EmittedService } from "typespec-http-zod";
|
|
|
10
50
|
*/
|
|
11
51
|
export declare function generatedBanner(hint: string | undefined): string;
|
|
12
52
|
/**
|
|
13
|
-
*
|
|
53
|
+
* The Hono route for a route template, from the segments `typespec-http-zod` reads out of the
|
|
54
|
+
* operation's RFC 6570 `uriTemplate`.
|
|
14
55
|
*
|
|
15
|
-
* **This used to
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
56
|
+
* **This used to convert `route.path`, which has every operator stripped**, so `array{.param*}`,
|
|
57
|
+
* `array{;param}` and `optional{/name}` were mounted as `array:param` and `optional:name`, which Hono
|
|
58
|
+
* does not match at all. Measured by request: 31 of the URIs `@typespec/http-specs` `routes` and
|
|
59
|
+
* `parameters/path` declare answered 404 from a server generated from them.
|
|
19
60
|
*
|
|
20
|
-
* A
|
|
21
|
-
*
|
|
61
|
+
* - A whole-segment expression is `:name`; a reserved or exploding `/` one crosses `/`, so it is
|
|
62
|
+
* `:name{.+}` (Hono's spelling of greedy); an optional last segment is `:name?`.
|
|
63
|
+
* - An expression written beside literal text, or with a label or matrix operator, is a pattern
|
|
64
|
+
* parameter matching the whole segment: `:param{array\.[^\x2F]*}`. The captured text is the
|
|
65
|
+
* segment, and the path validator undoes the expansion. `\x2F` rather than `/`, so the mounted
|
|
66
|
+
* path still splits into its segments on `/` for the ordering and sub-app rules below.
|
|
22
67
|
*
|
|
23
|
-
* **
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* `/files{path}` and agree. What actually reaches here is a wire name from `@path("...")`, and the
|
|
28
|
-
* forms that fail are a space, `+` and `!`. `*` is rejected by `@typespec/http` before it arrives.
|
|
68
|
+
* **A name Hono cannot carry is REFUSED rather than approximated**, and so is a segment holding two
|
|
69
|
+
* expressions, which no segment router can split. The segment stays literal, so it matches nothing
|
|
70
|
+
* rather than the wrong thing. A space, `+` and `!` are the characters that fail; a hyphen, a dot and
|
|
71
|
+
* a tilde do not.
|
|
29
72
|
*
|
|
30
|
-
* **This runs at RENDER time, not during collection
|
|
31
|
-
*
|
|
32
|
-
* operation (validators included) over a template no router could mount. What a request body must
|
|
33
|
-
* look like does not depend on that.
|
|
73
|
+
* **This runs at RENDER time, not during collection**, so what a request body must look like never
|
|
74
|
+
* depends on whether one framework's router can express the path.
|
|
34
75
|
*/
|
|
35
|
-
export declare function toHonoPath(
|
|
36
|
-
/**
|
|
37
|
-
* Wire names the document says carry RFC 6570 reserved expansion, so their value may contain `/`.
|
|
38
|
-
*
|
|
39
|
-
* **A hierarchical identifier is ONE value, not several segments.** An Obsidian note is
|
|
40
|
-
* `areas/health.md`; an S3 key and a GitHub file path are the same shape. A router that stops at
|
|
41
|
-
* the first `/` binds `areas` and 404s the rest. Hono spells the greedy form `:name{.+}`.
|
|
42
|
-
*
|
|
43
|
-
* Read from `EmittedRoute.reservedPathParameters`, which the library resolves from `allowReserved`
|
|
44
|
-
* on the parameter. **Never from the template**: the operator does not survive to `route.path`,
|
|
45
|
-
* `@typespec/http` strips it, and it can also be set with no operator in the template at all, so
|
|
46
|
-
* the template is a derived artefact rather than the source of truth.
|
|
47
|
-
*/
|
|
48
|
-
reserved?: ReadonlySet<string>): string;
|
|
76
|
+
export declare function toHonoPath(segments: readonly EmittedPathSegment[], refuse: (template: string, name: string) => void): string;
|
|
49
77
|
/** The one thing a Hono server cannot express, handed back rather than thrown. */
|
|
50
78
|
export interface RenderRefusals {
|
|
51
79
|
readonly unsupportedPathTemplate: (route: EmittedRoute, template: string, name: string) => void;
|
|
52
80
|
readonly unvalidatableMediaType: (route: EmittedRoute, types: readonly string[]) => void;
|
|
81
|
+
readonly unvalidatedResponseMediaType: (route: EmittedRoute, status: StatusKey, types: readonly string[]) => void;
|
|
53
82
|
}
|
|
54
83
|
/**
|
|
55
84
|
* The generated Hono server.
|
|
@@ -77,13 +106,4 @@ export declare function renderApp(emitted: EmittedService, refuse: RenderRefusal
|
|
|
77
106
|
* the document publishes `/api/v1/accounts`. Mounting at the root made every client generated from
|
|
78
107
|
* the document, and every "try it" in a rendered document, 404.
|
|
79
108
|
*/
|
|
80
|
-
basePaths?: readonly string[]
|
|
81
|
-
/**
|
|
82
|
-
* What the DOCUMENT says a caller must satisfy, per operation id.
|
|
83
|
-
*
|
|
84
|
-
* **Resolved by the caller rather than read off `EmittedRoute`**, because which schemes an
|
|
85
|
-
* operation accepts is a fact about the HTTP program and not part of the validator IR the library
|
|
86
|
-
* publishes. Keeping it out of that IR is what stops a Hono concern leaking into a package whose
|
|
87
|
-
* audience is wider.
|
|
88
|
-
*/
|
|
89
|
-
securityFor?: (verb: string, path: string) => readonly SecurityRequirement[]): string;
|
|
109
|
+
basePaths?: readonly string[]): string;
|