@honestjs/rpc-plugin 1.2.0 → 1.3.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.js CHANGED
@@ -36,23 +36,27 @@ __export(index_exports, {
36
36
  DEFAULT_OPTIONS: () => DEFAULT_OPTIONS,
37
37
  GENERIC_TYPES: () => GENERIC_TYPES,
38
38
  LOG_PREFIX: () => LOG_PREFIX,
39
+ OpenApiGeneratorService: () => OpenApiGeneratorService,
39
40
  RPCPlugin: () => RPCPlugin,
40
41
  RouteAnalyzerService: () => RouteAnalyzerService,
41
42
  SchemaGeneratorService: () => SchemaGeneratorService,
42
43
  buildFullApiPath: () => buildFullApiPath,
43
44
  buildFullPath: () => buildFullPath,
44
45
  camelCase: () => camelCase,
46
+ computeHash: () => computeHash,
45
47
  extractNamedType: () => extractNamedType,
46
- generateTypeImports: () => generateTypeImports,
47
48
  generateTypeScriptInterface: () => generateTypeScriptInterface,
48
49
  mapJsonSchemaTypeToTypeScript: () => mapJsonSchemaTypeToTypeScript,
49
- safeToString: () => safeToString
50
+ readChecksum: () => readChecksum,
51
+ safeToString: () => safeToString,
52
+ writeChecksum: () => writeChecksum
50
53
  });
51
54
  module.exports = __toCommonJS(index_exports);
52
55
 
53
56
  // src/rpc.plugin.ts
54
- var import_fs = __toESM(require("fs"));
55
- var import_path2 = __toESM(require("path"));
57
+ var import_fs2 = __toESM(require("fs"));
58
+ var import_path4 = __toESM(require("path"));
59
+ var import_ts_morph = require("ts-morph");
56
60
 
57
61
  // src/constants/defaults.ts
