@honestjs/rpc-plugin 1.4.1 → 1.6.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/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # RPC Plugin
2
2
 
3
- The RPC Plugin automatically analyzes your HonestJS controllers and generates a fully-typed TypeScript RPC client with
4
- proper parameter typing.
3
+ The RPC Plugin automatically analyzes your HonestJS controllers and, by default,
4
+ generates a fully-typed TypeScript RPC client with proper parameter typing. You
5
+ can also provide custom generators.
5
6
 
6
7
  ## Installation
7
8
 
@@ -16,102 +17,135 @@ pnpm add @honestjs/rpc-plugin
16
17
  ## Basic Setup
17
18
 
18
19
  ```typescript
19
- import { RPCPlugin } from '@honestjs/rpc-plugin'
20
- import { Application } from 'honestjs'
21
- import AppModule from './app.module'
20
+ import { RPCPlugin } from "@honestjs/rpc-plugin";
21
+ import { Application } from "honestjs";
22
+ import AppModule from "./app.module";
22
23
 
23
24
  const { hono } = await Application.create(AppModule, {
24
- plugins: [new RPCPlugin()]
25
- })
25
+ plugins: [new RPCPlugin()],
26
+ });
26
27
 
27
- export default hono
28
+ export default hono;
28
29
  ```
29
30
 
30
31
  ## Configuration Options
31
32
 
32
33
  ```typescript
33
34
  interface RPCPluginOptions {
34
- readonly controllerPattern?: string // Glob pattern for controller files (default: 'src/modules/*/*.controller.ts')
35
- readonly tsConfigPath?: string // Path to tsconfig.json (default: 'tsconfig.json')
36
- readonly outputDir?: string // Output directory for generated files (default: './generated/rpc')
37
- readonly generateOnInit?: boolean // Generate files on initialization (default: true)
35
+ readonly controllerPattern?: string; // Glob pattern for controller files (default: 'src/modules/*/*.controller.ts')
36
+ readonly tsConfigPath?: string; // Path to tsconfig.json (default: 'tsconfig.json')
37
+ readonly outputDir?: string; // Output directory for generated files (default: './generated/rpc')
38
+ readonly generateOnInit?: boolean; // Generate files on initialization (default: true)
39
+ readonly generators?: readonly RPCGenerator[]; // Optional list of generators to execute
40
+ readonly mode?: "strict" | "best-effort"; // strict fails on warnings/fallbacks
41
+ readonly logLevel?: "silent" | "error" | "warn" | "info" | "debug"; // default: info
42
+ readonly customClassMatcher?: (
43
+ classDeclaration: ClassDeclaration,
44
+ ) => boolean; // optional override; default discovery uses decorators
45
+ readonly failOnSchemaError?: boolean; // default true in strict mode
46
+ readonly failOnRouteAnalysisWarning?: boolean; // default true in strict mode
38
47
  readonly context?: {
39
- readonly namespace?: string // Default: 'rpc'
48
+ readonly namespace?: string; // Default: 'rpc'
40
49
  readonly keys?: {
41
- readonly artifact?: string // Default: 'artifact'
42
- }
43
- }
50
+ readonly artifact?: string; // Default: 'artifact'
51
+ };
52
+ };
44
53
  }
45
54
  ```
46
55
 
56
+ ### Generator Behavior
57
+
58
+ - If `generators` is omitted, the plugin uses the built-in
59
+ `TypeScriptClientGenerator` by default.
60
+ - If `generators` is provided, only those generators are executed.
61
+ - You can still use the built-in TypeScript client generator explicitly:
62
+
63
+ ```typescript
64
+ import { RPCPlugin, TypeScriptClientGenerator } from "@honestjs/rpc-plugin";
65
+
66
+ new RPCPlugin({
67
+ generators: [new TypeScriptClientGenerator("./generated/rpc")],
68
+ });
69
+ ```
70
+
47
71
  ## Application Context Artifact
48
72
 
49
73
  After analysis, RPC plugin publishes this artifact to the application context:
