@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.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  // src/rpc.plugin.ts
2
- import fs2 from "fs";
3
- import path2 from "path";
2
+ import fs3 from "fs";
3
+ import path4 from "path";
4
+ import { Project } from "ts-morph";
4
5
 
5
6
  // src/constants/defaults.ts
6
7
  var DEFAULT_OPTIONS = {
@@ -25,29 +26,66 @@ var BUILTIN_UTILITY_TYPES = /* @__PURE__ */ new Set([
25
26
  var BUILTIN_TYPES = /* @__PURE__ */ new Set(["string", "number", "boolean", "any", "void", "unknown"]);
26
27
  var GENERIC_TYPES = /* @__PURE__ */ new Set(["Array", "Promise", "Partial"]);
27
28
 
29
+ // src/utils/hash-utils.ts
30
+ import { createHash } from "crypto";
31
+ import { existsSync, readFileSync } from "fs";
32
+ import { mkdir, writeFile } from "fs/promises";
33
+ import path from "path";
34
+ var CHECKSUM_FILENAME = ".rpc-checksum";
35
+ function computeHash(filePaths) {
36
+ const sorted = [...filePaths].sort();
37
+ const hasher = createHash("sha256");
38
+ hasher.update(`files:${sorted.length}
39
+ `);
40
+ for (const filePath of sorted) {
41
+ hasher.update(readFileSync(filePath, "utf-8"));
42
+ hasher.update("\0");
43
+ }
44
+ return hasher.digest("hex");
45
+ }
46
+ function readChecksum(outputDir) {
47
+ const checksumPath = path.join(outputDir, CHECKSUM_FILENAME);
48
+ if (!existsSync(checksumPath)) return null;
49
+ try {
50
+ const raw = readFileSync(checksumPath, "utf-8");
51
+ const data = JSON.parse(raw);
52
+ if (typeof data.hash !== "string" || !Array.isArray(data.files)) {
53
+ return null;
54
+ }
55
+ return data;
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+ async function writeChecksum(outputDir, data) {
61
+ await mkdir(outputDir, { recursive: true });
62
+ const checksumPath = path.join(outputDir, CHECKSUM_FILENAME);
63
+ await writeFile(checksumPath, JSON.stringify(data, null, 2), "utf-8");
64
+ }
65
+
28
66
  // src/services/client-generator.service.ts
29
67
  import fs from "fs/promises";
30
- import path from "path";
68
+ import path2 from "path";
31
69
 
32
70
  // src/utils/path-utils.ts
33
71
  function buildFullPath(basePath, parameters) {
34
72
  if (!basePath || typeof basePath !== "string") return "/";
35
- let path3 = basePath;
73
+ let path5 = basePath;
36
74
  if (parameters && Array.isArray(parameters)) {
37
75
  for (const param of parameters) {
38
76
  if (param.data && typeof param.data === "string" && param.data.startsWith(":")) {
39
77
  const paramName = param.data.slice(1);
40
- path3 = path3.replace(`:${paramName}`, `\${${paramName}}`);
78
+ path5 = path5.replace(`:${paramName}`, `\${${paramName}}`);
41
79
  }
42
80
  }
43
81
  }
44
- return path3;
82
+ return path5;
45
83
  }
46
84
  function buildFullApiPath(route) {
47
85
  const prefix = route.prefix || "";
48
86
  const version = route.version || "";
49
87
  const routePath = route.route || "";
50
- const path3 = route.path || "";
88
+ const path5 = route.path || "";
51
89
  let fullPath = "";
52
90
  if (prefix && prefix !== "/") {
53
91
  fullPath += prefix.replace(/^\/+|\/+$/g, "");
@@ -58,11 +96,12 @@ function buildFullApiPath(route) {
58
96
  if (routePath && routePath !== "/") {
59
97
  fullPath += `/${routePath.replace(/^\/+|\/+$/g, "")}`;
60
98
  }
61
- if (path3 && path3 !== "/") {
62
- fullPath += `/${path3.replace(/^\/+|\/+$/g, "")}`;
63
- } else if (path3 === "/") {
99
+ if (path5 && path5 !== "/") {
100
+ fullPath += `/${path5.replace(/^\/+|\/+$/g, "")}`;
101
+ } else if (path5 === "/") {
64
102
  fullPath += "/";
65
103
  }
104
+ if (fullPath && !fullPath.startsWith("/")) fullPath = "/" + fullPath;
66
105
  return fullPath || "/";
67
106
  }
68
107
 
@@ -88,7 +127,7 @@ var ClientGeneratorService = class {
88
127
  await fs.mkdir(this.outputDir, { recursive: true });
89
128
  await this.generateClientFile(routes, schemas);
90
129
  const generatedInfo = {
91
- clientFile: path.join(this.outputDir, "client.ts"),
130
+ clientFile: path2.join(this.outputDir, "client.ts"),
92
131
  generatedAt: (/* @__PURE__ */ new Date()).toISOString()
93
132
  };
94
133
  return generatedInfo;
@@ -98,7 +137,7 @@ var ClientGeneratorService = class {
98
137
  */
99
138
  async generateClientFile(routes, schemas) {
100
139
  const clientContent = this.generateClientContent(routes, schemas);
101
- const clientPath = path.join(this.outputDir, "client.ts");
140
+ const clientPath = path2.join(this.outputDir, "client.ts");
102
141
  await fs.writeFile(clientPath, clientContent, "utf-8");
103
142
  }
104
143
  /**
@@ -110,15 +149,6 @@ var ClientGeneratorService = class {
110
149
  // TYPES SECTION
111
150
  // ============================================================================
112
151
 
113
- /**
114
- * API Response wrapper
115
- */
116
- export interface ApiResponse<T = any> {
117
- data: T
118
- message?: string
119
- success: boolean
120
- }
121
-
122
152
  /**
123
153
  * API Error class
124
154
  */
@@ -231,7 +261,7 @@ export class ApiClient {
231
261
  method: string,
232
262
  path: string,
233
263
  options: RequestOptions<any, any, any, any> = {}
234
- ): Promise<ApiResponse<T>> {
264
+ ): Promise<T> {
235
265
  const { params, query, body, headers = {} } = options as any
236
266
 
237
267
  // Build the final URL with path parameters
@@ -267,6 +297,14 @@ export class ApiClient {
267
297
 
268
298
  try {
269
299
  const response = await this.fetchFn(url.toString(), requestOptions)
300
+
301
+ if (response.status === 204 || response.headers.get('content-length') === '0') {
302
+ if (!response.ok) {
303
+ throw new ApiError(response.status, 'Request failed')
304
+ }
305
+ return undefined as T
306
+ }
307
+
270
308
  const responseData = await response.json()
271
309
 
272
310
  if (!response.ok) {
@@ -304,8 +342,9 @@ ${this.generateControllerMethods(controllerGroups)}
304
342
  const methodName = camelCase(safeToString(route.handler));
305
343
  const httpMethod = safeToString(route.method).toLowerCase();
306
344
  const { pathParams, queryParams, bodyParams } = this.analyzeRouteParameters(route);
345
+ const returnType = this.extractReturnType(route.returns);
307
346
  const hasRequiredParams = pathParams.length > 0 || queryParams.some((p) => p.required) || bodyParams.length > 0 && httpMethod !== "get";
308
- methods += ` ${methodName}: async (options${hasRequiredParams ? "" : "?"}: RequestOptions<`;
347
+ methods += ` ${methodName}: async <Result = ${returnType}>(options${hasRequiredParams ? "" : "?"}: RequestOptions<`;
309
348
  if (pathParams.length > 0) {
310
349
  const pathParamTypes = pathParams.map((p) => {
311
350
  const paramName = p.name;
@@ -339,8 +378,7 @@ ${this.generateControllerMethods(controllerGroups)}
339
378
  }
340
379
  methods += ", ";
341
380
  methods += "undefined";
342
- const returnType = this.extractReturnType(route.returns);
343
- methods += `>): Promise<ApiResponse<${returnType}>> => {
381
+ methods += `>) => {
344
382
  `;
345
383
  let requestPath = buildFullApiPath(route);
346
384
  if (pathParams.length > 0) {
@@ -350,7 +388,7 @@ ${this.generateControllerMethods(controllerGroups)}
350
388
  requestPath = requestPath.replace(placeholder, `:${paramName}`);
351
389
  }
352
390
  }
353
- methods += ` return this.request<${returnType}>('${httpMethod.toUpperCase()}', \`${requestPath}\`, options)
391
+ methods += ` return this.request<Result>('${httpMethod.toUpperCase()}', \`${requestPath}\`, options)
354
392
  `;
355
393
  methods += ` },
356
394
  `;
@@ -409,71 +447,216 @@ ${this.generateControllerMethods(controllerGroups)}
409
447
  */
410
448
  analyzeRouteParameters(route) {
411
449
  const parameters = route.parameters || [];
412
- const method = String(route.method || "").toLowerCase();
413
- const isInPath = (p) => {
414
- const pathSegment = p.data;
415
- return !!pathSegment && typeof pathSegment === "string" && route.path.includes(`:${pathSegment}`);
416
- };
417
- const pathParams = parameters.filter((p) => isInPath(p)).map((p) => ({ ...p, required: true }));
418
- const rawBody = parameters.filter((p) => !isInPath(p) && method !== "get");
419
- const bodyParams = rawBody.map((p) => ({
420
- ...p,
421
- required: true
422
- }));
423
- const queryParams = parameters.filter((p) => !isInPath(p) && method === "get").map((p) => ({
424
- ...p,
425
- required: p.required === true
426
- // default false if not provided
427
- }));
450
+ const pathParams = parameters.filter((p) => p.decoratorType === "param").map((p) => ({ ...p, required: true }));
451
+ const bodyParams = parameters.filter((p) => p.decoratorType === "body").map((p) => ({ ...p, required: true }));
452
+ const queryParams = parameters.filter((p) => p.decoratorType === "query").map((p) => ({ ...p, required: p.required === true }));
428
453
  return { pathParams, queryParams, bodyParams };
429
454
  }
430
455
  };
431
456
 
457
+ // src/services/openapi-generator.service.ts
458
+ import fs2 from "fs/promises";
459
+ import path3 from "path";
460
+ var OpenApiGeneratorService = class {
461
+ constructor(outputDir) {
462
+ this.outputDir = outputDir;
463
+ }
464
+ async generateSpec(routes, schemas, options) {
465
+ await fs2.mkdir(this.outputDir, { recursive: true });
466
+ const schemaMap = this.buildSchemaMap(schemas);
467
+ const spec = this.buildSpec(routes, schemaMap, options);
468
+ const outputPath = path3.join(this.outputDir, options.outputFile);
469
+ await fs2.writeFile(outputPath, JSON.stringify(spec, null, 2), "utf-8");
470
+ return outputPath;
471
+ }
472
+ buildSpec(routes, schemaMap, options) {
473
+ const spec = {
474
+ openapi: "3.0.3",
475
+ info: {
476
+ title: options.title,
477
+ version: options.version,
478
+ description: options.description
479
+ },
480
+ paths: {},
481
+ components: { schemas: schemaMap }
482
+ };
483
+ if (options.servers.length > 0) {
484
+ spec.servers = options.servers.map((s) => ({ ...s }));
485
+ }
486
+ for (const route of routes) {
487
+ const apiPath = this.toOpenApiPath(buildFullApiPath(route));
488
+ const method = safeToString(route.method).toLowerCase();
489
+ if (!spec.paths[apiPath]) {
490
+ spec.paths[apiPath] = {};
491
+ }
492
+ spec.paths[apiPath][method] = this.buildOperation(route, schemaMap);
493
+ }
494
+ return spec;
495
+ }
496
+ buildOperation(route, schemaMap) {
497
+ const controllerName = safeToString(route.controller).replace(/Controller$/, "");
498
+ const handlerName = safeToString(route.handler);
499
+ const parameters = route.parameters || [];
500
+ const operation = {
501
+ operationId: handlerName,
502
+ tags: [controllerName],
503
+ responses: this.buildResponses(route.returns, schemaMap)
504
+ };
505
+ const openApiParams = this.buildParameters(parameters);
506
+ if (openApiParams.length > 0) {
507
+ operation.parameters = openApiParams;
508
+ }
509
+ const requestBody = this.buildRequestBody(parameters, schemaMap);
510
+ if (requestBody) {
511
+ operation.requestBody = requestBody;
512
+ }
513
+ return operation;
514
+ }
515
+ buildParameters(parameters) {
516
+ const result = [];
517
+ for (const param of parameters) {
518
+ if (param.decoratorType === "param") {
519
+ result.push({
520
+ name: param.data ?? param.name,
521
+ in: "path",
522
+ required: true,
523
+ schema: this.tsTypeToJsonSchema(param.type)
524
+ });
525
+ } else if (param.decoratorType === "query") {
526
+ if (param.data) {
527
+ result.push({
528
+ name: param.data,
529
+ in: "query",
530
+ required: param.required === true,
531
+ schema: this.tsTypeToJsonSchema(param.type)
532
+ });
533
+ } else {
534
+ result.push({
535
+ name: param.name,
536
+ in: "query",
537
+ required: param.required === true,
538
+ schema: this.tsTypeToJsonSchema(param.type)
539
+ });
540
+ }
541
+ }
542
+ }
543
+ return result;
544
+ }
545
+ buildRequestBody(parameters, schemaMap) {
546
+ const bodyParams = parameters.filter((p) => p.decoratorType === "body");
547
+ if (bodyParams.length === 0) return null;
548
+ const bodyParam = bodyParams[0];
549
+ const typeName = this.extractBaseTypeName(bodyParam.type);
550
+ let schema;
551
+ if (typeName && schemaMap[typeName]) {
552
+ schema = { $ref: `#/components/schemas/${typeName}` };
553
+ } else {
554
+ schema = { type: "object" };
555
+ }
556
+ return {
557
+ required: true,
558
+ content: {
559
+ "application/json": { schema }
560
+ }
561
+ };
562
+ }
563
+ buildResponses(returns, schemaMap) {
564
+ const responseSchema = this.resolveResponseSchema(returns, schemaMap);
565
+ if (!responseSchema) {
566
+ return { "200": { description: "Successful response" } };
567
+ }
568
+ return {
569
+ "200": {
570
+ description: "Successful response",
571
+ content: {
572
+ "application/json": { schema: responseSchema }
573
+ }
574
+ }
575
+ };
576
+ }
577
+ resolveResponseSchema(returns, schemaMap) {
578
+ if (!returns) return null;
579
+ let innerType = returns;
580
+ const promiseMatch = returns.match(/^Promise<(.+)>$/);
581
+ if (promiseMatch) {
582
+ innerType = promiseMatch[1];
583
+ }
584
+ const isArray = innerType.endsWith("[]");
585
+ const baseType = isArray ? innerType.slice(0, -2) : innerType;
586
+ if (["string", "number", "boolean"].includes(baseType)) {
587
+ const primitiveSchema = this.tsTypeToJsonSchema(baseType);
588
+ return isArray ? { type: "array", items: primitiveSchema } : primitiveSchema;
589
+ }
590
+ if (["void", "any", "unknown"].includes(baseType)) return null;
591
+ if (schemaMap[baseType]) {
592
+ const ref = { $ref: `#/components/schemas/${baseType}` };
593
+ return isArray ? { type: "array", items: ref } : ref;
594
+ }
595
+ return null;
596
+ }
597
+ buildSchemaMap(schemas) {
598
+ const result = {};
599
+ for (const schemaInfo of schemas) {
600
+ const definition = schemaInfo.schema?.definitions?.[schemaInfo.type];
601
+ if (definition) {
602
+ result[schemaInfo.type] = definition;
603
+ }
604
+ }
605
+ return result;
606
+ }
607
+ /**
608
+ * Converts Express-style `:param` path to OpenAPI `{param}` syntax
609
+ */
610
+ toOpenApiPath(expressPath) {
611
+ return expressPath.replace(/:(\w+)/g, "{$1}");
612
+ }
613
+ tsTypeToJsonSchema(tsType) {
614
+ switch (tsType) {
615
+ case "number":
616
+ return { type: "number" };
617
+ case "boolean":
618
+ return { type: "boolean" };
619
+ case "string":
620
+ default:
621
+ return { type: "string" };
622
+ }
623
+ }
624
+ /**
625
+ * Extracts the base type name from a TS type string, stripping
626
+ * wrappers like `Partial<...>`, `...[]`, `Promise<...>`.
627
+ */
628
+ extractBaseTypeName(tsType) {
629
+ if (!tsType) return null;
630
+ let type = tsType;
631
+ const promiseMatch = type.match(/^Promise<(.+)>$/);
632
+ if (promiseMatch) type = promiseMatch[1];
633
+ type = type.replace(/\[\]$/, "");
634
+ const genericMatch = type.match(/^\w+<(\w+)>$/);
635
+ if (genericMatch) type = genericMatch[1];
636
+ if (["string", "number", "boolean", "any", "void", "unknown", "object"].includes(type)) {
637
+ return null;
638
+ }
639
+ return type;
640
+ }
641
+ };
642
+
432
643
  // src/services/route-analyzer.service.ts
433
644
  import { RouteRegistry } from "honestjs";
434
- import { Project } from "ts-morph";
435
645
  var RouteAnalyzerService = class {
436
- constructor(controllerPattern, tsConfigPath) {
437
- this.controllerPattern = controllerPattern;
438
- this.tsConfigPath = tsConfigPath;
439
- }
440
- // Track projects for cleanup
441
- projects = [];
442
646
  /**
443
647
  * Analyzes controller methods to extract type information
444
648
  */
445
- async analyzeControllerMethods() {
649
+ async analyzeControllerMethods(project) {
446
650
  const routes = RouteRegistry.getRoutes();
447
651
  if (!routes?.length) {
448
652
  return [];
449
653
  }
450
- const project = this.createProject();
451
654
  const controllers = this.findControllerClasses(project);
452
655
  if (controllers.size === 0) {
453
656
  return [];
454
657
  }
455
658
  return this.processRoutes(routes, controllers);
456
659
  }
457
- /**
458
- * Creates a new ts-morph project
459
- */
460
- createProject() {
461
- const project = new Project({
462
- tsConfigFilePath: this.tsConfigPath
463
- });
464
- project.addSourceFilesAtPaths([this.controllerPattern]);
465
- this.projects.push(project);
466
- return project;
467
- }
468
- /**
469
- * Cleanup resources to prevent memory leaks
470
- */
471
- dispose() {
472
- this.projects.forEach((project) => {
473
- project.getSourceFiles().forEach((file) => project.removeSourceFile(file));
474
- });
475
- this.projects = [];
476
- }
477
660
  /**
478
661
  * Finds controller classes in the project
479
662
  */
@@ -496,23 +679,17 @@ var RouteAnalyzerService = class {
496
679
  */
497
680
  processRoutes(routes, controllers) {
498
681
  const analyzedRoutes = [];
499
- const errors = [];
500
682
  for (const route of routes) {
501
683
  try {
502
684
  const extendedRoute = this.createExtendedRoute(route, controllers);
503
685
  analyzedRoutes.push(extendedRoute);
504
686
  } catch (routeError) {
505
- const error = routeError instanceof Error ? routeError : new Error(String(routeError));
506
- errors.push(error);
507
- console.error(
508
- `Error processing route ${safeToString(route.controller)}.${safeToString(route.handler)}:`,
687
+ console.warn(
688
+ `${LOG_PREFIX} Skipping route ${safeToString(route.controller)}.${safeToString(route.handler)}:`,
509
689
  routeError
510
690
  );
511
691
  }
512
692
  }
513
- if (errors.length > 0) {
514
- throw new Error(`Failed to process ${errors.length} routes: ${errors.map((e) => e.message).join(", ")}`);
515
- }
516
693
  return analyzedRoutes;
517
694
  }
518
695
  /**
@@ -565,6 +742,7 @@ var RouteAnalyzerService = class {
565
742
  const sortedParams = [...parameters].sort((a, b) => a.index - b.index);
566
743
  for (const param of sortedParams) {
567
744
  const index = param.index;
745
+ const decoratorType = param.name;
568
746
  if (index < declaredParams.length) {
569
747
  const declaredParam = declaredParams[index];
570
748
  const paramName = declaredParam.getName();
@@ -572,6 +750,7 @@ var RouteAnalyzerService = class {
572
750
  result.push({
573
751
  index,
574
752
  name: paramName,
753
+ decoratorType,
575
754
  type: paramType,
576
755
  required: true,
577
756
  data: param.data,
@@ -582,6 +761,7 @@ var RouteAnalyzerService = class {
582
761
  result.push({
583
762
  index,
584
763
  name: `param${index}`,
764
+ decoratorType,
585
765
  type: param.metatype?.name || "unknown",
586
766
  required: true,
587
767
  data: param.data,
@@ -596,7 +776,6 @@ var RouteAnalyzerService = class {
596
776
 
597
777
  // src/services/schema-generator.service.ts
598
778
  import { createGenerator } from "ts-json-schema-generator";
599
- import { Project as Project2 } from "ts-morph";
600
779
 
601
780
  // src/utils/schema-utils.ts
602
781
  function mapJsonSchemaTypeToTypeScript(schema) {
@@ -663,32 +842,6 @@ function extractNamedType(type) {
663
842
  if (BUILTIN_TYPES.has(name)) return null;
664
843
  return name;
665
844
  }
666
- function generateTypeImports(routes) {
667
- const types = /* @__PURE__ */ new Set();
668
- for (const route of routes) {
669
- if (route.parameters) {
670
- for (const param of route.parameters) {
671
- if (param.type && !["string", "number", "boolean"].includes(param.type)) {
672
- const typeMatch = param.type.match(/(\w+)(?:<.*>)?/);
673
- if (typeMatch) {
674
- const typeName = typeMatch[1];
675
- if (!BUILTIN_UTILITY_TYPES.has(typeName)) {
676
- types.add(typeName);
677
- }
678
- }
679
- }
680
- }
681
- }
682
- if (route.returns) {
683
- const returnType = route.returns.replace(/Promise<(.+)>/, "$1");
684
- const baseType = returnType.replace(/\[\]$/, "");
685
- if (!["string", "number", "boolean", "any", "void", "unknown"].includes(baseType)) {
686
- types.add(baseType);
687
- }
688
- }
689
- }
690
- return Array.from(types).join(", ");
691
- }
692
845
 
693
846
  // src/services/schema-generator.service.ts
694
847
  var SchemaGeneratorService = class {
@@ -696,37 +849,14 @@ var SchemaGeneratorService = class {
696
849
  this.controllerPattern = controllerPattern;
697
850
  this.tsConfigPath = tsConfigPath;
698
851
  }
699
- // Track projects for cleanup
700
- projects = [];
701
852
  /**
702
853
  * Generates JSON schemas from types used in controllers
703
854
  */
704
- async generateSchemas() {
705
- const project = this.createProject();
855
+ async generateSchemas(project) {
706
856
  const sourceFiles = project.getSourceFiles(this.controllerPattern);
707
857
  const collectedTypes = this.collectTypesFromControllers(sourceFiles);
708
858
  return this.processTypes(collectedTypes);
709
859
  }
710
- /**
711
- * Creates a new ts-morph project
712
- */
713
- createProject() {
714
- const project = new Project2({
715
- tsConfigFilePath: this.tsConfigPath
716
- });
717
- project.addSourceFilesAtPaths([this.controllerPattern]);
718
- this.projects.push(project);
719
- return project;
720
- }
721
- /**
722
- * Cleanup resources to prevent memory leaks
723
- */
724
- dispose() {
725
- this.projects.forEach((project) => {
726
- project.getSourceFiles().forEach((file) => project.removeSourceFile(file));
727
- });
728
- this.projects = [];
729
- }
730
860
  /**
731
861
  * Collects types from controller files
732
862
  */
@@ -808,20 +938,37 @@ var RPCPlugin = class {
808
938
  routeAnalyzer;
809
939
  schemaGenerator;
810
940
  clientGenerator;
941
+ openApiGenerator;
942
+ openApiOptions;
943
+ // Shared ts-morph project
944
+ project = null;
811
945
  // Internal state
812
946
  analyzedRoutes = [];
813
947
  analyzedSchemas = [];
814
948
  generatedInfo = null;
815
949
  constructor(options = {}) {
816
950
  this.controllerPattern = options.controllerPattern ?? DEFAULT_OPTIONS.controllerPattern;
817
- this.tsConfigPath = options.tsConfigPath ?? path2.resolve(process.cwd(), DEFAULT_OPTIONS.tsConfigPath);
818
- this.outputDir = options.outputDir ?? path2.resolve(process.cwd(), DEFAULT_OPTIONS.outputDir);
951
+ this.tsConfigPath = options.tsConfigPath ?? path4.resolve(process.cwd(), DEFAULT_OPTIONS.tsConfigPath);
952
+ this.outputDir = options.outputDir ?? path4.resolve(process.cwd(), DEFAULT_OPTIONS.outputDir);
819
953
  this.generateOnInit = options.generateOnInit ?? DEFAULT_OPTIONS.generateOnInit;
820
- this.routeAnalyzer = new RouteAnalyzerService(this.controllerPattern, this.tsConfigPath);
954
+ this.routeAnalyzer = new RouteAnalyzerService();
821
955
  this.schemaGenerator = new SchemaGeneratorService(this.controllerPattern, this.tsConfigPath);
822
956
  this.clientGenerator = new ClientGeneratorService(this.outputDir);
957
+ this.openApiOptions = this.resolveOpenApiOptions(options.openapi);
958
+ this.openApiGenerator = this.openApiOptions ? new OpenApiGeneratorService(this.outputDir) : null;
823
959
  this.validateConfiguration();
824
960
  }
961
+ resolveOpenApiOptions(input) {
962
+ if (!input) return null;
963
+ const opts = input === true ? {} : input;
964
+ return {
965
+ title: opts.title ?? "API",
966
+ version: opts.version ?? "1.0.0",
967
+ description: opts.description ?? "",
968
+ servers: opts.servers ?? [],
969
+ outputFile: opts.outputFile ?? "openapi.json"
970
+ };
971
+ }
825
972
  /**
826
973
  * Validates the plugin configuration
827
974
  */
@@ -833,7 +980,7 @@ var RPCPlugin = class {
833
980
  if (!this.tsConfigPath?.trim()) {
834
981
  errors.push("TypeScript config path cannot be empty");
835
982
  } else {
836
- if (!fs2.existsSync(this.tsConfigPath)) {
983
+ if (!fs3.existsSync(this.tsConfigPath)) {
837
984
  errors.push(`TypeScript config file not found at: ${this.tsConfigPath}`);
838
985
  }
839
986
  }
@@ -858,15 +1005,37 @@ var RPCPlugin = class {
858
1005
  /**
859
1006
  * Main analysis method that coordinates all three components
860
1007
  */
861
- async analyzeEverything() {
1008
+ async analyzeEverything(force = false) {
862
1009
  try {
863
1010
  this.log("Starting comprehensive RPC analysis...");
864
1011
  this.analyzedRoutes = [];
865
1012
  this.analyzedSchemas = [];
866
1013
  this.generatedInfo = null;
867
- this.analyzedRoutes = await this.routeAnalyzer.analyzeControllerMethods();
868
- this.analyzedSchemas = await this.schemaGenerator.generateSchemas();
1014
+ this.dispose();
1015
+ this.project = new Project({ tsConfigFilePath: this.tsConfigPath });
1016
+ this.project.addSourceFilesAtPaths([this.controllerPattern]);
1017
+ const filePaths = this.project.getSourceFiles().map((f) => f.getFilePath());
1018
+ if (!force) {
1019
+ const currentHash = computeHash(filePaths);
1020
+ const stored = readChecksum(this.outputDir);
1021
+ if (stored && stored.hash === currentHash && this.outputFilesExist()) {
1022
+ this.log("Source files unchanged \u2014 skipping regeneration");
1023
+ this.dispose();
1024
+ return;
1025
+ }
1026
+ }
1027
+ this.analyzedRoutes = await this.routeAnalyzer.analyzeControllerMethods(this.project);
1028
+ this.analyzedSchemas = await this.schemaGenerator.generateSchemas(this.project);
869
1029
  this.generatedInfo = await this.clientGenerator.generateClient(this.analyzedRoutes, this.analyzedSchemas);
1030
+ if (this.openApiGenerator && this.openApiOptions) {
1031
+ const specPath = await this.openApiGenerator.generateSpec(
1032
+ this.analyzedRoutes,
1033
+ this.analyzedSchemas,
1034
+ this.openApiOptions
1035
+ );
1036
+ this.log(`OpenAPI spec generated: ${specPath}`);
1037
+ }
1038
+ await writeChecksum(this.outputDir, { hash: computeHash(filePaths), files: filePaths });
870
1039
  this.log(
871
1040
  `\u2705 RPC analysis complete: ${this.analyzedRoutes.length} routes, ${this.analyzedSchemas.length} schemas`
872
1041
  );
@@ -877,10 +1046,11 @@ var RPCPlugin = class {
877
1046
  }
878
1047
  }
879
1048
  /**
880
- * Manually trigger analysis (useful for testing or re-generation)
1049
+ * Manually trigger analysis (useful for testing or re-generation).
1050
+ * Defaults to force=true to bypass cache; pass false to use caching.
881
1051
  */
882
- async analyze() {
883
- await this.analyzeEverything();
1052
+ async analyze(force = true) {
1053
+ await this.analyzeEverything(force);
884
1054
  }
885
1055
  /**
886
1056
  * Get the analyzed routes
@@ -900,13 +1070,24 @@ var RPCPlugin = class {
900
1070
  getGenerationInfo() {
901
1071
  return this.generatedInfo;
902
1072
  }
1073
+ /**
1074
+ * Checks whether expected output files exist on disk
1075
+ */
1076
+ outputFilesExist() {
1077
+ if (!fs3.existsSync(path4.join(this.outputDir, "client.ts"))) return false;
1078
+ if (this.openApiOptions) {
1079
+ return fs3.existsSync(path4.join(this.outputDir, this.openApiOptions.outputFile));
1080
+ }
1081
+ return true;
1082
+ }
903
1083
  /**
904
1084
  * Cleanup resources to prevent memory leaks
905
1085
  */
906
1086
  dispose() {
907
- this.routeAnalyzer.dispose();
908
- this.schemaGenerator.dispose();
909
- this.log("Resources cleaned up");
1087
+ if (this.project) {
1088
+ this.project.getSourceFiles().forEach((file) => this.project.removeSourceFile(file));
1089
+ this.project = null;
1090
+ }
910
1091
  }
911
1092
  // ============================================================================
912
1093
  // LOGGING UTILITIES
@@ -931,16 +1112,19 @@ export {
931
1112
  DEFAULT_OPTIONS,
932
1113
  GENERIC_TYPES,
933
1114
  LOG_PREFIX,
1115
+ OpenApiGeneratorService,
934
1116
  RPCPlugin,
935
1117
  RouteAnalyzerService,
936
1118
  SchemaGeneratorService,
937
1119
  buildFullApiPath,
938
1120
  buildFullPath,
939
1121
  camelCase,
1122
+ computeHash,
940
1123
  extractNamedType,
941
- generateTypeImports,
942
1124
  generateTypeScriptInterface,
943
1125
  mapJsonSchemaTypeToTypeScript,
944
- safeToString
1126
+ readChecksum,
1127
+ safeToString,
1128
+ writeChecksum
945
1129
  };
946
1130
  //# sourceMappingURL=index.mjs.map