typespec-hono 0.23.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 +17 -0
- package/dist/src/app.d.ts +32 -0
- package/dist/src/app.js +209 -27
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +2 -0
- 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 +38 -2
- package/dist/src/runtime.js +47 -3
- package/package.json +6 -6
- package/src/runtime.ts +51 -2
package/README.md
CHANGED
|
@@ -132,8 +132,20 @@ export interface Caller {
|
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
export const deps: RouteDeps<AppEnv, Caller> = {
|
|
135
|
+
// The requirements are the document's, verbatim: `[{ BearerAuth: [] }]`. Satisfying any ONE of
|
|
136
|
+
// them authorises; every scheme within one must be satisfied together. Waving a scheme through
|
|
137
|
+
// because you do not check it is how a gated route ends up ungated.
|
|
135
138
|
authorize: (requirements) => async (c, next) => {
|
|
139
|
+
const bearer = /^Bearer\s+(\S+)$/i.exec(c.req.header("authorization") ?? "")?.[1];
|
|
140
|
+
const satisfied = requirements.some((requirement) =>
|
|
141
|
+
Object.entries(requirement).every(
|
|
142
|
+
([scheme, scopes]) =>
|
|
143
|
+
scheme === "BearerAuth" && bearer !== undefined && scopes.length === 0,
|
|
144
|
+
),
|
|
145
|
+
);
|
|
146
|
+
if (!satisfied) return c.json({ error: "unauthorized" }, 401);
|
|
136
147
|
await next();
|
|
148
|
+
return undefined;
|
|
137
149
|
},
|
|
138
150
|
context: (c) => {
|
|
139
151
|
const userId = c.req.header("x-user");
|
|
@@ -145,6 +157,11 @@ export const deps: RouteDeps<AppEnv, Caller> = {
|
|
|
145
157
|
};
|
|
146
158
|
```
|
|
147
159
|
|
|
160
|
+
**`authorize` is the gate the document publishes, and an empty one is an open door.** A requirement
|
|
161
|
+
naming a scheme you do not check must not be satisfied: `[{}]`, which `@useAuth(NoAuth | X)` publishes
|
|
162
|
+
for its anonymous alternative, is the only requirement every caller satisfies, and it is satisfied by
|
|
163
|
+
`Object.entries({}).every(...)` being vacuously true rather than by a special case.
|
|
164
|
+
|
|
148
165
|
`context` is told `"none"` (the operation needs nobody), `"optional"` (anonymous access is one
|
|
149
166
|
alternative, so read a credential if one was presented) or `"required"`. Whatever it returns is what
|
|
150
167
|
every handler receives as `ctx`. Your Hono environment - bindings
|
package/dist/src/app.d.ts
CHANGED
|
@@ -74,6 +74,38 @@ export declare function generatedBanner(hint: string | undefined): string;
|
|
|
74
74
|
* depends on whether one framework's router can express the path.
|
|
75
75
|
*/
|
|
76
76
|
export declare function toHonoPath(segments: readonly EmittedPathSegment[], refuse: (template: string, name: string) => void): string;
|
|
77
|
+
/**
|
|
78
|
+
* Whether Hono's `RegExpRouter` can compile this route set, and the route it names if not.
|
|
79
|
+
*
|
|
80
|
+
* **Hono's own router, imported statically.** `hono` is a required peer - `runtime.ts` imports it and
|
|
81
|
+
* the generated server cannot run without it - so there is nothing to degrade gracefully around, and
|
|
82
|
+
* a guarded import would claim a resilience this package does not need. Running the real router is
|
|
83
|
+
* what stops the check drifting from a rule Hono never documented.
|
|
84
|
+
*
|
|
85
|
+
* **`match` as well as `add`.** `RegExpRouter` builds its expressions lazily, so a conflict added
|
|
86
|
+
* through `add` alone can go unreported until the first request. One `match` forces the build.
|
|
87
|
+
*/
|
|
88
|
+
export declare function regExpRouterRefusal(routes: readonly (readonly [string, string])[]): {
|
|
89
|
+
path: string;
|
|
90
|
+
reason: string;
|
|
91
|
+
} | undefined;
|
|
92
|
+
/**
|
|
93
|
+
* **The `[method, path]` pairs a router is given for one service**, exactly as the emitted server
|
|
94
|
+
* registers them.
|
|
95
|
+
*
|
|
96
|
+
* One definition, used by the `regexp-router-unsupported` linter rule, so the rule judges the route
|
|
97
|
+
* set the generated server really mounts. Built from the same helpers the render uses - the verb a
|
|
98
|
+
* route registers under, `app.on` falling back to `GET` as Hono itself does, and `toHonoPath` on the
|
|
99
|
+
* route's segments - and prefixed with each base path the service is served under.
|
|
100
|
+
*
|
|
101
|
+
* **The full path, never the one relative to a sub-app.** The relative form is what the emitted
|
|
102
|
+
* text says and not what any router is given. Judging it reports conflicts between routes that are
|
|
103
|
+
* not siblings at all: measured on five fixtures, two were called unmountable that mount perfectly.
|
|
104
|
+
*/
|
|
105
|
+
export declare function routerTableFor(routes: readonly {
|
|
106
|
+
readonly verb: string;
|
|
107
|
+
readonly pathSegments: readonly EmittedPathSegment[];
|
|
108
|
+
}[], basePaths: readonly string[]): (readonly [string, string])[];
|
|
77
109
|
/** The one thing a Hono server cannot express, handed back rather than thrown. */
|
|
78
110
|
export interface RenderRefusals {
|
|
79
111
|
readonly unsupportedPathTemplate: (route: EmittedRoute, template: string, name: string) => void;
|
package/dist/src/app.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { RegExpRouter } from "hono/router/reg-exp-router";
|
|
1
2
|
import { isRawBinaryMediaType, jsDocComment, objectKey, } from "typespec-http-zod";
|
|
2
3
|
/**
|
|
3
4
|
* Hono's status codes, group by group, exactly as `hono/utils/http-status` declares them.
|
|
@@ -59,8 +60,11 @@ function bodyServingOf(member) {
|
|
|
59
60
|
function membersOf(entry) {
|
|
60
61
|
return entry.route.responses.flatMap((response, index) => {
|
|
61
62
|
const schemaName = entry.names.arms[index]?.schema;
|
|
63
|
+
const headersSchemaName = entry.names.arms[index]?.headers;
|
|
62
64
|
if (response.contentTypes.length === 0) {
|
|
63
|
-
return [
|
|
65
|
+
return [
|
|
66
|
+
{ response, schemaName, headersSchemaName, mediaType: undefined, choosesMediaType: false },
|
|
67
|
+
];
|
|
64
68
|
}
|
|
65
69
|
/**
|
|
66
70
|
* The handler names the media type where the status offers several, and where the document names
|
|
@@ -70,6 +74,7 @@ function membersOf(entry) {
|
|
|
70
74
|
return response.contentTypes.map((mediaType) => ({
|
|
71
75
|
response,
|
|
72
76
|
schemaName,
|
|
77
|
+
headersSchemaName,
|
|
73
78
|
mediaType,
|
|
74
79
|
choosesMediaType,
|
|
75
80
|
}));
|
|
@@ -215,9 +220,19 @@ function serveCall(member, status, operationId, uses) {
|
|
|
215
220
|
const key = JSON.stringify(header.name);
|
|
216
221
|
headers.push(`${key}: result.headers${allOptional ? "?." : ""}[${key}]`);
|
|
217
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* **Declared headers are CHECKED, not only stringified**, which the body beside them always was.
|
|
225
|
+
* `headersOf` still serves a response whose only headers this emitter chose itself, such as the
|
|
226
|
+
* `Content-Type` a `c.body` call has to state.
|
|
227
|
+
*/
|
|
228
|
+
const checksHeaders = response.headers.length > 0 && member.headersSchemaName !== undefined;
|
|
218
229
|
if (headers.length > 0)
|
|
219
|
-
uses.runtime.add("headersOf");
|
|
220
|
-
const headersArgument = headers.length === 0
|
|
230
|
+
uses.runtime.add(checksHeaders ? "servedHeaders" : "headersOf");
|
|
231
|
+
const headersArgument = headers.length === 0
|
|
232
|
+
? ""
|
|
233
|
+
: checksHeaders
|
|
234
|
+
? `, servedHeaders(${member.headersSchemaName ?? "undefined"}, { ${headers.join(", ")} }, ${operationId}, ${status})`
|
|
235
|
+
: `, headersOf({ ${headers.join(", ")} })`;
|
|
221
236
|
const validated = () => {
|
|
222
237
|
uses.runtime.add("servedBody");
|
|
223
238
|
return `servedBody(${member.schemaName ?? "undefined"}, result.body, ${operationId}, ${status})`;
|
|
@@ -379,14 +394,18 @@ const HONO_METHOD = {
|
|
|
379
394
|
* A request location, as the DOCUMENT names it, mapped to the target `@hono/zod-validator` reads.
|
|
380
395
|
*
|
|
381
396
|
* **The two vocabularies are not the same, and only one of them is a contract fact.** OpenAPI says
|
|
382
|
-
* `path`, `query`, `header` and a request body; zValidator says `param`, `query`,
|
|
383
|
-
* `json`. The library publishes the first because that is what the document
|
|
384
|
-
* this package's job, and it is a map rather than a coincidence.
|
|
397
|
+
* `path`, `query`, `header`, `cookie` and a request body; zValidator says `param`, `query`,
|
|
398
|
+
* `header`, `cookie` and `json`. The library publishes the first because that is what the document
|
|
399
|
+
* states; translating is this package's job, and it is a map rather than a coincidence.
|
|
400
|
+
*
|
|
401
|
+
* `cookie` is the one location whose two spellings agree, and it is a first-class Hono target:
|
|
402
|
+
* `ValidationTargets` declares it and Hono's own validator reads it with `getCookie`.
|
|
385
403
|
*/
|
|
386
404
|
const VALIDATOR_TARGET = {
|
|
387
405
|
path: "param",
|
|
388
406
|
query: "query",
|
|
389
407
|
header: "header",
|
|
408
|
+
cookie: "cookie",
|
|
390
409
|
body: "json",
|
|
391
410
|
};
|
|
392
411
|
/**
|
|
@@ -494,17 +513,22 @@ function bodyValidationFor(contentTypes, textual) {
|
|
|
494
513
|
* the single permitted `.refine()` is a synchronous predicate on a multipart file part. So the async
|
|
495
514
|
* path was buying nothing and costing on every request.
|
|
496
515
|
*
|
|
497
|
-
* Measured on an emitted five-property model, zod 4.5
|
|
516
|
+
* Measured on an emitted five-property model, zod 4.6.5, 200k iterations after a 20k warm-up:
|
|
498
517
|
*
|
|
499
518
|
* | call | ns/parse |
|
|
500
519
|
* | --- | --- |
|
|
501
|
-
* | `safeParseAsync` |
|
|
502
|
-
* | `safeParse` |
|
|
520
|
+
* | `safeParseAsync` | 444 |
|
|
521
|
+
* | `safeParse` | 130 |
|
|
503
522
|
*
|
|
504
|
-
* **
|
|
523
|
+
* **3.4x, on every parameter group of every request**, for a promise nothing awaited a result from.
|
|
505
524
|
* It also unblocks `z.compile()`, whose fast path is bypassed for any async parse - measured from
|
|
506
525
|
* `zod/compile`'s own shim, which returns the uncompiled run for `ctx.async`, and confirmed on the
|
|
507
|
-
* same fixture: compiled `safeParse` is
|
|
526
|
+
* same fixture: compiled `safeParse` is 64 ns and compiled `safeParseAsync` is 489 ns.
|
|
527
|
+
*
|
|
528
|
+
* **Zod 4.6's `.validate()` does not apply here**, though it looks as though it should. It is 121 ns
|
|
529
|
+
* against 130 ns on the success path, and its advantage is on the FAILURE path, where it skips
|
|
530
|
+
* building a `ZodError`. This runtime needs that error: it is what `deps.invalid` receives and what
|
|
531
|
+
* `ResponseContractError` carries. The numbers on zod 4.5.2 were 958 and 371.
|
|
508
532
|
*
|
|
509
533
|
* `validationFunction` is `@hono/zod-validator`'s published option and its return type admits a
|
|
510
534
|
* synchronous result, so this is the package's own seam rather than a way around it.
|
|
@@ -513,8 +537,40 @@ function bodyValidationFor(contentTypes, textual) {
|
|
|
513
537
|
* synchronously, because `safeParse` THROWS on a schema that cannot - a claim about output this file
|
|
514
538
|
* does not produce is exactly the kind that needs an arm rather than a comment.
|
|
515
539
|
*/
|
|
516
|
-
const
|
|
517
|
-
|
|
540
|
+
const GUARDED_PARSE = `/**
|
|
541
|
+
* Parse, and treat input nested deeper than this runtime's stack as a refusal rather than a crash.
|
|
542
|
+
*
|
|
543
|
+
* **\`safeParse\` not throwing is the contract everything here rests on, and a deep enough value
|
|
544
|
+
* breaks it.** Zod recurses once per level, so a recursive model meets a \`RangeError\` that is not a
|
|
545
|
+
* validation failure and escapes to \`app.onError\` indistinguishable from a bug in the application's
|
|
546
|
+
* own code. Measured on \`workerd\`: a 40.5 KB body, about 1,925 levels, which Ajv on the published
|
|
547
|
+
* document calls VALID.
|
|
548
|
+
*
|
|
549
|
+
* It cannot be served, so it is refused - through \`deps.invalid\` like every other bad request, with
|
|
550
|
+
* an issue that says which limit was met rather than a generic failure. Anything that is not a
|
|
551
|
+
* \`RangeError\` is rethrown: a bug here must still look like one.
|
|
552
|
+
*/
|
|
553
|
+
function parsed(schema: z.ZodType, value: unknown): z.ZodSafeParseResult<unknown> {
|
|
554
|
+
try {
|
|
555
|
+
return schema.safeParse(value);
|
|
556
|
+
} catch (error) {
|
|
557
|
+
if (!(error instanceof RangeError)) throw error;
|
|
558
|
+
return {
|
|
559
|
+
success: false,
|
|
560
|
+
error: new z.ZodError([
|
|
561
|
+
{
|
|
562
|
+
code: "custom",
|
|
563
|
+
path: [],
|
|
564
|
+
message: "input is nested too deeply for this runtime to validate",
|
|
565
|
+
},
|
|
566
|
+
]),
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
`;
|
|
572
|
+
const SYNC_PARSE = `/** Parse synchronously: nothing emitted here is async, and the async path costs 3.4x. */
|
|
573
|
+
const SYNC = { validationFunction: (schema: z.ZodType, value: unknown) => parsed(schema, value) };
|
|
518
574
|
|
|
519
575
|
`;
|
|
520
576
|
const BODY_READER = `/** The two ways Hono can read a request body: \`c.req.json()\` and \`c.req.parseBody()\`. */
|
|
@@ -550,8 +606,8 @@ async function readBody(c: Context, target: BodyTarget): Promise<unknown> {
|
|
|
550
606
|
* is not a body that validated.
|
|
551
607
|
*/
|
|
552
608
|
function unreadableResult(schema: z.ZodType): { readonly success: boolean } {
|
|
553
|
-
const
|
|
554
|
-
return
|
|
609
|
+
const result = parsed(schema, undefined);
|
|
610
|
+
return result.success ? { success: false } : result;
|
|
555
611
|
}
|
|
556
612
|
|
|
557
613
|
/**
|
|
@@ -562,13 +618,63 @@ function unreadableResult(schema: z.ZodType): { readonly success: boolean } {
|
|
|
562
618
|
* generated. Parameters (\`; charset=utf-8\`, \`; boundary=...\`) are not part of the match, which
|
|
563
619
|
* matters because a multipart request always carries a boundary.
|
|
564
620
|
*
|
|
565
|
-
* A \`Content-Type\` matching nothing declared
|
|
566
|
-
*
|
|
621
|
+
* **A \`Content-Type\` matching nothing declared is REFUSED, and used to be parsed anyway.** It fell
|
|
622
|
+
* through to the first branch, and the comment here claimed the body would then "simply fail to
|
|
623
|
+
* parse" - which is not what happened: \`c.req.json()\` reads the bytes whatever the header says, so a
|
|
624
|
+
* route publishing \`application/json\` alone answered 200 to the same body sent as \`text/plain\`, as
|
|
625
|
+
* \`multipart/form-data\`, and with no \`Content-Type\` at all. The document says which media types an
|
|
626
|
+
* operation accepts, and a server that accepts others is not serving that document.
|
|
627
|
+
*
|
|
628
|
+
* \`undefined\` means "nothing declared covers this", and the caller refuses it through
|
|
629
|
+
* \`deps.invalid\` like any other bad request. No status is invented that the document does not
|
|
630
|
+
* describe, which is also what \`@typespec/http-server-js\` does with the identical case.
|
|
567
631
|
*/
|
|
568
|
-
function bodyTarget(
|
|
632
|
+
function bodyTarget(
|
|
633
|
+
c: Context,
|
|
634
|
+
branches: readonly (readonly [string, BodyTarget])[],
|
|
635
|
+
): BodyTarget | undefined {
|
|
569
636
|
const declared = (c.req.header("content-type") ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
|
|
570
|
-
|
|
571
|
-
|
|
637
|
+
if (declared === "") return undefined;
|
|
638
|
+
/**
|
|
639
|
+
* A declared media RANGE matches the types inside it, which a string comparison cannot do.
|
|
640
|
+
* \`@typespec/http\` reports a bare \`string\`-typed \`contentType\` as the wildcard range, and an author
|
|
641
|
+
* may write a subtype wildcard such as text-slash-star outright. Comparing the range TEXT refuses
|
|
642
|
+
* every real request, because no caller sends the range itself as its \`Content-Type\`. This is the
|
|
643
|
+
* request-side counterpart of \`mediaTypeWithin\` in the runtime module, inlined because the branch
|
|
644
|
+
* list is emitted here.
|
|
645
|
+
*/
|
|
646
|
+
const covers = (range: string): boolean => {
|
|
647
|
+
if (range === "*/*") return true;
|
|
648
|
+
if (!range.endsWith("/*")) return false;
|
|
649
|
+
const slash = declared.indexOf("/");
|
|
650
|
+
return slash > 0 && declared.slice(0, slash) === range.slice(0, -2);
|
|
651
|
+
};
|
|
652
|
+
return branches.find(([mediaType]) => {
|
|
653
|
+
const lowered = mediaType.toLowerCase();
|
|
654
|
+
return lowered === declared || covers(lowered);
|
|
655
|
+
})?.[1];
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* The failure an undeclared media type produces, in the shape every other rejection has.
|
|
660
|
+
*
|
|
661
|
+
* Reported as an issue on the body rather than a bare \`{ success: false }\`, so an application's
|
|
662
|
+
* \`invalid\` hook can tell a caller WHICH thing was wrong - the same reason \`unreadableResult\` parses
|
|
663
|
+
* \`undefined\` rather than inventing a result.
|
|
664
|
+
*/
|
|
665
|
+
function unsupportedMediaTypeResult(
|
|
666
|
+
branches: readonly (readonly [string, BodyTarget])[],
|
|
667
|
+
): z.ZodSafeParseResult<unknown> {
|
|
668
|
+
return {
|
|
669
|
+
success: false,
|
|
670
|
+
error: new z.ZodError([
|
|
671
|
+
{
|
|
672
|
+
code: "custom",
|
|
673
|
+
path: ["content-type"],
|
|
674
|
+
message: \`this operation accepts \${branches.map(([type]) => type).join(", ")}\`,
|
|
675
|
+
},
|
|
676
|
+
]),
|
|
677
|
+
};
|
|
572
678
|
}
|
|
573
679
|
|
|
574
680
|
/** The app's rejection hook, as this file needs to name it. */
|
|
@@ -610,9 +716,14 @@ function validateBody<E extends Env, S extends z.ZodType>(
|
|
|
610
716
|
string,
|
|
611
717
|
{ in: { json: z.input<S> }; out: { json: z.output<S> } }
|
|
612
718
|
> = async (ctx, proceed) => {
|
|
613
|
-
const
|
|
719
|
+
const target = bodyTarget(ctx, branches);
|
|
720
|
+
const raw = target === undefined ? UNREADABLE : await readBody(ctx, target);
|
|
614
721
|
const result =
|
|
615
|
-
|
|
722
|
+
target === undefined
|
|
723
|
+
? unsupportedMediaTypeResult(branches)
|
|
724
|
+
: raw === UNREADABLE
|
|
725
|
+
? unreadableResult(schema)
|
|
726
|
+
: parsed(schema, raw);
|
|
616
727
|
const response = invalid(result, ctx);
|
|
617
728
|
if (response !== undefined) return response;
|
|
618
729
|
if (!result.success) return ctx.json(result, 400);
|
|
@@ -660,9 +771,14 @@ function validateOptionalBody<E extends Env, S extends z.ZodType>(
|
|
|
660
771
|
await proceed();
|
|
661
772
|
return undefined;
|
|
662
773
|
}
|
|
663
|
-
const
|
|
774
|
+
const target = bodyTarget(ctx, branches);
|
|
775
|
+
const raw = target === undefined ? UNREADABLE : await readBody(ctx, target);
|
|
664
776
|
const result =
|
|
665
|
-
|
|
777
|
+
target === undefined
|
|
778
|
+
? unsupportedMediaTypeResult(branches)
|
|
779
|
+
: raw === UNREADABLE
|
|
780
|
+
? unreadableResult(schema)
|
|
781
|
+
: parsed(schema, raw);
|
|
666
782
|
const response = invalid(result, ctx);
|
|
667
783
|
if (response !== undefined) return response;
|
|
668
784
|
if (!result.success) return ctx.json(result, 400);
|
|
@@ -847,6 +963,64 @@ function inputTypeOf(entry) {
|
|
|
847
963
|
function capitaliseId(operationId) {
|
|
848
964
|
return `${operationId.charAt(0).toUpperCase()}${operationId.slice(1)}`;
|
|
849
965
|
}
|
|
966
|
+
/**
|
|
967
|
+
* Whether Hono's `RegExpRouter` can compile this route set, and the route it names if not.
|
|
968
|
+
*
|
|
969
|
+
* **Hono's own router, imported statically.** `hono` is a required peer - `runtime.ts` imports it and
|
|
970
|
+
* the generated server cannot run without it - so there is nothing to degrade gracefully around, and
|
|
971
|
+
* a guarded import would claim a resilience this package does not need. Running the real router is
|
|
972
|
+
* what stops the check drifting from a rule Hono never documented.
|
|
973
|
+
*
|
|
974
|
+
* **`match` as well as `add`.** `RegExpRouter` builds its expressions lazily, so a conflict added
|
|
975
|
+
* through `add` alone can go unreported until the first request. One `match` forces the build.
|
|
976
|
+
*/
|
|
977
|
+
export function regExpRouterRefusal(routes) {
|
|
978
|
+
const first = routes[0];
|
|
979
|
+
if (first === undefined)
|
|
980
|
+
return undefined;
|
|
981
|
+
const router = new RegExpRouter();
|
|
982
|
+
const noop = () => undefined;
|
|
983
|
+
try {
|
|
984
|
+
for (const [method, path] of routes)
|
|
985
|
+
router.add(method, path, noop);
|
|
986
|
+
router.match("GET", first[1]);
|
|
987
|
+
return undefined;
|
|
988
|
+
}
|
|
989
|
+
catch (error) {
|
|
990
|
+
/**
|
|
991
|
+
* **The CONSTRUCTOR name, not `.name`.** Hono's `UnsupportedPathError` extends `Error` without
|
|
992
|
+
* setting `this.name`, so the inherited `.name` reads `"Error"` and says nothing. Its `message`
|
|
993
|
+
* is the offending path and is the whole of what it reports.
|
|
994
|
+
*/
|
|
995
|
+
const thrown = error;
|
|
996
|
+
return {
|
|
997
|
+
path: thrown.message ?? "(unnamed)",
|
|
998
|
+
reason: thrown.constructor?.name ?? "the router refused it",
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* **The `[method, path]` pairs a router is given for one service**, exactly as the emitted server
|
|
1004
|
+
* registers them.
|
|
1005
|
+
*
|
|
1006
|
+
* One definition, used by the `regexp-router-unsupported` linter rule, so the rule judges the route
|
|
1007
|
+
* set the generated server really mounts. Built from the same helpers the render uses - the verb a
|
|
1008
|
+
* route registers under, `app.on` falling back to `GET` as Hono itself does, and `toHonoPath` on the
|
|
1009
|
+
* route's segments - and prefixed with each base path the service is served under.
|
|
1010
|
+
*
|
|
1011
|
+
* **The full path, never the one relative to a sub-app.** The relative form is what the emitted
|
|
1012
|
+
* text says and not what any router is given. Judging it reports conflicts between routes that are
|
|
1013
|
+
* not siblings at all: measured on five fixtures, two were called unmountable that mount perfectly.
|
|
1014
|
+
*/
|
|
1015
|
+
export function routerTableFor(routes, basePaths) {
|
|
1016
|
+
const noRefusal = () => undefined;
|
|
1017
|
+
return routes.flatMap((route) => {
|
|
1018
|
+
const verb = registrationVerbOf(route.verb);
|
|
1019
|
+
const method = HONO_METHOD[verb] === undefined ? "GET" : verb;
|
|
1020
|
+
const path = toHonoPath(route.pathSegments, noRefusal);
|
|
1021
|
+
return (basePaths.length === 0 ? [""] : basePaths).map((prefix) => [method, `${prefix}${path}` || "/"]);
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
850
1024
|
/**
|
|
851
1025
|
* The resource a route belongs to (its first path segment) or `undefined` when it has none.
|
|
852
1026
|
*
|
|
@@ -907,7 +1081,7 @@ basePaths = []) {
|
|
|
907
1081
|
return [];
|
|
908
1082
|
const validators = [];
|
|
909
1083
|
let body;
|
|
910
|
-
for (const location of ["path", "query", "header", "body"]) {
|
|
1084
|
+
for (const location of ["path", "query", "header", "cookie", "body"]) {
|
|
911
1085
|
const identifier = names[location];
|
|
912
1086
|
if (identifier === undefined)
|
|
913
1087
|
continue;
|
|
@@ -1016,6 +1190,12 @@ type Fields<T> = string extends keyof T ? ([T[string]] extends [never] ? unknown
|
|
|
1016
1190
|
*/
|
|
1017
1191
|
const validates = entries.some((entry) => entry.validators.filter(([target]) => entry.body === undefined || target !== VALIDATOR_TARGET.body).length > 0);
|
|
1018
1192
|
const syncHelper = validates ? SYNC_PARSE : "";
|
|
1193
|
+
/**
|
|
1194
|
+
* `parsed` is used by `SYNC` and by both body middlewares, so it is emitted where either is, and
|
|
1195
|
+
* BEFORE them: a generated file is read top to bottom and a helper used above its declaration
|
|
1196
|
+
* reads as a mistake even where hoisting makes it legal.
|
|
1197
|
+
*/
|
|
1198
|
+
const guardedParse = validates || mountsBody ? GUARDED_PARSE : "";
|
|
1019
1199
|
/**
|
|
1020
1200
|
* **One result type per operation: the union of every response its document declares.**
|
|
1021
1201
|
*
|
|
@@ -1458,7 +1638,7 @@ type Produced<T> = T extends (...args: never[]) => unknown
|
|
|
1458
1638
|
"\t\t\t},",
|
|
1459
1639
|
"\t\t)",
|
|
1460
1640
|
].join("\n");
|
|
1461
|
-
return { target, path: registeredPath, text, headOnly };
|
|
1641
|
+
return { target, path: registeredPath, mountedPath: path, method, text, headOnly };
|
|
1462
1642
|
});
|
|
1463
1643
|
/**
|
|
1464
1644
|
* Every identifier this file names, imported from the module that declares it.
|
|
@@ -1491,8 +1671,10 @@ type Produced<T> = T extends (...args: never[]) => unknown
|
|
|
1491
1671
|
entry.names.path,
|
|
1492
1672
|
entry.names.query,
|
|
1493
1673
|
entry.names.header,
|
|
1674
|
+
entry.names.cookie,
|
|
1494
1675
|
entry.names.body,
|
|
1495
1676
|
...entry.names.arms.map((arm) => arm.schema),
|
|
1677
|
+
...entry.names.arms.map((arm) => arm.headers),
|
|
1496
1678
|
].filter((name) => name !== undefined))),
|
|
1497
1679
|
]
|
|
1498
1680
|
.filter((identifier) => mentioned.has(identifier))
|
|
@@ -1659,7 +1841,7 @@ type Produced<T> = T extends (...args: never[]) => unknown
|
|
|
1659
1841
|
${validates ? 'import { zValidator } from "@hono/zod-validator";\n' : ""}${needsHonoValue ? 'import { Hono } from "hono";\n' : ""}import type { ${honoTypes.join(", ")} } from "hono";
|
|
1660
1842
|
${statusTypes.length > 0 ? `import type { ${statusTypes.join(", ")} } from "hono/utils/http-status";\n` : ""}${usesZod ? 'import { z } from "zod";\n' : ""}import type { ${runtimeTypes.join(", ")} } from ${runtimeModule};${runtimeValues.length > 0 ? `\nimport { ${runtimeValues.join(", ")} } from ${runtimeModule};` : ""}
|
|
1661
1843
|
${imports}
|
|
1662
|
-
${syncHelper}${bodyHelper}${fieldsHelper}${declaredHelper}/**
|
|
1844
|
+
${guardedParse}${syncHelper}${bodyHelper}${fieldsHelper}${declaredHelper}/**
|
|
1663
1845
|
* What each operation may answer with: one member per response the document declares.
|
|
1664
1846
|
*
|
|
1665
1847
|
* A handler returns \`{ status, body, headers }\` for whichever response it means. The generated route
|
package/dist/src/index.d.ts
CHANGED
package/dist/src/index.js
CHANGED
|
@@ -8,5 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
export * from "typespec-http-zod";
|
|
10
10
|
export { $lib } from "./lib.js";
|
|
11
|
+
// Explicit, so it shadows the linter `export *` would otherwise re-export from typespec-http-zod.
|
|
12
|
+
export { $linter } from "./linter.js";
|
|
11
13
|
export { $onEmit } from "./emitter.js";
|
|
12
14
|
export { renderApp, toHonoPath } from "./app.js";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type LinterDefinition } from "@typespec/compiler";
|
|
2
|
+
/**
|
|
3
|
+
* **The advisory checks, opt-in, as TypeSpec expects them.**
|
|
4
|
+
*
|
|
5
|
+
* Enabled from a consumer's `tspconfig.yaml` with `linter: { extends: ["typespec-hono/recommended"] }`.
|
|
6
|
+
*
|
|
7
|
+
* **`redos-prone-pattern` is registered here as well as in `typespec-http-zod`.** A consumer of this
|
|
8
|
+
* package lists only `typespec-hono`, and the library runs in-process, so it is never loaded as a
|
|
9
|
+
* TypeSpec library and cannot be named in a ruleset - under pnpm it is not even resolvable from the
|
|
10
|
+
* consumer's project. A rule's id is its registering library's name joined to the rule's, so here it
|
|
11
|
+
* is `typespec-hono/redos-prone-pattern`, and one ruleset turns on everything that applies.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Typed explicitly through this package's own `@typespec/compiler` specifier, for the reason
|
|
15
|
+
* `lib.ts` records: a side-by-side checkout resolves two copies of the compiler, and an INFERRED
|
|
16
|
+
* declaration type then names the other package's copy by a `.pnpm` path (`TS2883`, "likely not
|
|
17
|
+
* portable"). A published `.d.ts` naming such a path is broken for everyone who installed
|
|
18
|
+
* differently.
|
|
19
|
+
*/
|
|
20
|
+
export declare const $linter: LinterDefinition;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { defineLinter } from "@typespec/compiler";
|
|
2
|
+
import { redosPronePatternRule } from "typespec-http-zod";
|
|
3
|
+
import { regexpRouterUnsupportedRule } from "./rules/regexp-router-unsupported.rule.js";
|
|
4
|
+
/**
|
|
5
|
+
* **The advisory checks, opt-in, as TypeSpec expects them.**
|
|
6
|
+
*
|
|
7
|
+
* Enabled from a consumer's `tspconfig.yaml` with `linter: { extends: ["typespec-hono/recommended"] }`.
|
|
8
|
+
*
|
|
9
|
+
* **`redos-prone-pattern` is registered here as well as in `typespec-http-zod`.** A consumer of this
|
|
10
|
+
* package lists only `typespec-hono`, and the library runs in-process, so it is never loaded as a
|
|
11
|
+
* TypeSpec library and cannot be named in a ruleset - under pnpm it is not even resolvable from the
|
|
12
|
+
* consumer's project. A rule's id is its registering library's name joined to the rule's, so here it
|
|
13
|
+
* is `typespec-hono/redos-prone-pattern`, and one ruleset turns on everything that applies.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Typed explicitly through this package's own `@typespec/compiler` specifier, for the reason
|
|
17
|
+
* `lib.ts` records: a side-by-side checkout resolves two copies of the compiler, and an INFERRED
|
|
18
|
+
* declaration type then names the other package's copy by a `.pnpm` path (`TS2883`, "likely not
|
|
19
|
+
* portable"). A published `.d.ts` naming such a path is broken for everyone who installed
|
|
20
|
+
* differently.
|
|
21
|
+
*/
|
|
22
|
+
export const $linter = defineLinter({
|
|
23
|
+
rules: [regexpRouterUnsupportedRule, redosPronePatternRule],
|
|
24
|
+
ruleSets: {
|
|
25
|
+
recommended: {
|
|
26
|
+
enable: {
|
|
27
|
+
[`typespec-hono/${regexpRouterUnsupportedRule.name}`]: true,
|
|
28
|
+
[`typespec-hono/${redosPronePatternRule.name}`]: true,
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type CallableMessage, type LinterRuleDefinition } from "@typespec/compiler";
|
|
2
|
+
/**
|
|
3
|
+
* **A route set Hono's `RegExpRouter` refuses to compile.**
|
|
4
|
+
*
|
|
5
|
+
* **A linter rule, because TypeSpec's own definition puts it there.** A diagnostic says the program
|
|
6
|
+
* is not valid for this library; a linter says it "could be correct, but there might be room for
|
|
7
|
+
* improvements", and has to be enabled explicitly. Here the spec is valid, the document is correct,
|
|
8
|
+
* the emitted server is correct, and the default router serves it perfectly - `SmartRouter` tries
|
|
9
|
+
* `RegExpRouter` and falls back to `TrieRouter` for exactly this case. What can go wrong is a router
|
|
10
|
+
* choice the application makes, which this package cannot see.
|
|
11
|
+
*
|
|
12
|
+
* **Why that matters, measured.** Raised as an automatic warning it failed the build of a consumer
|
|
13
|
+
* that sets `warn-as-error: true` and constructs `new Hono()` - the default router, which holds its
|
|
14
|
+
* route set - over a problem that consumer does not have.
|
|
15
|
+
*
|
|
16
|
+
* **Why it is worth enabling.** `RegExpRouter` throws `UnsupportedPathError` for a static segment
|
|
17
|
+
* beside a path parameter at the same position - `/users/me` next to `/users/{id}`, the commonest
|
|
18
|
+
* shape in REST - inside `registerRoutes`, at module scope, so a Worker constructed with one never
|
|
19
|
+
* starts. The documentation once recommended it. Measured: one added route took a live worker from
|
|
20
|
+
* serving to connection-refused, from a compile that reported success.
|
|
21
|
+
*
|
|
22
|
+
* **Hono's real router is asked, not a reimplementation of its rule**, which is undocumented and
|
|
23
|
+
* version-specific, and the route set asked about is built by {@link routerTableFor} from the same
|
|
24
|
+
* pieces the render uses. The corpus oracle mounts every generated server under a bare
|
|
25
|
+
* `RegExpRouter` and fails if this rule's verdict disagrees in either direction.
|
|
26
|
+
*
|
|
27
|
+
* **Reported against the service**, because which routes `RegExpRouter` refuses is a property of the
|
|
28
|
+
* route SET: no single operation is the one at fault.
|
|
29
|
+
*/
|
|
30
|
+
export declare const regexpRouterUnsupportedRule: LinterRuleDefinition<"regexp-router-unsupported", {
|
|
31
|
+
readonly default: CallableMessage<["path", "reason"]>;
|
|
32
|
+
}>;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { createRule, paramMessage, } from "@typespec/compiler";
|
|
2
|
+
import { getAllHttpServices } from "@typespec/http";
|
|
3
|
+
import { routeTemplateOf } from "typespec-http-zod";
|
|
4
|
+
import { regExpRouterRefusal, routerTableFor } from "../app.js";
|
|
5
|
+
import { resolveBasePath } from "../base-path.js";
|
|
6
|
+
/**
|
|
7
|
+
* **A route set Hono's `RegExpRouter` refuses to compile.**
|
|
8
|
+
*
|
|
9
|
+
* **A linter rule, because TypeSpec's own definition puts it there.** A diagnostic says the program
|
|
10
|
+
* is not valid for this library; a linter says it "could be correct, but there might be room for
|
|
11
|
+
* improvements", and has to be enabled explicitly. Here the spec is valid, the document is correct,
|
|
12
|
+
* the emitted server is correct, and the default router serves it perfectly - `SmartRouter` tries
|
|
13
|
+
* `RegExpRouter` and falls back to `TrieRouter` for exactly this case. What can go wrong is a router
|
|
14
|
+
* choice the application makes, which this package cannot see.
|
|
15
|
+
*
|
|
16
|
+
* **Why that matters, measured.** Raised as an automatic warning it failed the build of a consumer
|
|
17
|
+
* that sets `warn-as-error: true` and constructs `new Hono()` - the default router, which holds its
|
|
18
|
+
* route set - over a problem that consumer does not have.
|
|
19
|
+
*
|
|
20
|
+
* **Why it is worth enabling.** `RegExpRouter` throws `UnsupportedPathError` for a static segment
|
|
21
|
+
* beside a path parameter at the same position - `/users/me` next to `/users/{id}`, the commonest
|
|
22
|
+
* shape in REST - inside `registerRoutes`, at module scope, so a Worker constructed with one never
|
|
23
|
+
* starts. The documentation once recommended it. Measured: one added route took a live worker from
|
|
24
|
+
* serving to connection-refused, from a compile that reported success.
|
|
25
|
+
*
|
|
26
|
+
* **Hono's real router is asked, not a reimplementation of its rule**, which is undocumented and
|
|
27
|
+
* version-specific, and the route set asked about is built by {@link routerTableFor} from the same
|
|
28
|
+
* pieces the render uses. The corpus oracle mounts every generated server under a bare
|
|
29
|
+
* `RegExpRouter` and fails if this rule's verdict disagrees in either direction.
|
|
30
|
+
*
|
|
31
|
+
* **Reported against the service**, because which routes `RegExpRouter` refuses is a property of the
|
|
32
|
+
* route SET: no single operation is the one at fault.
|
|
33
|
+
*/
|
|
34
|
+
export const regexpRouterUnsupportedRule = createRule({
|
|
35
|
+
name: "regexp-router-unsupported",
|
|
36
|
+
severity: "warning",
|
|
37
|
+
description: "Flag a route set Hono's RegExpRouter cannot compile.",
|
|
38
|
+
messages: {
|
|
39
|
+
default: paramMessage `This service mounts '${"path"}', which Hono's 'RegExpRouter' cannot compile: ${"reason"}. A bare 'RegExpRouter' throws inside 'registerRoutes' at module scope, so the Worker would not start at all. Use 'new Hono()' for the default 'SmartRouter', or 'new Hono({ router: new PatternRouter() })'. Both mount this route set. The default is the one to reach for first: it settles on 'TrieRouter', which measures the same as 'RegExpRouter' in steady state, while 'PatternRouter' costs about five times more.`,
|
|
40
|
+
},
|
|
41
|
+
create(context) {
|
|
42
|
+
return {
|
|
43
|
+
root: (program) => {
|
|
44
|
+
// Its diagnostics are the emitter's to report, once, during emission.
|
|
45
|
+
const [services] = getAllHttpServices(program);
|
|
46
|
+
for (const service of services) {
|
|
47
|
+
const routes = service.operations.map((operation) => ({
|
|
48
|
+
verb: operation.verb.toUpperCase(),
|
|
49
|
+
pathSegments: routeTemplateOf(operation.uriTemplate, operation.parameters.parameters
|
|
50
|
+
.filter((parameter) => parameter.type === "path")
|
|
51
|
+
.map((parameter) => ({
|
|
52
|
+
name: parameter.name,
|
|
53
|
+
optional: parameter.param.optional,
|
|
54
|
+
reserved: parameter.type === "path" && parameter.allowReserved,
|
|
55
|
+
}))).segments,
|
|
56
|
+
}));
|
|
57
|
+
const refusal = regExpRouterRefusal(routerTableFor(routes, resolveBasePath(program, service.namespace).basePaths));
|
|
58
|
+
if (refusal !== undefined) {
|
|
59
|
+
context.reportDiagnostic({ format: refusal, target: service.namespace });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
});
|
package/dist/src/runtime.d.ts
CHANGED
|
@@ -115,7 +115,7 @@ export type Awaitable<T> = T | Promise<T>;
|
|
|
115
115
|
* **Specificity was implemented as a tie-break and that was wrong three ways at once**, all of them
|
|
116
116
|
* live in a published runtime until `test/negotiation.test.ts` was written. Scoring every matching
|
|
117
117
|
* range and keeping the best `(q, specificity)` pair lets a permissive wildcard out-vote the precise
|
|
118
|
-
* 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
|
|
119
119
|
* JSON, which is the one outcome an explicit refusal must never produce. The prose above stated the
|
|
120
120
|
* right rules the whole time; nothing compared it to the code.
|
|
121
121
|
*
|
|
@@ -165,7 +165,17 @@ export declare class ResponseContractError extends Error {
|
|
|
165
165
|
readonly operationId: string;
|
|
166
166
|
readonly status: number;
|
|
167
167
|
readonly issues: ZodError["issues"];
|
|
168
|
-
|
|
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");
|
|
169
179
|
}
|
|
170
180
|
/**
|
|
171
181
|
* A handler answered with a status its operation does not declare.
|
|
@@ -203,6 +213,32 @@ export declare function servedBody<S extends ZodType>(schema: S, value: unknown,
|
|
|
203
213
|
* conditional per header.
|
|
204
214
|
*/
|
|
205
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>;
|
|
206
242
|
/**
|
|
207
243
|
* Whether a media type a handler answers with lies inside a range the document offers.
|
|
208
244
|
*
|
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
|
*
|
|
@@ -126,11 +126,18 @@ export class ResponseContractError extends Error {
|
|
|
126
126
|
operationId;
|
|
127
127
|
status;
|
|
128
128
|
issues;
|
|
129
|
-
|
|
130
|
-
|
|
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`);
|
|
131
137
|
this.operationId = operationId;
|
|
132
138
|
this.status = status;
|
|
133
139
|
this.issues = issues;
|
|
140
|
+
this.part = part;
|
|
134
141
|
this.name = "ResponseContractError";
|
|
135
142
|
}
|
|
136
143
|
}
|
|
@@ -190,6 +197,43 @@ export function headersOf(declared) {
|
|
|
190
197
|
}
|
|
191
198
|
return headers;
|
|
192
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
|
+
}
|
|
193
237
|
/** RFC 9110 `token`, less `*`, which names a range rather than a type. */
|
|
194
238
|
const MEDIA_TOKEN = "[!#$%&'+.^_`|~0-9A-Za-z-]+";
|
|
195
239
|
const SERVED_MEDIA_TYPE = new RegExp(`^(${MEDIA_TOKEN})/${MEDIA_TOKEN}\\s*(;.*)?$`);
|
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,7 +44,7 @@
|
|
|
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",
|
|
@@ -61,19 +61,19 @@
|
|
|
61
61
|
"@typespec/streams": "0.86.0",
|
|
62
62
|
"@typespec/versioning": "0.86.0",
|
|
63
63
|
"@typespec/xml": "0.86.0",
|
|
64
|
-
"hono": "^4.
|
|
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/",
|
package/src/runtime.ts
CHANGED
|
@@ -125,7 +125,7 @@ export type Awaitable<T> = T | Promise<T>;
|
|
|
125
125
|
* **Specificity was implemented as a tie-break and that was wrong three ways at once**, all of them
|
|
126
126
|
* live in a published runtime until `test/negotiation.test.ts` was written. Scoring every matching
|
|
127
127
|
* range and keeping the best `(q, specificity)` pair lets a permissive wildcard out-vote the precise
|
|
128
|
-
* rule a caller wrote about that exact type - so `Accept
|
|
128
|
+
* rule a caller wrote about that exact type - so an `Accept` of any type at all followed by `application/json;q=0` was served
|
|
129
129
|
* JSON, which is the one outcome an explicit refusal must never produce. The prose above stated the
|
|
130
130
|
* right rules the whole time; nothing compared it to the code.
|
|
131
131
|
*
|
|
@@ -226,8 +226,15 @@ export class ResponseContractError extends Error {
|
|
|
226
226
|
readonly operationId: string,
|
|
227
227
|
readonly status: number,
|
|
228
228
|
readonly issues: ZodError["issues"],
|
|
229
|
+
/**
|
|
230
|
+
* Which half of the response broke its contract. **Defaulted, so the three-argument form an
|
|
231
|
+
* application may already construct or match on keeps working.**
|
|
232
|
+
*/
|
|
233
|
+
readonly part: "body" | "headers" = "body",
|
|
229
234
|
) {
|
|
230
|
-
super(
|
|
235
|
+
super(
|
|
236
|
+
`${operationId} answered ${status} with ${part === "body" ? "a body" : "headers"} its document does not permit`,
|
|
237
|
+
);
|
|
231
238
|
this.name = "ResponseContractError";
|
|
232
239
|
}
|
|
233
240
|
}
|
|
@@ -294,6 +301,48 @@ export function headersOf(declared: Readonly<Record<string, unknown>>): Record<s
|
|
|
294
301
|
return headers;
|
|
295
302
|
}
|
|
296
303
|
|
|
304
|
+
/**
|
|
305
|
+
* Declared response headers, CHECKED against the schemas the document publishes for them.
|
|
306
|
+
*
|
|
307
|
+
* **The body beside them was always checked and these never were.** `servedBody` parses every
|
|
308
|
+
* response body against its status's schema and throws `ResponseContractError`; a declared header
|
|
309
|
+
* went through `headersOf`, which is `String(value)` and nothing else. Measured on a generated
|
|
310
|
+
* server: a header the document constrains to `^[A-Za-z0-9-]+$` was served as
|
|
311
|
+
* `not a valid id"; injected=1` with a 200, while a body breaking its own schema on the same route
|
|
312
|
+
* threw. That asymmetry is the whole of what this removes.
|
|
313
|
+
*
|
|
314
|
+
* **Not a response-splitting hole, and worth saying so.** `new Response` refuses a header value
|
|
315
|
+
* containing CR or LF with a `TypeError` on both `workerd` and undici, so the runtime already stops
|
|
316
|
+
* the injection. What it does not stop is a response that disagrees with its own document, which a
|
|
317
|
+
* client generated from that document is entitled to rely on.
|
|
318
|
+
*
|
|
319
|
+
* **Undefined values are dropped BEFORE the check, not after.** An optional header the handler did
|
|
320
|
+
* not supply is absent rather than explicitly `undefined`, which is what the emitted schema's
|
|
321
|
+
* `.exactOptional()` means, and it is also what Hono's `HeaderRecord` requires. The check then runs
|
|
322
|
+
* on the values as the handler produced them - a `retry-after` declared `int32` is still a number
|
|
323
|
+
* here - because checking the text would check a different thing than the document describes.
|
|
324
|
+
*
|
|
325
|
+
* **What is returned is every header, including ones the schema does not mention.** A response
|
|
326
|
+
* carries headers the document does not declare, the `Content-Type` this emitter sets among them,
|
|
327
|
+
* and those are not a contract violation. The schema is a plain object schema, so it ignores them.
|
|
328
|
+
*/
|
|
329
|
+
export function servedHeaders(
|
|
330
|
+
schema: ZodType,
|
|
331
|
+
declared: Readonly<Record<string, unknown>>,
|
|
332
|
+
operationId: string,
|
|
333
|
+
status: number,
|
|
334
|
+
): Record<string, string> {
|
|
335
|
+
const present: Record<string, unknown> = {};
|
|
336
|
+
for (const [name, value] of Object.entries(declared)) {
|
|
337
|
+
if (value !== undefined) present[name] = value;
|
|
338
|
+
}
|
|
339
|
+
const parsed = schema.safeParse(present);
|
|
340
|
+
if (!parsed.success) {
|
|
341
|
+
throw new ResponseContractError(operationId, status, parsed.error.issues, "headers");
|
|
342
|
+
}
|
|
343
|
+
return headersOf(present);
|
|
344
|
+
}
|
|
345
|
+
|
|
297
346
|
/** RFC 9110 `token`, less `*`, which names a range rather than a type. */
|
|
298
347
|
const MEDIA_TOKEN = "[!#$%&'+.^_`|~0-9A-Za-z-]+";
|
|
299
348
|
const SERVED_MEDIA_TYPE = new RegExp(`^(${MEDIA_TOKEN})/${MEDIA_TOKEN}\\s*(;.*)?$`);
|