typespec-hono 0.14.0 → 0.16.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/dist/src/app.js CHANGED
@@ -375,11 +375,68 @@ securityFor) {
375
375
  : validated === undefined
376
376
  ? negotiated
377
377
  : `${validated} & ${negotiated}`;
378
- const output = names.response === undefined ? "void" : `z.infer<typeof ${names.response}>`;
378
+ /**
379
+ * **The RETURN type drops index signatures; the input type keeps them.**
380
+ *
381
+ * A handler receives whatever the validator let through, and an open model's validator really
382
+ * does pass unknown keys along - so the input saying `[key: string]: unknown` describes the
383
+ * value in hand. Returning is the opposite direction: the handler supplies a value the
384
+ * application already holds, and an index signature there is an obligation rather than a
385
+ * description. TypeScript gives an interface no implicit index signature, so a domain type
386
+ * could not satisfy it without a spread at every level of the tree.
387
+ *
388
+ * `typespec-http-zod@0.17.0` took the catchall off the contract types for this reason. It did
389
+ * not reach here, because this signature is derived from `z.infer` rather than from those
390
+ * types - which is exactly the half-fix `test/openmodel/` now guards against.
391
+ */
392
+ const output = names.response === undefined ? "void" : `Declared<z.infer<typeof ${names.response}>>`;
379
393
  const signature = input === undefined ? "ctx: Ctx" : `ctx: Ctx, input: ${input}`;
380
394
  const doc = route.summary === undefined ? "" : `\t/** ${route.summary} */\n`;
381
395
  return `${doc}\t${route.operationId}(${signature}): Awaitable<Result<${output}>>;`;
382
396
  });
397
+ /**
398
+ * Emitted only when an operation actually returns something, because a generated file has to pass
399
+ * `noUnusedLocals` like any other - the lint that has already failed this emitter twice over an
400
+ * import written for a construct the service did not use.
401
+ *
402
+ * **Decided from the routes, not by searching the rendered text.** Asking whether the output
403
+ * mentions a name is how this package lost the `byContentType` import: the call gained an argument
404
+ * and the substring stopped matching, so a module referenced a function it no longer imported.
405
+ */
406
+ const returnsAnything = entries.some((entry) => entry.names.response !== undefined);
407
+ const declaredHelper = returnsAnything
408
+ ? `/**
409
+ * A shape with its index signatures removed, at every depth.
410
+ *
411
+ * An open model - one declared with \`...Record<T>\` - infers \`[key: string]: unknown\`, because its
412
+ * validator really does pass unknown keys through. That is true of what a handler RECEIVES, so the
413
+ * input types above keep it. It is not true of what a handler must SUPPLY: TypeScript gives an
414
+ * interface no implicit index signature, so a domain type could not be returned without spreading
415
+ * every level of the tree, which on one real service meant a structural deep copy per response.
416
+ *
417
+ * Returning extra properties still works - excess-property checks apply to object literals, not to
418
+ * a value the application already holds.
419
+ */
420
+ type Declared<T> = T extends (...args: never[]) => unknown
421
+ ? T
422
+ : T extends readonly (infer Element)[]
423
+ ? T extends Element[]
424
+ ? Declared<Element>[]
425
+ : readonly Declared<Element>[]
426
+ : T extends object
427
+ ? {
428
+ [K in keyof T as string extends K
429
+ ? never
430
+ : number extends K
431
+ ? never
432
+ : symbol extends K
433
+ ? never
434
+ : K]: Declared<T[K]>;
435
+ }
436
+ : T;
437
+
438
+ `
439
+ : "";
383
440
  const aliases = entries.map((entry) => `export type ${capitaliseId(entry.route.operationId)}Handler = Operations[${JSON.stringify(entry.route.operationId)}];`);
384
441
  /**
385
442
  * **Which resources get a sub-app, and which routes stay on the root.**
@@ -808,7 +865,7 @@ ${imports}
808
865
  * There is no cast anywhere in this file, and no dynamic lookup: the generated call sites name the
809
866
  * method, so an implementation whose input or output does not match the contract fails to compile.
810
867
  */
