Skip to content

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

bash
npm install @honestjs/rpc-plugin
# or
yarn add @honestjs/rpc-plugin
# or
pnpm add @honestjs/rpc-plugin

Basic Setup

typescript
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 hono

Configuration Options

typescript
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.

typescript
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 generators is omitted, the plugin uses the built-in TypeScriptClientGenerator by default.
  • If generators is provided, only those generators are executed.
  • You can still use the built-in TypeScript client generator explicitly:
typescript
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:

typescript
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:

typescript
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):

FileDescriptionWhen generated
client.tsType-safe RPC client with all DTOsWhen TypeScript generator runs
.rpc-checksumHash of source files for incremental cachingAlways
rpc-artifact.jsonSerialized routes/schemas artifact for cache-backed context publishingAlways
rpc-diagnostics.jsonDiagnostics 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
typescript
// 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

typescript
// 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

typescript
// 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

typescript
// 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:

typescript
// 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 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.

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.ts
  • src/pipeline/transform-stage.ts
  • src/pipeline/emit-stage.ts
  • src/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, and Record (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):

typescript
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):

typescript
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.json in the output directory: it lists warnings for 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 use mode: 'strict') to make schema failures throw and stop generation.

Example Generated Output

Generated Client

typescript
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:

typescript
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:

typescript
new RPCPlugin({
	controllerPattern: 'src/controllers/**/*.controller.ts',
	outputDir: './src/generated/api'
})

Manual Generation Control

Disable automatic generation and control when files are generated:

typescript
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:

typescript
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:

typescript
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:

typescript
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)
	}
}

Released under the MIT License.