50
74
 
51
75
  ```typescript
52
76
  type RpcArtifact = {
53
- routes: ExtendedRouteInfo[]
54
- schemas: SchemaInfo[]
55
- }
77
+ artifactVersion: string;
78
+ routes: ExtendedRouteInfo[];
79
+ schemas: SchemaInfo[];
80
+ };
56
81
  ```
57
82
 
58
- Default key is `'rpc.artifact'` (from `context.namespace + '.' + context.keys.artifact`). This enables direct integration with API docs:
83
+ Default key is `'rpc.artifact'` (from
84
+ `context.namespace + '.' + context.keys.artifact`). This enables direct
85
+ integration with API docs:
59
86
 
60
87
  ```typescript
61
- import { ApiDocsPlugin } from '@honestjs/api-docs-plugin'
88
+ import { ApiDocsPlugin } from "@honestjs/api-docs-plugin";
62
89
 
63
90
  const { hono } = await Application.create(AppModule, {
64
- plugins: [new RPCPlugin(), new ApiDocsPlugin({ artifact: 'rpc.artifact' })]
65
- })
91
+ plugins: [new RPCPlugin(), new ApiDocsPlugin({ artifact: "rpc.artifact" })],
92
+ });
66
93
  ```
67
94
 
95
+ `artifactVersion` is currently `"1"` and is used for compatibility checks.
96
+
68
97
  ## What It Generates
69
98
 
70
99
  The plugin generates files in the output directory (default: `./generated/rpc`):
71
100
 
72
- | File | Description | When generated |
73
- | --------------- | -------------------------------------------- | --------------------- |
74
- | `client.ts` | Type-safe RPC client with all DTOs | Always |
75
- | `.rpc-checksum` | Hash of source files for incremental caching | Always |
76
- | `rpc-artifact.json` | Serialized routes/schemas artifact for cache-backed context publishing | Always |
101
+ | File | Description | When generated |
102
+ | ---------------------- | ---------------------------------------------------------------------- | ------------------------------ |
103
+ | `client.ts` | Type-safe RPC client with all DTOs | When TypeScript generator runs |
104
+ | `.rpc-checksum` | Hash of source files for incremental caching | Always |
105
+ | `rpc-artifact.json` | Serialized routes/schemas artifact for cache-backed context publishing | Always |
106
+ | `rpc-diagnostics.json` | Diagnostics report (mode, warnings, cache status) | Always |
77
107
 
78
108
  ### TypeScript RPC Client (`client.ts`)
79
109
 
80
- The plugin generates a single comprehensive file that includes both the client and all type definitions:
110
+ The plugin generates a single comprehensive file that includes both the client
111
+ and all type definitions:
81
112
 
82
113
  - **Controller-based organization**: Methods grouped by controller
83
114
  - **Type-safe parameters**: Path, query, and body parameters with proper typing
84
- - **Flexible request options**: Clean separation of params, query, body, and headers
115
+ - **Flexible request options**: Clean separation of params, query, body, and
116
+ headers
85
117
  - **Error handling**: Built-in error handling with custom ApiError class
86
118
  - **Header management**: Easy custom header management
87
- - **Custom fetch support**: Inject custom fetch implementations for testing, middleware, and compatibility
88
- - **Integrated types**: All DTOs, interfaces, and utility types included in the same file
119
+ - **Custom fetch support**: Inject custom fetch implementations for testing,
120
+ middleware, and compatibility
121
+ - **Integrated types**: All DTOs, interfaces, and utility types included in the
122
+ same file
89
123
 