811
- export interface Operations {
868
+ ${declaredHelper}export interface Operations {
812
869
  ${methods.join("\n")}
813
870
  }
814
871
 
@@ -108,13 +108,23 @@ export type Awaitable<T> = T | Promise<T>;
108
108
  *
109
109
  * The rules that matter, and that a naive `includes()` gets wrong:
110
110
  * - **absent or empty `Accept` means anything is acceptable**, serve the first offer;
111
- * - **`q=0` is a REFUSAL**, not a weak preference, so a range scoring zero can never be chosen;
112
- * - **specificity breaks ties before quality does**: an exact type beats a subtype wildcard
113
- * (`text/*`), which beats the fully wildcard range, at equal `q`. That is why the score is a
114
- * pair rather than a number. The fully wildcard range is not written literally here because it
115
- * would close this comment;
111
+ * - **specificity SELECTS which rule applies, before quality is read at all.** For each offered
112
+ * type, the most specific range that matches it decides its quality: an exact type beats a
113
+ * subtype wildcard (`text/*`), which beats the fully wildcard range. The fully wildcard range is
114
+ * not written literally here because it would close this comment;
115
+ * - **`q=0` is a REFUSAL**, not a weak preference, so a type whose applicable rule scores zero is
116
+ * never chosen;
117
+ * - equal quality keeps the order the document offers, so nothing in the header displaces it;
118
+ * - a malformed `q` is IGNORED rather than read as zero - a typo should not turn into a 406;
116
119
  * - parameters after the media range (`;charset=utf-8`) are not part of the match.
117
120
  *
121
+ * **Specificity was implemented as a tie-break and that was wrong three ways at once**, all of them
122
+ * live in a published runtime until `test/negotiation.test.ts` was written. Scoring every matching
123
+ * range and keeping the best `(q, specificity)` pair lets a permissive wildcard out-vote the precise
124
+ * rule a caller wrote about that exact type - so `Accept: *​/*, application/json;q=0` was served
125
+ * JSON, which is the one outcome an explicit refusal must never produce. The prose above stated the
126
+ * right rules the whole time; nothing compared it to the code.
127
+ *
118
128
  * Returns `undefined` when nothing offered is acceptable. The caller answers 406, and the
119
129
  * difference between "no preference" and "no acceptable option" is exactly what that turns on.
120
130
  */
@@ -177,7 +187,17 @@ export type BodyTarget = "json" | "form";
177
187
  */
178
188
  export declare function byContentType<E extends Env, S extends ZodType>(schema: S, invalid: <P extends string, I extends Input>(result: {
179
189
  readonly success: boolean;
180
- }, c: Context<E, P, I>) => Response | undefined, branches: readonly (readonly [mediaType: string, target: BodyTarget])[]): MiddlewareHandler<E, string, {
190
+ }, c: Context<E, P, I>) => Response | undefined,
191
+ /**
192
+ * **A NON-EMPTY list, stated in the type.** The fallback below reads the first branch, and
193
+ * `branches[0]` on a plain array is `T | undefined`, which was silenced with a cast. A tuple says
194
+ * the same thing the emitter already guarantees - this middleware is only ever emitted for a route
195
+ * declaring at least one request media type - and removes the cast rather than typing around it.
196
+ */
197
+ branches: readonly [
198
+ readonly [mediaType: string, target: BodyTarget],
199
+ ...(readonly [mediaType: string, target: BodyTarget])[]
200
+ ]): MiddlewareHandler<E, string, {
181
201
  in: {
182
202
  json: input<S>;
183
203
  };
@@ -22,13 +22,23 @@ export function armFor(arms, status) {
22
22
  *
23
23
  * The rules that matter, and that a naive `includes()` gets wrong:
24
24
  * - **absent or empty `Accept` means anything is acceptable**, serve the first offer;
25
- * - **`q=0` is a REFUSAL**, not a weak preference, so a range scoring zero can never be chosen;
26
- * - **specificity breaks ties before quality does**: an exact type beats a subtype wildcard
27
- * (`text/*`), which beats the fully wildcard range, at equal `q`. That is why the score is a
28
- * pair rather than a number. The fully wildcard range is not written literally here because it
29
- * would close this comment;
25
+ * - **specificity SELECTS which rule applies, before quality is read at all.** For each offered
26
+ * type, the most specific range that matches it decides its quality: an exact type beats a
27
+ * subtype wildcard (`text/*`), which beats the fully wildcard range. The fully wildcard range is
28
+ * not written literally here because it would close this comment;
29
+ * - **`q=0` is a REFUSAL**, not a weak preference, so a type whose applicable rule scores zero is
30
+ * never chosen;
31
+ * - equal quality keeps the order the document offers, so nothing in the header displaces it;
32
+ * - a malformed `q` is IGNORED rather than read as zero - a typo should not turn into a 406;
30
33
  * - parameters after the media range (`;charset=utf-8`) are not part of the match.
31
34
  *
35
+ * **Specificity was implemented as a tie-break and that was wrong three ways at once**, all of them
36
+ * live in a published runtime until `test/negotiation.test.ts` was written. Scoring every matching
37
+ * range and keeping the best `(q, specificity)` pair lets a permissive wildcard out-vote the precise
38
+ * rule a caller wrote about that exact type - so `Accept: *​/*, application/json;q=0` was served
39
+ * JSON, which is the one outcome an explicit refusal must never produce. The prose above stated the
40
+ * right rules the whole time; nothing compared it to the code.
41
+ *
32
42
  * Returns `undefined` when nothing offered is acceptable. The caller answers 406, and the
33
43
  * difference between "no preference" and "no acceptable option" is exactly what that turns on.
34
44
  */
@@ -43,22 +53,35 @@ export function selectContentType(accept, offered) {
43
53
  const quality = parameters
44
54
  .map((parameter) => /^q=(?<value>[\d.]+)$/i.exec(parameter)?.groups?.value)
45
55
  .find((value) => value !== undefined);
46
- return { range: range.toLowerCase(), q: quality === undefined ? 1 : Number(quality) };
56
+ const q = quality === undefined ? 1 : Number(quality);
57
+ // A malformed `q` is treated as unstated. Reading `q=1.2.3` as zero would 406 a typo.
58
+ return { range: range.toLowerCase(), q: Number.isFinite(q) ? q : 1 };
47
59
  });
48
60
  let best;
49
61
  for (const type of offered) {
50
- const [group] = type.toLowerCase().split("/");
62
+ const lowered = type.toLowerCase();
63
+ const [group] = lowered.split("/");
64
+ /**
65
+ * **The most specific matching range decides this type's quality**, which is what makes an
66
+ * explicit `application/json;q=0` beat a wildcard that would otherwise accept it. Reading the
67
+ * best-scoring range instead lets a permissive rule override a precise one.
68
+ */
69
+ let applicable;
51
70
  for (const { range, q } of ranges) {
52
- // `q=0` is "I will not accept this", so it never becomes a candidate.
53
- if (!Number.isFinite(q) || q <= 0)
54
- continue;
55
- const specificity = range === type.toLowerCase() ? 2 : range === `${group}/*` ? 1 : range === "*/*" ? 0 : -1;
71
+ const specificity = range === lowered ? 2 : range === `${group}/*` ? 1 : range === "*/*" ? 0 : -1;
56
72
  if (specificity < 0)
57
73
  continue;
58
- if (best === undefined || q > best.q || (q === best.q && specificity > best.specificity)) {
59
- best = { type, q, specificity };
74
+ // Strictly greater, so two rules of equal specificity leave the first one in force.
75
+ if (applicable === undefined || specificity > applicable.specificity) {
76
+ applicable = { q, specificity };
60
77
  }
61
78
  }
79
+ // `q=0` is "I will not accept this", so a type its own rule scores zero is never a candidate.
80
+ if (applicable === undefined || applicable.q <= 0)
81
+ continue;
82
+ // Strictly greater, so equal quality keeps the order the document offers.
83
+ if (best === undefined || applicable.q > best.q)
84
+ best = { type, q: applicable.q };
62
85
  }
63
86
  return best?.type;
64
87
  }
@@ -138,7 +161,14 @@ const BODY_TARGET = "json";
138
161
  * handler's declared input type omitted the body entirely because the body was not among the route's
139
162
  * ordinary validators. Publishing to one known slot removes both, rather than typing around them.
140
163
  */
141
- export function byContentType(schema, invalid, branches) {
164
+ export function byContentType(schema, invalid,
165
+ /**
166
+ * **A NON-EMPTY list, stated in the type.** The fallback below reads the first branch, and
167
+ * `branches[0]` on a plain array is `T | undefined`, which was silenced with a cast. A tuple says
168
+ * the same thing the emitter already guarantees - this middleware is only ever emitted for a route
169
+ * declaring at least one request media type - and removes the cast rather than typing around it.
170
+ */
171
+ branches) {
142
172
  return async (c, next) => {
143
173
  const declared = (c.req.header("content-type") ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
144
174
  const matched = branches.find(([mediaType]) => mediaType.toLowerCase() === declared);
@@ -149,14 +179,14 @@ export function byContentType(schema, invalid, branches) {
149
179
  * one. Hono's `validator` is what `@hono/zod-validator` is built on, so this is the same
150
180
  * extraction, the same `HTTPException` on malformed JSON, and the same default rejection.
151
181
  *
152
- * `validator("json", ...)` hands over `{}` rather than throwing when the request is not JSON,
153
- * because it checks the `Content-Type` before reading. That is what leaves room for the form
154
- * branch to read the body itself with `c.req.parseBody({ all: true })`, which builds the same
155
- * object hono's own `"form"` target builds: a repeated key and a `key[]` name both become an
156
- * array, everything else stays a string.
182
+ * **Hono's own `validator` is deliberately not called here.** Its target fixes the reader at
183
+ * registration time, and the whole point of this function is that the reader is chosen when the
184
+ * request arrives. What it does instead is reproduce `validator`'s observable behaviour for both
185
+ * targets: {@link readBody} performs the same two extractions and raises the same
186
+ * `HTTPException` on malformed JSON, and the rejection below is `zValidator`'s own.
157
187
  *
158
188
  * Annotated rather than inlined because `Context` is invariant in its environment and in its
159
- * `Input`, and `validator` infers the first as `any`. Same reason `RouteDeps` is parameterised.
189
+ * `Input`. Same reason `RouteDeps` is parameterised.
160
190
  */
161
191
  const parse = async (ctx, proceed) => {
162
192
  const result = await schema.safeParseAsync(await readBody(ctx, target));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typespec-hono",
3
- "version": "0.14.0",
3
+ "version": "0.16.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.16.0"
47
+ "typespec-http-zod": "^0.17.0"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@hono/zod-openapi": "^1.4.0",
package/src/runtime.ts CHANGED
@@ -120,13 +120,23 @@ export type Awaitable<T> = T | Promise<T>;
120
120
  *
121
121
  * The rules that matter, and that a naive `includes()` gets wrong:
122
122
  * - **absent or empty `Accept` means anything is acceptable**, serve the first offer;
123
- * - **`q=0` is a REFUSAL**, not a weak preference, so a range scoring zero can never be chosen;
124
- * - **specificity breaks ties before quality does**: an exact type beats a subtype wildcard
125
- * (`text/*`), which beats the fully wildcard range, at equal `q`. That is why the score is a
126
- * pair rather than a number. The fully wildcard range is not written literally here because it
127
- * would close this comment;
123
+ * - **specificity SELECTS which rule applies, before quality is read at all.** For each offered
124
+ * type, the most specific range that matches it decides its quality: an exact type beats a
125
+ * subtype wildcard (`text/*`), which beats the fully wildcard range. The fully wildcard range is
126
+ * not written literally here because it would close this comment;
127
+ * - **`q=0` is a REFUSAL**, not a weak preference, so a type whose applicable rule scores zero is
128
+ * never chosen;
129
+ * - equal quality keeps the order the document offers, so nothing in the header displaces it;
130
+ * - a malformed `q` is IGNORED rather than read as zero - a typo should not turn into a 406;
128
131
  * - parameters after the media range (`;charset=utf-8`) are not part of the match.
129
132
  *
133
+ * **Specificity was implemented as a tie-break and that was wrong three ways at once**, all of them
134
+ * live in a published runtime until `test/negotiation.test.ts` was written. Scoring every matching
135
+ * range and keeping the best `(q, specificity)` pair lets a permissive wildcard out-vote the precise
136
+ * rule a caller wrote about that exact type - so `Accept: *​/*, application/json;q=0` was served
137
+ * JSON, which is the one outcome an explicit refusal must never produce. The prose above stated the
138
+ * right rules the whole time; nothing compared it to the code.
139
+ *
130
140
  * Returns `undefined` when nothing offered is acceptable. The caller answers 406, and the
131
141
  * difference between "no preference" and "no acceptable option" is exactly what that turns on.
132
142
  */
@@ -143,22 +153,34 @@ export function selectContentType(
143
153
  const quality = parameters
144
154
  .map((parameter) => /^q=(?<value>[\d.]+)$/i.exec(parameter)?.groups?.value)
145
155
  .find((value) => value !== undefined);
146
- return { range: range.toLowerCase(), q: quality === undefined ? 1 : Number(quality) };
156
+ const q = quality === undefined ? 1 : Number(quality);
157
+ // A malformed `q` is treated as unstated. Reading `q=1.2.3` as zero would 406 a typo.
158
+ return { range: range.toLowerCase(), q: Number.isFinite(q) ? q : 1 };
147
159
  });
148
160
 
149
- let best: { type: string; q: number; specificity: number } | undefined;
161
+ let best: { type: string; q: number } | undefined;
150
162
  for (const type of offered) {
151
- const [group] = type.toLowerCase().split("/");
163
+ const lowered = type.toLowerCase();
164
+ const [group] = lowered.split("/");
165
+ /**
166
+ * **The most specific matching range decides this type's quality**, which is what makes an
167
+ * explicit `application/json;q=0` beat a wildcard that would otherwise accept it. Reading the
168
+ * best-scoring range instead lets a permissive rule override a precise one.
169
+ */
170
+ let applicable: { q: number; specificity: number } | undefined;
152
171
  for (const { range, q } of ranges) {
153
- // `q=0` is "I will not accept this", so it never becomes a candidate.
154
- if (!Number.isFinite(q) || q <= 0) continue;
155
172
  const specificity =
156
- range === type.toLowerCase() ? 2 : range === `${group}/*` ? 1 : range === "*/*" ? 0 : -1;
173
+ range === lowered ? 2 : range === `${group}/*` ? 1 : range === "*/*" ? 0 : -1;
157
174
  if (specificity < 0) continue;
158
- if (best === undefined || q > best.q || (q === best.q && specificity > best.specificity)) {
159
- best = { type, q, specificity };
175
+ // Strictly greater, so two rules of equal specificity leave the first one in force.
176
+ if (applicable === undefined || specificity > applicable.specificity) {
177
+ applicable = { q, specificity };
160
178
  }
161
179
  }
180
+ // `q=0` is "I will not accept this", so a type its own rule scores zero is never a candidate.
181
+ if (applicable === undefined || applicable.q <= 0) continue;
182
+ // Strictly greater, so equal quality keeps the order the document offers.
183
+ if (best === undefined || applicable.q > best.q) best = { type, q: applicable.q };
162
184
  }
163
185
  return best?.type;
164
186
  }
@@ -253,26 +275,35 @@ export function byContentType<E extends Env, S extends ZodType>(
253
275
  result: { readonly success: boolean },
254
276
  c: Context<E, P, I>,
255
277
  ) => Response | undefined,
256
- branches: readonly (readonly [mediaType: string, target: BodyTarget])[],
278
+ /**
279
+ * **A NON-EMPTY list, stated in the type.** The fallback below reads the first branch, and
280
+ * `branches[0]` on a plain array is `T | undefined`, which was silenced with a cast. A tuple says
281
+ * the same thing the emitter already guarantees - this middleware is only ever emitted for a route
282
+ * declaring at least one request media type - and removes the cast rather than typing around it.
283
+ */
284
+ branches: readonly [
285
+ readonly [mediaType: string, target: BodyTarget],
286
+ ...(readonly [mediaType: string, target: BodyTarget])[],
287
+ ],
257
288
  ): MiddlewareHandler<E, string, { in: { json: input<S> }; out: { json: output<S> } }> {
258
289
  return async (c, next) => {
259
290
  const declared = (c.req.header("content-type") ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
260
291
  const matched = branches.find(([mediaType]) => mediaType.toLowerCase() === declared);
261
- const [, target] = matched ?? (branches[0] as (typeof branches)[number]);
292
+ const [, target] = matched ?? branches[0];
262
293
  /**
263
294
  * **Registered against the body slot whichever parser runs**, so the validated body is
264
295
  * published under one target and the handler reads it exactly as it reads a single-media-type
265
296
  * one. Hono's `validator` is what `@hono/zod-validator` is built on, so this is the same
266
297
  * extraction, the same `HTTPException` on malformed JSON, and the same default rejection.
267
298
  *
268
- * `validator("json", ...)` hands over `{}` rather than throwing when the request is not JSON,
269
- * because it checks the `Content-Type` before reading. That is what leaves room for the form
270
- * branch to read the body itself with `c.req.parseBody({ all: true })`, which builds the same
271
- * object hono's own `"form"` target builds: a repeated key and a `key[]` name both become an
272
- * array, everything else stays a string.
299
+ * **Hono's own `validator` is deliberately not called here.** Its target fixes the reader at
300
+ * registration time, and the whole point of this function is that the reader is chosen when the
301
+ * request arrives. What it does instead is reproduce `validator`'s observable behaviour for both
302
+ * targets: {@link readBody} performs the same two extractions and raises the same
303
+ * `HTTPException` on malformed JSON, and the rejection below is `zValidator`'s own.
273
304
  *
274
305
  * Annotated rather than inlined because `Context` is invariant in its environment and in its
275
- * `Input`, and `validator` infers the first as `any`. Same reason `RouteDeps` is parameterised.
306
+ * `Input`. Same reason `RouteDeps` is parameterised.
276
307
  */
277
308
  const parse: MiddlewareHandler<
278
309
  E,