RPC Plugin
The RPC Plugin automatically analyzes your HonestJS controllers and, by default, generates a fully-typed TypeScript RPC client with proper parameter typing. You can also provide custom generators.
Installation
npm install @honestjs/rpc-plugin
# or
yarn add @honestjs/rpc-plugin
# or
pnpm add @honestjs/rpc-pluginBasic Setup
import { RPCPlugin } from '@honestjs/rpc-plugin'
import { Application } from 'honestjs'
import AppModule from './app.module'
const { hono } = await Application.create(AppModule, {
plugins: [new RPCPlugin()]
})
export default honoConfiguration Options
interface RPCPluginOptions {
readonly controllerPattern?: string // Glob pattern for controller files (default: 'src/modules/*/*.controller.ts')
readonly tsConfigPath?: string // Path to tsconfig.json (default: 'tsconfig.json')
readonly outputDir?: string // Output directory for generated files (default: './generated/rpc')
readonly generateOnInit?: boolean // Generate files on initialization (default: true)
readonly generators?: readonly RPCGenerator[] // Optional list of generators to execute
readonly mode?: 'strict' | 'best-effort' // strict fails on warnings/fallbacks
readonly customClassMatcher?: (classDeclaration: ClassDeclaration) => boolean // optional override; default discovery uses decorators
readonly failOnSchemaError?: boolean // default true in strict mode
readonly failOnRouteAnalysisWarning?: boolean // default true in strict mode
readonly context?: {
readonly namespace?: string // Default: 'rpc'
readonly keys?: {
readonly artifact?: string // Default: 'artifact'
}
}
readonly hooks?: {
readonly preAnalysisFilters?: readonly RPCPreAnalysisFilter[]
readonly postAnalysisTransforms?: readonly RPCPostAnalysisTransform[]
readonly preEmitValidators?: readonly RPCPreEmitValidator[]
readonly postEmitReporters?: readonly RPCPostEmitReporter[]
}
}Generator Compatibility Contract
Custom generators participate in explicit API version and capability negotiation.
interface RPCGenerator {
readonly name: string
readonly supportedApiVersions?: readonly string[]
readonly requiredCapabilities?: readonly RPCGeneratorCapability[]
generate(context: RPCGeneratorContext): Promise<GeneratedClientInfo>
}
interface RPCGeneratorContext {
readonly outputDir: string
readonly routes: readonly ExtendedRouteInfo[]
readonly schemas: readonly SchemaInfo[]
readonly pluginApiVersion: string
readonly pluginCapabilities: readonly RPCGeneratorCapability[]
}The plugin validates this at construction time and fails fast if versions/capabilities are incompatible.
- No legacy compatibility adapter is provided for generator API mismatches.
- Generators must explicitly support the active plugin API version.
Generator Behavior
- If
generatorsis omitted, the plugin uses the built-inTypeScriptClientGeneratorby default. - If
generatorsis provided, only those generators are executed. - You can still use the built-in TypeScript client generator explicitly:
import { RPCPlugin, TypeScriptClientGenerator } from '@honestjs/rpc-plugin'
new RPCPlugin({
generators: [new TypeScriptClientGenerator('./generated/rpc')]
})Application Context Artifact
After analysis, RPC plugin publishes this artifact to the application context:
type RpcArtifact = {
artifactVersion: string
routes: ExtendedRouteInfo[]
schemas: SchemaInfo[]
}Default key is 'rpc.artifact' (from context.namespace + '.' + context.keys.artifact). This enables direct integration with API docs:
import { ApiDocsPlugin } from '@honestjs/api-docs-plugin'
const { hono } = await Application.create(AppModule, {
plugins: [new RPCPlugin(), new ApiDocsPlugin({ artifact: 'rpc.artifact' })]
})artifactVersion is currently "1" and is used for compatibility checks.
What It Generates
The plugin generates files in the output directory (default: ./generated/rpc):
| File | Description | When generated |
|---|---|---|
client.ts | Type-safe RPC client with all DTOs | When TypeScript generator runs |
.rpc-checksum | Hash of source files for incremental caching | Always |
rpc-artifact.json | Serialized routes/schemas artifact for cache-backed context publishing | Always |
rpc-diagnostics.json | Diagnostics report (mode, warnings, cache status) | Always |
TypeScript RPC Client (client.ts)
The plugin generates a single comprehensive file that includes both the client and all type definitions:
- Controller-based organization: Methods grouped by controller
- Type-safe parameters: Path, query, and body parameters with proper typing
- Flexible request options: Clean separation of params, query, body, and headers
- Error handling: Built-in error handling with custom ApiError class
- Header management: Easy custom header management
- Custom fetch support: Inject custom fetch implementations for testing, middleware, and compatibility
- Integrated types: All DTOs, interfaces, and utility types included in the same file
// Generated client usage
import { ApiClient } from './generated/rpc/client'
// Create client instance with base URL
const apiClient = new ApiClient('http://localhost:3000')
// Type-safe API calls
const user = await apiClient.users.create({
body: { name: 'John', email: '[email protected]' }
})
const users = await apiClient.users.list({
query: { page: 1, limit: 10 }
})
const user = await apiClient.users.getById({
params: { id: '123' }
})
// Set custom headers
apiClient.setDefaultHeaders({
'X-API-Key': 'your-api-key',
Authorization: 'Bearer your-jwt-token'
})The generated client.ts file contains everything you need:
- ApiClient class with all your controller methods
- Type definitions for requests, responses, and DTOs
- Utility types like RequestOptions
- Generated interfaces from your controller types
Custom Fetch Functions
The RPC client supports custom fetch implementations, which is useful for:
- Testing: Inject mock fetch functions for unit testing
- Custom Logic: Add logging, retries, or other middleware
- Environment Compatibility: Use different fetch implementations (node-fetch, undici, etc.)
- Interceptors: Wrap requests with custom logic before/after execution
Basic Custom Fetch Example
// Simple logging wrapper
const loggingFetch = (input: RequestInfo | URL, init?: RequestInit) => {
console.log(`[${new Date().toISOString()}] Making ${init?.method || 'GET'} request to:`, input)
return fetch(input, init)
}
const apiClient = new ApiClient('http://localhost:3000', {
fetchFn: loggingFetch
})Advanced Custom Fetch Examples
// Retry logic with exponential backoff
const retryFetch = (maxRetries = 3) => {
return async (input: RequestInfo | URL, init?: RequestInit) => {
for (let i = 0; i <= maxRetries; i++) {
try {
const response = await fetch(input, init)
if (response.ok) return response
if (i === maxRetries) return response
// Wait with exponential backoff
await new Promise((resolve) => setTimeout(resolve, Math.pow(2, i) * 1000))
} catch (error) {
if (i === maxRetries) throw error
}
}
throw new Error('Max retries exceeded')
}
}
const apiClientWithRetry = new ApiClient('http://localhost:3000', {
fetchFn: retryFetch(3)
})
// Request/response interceptor
const interceptorFetch = (input: RequestInfo | URL, init?: RequestInit) => {
// Pre-request logic
const enhancedInit = {
...init,
headers: {
...init?.headers,
'X-Request-ID': crypto.randomUUID()
}
}
return fetch(input, enhancedInit).then((response) => {
// Post-response logic
console.log(`Response status: ${response.status}`)
return response
})
}
const apiClientWithInterceptor = new ApiClient('http://localhost:3000', {
fetchFn: interceptorFetch
})Testing with Custom Fetch
// Mock fetch for testing
const mockFetch = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: { id: '123', name: 'Test User' } })
})
const testApiClient = new ApiClient('http://test.com', {
fetchFn: mockFetch
})
// Your test can now verify the mock was called
expect(mockFetch).toHaveBeenCalledWith('http://test.com/api/v1/users/123', expect.objectContaining({ method: 'GET' }))Hash-based Caching
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.
Caching is automatic and requires no configuration. To force regeneration:
// Explicit cache bypass
await rpcPlugin.analyze({ force: true })
// Respect the cache (same behavior as automatic startup)
await rpcPlugin.analyze({ force: false })You can also delete .rpc-checksum from the output directory to clear the cache.
Note: The hash covers controller files matched by the
controllerPatternglob. If you only change a DTO/model file that lives outside that pattern, the cache won't invalidate automatically. Useanalyze()or delete.rpc-checksumin that case.
How It Works
Stage 1. Analysis
- Scans route registry and source files once
- Builds a shared analysis graph from controller AST traversal
- Extracts route metadata and candidate schema types
- Produces stage diagnostics and warnings
Stage 2. Transform
- Applies post-analysis transforms
- Runs pre-emit validators
- Enforces strict-mode warning gates before emit
Stage 3. Emit
- Executes configured generators with validated context
- Persists artifact + diagnostics atomically
- Runs post-emit reporters
Stage 4. Caching
- Uses checksum + generator hash to determine cache hit/miss
- Skips full pipeline on valid cache hit
- Writes refreshed checksum and diagnostics on successful emits
This staged flow is implemented in:
src/pipeline/analysis-stage.tssrc/pipeline/transform-stage.tssrc/pipeline/emit-stage.tssrc/pipeline/pipeline-coordinator.ts
Generated Client Features
- Groups routes by controller for organization
- Generates type-safe method signatures
- Creates parameter validation and typing
- Builds the complete RPC client with proper error handling
Type Inference and Limitations
The plugin extracts type names from controller method parameters and return types, then uses ts-json-schema-generator to convert those names into JSON schemas and TypeScript interfaces for the generated client. Not all TypeScript type shapes can be reliably converted; this section describes what works and what does not.
Supported Type Patterns
- Explicit interfaces and type aliases with plain property shapes
- Classes with simple properties
- Built-in utility types such as
Partial,Pick,Omit, andRecord(when applied to simple types) - Arrays of named types (e.g.
User[]) - Enums and primitive unions
Unsupported Type Patterns
- Complex inferred types from ORMs or libraries (e.g.
typeof table.$inferSelect,$inferInsert) - Deeply nested conditional, mapped, or intersection types
- Anonymous internal symbols (e.g.
__type) that the compiler uses for inline object types - Types that depend on heavy generic instantiation chains
When schema generation fails for a type, the plugin logs a warning and, in best-effort mode, emits an empty interface with a // No schema definition found comment in the generated client so generation continues.
Best Practice: Explicit DTOs
Use explicit interfaces or type aliases for controller parameters and return types. Keep ORM-inferred types in your services and map to explicit DTOs at the controller boundary.
Problematic (schema generation may fail):
import type { links } from '../db/schema'
export type Link = typeof links.$inferSelect
@Controller('links')
class LinksController {
@Get('/:code')
async getLink(@Param('code') code: string): Promise<Link> {
// ...
}
}Recommended (reliable schema generation):
export interface Link {
id: number
code: string
url: string
clicks: number
lastClickedAt: Date | null
expiresAt: Date | null
createdAt: Date
updatedAt: Date
}
@Controller('links')
class LinksController {
@Get('/:code')
async getLink(@Param('code') code: string): Promise<Link> {
// ...
}
}Inline Return Types
When the return type is an inline object literal (e.g. Promise<{ data: Link[]; total: number }>), the plugin inlines it directly in the generated client and does not need to resolve a named type for schema generation. Those return types always work regardless of whether Link is inferred or explicit.
Diagnosing Schema Issues
- Check
rpc-diagnostics.jsonin the output directory: it listswarningsfor each type that failed schema generation. - In best-effort mode (default), failed types result in empty interfaces; the client still generates.
- Set
failOnSchemaError: true(or usemode: 'strict') to make schema failures throw and stop generation.
Example Generated Output
Generated Client
export class ApiClient {
get users() {
return {
create: async <Result = User>(
options: RequestOptions<{ name: string; email: string }, undefined, undefined, undefined>
) => {
return this.request<Result>('POST', `/api/v1/users/`, options)
},
list: async <Result = User[]>(
options?: RequestOptions<undefined, { page: number; limit: number }, undefined, undefined>
) => {
return this.request<Result>('GET', `/api/v1/users/`, options)
},
getById: async <Result = User>(
options: RequestOptions<undefined, { id: string }, undefined, undefined>
) => {
return this.request<Result>('GET', `/api/v1/users/:id`, options)
}
}
}
}
// RequestOptions type definition
export type RequestOptions<
TParams = undefined,
TQuery = undefined,
TBody = undefined,
THeaders = undefined
> = (TParams extends undefined ? object : { params: TParams }) &
(TQuery extends undefined ? object : { query: TQuery }) &
(TBody extends undefined ? object : { body: TBody }) &
(THeaders extends undefined ? object : { headers: THeaders })Plugin Lifecycle
The plugin automatically generates files when your HonestJS application starts up (if generateOnInit is true). On subsequent startups, the hash-based cache will skip regeneration if controller files haven't changed.
You can also manually trigger generation:
const rpcPlugin = new RPCPlugin()
await rpcPlugin.analyze({ force: true }) // Force regeneration (bypasses cache)
await rpcPlugin.analyze({ force: false }) // Respect cache
// Analyze-only mode (no files generated, diagnostics still emitted)
await rpcPlugin.analyze({ force: true, dryRun: true })Advanced Usage
Custom Controller Pattern
If your controllers follow a different file structure:
new RPCPlugin({
controllerPattern: 'src/controllers/**/*.controller.ts',
outputDir: './src/generated/api'
})Manual Generation Control
Disable automatic generation and control when files are generated:
const rpcPlugin = new RPCPlugin({
generateOnInit: false
})
// Later in your code
await rpcPlugin.analyze()Integration with HonestJS
Controller Example
Here's how your controllers should be structured for optimal RPC generation:
import { Body, Controller, Get, Param, Post, Query } from 'honestjs'
interface CreateUserDto {
name: string
email: string
}
interface ListUsersQuery {
page?: number
limit?: number
}
@Controller('/users')
export class UsersController {
@Post('/')
async create(@Body() createUserDto: CreateUserDto): Promise<User> {
// Implementation
}
@Get('/')
async list(@Query() query: ListUsersQuery): Promise<User[]> {
// Implementation
}
@Get('/:id')
async getById(@Param('id') id: string): Promise<User> {
// Implementation
}
}Module Registration
Ensure your controllers are properly registered in modules:
import { Module } from 'honestjs'
import { UsersController } from './users.controller'
import { UsersService } from './users.service'
@Module({
controllers: [UsersController],
services: [UsersService]
})
export class UsersModule {}Error Handling
The generated client includes comprehensive error handling:
try {
const user = await apiClient.users.create({
body: { name: 'John', email: '[email protected]' }
})
} catch (error) {
if (error instanceof ApiError) {
console.error(`API Error ${error.statusCode}: ${error.message}`)
} else {
console.error('Unexpected error:', error)
}
}