npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@honestjs/rpc-plugin

v1.7.10

Published

RPC plugin for HonestJS framework

Downloads

187

Readme

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-plugin

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

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

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

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}`, error?.responseData)
	} else {
		console.error('Unexpected error:', error)
	}
}