90
124
  ```typescript
91
125
  // Generated client usage
92
- import { ApiClient } from './generated/rpc/client'
126
+ import { ApiClient } from "./generated/rpc/client";
93
127
 
94
128
  // Create client instance with base URL
95
- const apiClient = new ApiClient('http://localhost:3000')
129
+ const apiClient = new ApiClient("http://localhost:3000");
96
130
 
97
131
  // Type-safe API calls
98
132
  const user = await apiClient.users.create({
99
- body: { name: 'John', email: 'john@example.com' }
100
- })
133
+ body: { name: "John", email: "john@example.com" },
134
+ });
101
135
 
102
136
  const users = await apiClient.users.list({
103
- query: { page: 1, limit: 10 }
104
- })
137
+ query: { page: 1, limit: 10 },
138
+ });
105
139
 
106
140
  const user = await apiClient.users.getById({
107
- params: { id: '123' }
108
- })
141
+ params: { id: "123" },
142
+ });
109
143
 
110
144
  // Set custom headers
111
145
  apiClient.setDefaultHeaders({
112
- 'X-API-Key': 'your-api-key',
113
- Authorization: 'Bearer your-jwt-token'
114
- })
146
+ "X-API-Key": "your-api-key",
147
+ Authorization: "Bearer your-jwt-token",
148
+ });
115
149
  ```
116
150
 
117
151
  The generated `client.ts` file contains everything you need:
@@ -127,7 +161,8 @@ The RPC client supports custom fetch implementations, which is useful for:
127
161
 
128
162
  - **Testing**: Inject mock fetch functions for unit testing
129
163
  - **Custom Logic**: Add logging, retries, or other middleware
130
- - **Environment Compatibility**: Use different fetch implementations (node-fetch, undici, etc.)
164
+ - **Environment Compatibility**: Use different fetch implementations
165
+ (node-fetch, undici, etc.)
131
166
  - **Interceptors**: Wrap requests with custom logic before/after execution
132
167
 
133
168
  ### Basic Custom Fetch Example
@@ -135,13 +170,18 @@ The RPC client supports custom fetch implementations, which is useful for:
135
170
  ```typescript
136
171
  // Simple logging wrapper
137
172
  const loggingFetch = (input: RequestInfo | URL, init?: RequestInit) => {
138
- console.log(`[${new Date().toISOString()}] Making ${init?.method || 'GET'} request to:`, input)
139
- return fetch(input, init)
140
- }
141
-
142
- const apiClient = new ApiClient('http://localhost:3000', {
143
- fetchFn: loggingFetch
144
- })
173
+ console.log(
174
+ `[${new Date().toISOString()}] Making ${
175
+ init?.method || "GET"
176
+ } request to:`,
177
+ input,
178
+ );
179
+ return fetch(input, init);
180
+ };
181
+
182
+ const apiClient = new ApiClient("http://localhost:3000", {
183
+ fetchFn: loggingFetch,
184
+ });
145
185
  ```
146
186
 
147
187
  ### Advanced Custom Fetch Examples
@@ -152,24 +192,26 @@ const retryFetch = (maxRetries = 3) => {
152
192
  return async (input: RequestInfo | URL, init?: RequestInit) => {
153
193
  for (let i = 0; i <= maxRetries; i++) {
154
194
  try {
155
- const response = await fetch(input, init)
156
- if (response.ok) return response
195
+ const response = await fetch(input, init);
196
+ if (response.ok) return response;
157
197
 
158
- if (i === maxRetries) return response
198
+ if (i === maxRetries) return response;
159
199
 
160
200
  // Wait with exponential backoff
161
- await new Promise((resolve) => setTimeout(resolve, Math.pow(2, i) * 1000))
201
+ await new Promise((resolve) =>
202
+ setTimeout(resolve, Math.pow(2, i) * 1000)
203
+ );
162
204
  } catch (error) {
163
- if (i === maxRetries) throw error
205
+ if (i === maxRetries) throw error;
164
206
  }
165
207
  }
166
- throw new Error('Max retries exceeded')
167
- }
168
- }
208
+ throw new Error("Max retries exceeded");
209
+ };
210
+ };
169
211
 
170
- const apiClientWithRetry = new ApiClient('http://localhost:3000', {
171
- fetchFn: retryFetch(3)
172
- })
212
+ const apiClientWithRetry = new ApiClient("http://localhost:3000", {
213
+ fetchFn: retryFetch(3),
214
+ });
173
215
 