58
62
  var DEFAULT_OPTIONS = {
@@ -77,29 +81,66 @@ var BUILTIN_UTILITY_TYPES = /* @__PURE__ */ new Set([
77
81
  var BUILTIN_TYPES = /* @__PURE__ */ new Set(["string", "number", "boolean", "any", "void", "unknown"]);
78
82
  var GENERIC_TYPES = /* @__PURE__ */ new Set(["Array", "Promise", "Partial"]);
79
83
 
80
- // src/services/client-generator.service.ts
81
- var import_promises = __toESM(require("fs/promises"));
84
+ // src/utils/hash-utils.ts
85
+ var import_crypto = require("crypto");
86
+ var import_fs = require("fs");
87
+ var import_promises = require("fs/promises");
82
88
  var import_path = __toESM(require("path"));
89
+ var CHECKSUM_FILENAME = ".rpc-checksum";
90
+ function computeHash(filePaths) {
91
+ const sorted = [...filePaths].sort();
92
+ const hasher = (0, import_crypto.createHash)("sha256");
93
+ hasher.update(`files:${sorted.length}
94
+ `);
95
+ for (const filePath of sorted) {
96
+ hasher.update((0, import_fs.readFileSync)(filePath, "utf-8"));
97
+ hasher.update("\0");
98
+ }
99
+ return hasher.digest("hex");
100
+ }
101
+ function readChecksum(outputDir) {
102
+ const checksumPath = import_path.default.join(outputDir, CHECKSUM_FILENAME);
103
+ if (!(0, import_fs.existsSync)(checksumPath)) return null;
104
+ try {
105
+ const raw = (0, import_fs.readFileSync)(checksumPath, "utf-8");
106
+ const data = JSON.parse(raw);
107
+ if (typeof data.hash !== "string" || !Array.isArray(data.files)) {
108
+ return null;
109
+ }
110
+ return data;
111
+ } catch {
112
+ return null;
113
+ }
114
+ }
115
+ async function writeChecksum(outputDir, data) {
116
+ await (0, import_promises.mkdir)(outputDir, { recursive: true });
117
+ const checksumPath = import_path.default.join(outputDir, CHECKSUM_FILENAME);
118
+ await (0, import_promises.writeFile)(checksumPath, JSON.stringify(data, null, 2), "utf-8");
119
+ }
120
+
121
+ // src/services/client-generator.service.ts
122
+ var import_promises2 = __toESM(require("fs/promises"));
123
+ var import_path2 = __toESM(require("path"));
83
124
 
84
125
  // src/utils/path-utils.ts
85
126
  function buildFullPath(basePath, parameters) {
86
127
  if (!basePath || typeof basePath !== "string") return "/";
87
- let path3 = basePath;
128
+ let path5 = basePath;
88
129
  if (parameters && Array.isArray(parameters)) {
89
130
  for (const param of parameters) {
90
131
  if (param.data && typeof param.data === "string" && param.data.startsWith(":")) {
91
132
  const paramName = param.data.slice(1);
92
- path3 = path3.replace(`:${paramName}`, `\${${paramName}}`);
133
+ path5 = path5.replace(`:${paramName}`, `\${${paramName}}`);
93
134
  }
94
135
  }
95
136
  }
96
- return path3;
137
+ return path5;
97
138
  }
98
139
  function buildFullApiPath(route) {
99
140
  const prefix = route.prefix || "";
100
141
  const version = route.version || "";
101
142
  const routePath = route.route || "";
102
- const path3 = route.path || "";
143
+ const path5 = route.path || "";
103
144
  let fullPath = "";
104
145
  if (prefix && prefix !== "/") {
105
146
  fullPath += prefix.replace(/^\/+|\/+$/g, "");
@@ -110,11 +151,12 @@ function buildFullApiPath(route) {
110
151
  if (routePath && routePath !== "/") {
111
152
  fullPath += `/${routePath.replace(/^\/+|\/+$/g, "")}`;
112
153
  }
113
- if (path3 && path3 !== "/") {
114
- fullPath += `/${path3.replace(/^\/+|\/+$/g, "")}`;
115
- } else if (path3 === "/") {
154
+ if (path5 && path5 !== "/") {
155
+ fullPath += `/${path5.replace(/^\/+|\/+$/g, "")}`;
156
+ } else if (path5 === "/") {
116
157
  fullPath += "/";
117
158
  }
159
+ if (fullPath && !fullPath.startsWith("/")) fullPath = "/" + fullPath;
118
160
  return fullPath || "/";
119
161
  }
120
162
 
@@ -137,10 +179,10 @@ var ClientGeneratorService = class {
137
179
  * Generates the TypeScript RPC client
138
180
  */
139
181
  async generateClient(routes, schemas) {
140
- await import_promises.default.mkdir(this.outputDir, { recursive: true });
182
+ await import_promises2.default.mkdir(this.outputDir, { recursive: true });
141
183
  await this.generateClientFile(routes, schemas);
142
184
  const generatedInfo = {
143
- clientFile: import_path.default.join(this.outputDir, "client.ts"),
185
+ clientFile: import_path2.default.join(this.outputDir, "client.ts"),
144
186
  generatedAt: (/* @__PURE__ */ new Date()).toISOString()
145
187
  };
146
188
  return generatedInfo;
@@ -150,8 +192,8 @@ var ClientGeneratorService = class {
150
192
  */
151
193
  async generateClientFile(routes, schemas) {
152
194
  const clientContent = this.generateClientContent(routes, schemas);
153
- const clientPath = import_path.default.join(this.outputDir, "client.ts");
154
- await import_promises.default.writeFile(clientPath, clientContent, "utf-8");
195
+ const clientPath = import_path2.default.join(this.outputDir, "client.ts");
196
+ await import_promises2.default.writeFile(clientPath, clientContent, "utf-8");
155
197
  }
156
198
  /**
157
199
  * Generates the client TypeScript content with types included
@@ -310,6 +352,14 @@ export class ApiClient {
310
352
 
311
353
  try {
312
354
  const response = await this.fetchFn(url.toString(), requestOptions)
355
+
356
+ if (response.status === 204 || response.headers.get('content-length') === '0') {
357
+ if (!response.ok) {
358
+ throw new ApiError(response.status, 'Request failed')
359
+ }
360
+ return undefined as T
361
+ }
362
+
313
363
  const responseData = await response.json()
314
364
 
315
365
  if (!response.ok) {
@@ -452,71 +502,216 @@ ${this.generateControllerMethods(controllerGroups)}
452
502
  */
453
503
  analyzeRouteParameters(route) {
454
504
  const parameters = route.parameters || [];
455
- const method = String(route.method || "").toLowerCase();
456
- const isInPath = (p) => {
457
- const pathSegment = p.data;
458
- return !!pathSegment && typeof pathSegment === "string" && route.path.includes(`:${pathSegment}`);
459
- };
460
- const pathParams = parameters.filter((p) => isInPath(p)).map((p) => ({ ...p, required: true }));
461
- const rawBody = parameters.filter((p) => !isInPath(p) && method !== "get");
462
- const bodyParams = rawBody.map((p) => ({
463
- ...p,
464
- required: true
465
- }));
466
- const queryParams = parameters.filter((p) => !isInPath(p) && method === "get").map((p) => ({
467
- ...p,
468
- required: p.required === true
469
- // default false if not provided
470
- }));
505
+ const pathParams = parameters.filter((p) => p.decoratorType === "param").map((p) => ({ ...p, required: true }));
506
+ const bodyParams = parameters.filter((p) => p.decoratorType === "body").map((p) => ({ ...p, required: true }));
507
+ const queryParams = parameters.filter((p) => p.decoratorType === "query").map((p) => ({ ...p, required: p.required === true }));
471
508
  return { pathParams, queryParams, bodyParams };
472
509
  }
473
510
  };
474
511
 
512
+ // src/services/openapi-generator.service.ts
513
+ var import_promises3 = __toESM(require("fs/promises"));
514
+ var import_path3 = __toESM(require("path"));
515
+ var OpenApiGeneratorService = class {
516
+ constructor(outputDir) {
517
+ this.outputDir = outputDir;
518
+ }
519
+ async generateSpec(routes, schemas, options) {
520
+ await import_promises3.default.mkdir(this.outputDir, { recursive: true });
521
+ const schemaMap = this.buildSchemaMap(schemas);
522
+ const spec = this.buildSpec(routes, schemaMap, options);
523
+ const outputPath = import_path3.default.join(this.outputDir, options.outputFile);
524
+ await import_promises3.default.writeFile(outputPath, JSON.stringify(spec, null, 2), "utf-8");
525
+ return outputPath;
526
+ }
527
+ buildSpec(routes, schemaMap, options) {
528
+ const spec = {
529
+ openapi: "3.0.3",
530
+ info: {
531
+ title: options.title,
532
+ version: options.version,
533
+ description: options.description
534
+ },
535
+ paths: {},
536
+ components: { schemas: schemaMap }
537
+ };
538
+ if (options.servers.length > 0) {
539
+ spec.servers = options.servers.map((s) => ({ ...s }));
540
+ }
541
+ for (const route of routes) {
542
+ const apiPath = this.toOpenApiPath(buildFullApiPath(route));
543
+ const method = safeToString(route.method).toLowerCase();
544
+ if (!spec.paths[apiPath]) {
545
+ spec.paths[apiPath] = {};
546
+ }
547
+ spec.paths[apiPath][method] = this.buildOperation(route, schemaMap);
548
+ }
549
+ return spec;
550
+ }
551
+ buildOperation(route, schemaMap) {
552
+ const controllerName = safeToString(route.controller).replace(/Controller$/, "");
553
+ const handlerName = safeToString(route.handler);
554
+ const parameters = route.parameters || [];
555
+ const operation = {
556
+ operationId: handlerName,
557
+ tags: [controllerName],
558
+ responses: this.buildResponses(route.returns, schemaMap)
559
+ };
560
+ const openApiParams = this.buildParameters(parameters);
561
+ if (openApiParams.length > 0) {
562
+ operation.parameters = openApiParams;
563
+ }
564
+ const requestBody = this.buildRequestBody(parameters, schemaMap);
565
+ if (requestBody) {
566
+ operation.requestBody = requestBody;
567
+ }
568
+ return operation;
569
+ }
570
+ buildParameters(parameters) {
571
+ const result = [];
572
+ for (const param of parameters) {
573
+ if (param.decoratorType === "param") {
574
+ result.push({
575
+ name: param.data ?? param.name,
576
+ in: "path",
577
+ required: true,
578
+ schema: this.tsTypeToJsonSchema(param.type)
579
+ });
580
+ } else if (param.decoratorType === "query") {
581
+ if (param.data) {
582
+ result.push({
583
+ name: param.data,
584
+ in: "query",
585
+ required: param.required === true,
586
+ schema: this.tsTypeToJsonSchema(param.type)
587
+ });
588
+ } else {
589
+ result.push({
590
+ name: param.name,
591
+ in: "query",
592
+ required: param.required === true,
593
+ schema: this.tsTypeToJsonSchema(param.type)
594
+ });
595
+ }
596
+ }
597
+ }
598
+ return result;
599
+ }
600
+ buildRequestBody(parameters, schemaMap) {
601
+ const bodyParams = parameters.filter((p) => p.decoratorType === "body");
602
+ if (bodyParams.length === 0) return null;
603
+ const bodyParam = bodyParams[0];
604
+ const typeName = this.extractBaseTypeName(bodyParam.type);
605
+ let schema;
606
+ if (typeName && schemaMap[typeName]) {
607
+ schema = { $ref: `#/components/schemas/${typeName}` };
608
+ } else {
609
+ schema = { type: "object" };
610
+ }
611
+ return {
612
+ required: true,
613
+ content: {
614
+ "application/json": { schema }
615
+ }
616
+ };
617
+ }
618
+ buildResponses(returns, schemaMap) {
619
+ const responseSchema = this.resolveResponseSchema(returns, schemaMap);
620
+ if (!responseSchema) {
621
+ return { "200": { description: "Successful response" } };
622
+ }
623
+ return {
624
+ "200": {
625
+ description: "Successful response",
626
+ content: {
627
+ "application/json": { schema: responseSchema }
628
+ }
629
+ }
630
+ };
631
+ }
632
+ resolveResponseSchema(returns, schemaMap) {
633
+ if (!returns) return null;
634
+ let innerType = returns;
635
+ const promiseMatch = returns.match(/^Promise<(.+)>$/);
636
+ if (promiseMatch) {
637
+ innerType = promiseMatch[1];
638
+ }
639
+ const isArray = innerType.endsWith("[]");
640
+ const baseType = isArray ? innerType.slice(0, -2) : innerType;
641
+ if (["string", "number", "boolean"].includes(baseType)) {
642
+ const primitiveSchema = this.tsTypeToJsonSchema(baseType);
643
+ return isArray ? { type: "array", items: primitiveSchema } : primitiveSchema;
644
+ }
645
+ if (["void", "any", "unknown"].includes(baseType)) return null;
646
+ if (schemaMap[baseType]) {
647
+ const ref = { $ref: `#/components/schemas/${baseType}` };
648
+ return isArray ? { type: "array", items: ref } : ref;
649
+ }
650
+ return null;
651
+ }
652
+ buildSchemaMap(schemas) {
653
+ const result = {};
654
+ for (const schemaInfo of schemas) {
655
+ const definition = schemaInfo.schema?.definitions?.[schemaInfo.type];
656
+ if (definition) {
657
+ result[schemaInfo.type] = definition;
658
+ }
659
+ }
660
+ return result;
661
+ }
662
+ /**
663
+ * Converts Express-style `:param` path to OpenAPI `{param}` syntax
664
+ */
665
+ toOpenApiPath(expressPath) {
666
+ return expressPath.replace(/:(\w+)/g, "{$1}");
667
+ }
668
+ tsTypeToJsonSchema(tsType) {
669
+ switch (tsType) {
670
+ case "number":
671
+ return { type: "number" };
672
+ case "boolean":
673
+ return { type: "boolean" };
674
+ case "string":
675
+ default:
676
+ return { type: "string" };
677
+ }
678
+ }
679
+ /**
680
+ * Extracts the base type name from a TS type string, stripping
681
+ * wrappers like `Partial<...>`, `...[]`, `Promise<...>`.
682
+ */
683
+ extractBaseTypeName(tsType) {
684
+ if (!tsType) return null;
685
+ let type = tsType;
686
+ const promiseMatch = type.match(/^Promise<(.+)>$/);
687
+ if (promiseMatch) type = promiseMatch[1];
688
+ type = type.replace(/\[\]$/, "");
689
+ const genericMatch = type.match(/^\w+<(\w+)>$/);
690
+ if (genericMatch) type = genericMatch[1];
691
+ if (["string", "number", "boolean", "any", "void", "unknown", "object"].includes(type)) {
692
+ return null;
693
+ }
694
+ return type;
695
+ }
696
+ };
697
+
475
698
  // src/services/route-analyzer.service.ts
476
699
  var import_honestjs = require("honestjs");
477
- var import_ts_morph = require("ts-morph");
478
700
  var RouteAnalyzerService = class {
479
- constructor(controllerPattern, tsConfigPath) {
480
- this.controllerPattern = controllerPattern;
481
- this.tsConfigPath = tsConfigPath;
482
- }
483
- // Track projects for cleanup
484
- projects = [];
485
701
  /**
486
702
  * Analyzes controller methods to extract type information
487
703
  */
488
- async analyzeControllerMethods() {
704
+ async analyzeControllerMethods(project) {
489
705
  const routes = import_honestjs.RouteRegistry.getRoutes();
490
706
  if (!routes?.length) {
491
707
  return [];
492
708
  }
493
- const project = this.createProject();
494
709
  const controllers = this.findControllerClasses(project);
495
710
  if (controllers.size === 0) {
496
711
  return [];
497
712
  }
498
713
  return this.processRoutes(routes, controllers);
499
714
  }
500
- /**
501
- * Creates a new ts-morph project
502
- */
503
- createProject() {
504
- const project = new import_ts_morph.Project({
505
- tsConfigFilePath: this.tsConfigPath
506
- });
507
- project.addSourceFilesAtPaths([this.controllerPattern]);
508
- this.projects.push(project);
509
- return project;
510
- }
511
- /**
512
- * Cleanup resources to prevent memory leaks
513
- */
514
- dispose() {
515
- this.projects.forEach((project) => {
516
- project.getSourceFiles().forEach((file) => project.removeSourceFile(file));
517
- });
518
- this.projects = [];
519
- }
520
715
  /**
521
716
  * Finds controller classes in the project
522
717
  */
@@ -539,23 +734,17 @@ var RouteAnalyzerService = class {
539
734
  */
540
735
  processRoutes(routes, controllers) {
541
736
  const analyzedRoutes = [];
542
- const errors = [];
543
737
  for (const route of routes) {
544
738
  try {
545
739
  const extendedRoute = this.createExtendedRoute(route, controllers);
546
740
  analyzedRoutes.push(extendedRoute);
547
741
  } catch (routeError) {
548
- const error = routeError instanceof Error ? routeError : new Error(String(routeError));
549
- errors.push(error);
550
- console.error(
551
- `Error processing route ${safeToString(route.controller)}.${safeToString(route.handler)}:`,
742
+ console.warn(
743
+ `${LOG_PREFIX} Skipping route ${safeToString(route.controller)}.${safeToString(route.handler)}:`,
552
744
  routeError
553
745
  );
554
746
  }
555
747
  }
556
- if (errors.length > 0) {
557
- throw new Error(`Failed to process ${errors.length} routes: ${errors.map((e) => e.message).join(", ")}`);
558
- }
559
748
  return analyzedRoutes;
560
749
  }
561
750
  /**
@@ -608,6 +797,7 @@ var RouteAnalyzerService = class {
608
797
  const sortedParams = [...parameters].sort((a, b) => a.index - b.index);
609
798
  for (const param of sortedParams) {
610
799
  const index = param.index;
800
+ const decoratorType = param.name;
611
801
  if (index < declaredParams.length) {
612
802
  const declaredParam = declaredParams[index];
613
803
  const paramName = declaredParam.getName();
@@ -615,6 +805,7 @@ var RouteAnalyzerService = class {
615
805
  result.push({
616
806
  index,
617
807
  name: paramName,
808
+ decoratorType,
618
809
  type: paramType,
619
810
  required: true,
620
811
  data: param.data,
@@ -625,6 +816,7 @@ var RouteAnalyzerService = class {
625
816
  result.push({
626
817
  index,
627
818
  name: `param${index}`,
819
+ decoratorType,
628
820
  type: param.metatype?.name || "unknown",
629
821
  required: true,
630
822
  data: param.data,
@@ -639,7 +831,6 @@ var RouteAnalyzerService = class {
639
831
 
640
832
  // src/services/schema-generator.service.ts
641
833
  var import_ts_json_schema_generator = require("ts-json-schema-generator");
642
- var import_ts_morph2 = require("ts-morph");
643
834
 
644
835
  // src/utils/schema-utils.ts
645
836
  function mapJsonSchemaTypeToTypeScript(schema) {
@@ -706,32 +897,6 @@ function extractNamedType(type) {
706
897
  if (BUILTIN_TYPES.has(name)) return null;
707
898
  return name;
708
899
  }
709
- function generateTypeImports(routes) {
710
- const types = /* @__PURE__ */ new Set();
711
- for (const route of routes) {
712
- if (route.parameters) {
713
- for (const param of route.parameters) {
714
- if (param.type && !["string", "number", "boolean"].includes(param.type)) {
715
- const typeMatch = param.type.match(/(\w+)(?:<.*>)?/);
716
- if (typeMatch) {
717
- const typeName = typeMatch[1];
718
- if (!BUILTIN_UTILITY_TYPES.has(typeName)) {
719
- types.add(typeName);
720
- }
721
- }
722
- }
723
- }
724
- }
725
- if (route.returns) {
726
- const returnType = route.returns.replace(/Promise<(.+)>/, "$1");
727
- const baseType = returnType.replace(/\[\]$/, "");
728
- if (!["string", "number", "boolean", "any", "void", "unknown"].includes(baseType)) {
729
- types.add(baseType);
730
- }
731
- }
732
- }
733
- return Array.from(types).join(", ");
734
- }
735
900
 
736
901
  // src/services/schema-generator.service.ts
737
902
  var SchemaGeneratorService = class {
@@ -739,37 +904,14 @@ var SchemaGeneratorService = class {
739
904
  this.controllerPattern = controllerPattern;
740
905
  this.tsConfigPath = tsConfigPath;
741
906
  }
742
- // Track projects for cleanup
743
- projects = [];
744
907
  /**
745
908
  * Generates JSON schemas from types used in controllers
746
909
  */
747
- async generateSchemas() {
748
- const project = this.createProject();
910
+ async generateSchemas(project) {
749
911
  const sourceFiles = project.getSourceFiles(this.controllerPattern);
750
912
  const collectedTypes = this.collectTypesFromControllers(sourceFiles);
751
913
  return this.processTypes(collectedTypes);
752
914
  }
753
- /**
754
- * Creates a new ts-morph project
755
- */
756
- createProject() {
757
- const project = new import_ts_morph2.Project({
758
- tsConfigFilePath: this.tsConfigPath
759
- });
760
- project.addSourceFilesAtPaths([this.controllerPattern]);
761
- this.projects.push(project);
762
- return project;
763
- }
764
- /**
765
- * Cleanup resources to prevent memory leaks
766
- */
767
- dispose() {
768
- this.projects.forEach((project) => {
769
- project.getSourceFiles().forEach((file) => project.removeSourceFile(file));
770
- });
771
- this.projects = [];
772
- }
773
915
  /**
774
916
  * Collects types from controller files
775
917
  */
@@ -851,20 +993,37 @@ var RPCPlugin = class {
851
993
  routeAnalyzer;
852
994
  schemaGenerator;
853
995
  clientGenerator;
996
+ openApiGenerator;
997
+ openApiOptions;
998
+ // Shared ts-morph project
999
+ project = null;
854
1000
  // Internal state
855
1001
  analyzedRoutes = [];
856
1002
  analyzedSchemas = [];
857
1003
  generatedInfo = null;
858
1004
  constructor(options = {}) {
859
1005
  this.controllerPattern = options.controllerPattern ?? DEFAULT_OPTIONS.controllerPattern;
860
- this.tsConfigPath = options.tsConfigPath ?? import_path2.default.resolve(process.cwd(), DEFAULT_OPTIONS.tsConfigPath);
861
- this.outputDir = options.outputDir ?? import_path2.default.resolve(process.cwd(), DEFAULT_OPTIONS.outputDir);
1006
+ this.tsConfigPath = options.tsConfigPath ?? import_path4.default.resolve(process.cwd(), DEFAULT_OPTIONS.tsConfigPath);
1007
+ this.outputDir = options.outputDir ?? import_path4.default.resolve(process.cwd(), DEFAULT_OPTIONS.outputDir);
862
1008
  this.generateOnInit = options.generateOnInit ?? DEFAULT_OPTIONS.generateOnInit;
863
- this.routeAnalyzer = new RouteAnalyzerService(this.controllerPattern, this.tsConfigPath);
1009
+ this.routeAnalyzer = new RouteAnalyzerService();
864
1010
  this.schemaGenerator = new SchemaGeneratorService(this.controllerPattern, this.tsConfigPath);
865
1011
  this.clientGenerator = new ClientGeneratorService(this.outputDir);
1012
+ this.openApiOptions = this.resolveOpenApiOptions(options.openapi);
1013
+ this.openApiGenerator = this.openApiOptions ? new OpenApiGeneratorService(this.outputDir) : null;
866
1014
  this.validateConfiguration();
867
1015
  }
1016
+ resolveOpenApiOptions(input) {
1017
+ if (!input) return null;
1018
+ const opts = input === true ? {} : input;
1019
+ return {
1020
+ title: opts.title ?? "API",
1021
+ version: opts.version ?? "1.0.0",
1022
+ description: opts.description ?? "",
1023
+ servers: opts.servers ?? [],
1024
+ outputFile: opts.outputFile ?? "openapi.json"
1025
+ };
1026
+ }
868
1027
  /**
869
1028
  * Validates the plugin configuration
870
1029
  */
@@ -876,7 +1035,7 @@ var RPCPlugin = class {
876
1035
  if (!this.tsConfigPath?.trim()) {
877
1036
  errors.push("TypeScript config path cannot be empty");
878
1037
  } else {
879
- if (!import_fs.default.existsSync(this.tsConfigPath)) {
1038
+ if (!import_fs2.default.existsSync(this.tsConfigPath)) {
880
1039
  errors.push(`TypeScript config file not found at: ${this.tsConfigPath}`);
881
1040
  }
882
1041
  }
@@ -901,15 +1060,37 @@ var RPCPlugin = class {
901
1060
  /**
902
1061
  * Main analysis method that coordinates all three components
903
1062
  */
904
- async analyzeEverything() {
1063
+ async analyzeEverything(force = false) {
905
1064
  try {
906
1065
  this.log("Starting comprehensive RPC analysis...");
907
1066
  this.analyzedRoutes = [];
908
1067
  this.analyzedSchemas = [];
909
1068
  this.generatedInfo = null;
910
- this.analyzedRoutes = await this.routeAnalyzer.analyzeControllerMethods();
911
- this.analyzedSchemas = await this.schemaGenerator.generateSchemas();
1069
+ this.dispose();
1070
+ this.project = new import_ts_morph.Project({ tsConfigFilePath: this.tsConfigPath });
1071
+ this.project.addSourceFilesAtPaths([this.controllerPattern]);
1072
+ const filePaths = this.project.getSourceFiles().map((f) => f.getFilePath());
1073
+ if (!force) {
1074
+ const currentHash = computeHash(filePaths);
1075
+ const stored = readChecksum(this.outputDir);
1076
+ if (stored && stored.hash === currentHash && this.outputFilesExist()) {
1077
+ this.log("Source files unchanged \u2014 skipping regeneration");
1078
+ this.dispose();
1079
+ return;
1080
+ }
1081
+ }
1082
+ this.analyzedRoutes = await this.routeAnalyzer.analyzeControllerMethods(this.project);
1083
+ this.analyzedSchemas = await this.schemaGenerator.generateSchemas(this.project);
912
1084
  this.generatedInfo = await this.clientGenerator.generateClient(this.analyzedRoutes, this.analyzedSchemas);
1085
+ if (this.openApiGenerator && this.openApiOptions) {
1086
+ const specPath = await this.openApiGenerator.generateSpec(
1087
+ this.analyzedRoutes,
1088
+ this.analyzedSchemas,
1089
+ this.openApiOptions
1090
+ );
1091
+ this.log(`OpenAPI spec generated: ${specPath}`);
1092
+ }
1093
+ await writeChecksum(this.outputDir, { hash: computeHash(filePaths), files: filePaths });
913
1094
  this.log(
914
1095
  `\u2705 RPC analysis complete: ${this.analyzedRoutes.length} routes, ${this.analyzedSchemas.length} schemas`
915
1096
  );
@@ -920,10 +1101,11 @@ var RPCPlugin = class {
920
1101
  }
921
1102
  }
922
1103
  /**
923
- * Manually trigger analysis (useful for testing or re-generation)
1104
+ * Manually trigger analysis (useful for testing or re-generation).
1105
+ * Defaults to force=true to bypass cache; pass false to use caching.
924
1106
  */
925
- async analyze() {
926
- await this.analyzeEverything();
1107
+ async analyze(force = true) {
1108
+ await this.analyzeEverything(force);
927
1109
  }
928
1110
  /**
929
1111
  * Get the analyzed routes
@@ -943,13 +1125,24 @@ var RPCPlugin = class {
943
1125
  getGenerationInfo() {
944
1126
  return this.generatedInfo;
945
1127
  }
1128
+ /**
1129
+ * Checks whether expected output files exist on disk
1130
+ */
1131
+ outputFilesExist() {
1132
+ if (!import_fs2.default.existsSync(import_path4.default.join(this.outputDir, "client.ts"))) return false;
1133
+ if (this.openApiOptions) {
1134
+ return import_fs2.default.existsSync(import_path4.default.join(this.outputDir, this.openApiOptions.outputFile));
1135
+ }
1136
+ return true;
1137
+ }
946
1138
  /**
947
1139
  * Cleanup resources to prevent memory leaks
948
1140
  */
949
1141
  dispose() {
950
- this.routeAnalyzer.dispose();
951
- this.schemaGenerator.dispose();
952
- this.log("Resources cleaned up");
1142
+ if (this.project) {
1143
+ this.project.getSourceFiles().forEach((file) => this.project.removeSourceFile(file));
1144
+ this.project = null;
1145
+ }
953
1146
  }
954
1147
  // ============================================================================
955
1148
  // LOGGING UTILITIES
@@ -975,16 +1168,19 @@ var RPCPlugin = class {
975
1168
  DEFAULT_OPTIONS,
976
1169
  GENERIC_TYPES,
977
1170
  LOG_PREFIX,
1171
+ OpenApiGeneratorService,
978
1172
  RPCPlugin,
979
1173
  RouteAnalyzerService,
980
1174
  SchemaGeneratorService,
981
1175
  buildFullApiPath,
982
1176
  buildFullPath,
983
1177
  camelCase,
1178
+ computeHash,
984
1179
  extractNamedType,
985
- generateTypeImports,
986
1180
  generateTypeScriptInterface,
987
1181
  mapJsonSchemaTypeToTypeScript,
988
- safeToString
1182
+ readChecksum,
1183
+ safeToString,
1184
+ writeChecksum
989
1185
  });
990
1186
  //# sourceMappingURL=index.js.map