@honestjs/rpc-plugin 1.1.1 → 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
@@ -162,15 +204,6 @@ var ClientGeneratorService = class {
162
204
  // TYPES SECTION
163
205
  // ============================================================================
164
206
 
165
- /**
166
- * API Response wrapper
167
- */
168
- export interface ApiResponse<T = any> {
169
- data: T
170
- message?: string
171
- success: boolean
172
- }
173
-
174
207
  /**
175
208
  * API Error class
176
209
  */
@@ -283,7 +316,7 @@ export class ApiClient {
283
316
  method: string,
284
317
  path: string,
285
318
  options: RequestOptions<any, any, any, any> = {}
286
- ): Promise<ApiResponse<T>> {
319
+ ): Promise<T> {
287
320
  const { params, query, body, headers = {} } = options as any
288
321
 
289
322
  // Build the final URL with path parameters
@@ -319,6 +352,14 @@ export class ApiClient {
319
352
 
320
353
  try {
321
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
+
322
363
  const responseData = await response.json()
323
364
 
324
365
  if (!response.ok) {
@@ -356,8 +397,9 @@ ${this.generateControllerMethods(controllerGroups)}
356
397
  const methodName = camelCase(safeToString(route.handler));
357
398
  const httpMethod = safeToString(route.method).toLowerCase();
358
399
  const { pathParams, queryParams, bodyParams } = this.analyzeRouteParameters(route);
400
+ const returnType = this.extractReturnType(route.returns);
359
401
  const hasRequiredParams = pathParams.length > 0 || queryParams.some((p) => p.required) || bodyParams.length > 0 && httpMethod !== "get";
360
- methods += ` ${methodName}: async (options${hasRequiredParams ? "" : "?"}: RequestOptions<`;
402
+ methods += ` ${methodName}: async <Result = ${returnType}>(options${hasRequiredParams ? "" : "?"}: RequestOptions<`;
361
403
  if (pathParams.length > 0) {
362
404
  const pathParamTypes = pathParams.map((p) => {
363
405
  const paramName = p.name;
@@ -391,8 +433,7 @@ ${this.generateControllerMethods(controllerGroups)}
391
433
  }
392
434
  methods += ", ";
393
435
  methods += "undefined";
394
- const returnType = this.extractReturnType(route.returns);
395
- methods += `>): Promise<ApiResponse<${returnType}>> => {
436
+ methods += `>) => {
396
437
  `;
397
438
  let requestPath = buildFullApiPath(route);
398
439
  if (pathParams.length > 0) {
@@ -402,7 +443,7 @@ ${this.generateControllerMethods(controllerGroups)}
402
443
  requestPath = requestPath.replace(placeholder, `:${paramName}`);
403
444
  }
404
445
  }
405
- methods += ` return this.request<${returnType}>('${httpMethod.toUpperCase()}', \`${requestPath}\`, options)
446
+ methods += ` return this.request<Result>('${httpMethod.toUpperCase()}', \`${requestPath}\`, options)
406
447
  `;
407
448
  methods += ` },
408
449
  `;
@@ -461,71 +502,216 @@ ${this.generateControllerMethods(controllerGroups)}
461
502
  */
462
503
  analyzeRouteParameters(route) {
463
504
  const parameters = route.parameters || [];
464
- const method = String(route.method || "").toLowerCase();
465
- const isInPath = (p) => {
466
- const pathSegment = p.data;
467
- return !!pathSegment && typeof pathSegment === "string" && route.path.includes(`:${pathSegment}`);
468
- };
469
- const pathParams = parameters.filter((p) => isInPath(p)).map((p) => ({ ...p, required: true }));
470
- const rawBody = parameters.filter((p) => !isInPath(p) && method !== "get");
471
- const bodyParams = rawBody.map((p) => ({
472
- ...p,
473
- required: true
474
- }));
475
- const queryParams = parameters.filter((p) => !isInPath(p) && method === "get").map((p) => ({
476
- ...p,
477
- required: p.required === true
478
- // default false if not provided
479
- }));
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 }));
480
508
  return { pathParams, queryParams, bodyParams };
481
509
  }
482
510
  };
483
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
+
484
698
  // src/services/route-analyzer.service.ts
485
699
  var import_honestjs = require("honestjs");
486
- var import_ts_morph = require("ts-morph");
487
700
  var RouteAnalyzerService = class {
488
- constructor(controllerPattern, tsConfigPath) {
489
- this.controllerPattern = controllerPattern;
490
- this.tsConfigPath = tsConfigPath;
491
- }
492
- // Track projects for cleanup
493
- projects = [];
494
701
  /**
495
702
  * Analyzes controller methods to extract type information
496
703
  */
497
- async analyzeControllerMethods() {
704
+ async analyzeControllerMethods(project) {
498
705
  const routes = import_honestjs.RouteRegistry.getRoutes();
499
706
  if (!routes?.length) {
500
707
  return [];
501
708
  }
502
- const project = this.createProject();
503
709
  const controllers = this.findControllerClasses(project);
504
710
  if (controllers.size === 0) {
505
711
  return [];
506
712
  }
507
713
  return this.processRoutes(routes, controllers);
508
714
  }
509
- /**
510
- * Creates a new ts-morph project
511
- */
512
- createProject() {
513
- const project = new import_ts_morph.Project({
514
- tsConfigFilePath: this.tsConfigPath
515
- });
516
- project.addSourceFilesAtPaths([this.controllerPattern]);
517
- this.projects.push(project);
518
- return project;
519
- }
520
- /**
521
- * Cleanup resources to prevent memory leaks
522
- */
523
- dispose() {
524
- this.projects.forEach((project) => {
525
- project.getSourceFiles().forEach((file) => project.removeSourceFile(file));
526
- });
527
- this.projects = [];
528
- }
529
715
  /**
530
716
  * Finds controller classes in the project
531
717
  */
@@ -548,23 +734,17 @@ var RouteAnalyzerService = class {
548
734
  */
549
735
  processRoutes(routes, controllers) {
550
736
  const analyzedRoutes = [];
551
- const errors = [];
552
737
  for (const route of routes) {
553
738
  try {
554
739
  const extendedRoute = this.createExtendedRoute(route, controllers);
555
740
  analyzedRoutes.push(extendedRoute);
556
741
  } catch (routeError) {
557
- const error = routeError instanceof Error ? routeError : new Error(String(routeError));
558
- errors.push(error);
559
- console.error(
560
- `Error processing route ${safeToString(route.controller)}.${safeToString(route.handler)}:`,
742
+ console.warn(
743
+ `${LOG_PREFIX} Skipping route ${safeToString(route.controller)}.${safeToString(route.handler)}:`,
561
744
  routeError
562
745
  );
563
746
  }
564
747
  }
565
- if (errors.length > 0) {
566
- throw new Error(`Failed to process ${errors.length} routes: ${errors.map((e) => e.message).join(", ")}`);
567
- }
568
748
  return analyzedRoutes;
569
749
  }
570
750
  /**
@@ -617,6 +797,7 @@ var RouteAnalyzerService = class {
617
797
  const sortedParams = [...parameters].sort((a, b) => a.index - b.index);
618
798
  for (const param of sortedParams) {
619
799
  const index = param.index;
800
+ const decoratorType = param.name;
620
801
  if (index < declaredParams.length) {
621
802
  const declaredParam = declaredParams[index];
622
803
  const paramName = declaredParam.getName();
@@ -624,6 +805,7 @@ var RouteAnalyzerService = class {
624
805
  result.push({
625
806
  index,
626
807
  name: paramName,
808
+ decoratorType,
627
809
  type: paramType,
628
810
  required: true,
629
811
  data: param.data,
@@ -634,6 +816,7 @@ var RouteAnalyzerService = class {
634
816
  result.push({
635
817
  index,
636
818
  name: `param${index}`,
819
+ decoratorType,
637
820
  type: param.metatype?.name || "unknown",
638
821
  required: true,
639
822
  data: param.data,
@@ -648,7 +831,6 @@ var RouteAnalyzerService = class {
648
831
 
649
832
  // src/services/schema-generator.service.ts
650
833
  var import_ts_json_schema_generator = require("ts-json-schema-generator");
651
- var import_ts_morph2 = require("ts-morph");
652
834
 
653
835
  // src/utils/schema-utils.ts
654
836
  function mapJsonSchemaTypeToTypeScript(schema) {
@@ -715,32 +897,6 @@ function extractNamedType(type) {
715
897
  if (BUILTIN_TYPES.has(name)) return null;
716
898
  return name;
717
899
  }
718
- function generateTypeImports(routes) {
719
- const types = /* @__PURE__ */ new Set();
720
- for (const route of routes) {
721
- if (route.parameters) {
722
- for (const param of route.parameters) {
723
- if (param.type && !["string", "number", "boolean"].includes(param.type)) {
724
- const typeMatch = param.type.match(/(\w+)(?:<.*>)?/);
725
- if (typeMatch) {
726
- const typeName = typeMatch[1];
727
- if (!BUILTIN_UTILITY_TYPES.has(typeName)) {
728
- types.add(typeName);
729
- }
730
- }
731
- }
732
- }
733
- }
734
- if (route.returns) {
735
- const returnType = route.returns.replace(/Promise<(.+)>/, "$1");
736
- const baseType = returnType.replace(/\[\]$/, "");
737
- if (!["string", "number", "boolean", "any", "void", "unknown"].includes(baseType)) {
738
- types.add(baseType);
739
- }
740
- }
741
- }
742
- return Array.from(types).join(", ");
743
- }
744
900
 
745
901
  // src/services/schema-generator.service.ts
746
902
  var SchemaGeneratorService = class {
@@ -748,37 +904,14 @@ var SchemaGeneratorService = class {
748
904
  this.controllerPattern = controllerPattern;
749
905
  this.tsConfigPath = tsConfigPath;
750
906
  }
751
- // Track projects for cleanup
752
- projects = [];
753
907
  /**
754
908
  * Generates JSON schemas from types used in controllers
755
909
  */
756
- async generateSchemas() {
757
- const project = this.createProject();
910
+ async generateSchemas(project) {
758
911
  const sourceFiles = project.getSourceFiles(this.controllerPattern);
759
912
  const collectedTypes = this.collectTypesFromControllers(sourceFiles);
760
913
  return this.processTypes(collectedTypes);
761
914
  }
762
- /**
763
- * Creates a new ts-morph project
764
- */
765
- createProject() {
766
- const project = new import_ts_morph2.Project({
767
- tsConfigFilePath: this.tsConfigPath
768
- });
769
- project.addSourceFilesAtPaths([this.controllerPattern]);
770
- this.projects.push(project);
771
- return project;
772
- }
773
- /**
774
- * Cleanup resources to prevent memory leaks
775
- */
776
- dispose() {
777
- this.projects.forEach((project) => {
778
- project.getSourceFiles().forEach((file) => project.removeSourceFile(file));
779
- });
780
- this.projects = [];
781
- }
782
915
  /**
783
916
  * Collects types from controller files
784
917
  */
@@ -860,20 +993,37 @@ var RPCPlugin = class {
860
993
  routeAnalyzer;
861
994
  schemaGenerator;
862
995
  clientGenerator;
996
+ openApiGenerator;
997
+ openApiOptions;
998
+ // Shared ts-morph project
999
+ project = null;
863
1000
  // Internal state
864
1001
  analyzedRoutes = [];
865
1002
  analyzedSchemas = [];
866
1003
  generatedInfo = null;
867
1004
  constructor(options = {}) {
868
1005
  this.controllerPattern = options.controllerPattern ?? DEFAULT_OPTIONS.controllerPattern;
869
- this.tsConfigPath = options.tsConfigPath ?? import_path2.default.resolve(process.cwd(), DEFAULT_OPTIONS.tsConfigPath);
870
- 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);
871
1008
  this.generateOnInit = options.generateOnInit ?? DEFAULT_OPTIONS.generateOnInit;
872
- this.routeAnalyzer = new RouteAnalyzerService(this.controllerPattern, this.tsConfigPath);
1009
+ this.routeAnalyzer = new RouteAnalyzerService();
873
1010
  this.schemaGenerator = new SchemaGeneratorService(this.controllerPattern, this.tsConfigPath);
874
1011
  this.clientGenerator = new ClientGeneratorService(this.outputDir);
1012
+ this.openApiOptions = this.resolveOpenApiOptions(options.openapi);
1013
+ this.openApiGenerator = this.openApiOptions ? new OpenApiGeneratorService(this.outputDir) : null;
875
1014
  this.validateConfiguration();
876
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
+ }
877
1027
  /**
878
1028
  * Validates the plugin configuration
879
1029
  */
@@ -885,7 +1035,7 @@ var RPCPlugin = class {
885
1035
  if (!this.tsConfigPath?.trim()) {
886
1036
  errors.push("TypeScript config path cannot be empty");
887
1037
  } else {
888
- if (!import_fs.default.existsSync(this.tsConfigPath)) {
1038
+ if (!import_fs2.default.existsSync(this.tsConfigPath)) {
889
1039
  errors.push(`TypeScript config file not found at: ${this.tsConfigPath}`);
890
1040
  }
891
1041
  }
@@ -910,15 +1060,37 @@ var RPCPlugin = class {
910
1060
  /**
911
1061
  * Main analysis method that coordinates all three components
912
1062
  */
913
- async analyzeEverything() {
1063
+ async analyzeEverything(force = false) {
914
1064
  try {
915
1065
  this.log("Starting comprehensive RPC analysis...");
916
1066
  this.analyzedRoutes = [];
917
1067
  this.analyzedSchemas = [];
918
1068
  this.generatedInfo = null;
919
- this.analyzedRoutes = await this.routeAnalyzer.analyzeControllerMethods();
920
- 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);
921
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 });
922
1094
  this.log(
923
1095
  `\u2705 RPC analysis complete: ${this.analyzedRoutes.length} routes, ${this.analyzedSchemas.length} schemas`
924
1096
  );
@@ -929,10 +1101,11 @@ var RPCPlugin = class {
929
1101
  }
930
1102
  }
931
1103
  /**
932
- * 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.
933
1106
  */
934
- async analyze() {
935
- await this.analyzeEverything();
1107
+ async analyze(force = true) {
1108
+ await this.analyzeEverything(force);
936
1109
  }
937
1110
  /**
938
1111
  * Get the analyzed routes
@@ -952,13 +1125,24 @@ var RPCPlugin = class {
952
1125
  getGenerationInfo() {
953
1126
  return this.generatedInfo;
954
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
+ }
955
1138
  /**
956
1139
  * Cleanup resources to prevent memory leaks
957
1140
  */
958
1141
  dispose() {
959
- this.routeAnalyzer.dispose();
960
- this.schemaGenerator.dispose();
961
- this.log("Resources cleaned up");
1142
+ if (this.project) {
1143
+ this.project.getSourceFiles().forEach((file) => this.project.removeSourceFile(file));
1144
+ this.project = null;
1145
+ }
962
1146
  }
963
1147
  // ============================================================================
964
1148
  // LOGGING UTILITIES
@@ -984,16 +1168,19 @@ var RPCPlugin = class {
984
1168
  DEFAULT_OPTIONS,
985
1169
  GENERIC_TYPES,
986
1170
  LOG_PREFIX,
1171
+ OpenApiGeneratorService,
987
1172
  RPCPlugin,
988
1173
  RouteAnalyzerService,
989
1174
  SchemaGeneratorService,
990
1175
  buildFullApiPath,
991
1176
  buildFullPath,
992
1177
  camelCase,
1178
+ computeHash,
993
1179
  extractNamedType,
994
- generateTypeImports,
995
1180
  generateTypeScriptInterface,
996
1181
  mapJsonSchemaTypeToTypeScript,
997
- safeToString
1182
+ readChecksum,
1183
+ safeToString,
1184
+ writeChecksum
998
1185
  });
999
1186
  //# sourceMappingURL=index.js.map