174
216
  // Request/response interceptor
175
217
  const interceptorFetch = (input: RequestInfo | URL, init?: RequestInit) => {
@@ -178,20 +220,20 @@ const interceptorFetch = (input: RequestInfo | URL, init?: RequestInit) => {
178
220
  ...init,
179
221
  headers: {
180
222
  ...init?.headers,
181
- 'X-Request-ID': crypto.randomUUID()
182
- }
183
- }
223
+ "X-Request-ID": crypto.randomUUID(),
224
+ },
225
+ };
184
226
 
185
227
  return fetch(input, enhancedInit).then((response) => {
186
228
  // Post-response logic
187
- console.log(`Response status: ${response.status}`)
188
- return response
189
- })
190
- }
191
-
192
- const apiClientWithInterceptor = new ApiClient('http://localhost:3000', {
193
- fetchFn: interceptorFetch
194
- })
229
+ console.log(`Response status: ${response.status}`);
230
+ return response;
231
+ });
232
+ };
233
+
234
+ const apiClientWithInterceptor = new ApiClient("http://localhost:3000", {
235
+ fetchFn: interceptorFetch,
236
+ });
195
237
  ```
196
238
 
197
239
  ### Testing with Custom Fetch
@@ -200,37 +242,45 @@ const apiClientWithInterceptor = new ApiClient('http://localhost:3000', {
200
242
  // Mock fetch for testing
201
243
  const mockFetch = jest.fn().mockResolvedValue({
202
244
  ok: true,
203
- json: () => Promise.resolve({ data: { id: '123', name: 'Test User' } })
204
- })
245
+ json: () => Promise.resolve({ data: { id: "123", name: "Test User" } }),
246
+ });
205
247
 
206
- const testApiClient = new ApiClient('http://test.com', {
207
- fetchFn: mockFetch
208
- })
248
+ const testApiClient = new ApiClient("http://test.com", {
249
+ fetchFn: mockFetch,
250
+ });
209
251
 
210
252
  // Your test can now verify the mock was called
211
- expect(mockFetch).toHaveBeenCalledWith('http://test.com/api/v1/users/123', expect.objectContaining({ method: 'GET' }))
253
+ expect(mockFetch).toHaveBeenCalledWith(
254
+ "http://test.com/api/v1/users/123",
255
+ expect.objectContaining({ method: "GET" }),
256
+ );
212
257
  ```
213
258
 
214
259
  ## Hash-based Caching
215
260
 
216
- On startup the plugin hashes all controller source files (SHA-256) and stores the checksum in `.rpc-checksum` inside the output directory. On subsequent runs, if the hash matches and the expected output files already exist, the expensive analysis and generation pipeline is skipped entirely. This significantly reduces startup time in large projects.
261
+ On startup the plugin hashes all controller source files (SHA-256) and stores
262
+ the checksum in `.rpc-checksum` inside the output directory. On subsequent runs,
263
+ if the hash matches and the expected output files already exist, the expensive
264
+ analysis and generation pipeline is skipped entirely. This significantly reduces
265
+ startup time in large projects.
217
266
 
218
267
  Caching is automatic and requires no configuration. To force regeneration:
219
268
 
220
269
  ```typescript
221
- // Manual call — defaults to force=true, always regenerates
222
- await rpcPlugin.analyze()
223
-
224
270
  // Explicit cache bypass
225
- await rpcPlugin.analyze(true)
271
+ await rpcPlugin.analyze({ force: true });
226
272
 
227
273
  // Respect the cache (same behavior as automatic startup)
228
- await rpcPlugin.analyze(false)
274
+ await rpcPlugin.analyze({ force: false });
229
275
  ```
230
276
 
231
- You can also delete `.rpc-checksum` from the output directory to clear the cache.
277
+ You can also delete `.rpc-checksum` from the output directory to clear the
278
+ cache.
232
279
 
