@honestjs/rpc-plugin 1.2.0 → 1.4.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/index.mjs CHANGED
@@ -1,13 +1,20 @@
1
1
  // src/rpc.plugin.ts
2
2
  import fs2 from "fs";
3
- import path2 from "path";
3
+ import path3 from "path";
4
+ import { Project } from "ts-morph";
4
5
 
5
6
  // src/constants/defaults.ts
6
7
  var DEFAULT_OPTIONS = {
7
8
  controllerPattern: "src/modules/*/*.controller.ts",
8
9
  tsConfigPath: "tsconfig.json",
9
10
  outputDir: "./generated/rpc",
10
- generateOnInit: true
11
+ generateOnInit: true,
12
+ context: {
13
+ namespace: "rpc",
14
+ keys: {
15
+ artifact: "artifact"
16
+ }
17
+ }
11
18
  };
12
19
  var LOG_PREFIX = "[ RPCPlugin ]";
13
20
  var BUILTIN_UTILITY_TYPES = /* @__PURE__ */ new Set([
@@ -25,29 +32,66 @@ var BUILTIN_UTILITY_TYPES = /* @__PURE__ */ new Set([
25
32
  var BUILTIN_TYPES = /* @__PURE__ */ new Set(["string", "number", "boolean", "any", "void", "unknown"]);
26
33
  var GENERIC_TYPES = /* @__PURE__ */ new Set(["Array", "Promise", "Partial"]);
27
34
 
35
+ // src/utils/hash-utils.ts
36
+ import { createHash } from "crypto";
37
+ import { existsSync, readFileSync } from "fs";
38
+ import { mkdir, writeFile } from "fs/promises";
39
+ import path from "path";
40
+ var CHECKSUM_FILENAME = ".rpc-checksum";
41
+ function computeHash(filePaths) {
42
+ const sorted = [...filePaths].sort();
43
+ const hasher = createHash("sha256");
44
+ hasher.update(`files:${sorted.length}
45
+ `);
46
+ for (const filePath of sorted) {
47
+ hasher.update(readFileSync(filePath, "utf-8"));
48
+ hasher.update("\0");
49
+ }
50
+ return hasher.digest("hex");
51
+ }
52
+ function readChecksum(outputDir) {
53
+ const checksumPath = path.join(outputDir, CHECKSUM_FILENAME);
54
+ if (!existsSync(checksumPath)) return null;
55
+ try {
56
+ const raw = readFileSync(checksumPath, "utf-8");
57
+ const data = JSON.parse(raw);
58
+ if (typeof data.hash !== "string" || !Array.isArray(data.files)) {
59
+ return null;
60
+ }
61
+ return data;
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+ async function writeChecksum(outputDir, data) {
67
+ await mkdir(outputDir, { recursive: true });
68
+ const checksumPath = path.join(outputDir, CHECKSUM_FILENAME);
69
+ await writeFile(checksumPath, JSON.stringify(data, null, 2), "utf-8");
70
+ }
71
+
28
72
  // src/services/client-generator.service.ts
29
73
  import fs from "fs/promises";
30
- import path from "path";
74
+ import path2 from "path";
31
75
 
32
76
  // src/utils/path-utils.ts
33
77
  function buildFullPath(basePath, parameters) {
34
78
  if (!basePath || typeof basePath !== "string") return "/";
35
- let path3 = basePath;
79
+ let path4 = basePath;
36
80
  if (parameters && Array.isArray(parameters)) {
37
81
  for (const param of parameters) {
38
82
  if (param.data && typeof param.data === "string" && param.data.startsWith(":")) {
39
83
  const paramName = param.data.slice(1);
40
- path3 = path3.replace(`:${paramName}`, `\${${paramName}}`);
84
+ path4 = path4.replace(`:${paramName}`, `\${${paramName}}`);
41
85
  }
42
86
  }
43
87
  }
44
- return path3;
88
+ return path4;
45
89
  }
46
90
  function buildFullApiPath(route) {
47
91
  const prefix = route.prefix || "";
48
92
  const version = route.version || "";
49
93
  const routePath = route.route || "";
50
- const path3 = route.path || "";
94
+ const path4 = route.path || "";
51
95
  let fullPath = "";
52
96
  if (prefix && prefix !== "/") {
53
97
  fullPath += prefix.replace(/^\/+|\/+$/g, "");
@@ -58,11 +102,12 @@ function buildFullApiPath(route) {
58
102
  if (routePath && routePath !== "/") {
59
103
  fullPath += `/${routePath.replace(/^\/+|\/+$/g, "")}`;
60
104
  }
61
- if (path3 && path3 !== "/") {
62
- fullPath += `/${path3.replace(/^\/+|\/+$/g, "")}`;
63
- } else if (path3 === "/") {
105
+ if (path4 && path4 !== "/") {
106
+ fullPath += `/${path4.replace(/^\/+|\/+$/g, "")}`;
107
+ } else if (path4 === "/") {
64
108
  fullPath += "/";
65
109
  }
110
+ if (fullPath && !fullPath.startsWith("/")) fullPath = "/" + fullPath;
66
111
  return fullPath || "/";
67
112
  }
68
113
 
@@ -88,7 +133,7 @@ var ClientGeneratorService = class {
88
133
  await fs.mkdir(this.outputDir, { recursive: true });
89
134
  await this.generateClientFile(routes, schemas);
90
135
  const generatedInfo = {
91
- clientFile: path.join(this.outputDir, "client.ts"),
136
+ clientFile: path2.join(this.outputDir, "client.ts"),
92
137
  generatedAt: (/* @__PURE__ */ new Date()).toISOString()
93
138
  };
94
139
  return generatedInfo;
@@ -98,7 +143,7 @@ var ClientGeneratorService = class {
98
143
  */
99
144
  async generateClientFile(routes, schemas) {
100
145
  const clientContent = this.generateClientContent(routes, schemas);
101
- const clientPath = path.join(this.outputDir, "client.ts");
146
+ const clientPath = path2.join(this.outputDir, "client.ts");
102
147
  await fs.writeFile(clientPath, clientContent, "utf-8");
103
148
  }
104
149
  /**
@@ -258,6 +303,14 @@ export class ApiClient {
258
303
 
259
304
  try {
260
305
  const response = await this.fetchFn(url.toString(), requestOptions)
306
+
307
+ if (response.status === 204 || response.headers.get('content-length') === '0') {
308
+ if (!response.ok) {
309
+ throw new ApiError(response.status, 'Request failed')
310
+ }
311
+ return undefined as T
312
+ }
313
+
261
314
  const responseData = await response.json()
262
315
 
263
316
  if (!response.ok) {
@@ -400,71 +453,30 @@ ${this.generateControllerMethods(controllerGroups)}
400
453
  */
401
454
  analyzeRouteParameters(route) {
402
455
  const parameters = route.parameters || [];
403
- const method = String(route.method || "").toLowerCase();
404
- const isInPath = (p) => {
405
- const pathSegment = p.data;
406
- return !!pathSegment && typeof pathSegment === "string" && route.path.includes(`:${pathSegment}`);
407
- };
408
- const pathParams = parameters.filter((p) => isInPath(p)).map((p) => ({ ...p, required: true }));
409
- const rawBody = parameters.filter((p) => !isInPath(p) && method !== "get");
410
- const bodyParams = rawBody.map((p) => ({
411
- ...p,
412
- required: true
413
- }));
414
- const queryParams = parameters.filter((p) => !isInPath(p) && method === "get").map((p) => ({
415
- ...p,
416
- required: p.required === true
417
- // default false if not provided
418
- }));
456
+ const pathParams = parameters.filter((p) => p.decoratorType === "param").map((p) => ({ ...p, required: true }));
457
+ const bodyParams = parameters.filter((p) => p.decoratorType === "body").map((p) => ({ ...p, required: true }));
458
+ const queryParams = parameters.filter((p) => p.decoratorType === "query").map((p) => ({ ...p, required: p.required === true }));
419
459
  return { pathParams, queryParams, bodyParams };
420
460
  }
421
461
  };
422
462
 
423
463
  // src/services/route-analyzer.service.ts
424
464
  import { RouteRegistry } from "honestjs";
425
- import { Project } from "ts-morph";
426
465
  var RouteAnalyzerService = class {
427
- constructor(controllerPattern, tsConfigPath) {
428
- this.controllerPattern = controllerPattern;
429
- this.tsConfigPath = tsConfigPath;
430
- }
431
- // Track projects for cleanup
432
- projects = [];
433
466
  /**
434
467
  * Analyzes controller methods to extract type information
435
468
  */
436
- async analyzeControllerMethods() {
469
+ async analyzeControllerMethods(project) {
437
470
  const routes = RouteRegistry.getRoutes();
438
471
  if (!routes?.length) {
439
472
  return [];
440
473
  }
441
- const project = this.createProject();
442
474
  const controllers = this.findControllerClasses(project);
443
475
  if (controllers.size === 0) {
444
476
  return [];
445
477
  }
446
478
  return this.processRoutes(routes, controllers);
447
479
  }
448
- /**
449
- * Creates a new ts-morph project
450
- */
451
- createProject() {
452
- const project = new Project({
453
- tsConfigFilePath: this.tsConfigPath
454
- });
455
- project.addSourceFilesAtPaths([this.controllerPattern]);
456
- this.projects.push(project);
457
- return project;
458
- }
459
- /**
460
- * Cleanup resources to prevent memory leaks
461
- */
462
- dispose() {
463
- this.projects.forEach((project) => {
464
- project.getSourceFiles().forEach((file) => project.removeSourceFile(file));
465
- });
466
- this.projects = [];
467
- }
468
480
  /**
469
481
  * Finds controller classes in the project
470
482
  */
@@ -487,23 +499,17 @@ var RouteAnalyzerService = class {
487
499
  */
488
500
  processRoutes(routes, controllers) {
489
501
  const analyzedRoutes = [];
490
- const errors = [];
491
502
  for (const route of routes) {
492
503
  try {
493
504
  const extendedRoute = this.createExtendedRoute(route, controllers);
494
505
  analyzedRoutes.push(extendedRoute);
495
506
  } catch (routeError) {
496
- const error = routeError instanceof Error ? routeError : new Error(String(routeError));
497
- errors.push(error);
498
- console.error(
499
- `Error processing route ${safeToString(route.controller)}.${safeToString(route.handler)}:`,
507
+ console.warn(
508
+ `${LOG_PREFIX} Skipping route ${safeToString(route.controller)}.${safeToString(route.handler)}:`,
500
509
  routeError
501
510
  );
502
511
  }
503
512
  }
504
- if (errors.length > 0) {
505
- throw new Error(`Failed to process ${errors.length} routes: ${errors.map((e) => e.message).join(", ")}`);
506
- }
507
513
  return analyzedRoutes;
508
514
  }
509
515
  /**
@@ -556,6 +562,7 @@ var RouteAnalyzerService = class {
556
562
  const sortedParams = [...parameters].sort((a, b) => a.index - b.index);
557
563
  for (const param of sortedParams) {
558
564
  const index = param.index;
565
+ const decoratorType = param.name;
559
566
  if (index < declaredParams.length) {
560
567
  const declaredParam = declaredParams[index];
561
568
  const paramName = declaredParam.getName();
@@ -563,6 +570,7 @@ var RouteAnalyzerService = class {
563
570
  result.push({
564
571
  index,
565
572
  name: paramName,
573
+ decoratorType,
566
574
  type: paramType,
567
575
  required: true,
568
576
  data: param.data,
@@ -573,6 +581,7 @@ var RouteAnalyzerService = class {
573
581
  result.push({
574
582
  index,
575
583
  name: `param${index}`,
584
+ decoratorType,
576
585
  type: param.metatype?.name || "unknown",
577
586
  required: true,
578
587
  data: param.data,
@@ -587,7 +596,6 @@ var RouteAnalyzerService = class {
587
596
 
588
597
  // src/services/schema-generator.service.ts
589
598
  import { createGenerator } from "ts-json-schema-generator";
590
- import { Project as Project2 } from "ts-morph";
591
599
 
592
600
  // src/utils/schema-utils.ts
593
601
  function mapJsonSchemaTypeToTypeScript(schema) {
@@ -654,32 +662,6 @@ function extractNamedType(type) {
654
662
  if (BUILTIN_TYPES.has(name)) return null;
655
663
  return name;
656
664
  }
657
- function generateTypeImports(routes) {
658
- const types = /* @__PURE__ */ new Set();
659
- for (const route of routes) {
660
- if (route.parameters) {
661
- for (const param of route.parameters) {
662
- if (param.type && !["string", "number", "boolean"].includes(param.type)) {
663
- const typeMatch = param.type.match(/(\w+)(?:<.*>)?/);
664
- if (typeMatch) {
665
- const typeName = typeMatch[1];
666
- if (!BUILTIN_UTILITY_TYPES.has(typeName)) {
667
- types.add(typeName);
668
- }
669
- }
670
- }
671
- }
672
- }
673
- if (route.returns) {
674
- const returnType = route.returns.replace(/Promise<(.+)>/, "$1");
675
- const baseType = returnType.replace(/\[\]$/, "");
676
- if (!["string", "number", "boolean", "any", "void", "unknown"].includes(baseType)) {
677
- types.add(baseType);
678
- }
679
- }
680
- }
681
- return Array.from(types).join(", ");
682
- }
683
665
 
684
666
  // src/services/schema-generator.service.ts
685
667
  var SchemaGeneratorService = class {
@@ -687,37 +669,14 @@ var SchemaGeneratorService = class {
687
669
  this.controllerPattern = controllerPattern;
688
670
  this.tsConfigPath = tsConfigPath;
689
671
  }
690
- // Track projects for cleanup
691
- projects = [];
692
672
  /**
693
673
  * Generates JSON schemas from types used in controllers
694
674
  */
695
- async generateSchemas() {
696
- const project = this.createProject();
675
+ async generateSchemas(project) {
697
676
  const sourceFiles = project.getSourceFiles(this.controllerPattern);
698
677
  const collectedTypes = this.collectTypesFromControllers(sourceFiles);
699
678
  return this.processTypes(collectedTypes);
700
679
  }
701
- /**
702
- * Creates a new ts-morph project
703
- */
704
- createProject() {
705
- const project = new Project2({
706
- tsConfigFilePath: this.tsConfigPath
707
- });
708
- project.addSourceFilesAtPaths([this.controllerPattern]);
709
- this.projects.push(project);
710
- return project;
711
- }
712
- /**
713
- * Cleanup resources to prevent memory leaks
714
- */
715
- dispose() {
716
- this.projects.forEach((project) => {
717
- project.getSourceFiles().forEach((file) => project.removeSourceFile(file));
718
- });
719
- this.projects = [];
720
- }
721
680
  /**
722
681
  * Collects types from controller files
723
682
  */
@@ -795,20 +754,27 @@ var RPCPlugin = class {
795
754
  tsConfigPath;
796
755
  outputDir;
797
756
  generateOnInit;
757
+ contextNamespace;
758
+ contextArtifactKey;
798
759
  // Services
799
760
  routeAnalyzer;
800
761
  schemaGenerator;
801
762
  clientGenerator;
763
+ // Shared ts-morph project
764
+ project = null;
802
765
  // Internal state
803
766
  analyzedRoutes = [];
804
767
  analyzedSchemas = [];
805
768
  generatedInfo = null;
769
+ app = null;
806
770
  constructor(options = {}) {
807
771
  this.controllerPattern = options.controllerPattern ?? DEFAULT_OPTIONS.controllerPattern;
808
- this.tsConfigPath = options.tsConfigPath ?? path2.resolve(process.cwd(), DEFAULT_OPTIONS.tsConfigPath);
809
- this.outputDir = options.outputDir ?? path2.resolve(process.cwd(), DEFAULT_OPTIONS.outputDir);
772
+ this.tsConfigPath = options.tsConfigPath ?? path3.resolve(process.cwd(), DEFAULT_OPTIONS.tsConfigPath);
773
+ this.outputDir = options.outputDir ?? path3.resolve(process.cwd(), DEFAULT_OPTIONS.outputDir);
810
774
  this.generateOnInit = options.generateOnInit ?? DEFAULT_OPTIONS.generateOnInit;
811
- this.routeAnalyzer = new RouteAnalyzerService(this.controllerPattern, this.tsConfigPath);
775
+ this.contextNamespace = options.context?.namespace ?? DEFAULT_OPTIONS.context.namespace;
776
+ this.contextArtifactKey = options.context?.keys?.artifact ?? DEFAULT_OPTIONS.context.keys.artifact;
777
+ this.routeAnalyzer = new RouteAnalyzerService();
812
778
  this.schemaGenerator = new SchemaGeneratorService(this.controllerPattern, this.tsConfigPath);
813
779
  this.clientGenerator = new ClientGeneratorService(this.outputDir);
814
780
  this.validateConfiguration();
@@ -831,6 +797,12 @@ var RPCPlugin = class {
831
797
  if (!this.outputDir?.trim()) {
832
798
  errors.push("Output directory cannot be empty");
833
799
  }
800
+ if (!this.contextNamespace?.trim()) {
801
+ errors.push("Context namespace cannot be empty");
802
+ }
803
+ if (!this.contextArtifactKey?.trim()) {
804
+ errors.push("Context artifact key cannot be empty");
805
+ }
834
806
  if (errors.length > 0) {
835
807
  throw new Error(`Configuration validation failed: ${errors.join(", ")}`);
836
808
  }
@@ -842,22 +814,42 @@ var RPCPlugin = class {
842
814
  * Called after all modules are registered
843
815
  */
844
816
  afterModulesRegistered = async (app, hono) => {
817
+ this.app = app;
845
818
  if (this.generateOnInit) {
846
819
  await this.analyzeEverything();
820
+ this.publishArtifact(app);
847
821
  }
848
822
  };
849
823
  /**
850
824
  * Main analysis method that coordinates all three components
851
825
  */
852
- async analyzeEverything() {
826
+ async analyzeEverything(force = false) {
853
827
  try {
854
828
  this.log("Starting comprehensive RPC analysis...");
829
+ this.dispose();
830
+ this.project = new Project({ tsConfigFilePath: this.tsConfigPath });
831
+ this.project.addSourceFilesAtPaths([this.controllerPattern]);
832
+ const filePaths = this.project.getSourceFiles().map((f) => f.getFilePath());
833
+ if (!force) {
834
+ const currentHash = computeHash(filePaths);
835
+ const stored = readChecksum(this.outputDir);
836
+ if (stored && stored.hash === currentHash && this.outputFilesExist()) {
837
+ if (this.loadArtifactFromDisk()) {
838
+ this.log("Source files unchanged \u2014 skipping regeneration");
839
+ this.dispose();
840
+ return;
841
+ }
842
+ this.log("Source files unchanged but cached artifact missing/invalid \u2014 regenerating");
843
+ }
844
+ }
855
845
  this.analyzedRoutes = [];
856
846
  this.analyzedSchemas = [];
857
847
  this.generatedInfo = null;
858
- this.analyzedRoutes = await this.routeAnalyzer.analyzeControllerMethods();
859
- this.analyzedSchemas = await this.schemaGenerator.generateSchemas();
848
+ this.analyzedRoutes = await this.routeAnalyzer.analyzeControllerMethods(this.project);
849
+ this.analyzedSchemas = await this.schemaGenerator.generateSchemas(this.project);
860
850
  this.generatedInfo = await this.clientGenerator.generateClient(this.analyzedRoutes, this.analyzedSchemas);
851
+ await writeChecksum(this.outputDir, { hash: computeHash(filePaths), files: filePaths });
852
+ this.writeArtifactToDisk();
861
853
  this.log(
862
854
  `\u2705 RPC analysis complete: ${this.analyzedRoutes.length} routes, ${this.analyzedSchemas.length} schemas`
863
855
  );
@@ -868,10 +860,14 @@ var RPCPlugin = class {
868
860
  }
869
861
  }
870
862
  /**
871
- * Manually trigger analysis (useful for testing or re-generation)
863
+ * Manually trigger analysis (useful for testing or re-generation).
864
+ * Defaults to force=true to bypass cache; pass false to use caching.
872
865
  */
873
- async analyze() {
874
- await this.analyzeEverything();
866
+ async analyze(force = true) {
867
+ await this.analyzeEverything(force);
868
+ if (this.app) {
869
+ this.publishArtifact(this.app);
870
+ }
875
871
  }
876
872
  /**
877
873
  * Get the analyzed routes
@@ -891,13 +887,55 @@ var RPCPlugin = class {
891
887
  getGenerationInfo() {
892
888
  return this.generatedInfo;
893
889
  }
890
+ /**
891
+ * Checks whether expected output files exist on disk
892
+ */
893
+ outputFilesExist() {
894
+ return fs2.existsSync(path3.join(this.outputDir, "client.ts")) && fs2.existsSync(path3.join(this.outputDir, "rpc-artifact.json"));
895
+ }
896
+ getArtifactPath() {
897
+ return path3.join(this.outputDir, "rpc-artifact.json");
898
+ }
899
+ writeArtifactToDisk() {
900
+ const artifact = {
901
+ routes: this.analyzedRoutes,
902
+ schemas: this.analyzedSchemas
903
+ };
904
+ fs2.mkdirSync(this.outputDir, { recursive: true });
905
+ fs2.writeFileSync(this.getArtifactPath(), JSON.stringify(artifact));
906
+ }
907
+ loadArtifactFromDisk() {
908
+ try {
909
+ const raw = fs2.readFileSync(this.getArtifactPath(), "utf8");
910
+ const parsed = JSON.parse(raw);
911
+ if (!Array.isArray(parsed.routes) || !Array.isArray(parsed.schemas)) {
912
+ return false;
913
+ }
914
+ this.analyzedRoutes = parsed.routes;
915
+ this.analyzedSchemas = parsed.schemas;
916
+ this.generatedInfo = null;
917
+ return true;
918
+ } catch {
919
+ return false;
920
+ }
921
+ }
922
+ publishArtifact(app) {
923
+ app.getContext().set(this.getArtifactContextKey(), {
924
+ routes: this.analyzedRoutes,
925
+ schemas: this.analyzedSchemas
926
+ });
927
+ }
928
+ getArtifactContextKey() {
929
+ return `${this.contextNamespace}.${this.contextArtifactKey}`;
930
+ }
894
931
  /**
895
932
  * Cleanup resources to prevent memory leaks
896
933
  */
897
934
  dispose() {
898
- this.routeAnalyzer.dispose();
899
- this.schemaGenerator.dispose();
900
- this.log("Resources cleaned up");
935
+ if (this.project) {
936
+ this.project.getSourceFiles().forEach((file) => this.project.removeSourceFile(file));
937
+ this.project = null;
938
+ }
901
939
  }
902
940
  // ============================================================================
903
941
  // LOGGING UTILITIES
@@ -928,10 +966,12 @@ export {
928
966
  buildFullApiPath,
929
967
  buildFullPath,
930
968
  camelCase,
969
+ computeHash,
931
970
  extractNamedType,
932
- generateTypeImports,
933
971
  generateTypeScriptInterface,
934
972
  mapJsonSchemaTypeToTypeScript,
935
- safeToString
973
+ readChecksum,
974
+ safeToString,
975
+ writeChecksum
936
976
  };
937
977
  //# sourceMappingURL=index.mjs.map