npm package: @honestjs/rpc-plugin
Description: RPC plugin for HonestJS framework
Author: Orkhan Karimov
Latest version: 1.7.10

# 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`):

| 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

```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: 'john@example.com' }
})

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: 'john@example.com' }
	})
} catch (error) {
	if (error instanceof ApiError) {
		console.error(`API Error ${error.statusCode}: ${error.message}`, error?.responseData)
	} else {
		console.error('Unexpected error:', error)
	}
}
```