233
- > **Note:** The hash covers controller files matched by the `controllerPattern` glob. If you only change a DTO/model file that lives outside that pattern, the cache won't invalidate automatically. Use `analyze()` or delete `.rpc-checksum` in that case.
280
+ > **Note:** The hash covers controller files matched by the `controllerPattern`
281
+ > glob. If you only change a DTO/model file that lives outside that pattern, the
282
+ > cache won't invalidate automatically. Use `analyze()` or delete
283
+ > `.rpc-checksum` in that case.
234
284
 
235
285
  ## How It Works
236
286
 
@@ -270,21 +320,40 @@ export class ApiClient {
270
320
  get users() {
271
321
  return {
272
322
  create: async <Result = User>(
273
- options: RequestOptions<{ name: string; email: string }, undefined, undefined, undefined>
323
+ options: RequestOptions<
324
+ { name: string; email: string },
325
+ undefined,
326
+ undefined,
327
+ undefined
328
+ >,
274
329
  ) => {
275
- return this.request<Result>('POST', `/api/v1/users/`, options)
330
+ return this.request<Result>("POST", `/api/v1/users/`, options);
276
331
  },
277
332
  list: async <Result = User[]>(
278
- options?: RequestOptions<undefined, { page: number; limit: number }, undefined, undefined>
333
+ options?: RequestOptions<
334
+ undefined,
335
+ { page: number; limit: number },
336
+ undefined,
337
+ undefined
338
+ >,
279
339
  ) => {
280
- return this.request<Result>('GET', `/api/v1/users/`, options)
340
+ return this.request<Result>("GET", `/api/v1/users/`, options);
281
341
  },
282
342
  getById: async <Result = User>(
283
- options: RequestOptions<undefined, { id: string }, undefined, undefined>
343
+ options: RequestOptions<
344
+ undefined,
345
+ { id: string },
346
+ undefined,
347
+ undefined
348
+ >,
284
349
  ) => {
285
- return this.request<Result>('GET', `/api/v1/users/:id`, options)
286
- }
287
- }
350
+ return this.request<Result>(
351
+ "GET",
352
+ `/api/v1/users/:id`,
353
+ options,
354
+ );
355
+ },
356
+ };
288
357
  }
289
358
  }
290
359
 
@@ -293,24 +362,29 @@ export type RequestOptions<
293
362
  TParams = undefined,
294
363
  TQuery = undefined,
295
364
  TBody = undefined,
296
- THeaders = undefined
297
- > = (TParams extends undefined ? object : { params: TParams }) &
298
- (TQuery extends undefined ? object : { query: TQuery }) &
299
- (TBody extends undefined ? object : { body: TBody }) &
300
- (THeaders extends undefined ? object : { headers: THeaders })
365
+ THeaders = undefined,
366
+ > =
367
+ & (TParams extends undefined ? object : { params: TParams })
368
+ & (TQuery extends undefined ? object : { query: TQuery })
369
+ & (TBody extends undefined ? object : { body: TBody })
370
+ & (THeaders extends undefined ? object : { headers: THeaders });
301
371
  ```
302
372
 
303
373
  ## Plugin Lifecycle
304
374
 
305
- The plugin automatically generates files when your HonestJS application starts up (if `generateOnInit` is true). On
306
- subsequent startups, the hash-based cache will skip regeneration if controller files haven't changed.
375
+ The plugin automatically generates files when your HonestJS application starts
376
+ up (if `generateOnInit` is true). On subsequent startups, the hash-based cache
377
+ will skip regeneration if controller files haven't changed.
307
378
 
308
379
  You can also manually trigger generation:
309
380
 
310
381
  ```typescript
311
- const rpcPlugin = new RPCPlugin()
312
- await rpcPlugin.analyze() // Force regeneration (bypasses cache)
313
- await rpcPlugin.analyze(false) // Respect cache
382
+ const rpcPlugin = new RPCPlugin();
383
+ await rpcPlugin.analyze({ force: true }); // Force regeneration (bypasses cache)
384
+ await rpcPlugin.analyze({ force: false }); // Respect cache
385
+
386
+ // Analyze-only mode (no files generated, diagnostics still emitted)
387
+ await rpcPlugin.analyze({ force: true, dryRun: true });
314
388
  ```
315
389
 
316
390
  ## Advanced Usage
@@ -321,9 +395,9 @@ If your controllers follow a different file structure:
321
395
 
322
396
  ```typescript
