Getting Started
This guide will walk you through setting up IRPC in your project and creating your first remote function.
Quick Start
The fastest way to get started is using the IRPC Bun starter template:
npx degit beerush-id/anchor/templates/irpc-bun-starter my-api
cd my-api
bun install
bun run serveServer runs on http://localhost:3000
The template includes:
- Pre-configured IRPC package and HTTP transport
- Example IRPC functions
- Server setup with middleware
- TypeScript configuration
- Docker support
Manual Setup
Installation
npm install @irpclib/irpc @irpclib/http @irpclib/wsIRPC supports multiple transports:
- @irpclib/http: HTTP transport with automatic batching and streaming
- @irpclib/ws: WebSocket transport for persistent connections and lower latency
Project Structure
my-api/
├── lib/
│ └── module.ts # Package and transport configuration
├── rpc/
│ └── hello/
│ ├── index.ts # Function declaration
│ └── constructor.ts # Handler implementation
├── server.ts # Server setup
└── client.ts # Client usageStep 1: Create Package
Create a package to namespace your IRPC functions.
// lib/module.ts
import { createPackage } from '@irpclib/irpc';
import { HTTPTransport } from '@irpclib/http';
export const irpc = createPackage({
name: 'my-api',
version: '1.0.0'
});
export const transport = new HTTPTransport({
endpoint: `/irpc/${irpc.href}`,
});
irpc.use(transport);The package name and version create a unique namespace (my-api/1.0.0). The transport handles network communication.
Choosing a Transport
IRPC supports multiple transport protocols:
HTTP Transport (recommended for most use cases):
import { HTTPTransport } from '@irpclib/http';
const transport = new HTTPTransport({
endpoint: `/irpc/${irpc.href}`,
timeout: 10000,
maxRetries: 3,
});WebSocket Transport (for persistent connections):
import { WebSocketTransport } from '@irpclib/ws';
const transport = new WebSocketTransport({
url: 'ws://localhost:8080',
autoReconnect: true,
maxReconnectAttempts: 5,
});Step 2: Declare Functions
Declare the function signatures that both client and server will use.
// rpc/hello/index.ts
import { irpc } from '../lib/module.js';
export type HelloFn = (name: string) => Promise<string>;
export const hello = irpc.declare<HelloFn>({ name: 'hello' });You can seamlessly declare reactive streams using the exact same signature syntax:
// rpc/poem/index.ts
import { irpc } from '../lib/module.js';
import type { RemoteState } from '@irpclib/irpc';
export type GeneratePoemFn = (prompt: string) => RemoteState<string>;
export const generatePoem = irpc.declare<GeneratePoemFn>({
name: 'generatePoem',
init: () => '', // Initial client-side state before server data arrives
});Important!
When declaring a function that returns RemoteState<T>, irpc.declare() requires an init factory in its options. On the client, when the stub is called, a RemoteState is instantiated immediately — before any data arrives from the server. The init factory seeds the initial value of state.data so UI frameworks can bind to the reactive proxy right away and progressively render as server mutations arrive.
What is RemoteState?
RemoteState<T> is IRPC's core reactive primitive. It extends a standard native Promise<T>, meaning it can be standardly await-ed. But it is augmented with a .subscribe() method that allows clients to actively listen to temporal updates (via a mutable proxy) from the server over the wire without blocking the execution thread.
Because it exposes state.data, state.status, and state.error, you can use RemoteState to:
- Hydrate UI frameworks (React, Vue, Svelte) automatically as data arrives without writing WebSocket hooks.
- Monitor the exact execution status (
PENDING,SUCCESS, orERROR). - Handle mid-stream pipeline errors directly via
state.error.
The function signature is isomorphic—it works perfectly across the client and server.
Step 3: Implement Handlers (Server)
Implement the actual logic on the server. Handlers can return standard promises or continuous streams.
// rpc/hello/constructor.ts
import { irpc } from '../lib/module.js';
import { hello } from './index.js';
irpc.construct(hello, async (name) => {
return `Hello ${name}`;
});To implement a reactive pipeline, use the stream factory to yield continuous data back to the client:
// rpc/poem/constructor.ts
import { irpc } from '../lib/module.js';
import { generatePoem } from './index.js';
import { stream } from '@irpclib/irpc';
irpc.construct(generatePoem, (prompt) => {
return stream<string>(async (data, resolve, reject) => {
// Stream external generators
const response = await ai.generate({ prompt, stream: true });
for await (const chunk of response) {
data = (data || '') + chunk.text;
}
resolve(data);
});
});The handler receives the same arguments as the declared function.
Step 4: Setup Server
Configure the server to handle IRPC requests.
// server.ts
import { setContextProvider } from '@irpclib/irpc';
import { AsyncLocalStorage } from 'node:async_hooks';
import { HTTPRouter } from '@irpclib/http';
import { irpc, transport } from './lib/module.js';
import './rpc/hello/constructor.js'; // Import handlers
setContextProvider(new AsyncLocalStorage());
const router = new HTTPRouter(irpc, transport);
Bun.serve({
port: 3000,
fetch(req) {
if (req.url.endsWith(transport.endpoint) && req.method === 'POST') {
return router.resolve(req);
}
return new Response('Not Found', { status: 404 });
},
});
console.log('Server running on http://localhost:3000');The router automatically handles batching, routing, and streaming responses.
Step 5: Execute & Stream on Client
Call the function like any local async function, or use .subscribe() to react to continuous state changes.
// client.ts
import { hello } from './rpc/hello/index.js';
import { generatePoem } from './rpc/poem/index.js';
// Standard execution
const message = await hello('John');
console.log(message); // "Hello John"
// Real-time streaming
const call = generatePoem('Space');
call.subscribe(state => {
console.log('Stream chunk:', state.data);
});No fetch calls, manual serialization, or separate WebSocket connections required.
Advanced Features
Progressive Data Hydration (Dashboard Streams)
Solve the N+1 problem and avoid UI waterfalls by progressively yielding multiple parallel data aggregations through a single stream request.
// 1. Declare the stub (Shared)
// rpc/dashboard/index.ts
import type { RemoteState } from '@irpclib/irpc';
export type GetDashboardFn = (userId: string) => RemoteState<DashboardData>;
export const getDashboard = irpc.declare<GetDashboardFn>({
name: 'getDashboard',
init: () => ({} as DashboardData), // Initial client-side state before server data arrives
});// 2. Implement the stream (Server-Only)
// rpc/dashboard/constructor.ts
import { getDashboard } from './index.js';
import { stream } from '@irpclib/irpc';
irpc.construct(getDashboard, (userId) => {
return stream((data, resolve) => {
// Execute queries concurrently and mutate the reactive state directly
const q1 = db.users.get(userId).then(res => data.user = res);
const q2 = db.sales.aggregate(userId).then(res => data.sales = res);
const q3 = externalApi.fetchMetrics().then(res => data.telemetry = res);
// Conclude the stream when all continuous queries complete
Promise.all([q1, q2, q3]).then(() => resolve());
}, {}); // Server-side initial value passed to the stream constructor
});On the client, the UI skeleton can begin rendering as the stream fields populate over time.
const call = getDashboard('user-123');
call.subscribe(state => {
console.log('Hydrating partial UI fields...', state.data);
});Caching
Cache responses to reduce network calls.
export const getUser = irpc.declare<GetUserFn>({
name: 'getUser',
maxAge: 60000, // Cache for 60 seconds
});Subsequent calls with the same arguments return cached data.
Timeout
Set per-function timeouts.
export const slowQuery = irpc.declare<SlowQueryFn>({
name: 'slowQuery',
timeout: 30000, // 30 second timeout
});Calls exceeding the timeout will reject with an error.
Retry Configuration
Configure retry behavior per function, package, or transport level.
// Function-level (highest priority)
export const criticalFunction = irpc.declare<CriticalFn>({
name: 'processPayment',
maxRetries: 5, // 5 retry attempts
retryMode: 'exponential', // 1s, 2s, 4s, 8s, 16s delays
retryDelay: 1000, // 1 second base delay
});
// Package-level (medium priority)
const irpc = createPackage({
name: 'my-api',
maxRetries: 3, // Default for all functions
retryMode: 'linear', // Fixed delays
});
// Transport-level (lowest priority)
const transport = new HTTPTransport({
endpoint: '/api',
maxRetries: 1, // Fallback for functions without config
});Priority Order: Function → Package → Transport
Network errors trigger automatic retries. Handler errors fail immediately without retry.
Coalesce
Combine multiple calls with the same arguments to avoid duplicate executions.
export const expensiveQuery = irpc.declare<ExpensiveQueryFn>({
name: 'expensiveQuery',
coalesce: true, // Enable coalescing
});When multiple calls with identical arguments are made simultaneously, only one execution occurs. All callers receive the same result.
Cache Invalidation
Manually clear cached responses when data becomes stale.
// Invalidate specific cache entry
irpc.invalidate(getUser, 'user-123');
// Invalidate all cache entries for a function
irpc.invalidate(getUser);Use this when you know cached data is no longer valid, such as after mutations.
Validation (Optional)
Use Zod for runtime validation.
import { z } from 'zod';
export const createUser = irpc.declare({
name: 'createUser',
schema: {
input: [z.object({
name: z.string(),
email: z.string().email(),
})],
output: z.object({
id: z.string(),
name: z.string(),
}),
},
});Invalid inputs or outputs will throw validation errors.
Context
Access request context in handlers.
import { getContext, setContext } from '@irpclib/irpc';
// In middleware
router.use(async () => {
const req = getContext<Request>('request');
const userId = req.headers.get('x-user-id');
setContext('userId', userId);
});
// In handler
irpc.construct(getProfile, async () => {
const userId = getContext('userId');
return await db.users.findById(userId);
});Context is automatically scoped to each request.
Multiple Functions
Create multiple functions in the same package.
// rpc/users/index.ts
export const getUser = irpc.declare<GetUserFn>({ name: 'getUser' });
export const createUser = irpc.declare<CreateUserFn>({ name: 'createUser' });
export const updateUser = irpc.declare<UpdateUserFn>({ name: 'updateUser' });
// rpc/users/constructor.ts
irpc.construct(getUser, async (id) => { /* ... */ });
irpc.construct(createUser, async (data) => { /* ... */ });
irpc.construct(updateUser, async (id, data) => { /* ... */ });All functions share the same transport and batching.
Automatic Batching
When you call multiple functions simultaneously, they're automatically batched.
const [user, posts, stats] = await Promise.all([
getUser('123'),
getPosts('123'),
getStats('123'),
]);This sends 1 HTTP request instead of 3, with responses streaming back as they complete.
Distribution
IRPC supports publishing your function stubs to NPM while keeping handlers private on the server.
Publishing to NPM
Configure your package.json to publish only the dist/ directory:
{
"name": "my-api",
"version": "1.0.0",
"types": "./dist/index.d.ts",
"module": "./dist/index.js",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./*": "./dist/rpc/*"
},
"files": ["dist"]
}Excluding Server Code
Use .npmignore to exclude server-side code:
# Source code (keep only dist/)
src
# Server files
**/constructor.ts
**/constructor.js
# Environment
.env
.env.exampleBuild and Publish
# Build client stubs
npm run build
# Publish to NPM
npm publishClient Usage
Clients install your package and use the stubs:
npm install my-apiimport { getUser, createUser } from 'my-api/user';
const user = await getUser('123');The stubs automatically connect to your server endpoint. Handlers remain private on your server.
Next Steps
- Comparison - See how IRPC compares to alternatives
- HTTP Transport - Configure transport options
- Specification - Full protocol details