typespec-hono 0.5.0 → 0.7.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.
Files changed (2) hide show
  1. package/dist/src/app.js +54 -12
  2. package/package.json +2 -2
package/dist/src/app.js CHANGED
@@ -248,7 +248,7 @@ basePaths = [],
248
248
  * audience is wider.
249
249
  */
250
250
  securityFor) {
251
- const entries = emitted.routes.flatMap((route) => {
251
+ const mounted = emitted.routes.flatMap((route) => {
252
252
  let dispatched;
253
253
  const names = emitted.schemaNames.get(route.operationId);
254
254
  // The library declares a `Responses` const for every operation, so a missing entry is a bug in
@@ -291,6 +291,7 @@ securityFor) {
291
291
  }
292
292
  return [{ route, names, validators, dispatched }];
293
293
  });
294
+ const entries = mounted;
294
295
  /**
295
296
  * **One registration per verb+path, not per operation.**
296
297
  *
@@ -600,7 +601,7 @@ securityFor) {
600
601
  "\t\t\t},",
601
602
  "\t\t)",
602
603
  ].join("\n");
603
- return { target, text };
604
+ return { target, text, headOnly };
604
605
  });
605
606
  /**
606
607
  * Every identifier this file names, imported from the module that declares it.
@@ -608,8 +609,21 @@ securityFor) {
608
609
  * **Derived from what the rendered text actually references, not from what was available.** An
609
610
  * unused import fails the lint a generated file has to pass like any other, and a missing one is a
610
611
  * file that does not compile. Both have happened.
612
+ *
613
+ * **The text is TOKENISED and compared by equality, never matched name by name.** It used to build
614
+ * `new RegExp("\\b" + identifier + "\\b")` per name, and that was wrong twice over for any
615
+ * identifier containing a `$`, which TypeSpec permits in a model or operation name: `$` is an
616
+ * anchor in a regular expression, and escaping it alone does not help because `\b` is a WORD
617
+ * boundary and `$` is not a word character, so `\b\$select\b` cannot match either. Measured on
618
+ * `op list$Items(...): Item$Ref[]`, every identifier reported ABSENT, the whole
619
+ * `./schemas.gen.js` import was dropped, and the emitted file failed with four
620
+ * `TS2304: Cannot find name`. No diagnostic anywhere.
621
+ *
622
+ * Splitting on the language's own identifier rule and testing membership has neither failure: no
623
+ * metacharacter can be misread, and `Foo` cannot match `FooExtra`.
611
624
  */
612
625
  const rendered = [...registrations.map((r) => r.text), ...methods, ...aliases].join("\n");
626
+ const mentioned = new Set(rendered.match(/[A-Za-z_$][A-Za-z0-9_$]*/g) ?? []);
613
627
  const referenced = [
614
628
  ...new Set(entries.flatMap((entry) => [
615
629
  entry.names.path,
@@ -620,7 +634,7 @@ securityFor) {
620
634
  entry.names.responses,
621
635
  ].filter((name) => name !== undefined))),
622
636
  ]
623
- .filter((identifier) => new RegExp(`\\b${identifier}\\b`).test(rendered))
637
+ .filter((identifier) => mentioned.has(identifier))
624
638
  .toSorted();
625
639
  const imports = referenced.length === 0
626
640
  ? ""
@@ -670,17 +684,47 @@ securityFor) {
670
684
  // Imported only where a route actually negotiates: an unused import fails the repo's own lint.
671
685
  const negotiates = [...grouped.values()].some((group) => group.length > 1);
672
686
  // Same rule: imported only where a HEAD operation stands alone on its path.
673
- const guardsHead = registrations.some((registration) => registration.text.includes("headOnly,"));
687
+ const guardsHead = registrations.some((registration) => registration.headOnly);
674
688
  /**
675
- * Read from the entries rather than from the rendered text, which the two lines above still do.
689
+ * **Which runtime imports to write is read from the DATA, never from the rendered text.**
676
690
  *
677
- * **Matching a substring of generated output is how this import went missing once already.** It
678
- * tested for `byContentType([`, the emitted call gained arguments before its bracket, the substring
679
- * stopped matching, the import stopped being written, and the emitted module threw
691
+ * Matching a substring of generated output is how one of these went missing already: it tested for
692
+ * `byContentType([`, the emitted call gained arguments before its bracket, the substring stopped
693
+ * matching, the import stopped being written, and the emitted module threw
680
694
  * `ReferenceError: byContentType is not defined` at registration. Nothing in the emitter objected,
681
695
  * because the check was a string about a string.
696
+ *
697
+ * `guardsHead` above had the same shape, and the sibling library had it in a worse place, deciding
698
+ * whether a parameter gets a wire decoder by `declared.startsWith("z.number()")`. A literal union
699
+ * (`@query size: 10 | 25 | 50`) begins with neither prefix, so every conformant caller of that
700
+ * parameter got a 400 that no document comparison could see.
701
+ *
702
+ * The rendered text is still read to decide which VALIDATOR identifiers to import, and that one is
703
+ * safe in a way these are not: an identifier absent from the text is genuinely not needed, so the
704
+ * check cannot be wrong in the direction that breaks a build.
682
705
  */
683
706
  const dispatchesBody = entries.some((entry) => entry.dispatched !== undefined);
707
+ /**
708
+ * **Imported only where a route actually validates something, like every other value import here.**
709
+ *
710
+ * This one was unconditional while the three around it were not, and the comment above them stated
711
+ * the rule it was breaking. A service whose operations declare no parameters at all -- two bare
712
+ * `GET`s, which is where a health check starts and therefore where a new consumer starts -- emitted
713
+ * an import nothing used, and `tsp compile` reported success:
714
+ * `TS6133: 'zValidator' is declared but its value is never read`.
715
+ *
716
+ * Counted from the same filtered list the middleware is rendered from, so it cannot disagree with
717
+ * what was emitted: a dispatched body's validator is `byContentType`, not a `zValidator`.
718
+ */
719
+ /**
720
+ * **`z` is only ever reached through `z.infer`**, which appears where an operation has an input
721
+ * type or a response body. A service whose every operation takes nothing and returns `void` names
722
+ * neither, so the import was written and never used:
723
+ * `TS6133: 'z' is declared but its value is never read`. Same shape as the `zValidator` one above,
724
+ * one step further along.
725
+ */
726
+ const usesZod = entries.some((entry) => inputTypeOf(entry) !== undefined || entry.names.response !== undefined);
727
+ const validates = entries.some((entry) => entry.validators.filter(([target]) => entry.dispatched === undefined || target !== VALIDATOR_TARGET.body).length > 0);
684
728
  const runtimeModule = JSON.stringify(emitted.options.runtimeModule);
685
729
  /**
686
730
  * **One base sub-app, mounted with `app.route()`. Hono's own nesting, not a rewritten path on
@@ -692,10 +736,8 @@ securityFor) {
692
736
  const usesBasePath = basePaths.length > 0;
693
737
  const needsHonoValue = subApps.size > 0 || usesBasePath;
694
738
  return `${GENERATED_BANNER}
695
- import { zValidator } from "@hono/zod-validator";
696
- ${needsHonoValue ? 'import { Hono } from "hono";\nimport type { Context, Input } from "hono";' : 'import type { Context, Hono, Input } from "hono";'}
697
- import { z } from "zod";
698
- import type { AppEnv, Awaitable, Ctx, Result, RouteDeps } from ${runtimeModule};${negotiates ? `\nimport { selectContentType } from ${runtimeModule};` : ""}${guardsHead ? `\nimport { headOnly } from ${runtimeModule};` : ""}${dispatchesBody ? `\nimport { byContentType } from ${runtimeModule};` : ""}
739
+ ${validates ? 'import { zValidator } from "@hono/zod-validator";\n' : ""}${needsHonoValue ? 'import { Hono } from "hono";\nimport type { Context, Input } from "hono";' : 'import type { Context, Hono, Input } from "hono";'}
740
+ ${usesZod ? 'import { z } from "zod";\n' : ""}import type { AppEnv, Awaitable, Ctx, Result, RouteDeps } from ${runtimeModule};${negotiates ? `\nimport { selectContentType } from ${runtimeModule};` : ""}${guardsHead ? `\nimport { headOnly } from ${runtimeModule};` : ""}${dispatchesBody ? `\nimport { byContentType } from ${runtimeModule};` : ""}
699
741
  ${imports}
700
742
  /**
701
743
  * One method per operation, each concretely typed from the schemas it validates against.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typespec-hono",
3
- "version": "0.5.0",
3
+ "version": "0.7.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.5.0"
47
+ "typespec-http-zod": "^0.9.0"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@hono/zod-openapi": "^1.4.0",