323
397
  new RPCPlugin({
324
- controllerPattern: 'src/controllers/**/*.controller.ts',
325
- outputDir: './src/generated/api'
326
- })
398
+ controllerPattern: "src/controllers/**/*.controller.ts",
399
+ outputDir: "./src/generated/api",
400
+ });
327
401
  ```
328
402
 
329
403
  ### Manual Generation Control
@@ -332,11 +406,11 @@ Disable automatic generation and control when files are generated:
332
406
 
333
407
  ```typescript
334
408
  const rpcPlugin = new RPCPlugin({
335
- generateOnInit: false
336
- })
409
+ generateOnInit: false,
410
+ });
337
411
 
338
412
  // Later in your code
339
- await rpcPlugin.analyze()
413
+ await rpcPlugin.analyze();
340
414
  ```
341
415
 
342
416
  ## Integration with HonestJS
@@ -346,32 +420,32 @@ await rpcPlugin.analyze()
346
420
  Here's how your controllers should be structured for optimal RPC generation:
347
421
 
348
422
  ```typescript
349
- import { Controller, Post, Get, Body, Param, Query } from 'honestjs'
423
+ import { Body, Controller, Get, Param, Post, Query } from "honestjs";
350
424
 
351
425
  interface CreateUserDto {
352
- name: string
353
- email: string
426
+ name: string;
427
+ email: string;
354
428
  }
355
429
 
356
430
  interface ListUsersQuery {
357
- page?: number
358
- limit?: number
431
+ page?: number;
432
+ limit?: number;
359
433
  }
360
434
 
361
- @Controller('/users')
435
+ @Controller("/users")
362
436
  export class UsersController {
363
- @Post('/')
437
+ @Post("/")
364
438
  async create(@Body() createUserDto: CreateUserDto): Promise<User> {
365
439
  // Implementation
366
440
  }
367
441
 
368
- @Get('/')
442
+ @Get("/")
369
443
  async list(@Query() query: ListUsersQuery): Promise<User[]> {
370
444
  // Implementation
371
445
  }
372
446
 
373
- @Get('/:id')
374
- async getById(@Param('id') id: string): Promise<User> {
447
+ @Get("/:id")
448
+ async getById(@Param("id") id: string): Promise<User> {
375
449
  // Implementation
376
450
  }
377
451
  }
@@ -382,13 +456,13 @@ export class UsersController {
382
456
  Ensure your controllers are properly registered in modules:
383
457
 
384
458
  ```typescript
385
- import { Module } from 'honestjs'
386
- import { UsersController } from './users.controller'
387
- import { UsersService } from './users.service'
459
+ import { Module } from "honestjs";
460
+ import { UsersController } from "./users.controller";
461
+ import { UsersService } from "./users.service";
388
462
 
389
463
  @Module({
390
464
  controllers: [UsersController],
391
- providers: [UsersService]
465
+ services: [UsersService],
392
466
  })
393
467
  export class UsersModule {}
394
468
  ```
@@ -400,13 +474,13 @@ The generated client includes comprehensive error handling:
400
474
  ```typescript
401
475
  try {
402
476
  const user = await apiClient.users.create({
403
- body: { name: 'John', email: 'john@example.com' }
404
- })
477
+ body: { name: "John", email: "john@example.com" },
478
+ });
405
479
  } catch (error) {
406
480
  if (error instanceof ApiError) {
407
- console.error(`API Error ${error.statusCode}: ${error.message}`)
481
+ console.error(`API Error ${error.statusCode}: ${error.message}`);
408
482
  } else {
409
- console.error('Unexpected error:', error)
483
+ console.error("Unexpected error:", error);
410
484
  }
411
485
  }
412
486
  ```