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/airstack/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 hooks
- TypeScript configuration
- Docker support
Manual Setup
Installation
Install the core library alongside your preferred transport:
npm install @irpclib/irpc @irpclib/httpIRPC supports multiple transports, and allows you to create your own:
- @irpclib/http: HTTP transport with zero-latency batching and SSE streaming.
- @irpclib/ws: WebSocket transport for persistent low-latency real-time states.
- @irpclib/broadcast: BroadcastChannel transport for zero-server, cross-tab & Web Worker execution.
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
Start by defining an IRPC package. Packages group and version your remote functions, allowing you to run multiple APIs on a single server without route collisions, and making it extremely easy to publish your client-side type stubs directly to NPM.
// 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 structurally bounds your function signature types to a unique identifier (my-api/1.0.0), ensuring that execution payloads never cross breaking versions. The attached transport then resolves the network transmission for all functions bound to the package.
Microservice Distribution
Because each package accepts its own transport instance, you can distribute your architecture across microservices without needing an API Gateway or meddling with URL route configurations on the client. You simply wire the packages to different transport destinations:
// Define isolated packages
export const userService = createPackage({ name: 'users', version: '1.0.0' });
export const projectService = createPackage({ name: 'projects', version: '1.0.0' });
// Wire packages to completely different physical servers
userService.use(new HTTPTransport({ endpoint: 'https://server-a.internal/users/1.0.0' }));
projectService.use(new HTTPTransport({ endpoint: 'https://server-b.internal/projects/1.0.0' }));From the client's perspective, they just call await getUser() and await getProject() normally. The packages securely direct the execution payloads to their respective server microservices behind the scenes.
Choosing a Transport
IRPC supports multiple robust transport protocols out of the box, and you can easily write your own Custom Transports for edge 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 sustained persistent connections):
import { WebSocketTransport } from '@irpclib/ws';
const transport = new WebSocketTransport({
url: 'ws://localhost:8080',
autoReconnect: true,
maxReconnectAttempts: 5,
});BroadcastChannel Transport (for Web Workers & multi-tab coordination without a server):
import { BroadcastTransport } from '@irpclib/broadcast';
const transport = new BroadcastTransport({
channel: irpc.href, // 'my-api/1.0.0'
});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>('hello', () => '');You can 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>('generatePoem', () => '');Important!
When declaring a function that returns RemoteState<T>, the seed factory is required. On the client, when the stub is called, a RemoteState is instantiated immediately — before any data arrives from the server. The seed factory seeds the initial value of state.data so UI frameworks can bind to the reactive proxy right away and render as server mutations arrive.
RemoteState<T> is IRPC's core reactive primitive. It extends a standard native Promise<T>, meaning it can be standardly await-ed. But its massive architectural advantage is that it operates as an autonomous reactive proxy; UI frameworks can actively bind to its temporal data mutations from the server over the wire without writing a single event listener or 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) as data arrives without writing WebSocket hooks.
- Monitor the exact execution status (
PENDING,SUCCESS, orERROR). - Handle mid-stream pipeline errors directly via
state.error.
Deferred by Default
Stream calls are deferred — calling the stub returns a RemoteState seeded with the init() value, but the handler doesn't execute until .start() is called. Deferring prevents wasted I/O during SSR (where the connection would be discarded once the HTML is sent) and avoids hydration mismatches caused by data arriving between the server render and client mount.
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: string) => {
return `Hello, ${name}! Welcome to Anchor!`;
});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 (state, resolve, reject) => {
const response = await ai.generate({ prompt, stream: true });
for await (const chunk of response) {
if (state.status !== 'pending') break;
state.data = (state.data || '') + chunk.text;
}
resolve();
});
});The handler receives the exact same typed arguments as the declared function.
File Uploads
IRPC supports file uploads natively through the IRPCFile class. Files can be passed as top-level arguments or nested deeply within object payloads. The transport automatically extracts and reconstructs the binaries alongside the JSON payload.
// Shared Interface
import { irpc, type IRPCFile } from '@irpclib/irpc';
export type UserProfile = {
username: string;
avatar: IRPCFile;
settings: { theme: string };
};
export type UpdateProfileFn = (profile: UserProfile) => Promise<string>;
export const updateProfile = irpc.declare<UpdateProfileFn>('updateProfile', () => '');
// Client Usage
import { IRPCFile } from '@irpclib/irpc';
const fileInput = document.querySelector('input[type="file"]');
const file = fileInput.files[0];
// Wrap standard browser files/blobs in an IRPCFile
const avatar = new IRPCFile({ name: file.name, size: file.size, type: file.type }, file);
// IRPC recursively scans and extracts files from anywhere in the payload
await updateProfile({
username: 'john_doe',
avatar,
settings: { theme: 'dark' }
});
// Server Implementation
irpc.construct(updateProfile, async (profile) => {
// `profile.avatar` arrives fully reconstructed.
// The base IRPCFile type natively exposes `.data` (Blob) and `.meta`.
const buffer = await profile.avatar.data.arrayBuffer();
await storage.save(profile.avatar.meta.name, buffer);
await db.users.update({ username: profile.username });
return 'Success';
});Transport Recommendation
While WebSocket transport supports binary framing for files, sending large files over a single-threaded WebSocket connection will block other real-time messages from resolving until the entire binary finishes transferring.
For applications handling significant file uploads, HTTP Transport is heavily recommended because the browser natively offloads HTTP file uploads to a background thread, preventing UI blocking and keeping the main WebSocket connection free for snappy real-time interactions.
Step 4: Setup Server
Configure the server to handle IRPC requests.
The server requires AsyncLocalStorage to isolate context across concurrent requests. Without it, context from one user's request could leak into another's.
The router's resolve method accepts the HTTP request and an optional initContext — an array of [key, value] tuples that seed the request context. Extract application-level values (tokens, locale, tenant ID) at the integration point. Hooks and handlers then consume these values through getContext() without knowing which transport delivered the request.
// server.ts
import '@irpclib/irpc/server';
import { HTTPRouter } from '@irpclib/http/router';
import { irpc, transport } from './lib/module.js';
import './rpc/hello/constructor.js';
const router = new HTTPRouter(irpc, transport);
router.use(async () => {
const token = getContext<string>('token');
if (!token) throw new Error('Unauthorized');
setContext('user', await verifyToken(token));
});
Bun.serve({
port: 3000,
fetch(req) {
if (req.url.endsWith(transport.endpoint) && req.method === 'POST') {
return router.resolve(req, [
['token', req.headers.get('authorization')],
]);
}
return new Response('Not Found', { status: 404 });
},
});Step 5: Execute & Stream on Client
Call the function like any local async function, or bind it directly to UI components to react to continuous network state changes.
// client.ts
import { hello } from './rpc/hello/index.js';
import { generatePoem } from './rpc/poem/index.js';
// Standard isolated execution
const message = await hello('John');
// Reactive UI binding over the network
const PoemWidget = setup(() => {
const poem = generatePoem('Space');
// Start the stream after hydration.
onMount(() => poem.start());
return render(() => <div>{poem.data}</div>);
});No fetch calls, manual serialization, or separate WebSocket connections required.
Automatic Stream Cleanup
Stream proxies (RemoteState) are automatically closed when the owning component unmounts. You do not need to manually call .close() in onCleanup() — the framework handles this via createLifecycle.
Advanced Features
Stream Subscriptions & Cleanup
For streams that manage persistent open connections or event listeners, return a cleanup function. The underlying architecture supports async initialization workflows:
1. Shared Interface (src/shared/rpc.ts)
import { irpc } from '@irpclib/irpc';
import type { RemoteState } from '@irpclib/irpc';
export type WatchPricesFn = (ticker: string) => RemoteState<number>;
export const watchPrices = irpc.declare<WatchPricesFn>('watchPrices', () => 0);2. Backend Implementation (src/server/rpc.ts)
import { irpc } from '../lib/module.js';
import { watchPrices } from '../shared/rpc.js';
import { stream } from '@irpclib/irpc';
irpc.construct(watchPrices, (ticker) => {
return stream(async (state) => {
// 1. Asynchronous initialization
const connection = await redis.connect();
// 2. Real-time mutations
connection.on('price_update', (price) => {
state.data = price;
});
// 3. Guaranteed cleanup execution
return () => {
connection.close();
};
});
});Because the stream wrapper hooks into the transport's AbortController (which identically drops across all terminal boundaries), it guarantees the cleanup runs universally under all standard lifecycle conditions:
- Successful Completion: When
resolve()is explicitly called by your code, the transport closes the reader and safely fires the abort sequence tearing down the hook. - Error Rejection: When
reject()is called, identical cleanup occurs. - Client Disconnection: When the remote client explicitly drops the websocket or calls
call.close(). - Timeouts: When a router forcibly aborts the pipeline because its execution exceeded the specified
ttlbounds limit. - Asynchronous Hand-offs: If an abort drops during an
await redis.connect()initialization, the framework guarantees the returned cleanup hook will still fire synchronously exactly when the initialization wrapper Promise resolves.
WARNING
The single exception to the universal teardown rule is unhandled asynchronous exceptions. If your code throws a fatal exception before returning the cleanup hook block to the framework (e.g., throwing inside an async stream before the return statement), the hook is never physically registered, meaning your setup components (like open DB connections) won't be caught by the teardown. Always wrap dangerous initializations in try...catch blocks to ensure execution physically registers the cleanup accurately!
Manual RemoteState Operations
If you bypass the stream() utility and manually construct and return a new RemoteState(), you are responsible for wiring teardown listeners securely yourself by invoking getAbortSignal():
import { RemoteState, getAbortSignal } from '@irpclib/irpc';
irpc.construct(watchPrices, (ticker) => {
const state = new RemoteState<number>();
const signal = getAbortSignal();
const handle = externalDataSource.subscribe(ticker, (price) => {
state.data = price;
});
// Listen explicitly for lifecycle teardown events without the stream wrapper
signal?.addEventListener('abort', () => {
externalDataSource.unsubscribe(handle);
});
return state;
});Progressive Data Hydration (Dashboard Streams)
Solve the N+1 problem and avoid UI waterfalls by yielding multiple parallel data aggregations through a single stream request.
1. Shared Interface (src/shared/rpc.ts)
import { irpc } from '@irpclib/irpc';
export const getDashboard = irpc.declare<GetDashboardFn>('getDashboard', () => ({
user: null, sales: null, telemetry: null,
}));2. Backend Implementation (src/server/rpc.ts)
import { irpc } from '../lib/module.js';
import { getDashboard } from '../shared/rpc.js';
import { stream } from '@irpclib/irpc';
irpc.construct(getDashboard, (userId) => {
return stream((state, resolve) => {
// Execute queries concurrently and mutate the reactive state.data directly
const q1 = db.users.get(userId).then(res => state.data.user = res);
const q2 = db.sales.aggregate(userId).then(res => state.data.sales = res);
const q3 = externalApi.fetchMetrics().then(res => state.data.telemetry = res);
// Conclude the stream when all continuous queries complete
Promise.all([q1, q2, q3]).then(() => resolve());
}, {} as DashboardData);
});On the client, the UI skeleton can begin rendering as the stream fields populate over time.
const DashboardWidget = setup(({ user }) => {
const dashboard = getDashboard(user.id);
onMount(() => dashboard.start());
return render(() => (
<div>
<Metrics block={dashboard.data.telemetry} />
<Sales volume={dashboard.data.sales} />
<User target={dashboard.data.user} />
</div>
));
});Caching
Cache responses to reduce network calls.
export const getUser = irpc.declare<GetUserFn>('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>('slowQuery', () => undefined, {
timeout: 30000, // 30 second timeout
});Calls exceeding the timeout will reject with an error.
TTL (Time-To-Live)
Set a maximum lifetime for stream handlers. If a stream remains open longer than the specified TTL, the router automatically aborts its AbortController, shutting down the stream server-side.
export const watchPrices = irpc.declare<WatchPricesFn>('watchPrices', () => 0, {
ttl: 300000, // 5 minute maximum stream lifetime
});TTL is enforced by the router (HTTP, WebSocket, and BroadcastChannel). It applies only to stream handlers — standard promise-based calls use timeout instead.
Retry Configuration
Configure retry behavior per function, package, or transport level.
// Function-level (highest priority)
export const criticalFunction = irpc.declare<CriticalFn>('processPayment', () => undefined, {
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>('expensiveQuery', () => undefined, {
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
Every router's resolve method accepts an initContext parameter — an array of [key, value] tuples that seed the request context. Hooks and handlers access these values via getContext() and can add more via setContext().
The router itself only manages the internal AbortController. All other context is the caller's responsibility. This is the key architectural constraint: the integration point extracts application-level values from whatever transport object it has, and injects them as standardized keys. Hooks and handlers never reference Request, WebSocket, or BroadcastChannel.
Define your application's context contract once. Each transport maps its specific objects into the same keys. The hook stays identical:
import { getContext, setContext } from '@irpclib/irpc';
// HTTP
router.resolve(req, [['token', req.headers.get('authorization')]]);
// WebSocket
router.resolve(msg, ws, [['token', ws.data.token]]);
// Broadcast
router.resolve(requests, [['token', self.workerToken]]);
router.use(async () => {
const token = getContext<string>('token');
if (!token) throw new Error('Unauthorized');
setContext('user', await verifyToken(token));
});
irpc.construct(getProfile, async () => {
const user = getContext<User>('user');
return await db.users.findById(user.id);
});Context is scoped to each request.
Hooks
IRPC has a two-level hook system for guarding and intercepting calls.
Router Hooks (Global)
Router hooks run before every request processed by that router. They have no access to the request arguments — they read from the standardized context keys seeded by initContext.
router.use(async () => {
const token = getContext<string>('token');
if (!token) throw new Error('Unauthorized');
setContext('user', await verifyToken(token));
});Router hooks run in order. If any hook throws, the request is rejected before the handler executes.
Spec Hooks (Per-Function)
Spec hooks guard individual functions. They receive the typed request arguments and run at the module level — right before the handler.
irpc.hook(deleteUser, async (req) => {
const user = getContext<User>('user');
if (!user?.admin) throw new Error('Forbidden');
});The hook receives a typed req object with name and args inferred from the stub's type signature:
type DeleteUserFn = (userId: string) => Promise<void>;
const deleteUser = irpc.declare<DeleteUserFn>('deleteUser', () => undefined);
irpc.hook(deleteUser, (req) => {
req.args[0]; // typed as string (userId)
});Execution Order
- Router hooks — global guards (auth, rate limiting)
- Spec hooks — per-function guards (authorization, validation)
- Handler — business logic
If any hook throws, execution stops and the error propagates.
Isomorphic Hooks
Spec hooks are module-scoped, not environment-scoped. When client and server share a module instance (SSR), all registered hooks run at the call site. On the client, hooks execute synchronously. On the server (inside the resolver), hooks are awaited.
Monitoring (IRPCStore)
IRPC provides a global store for observing the system's live state. Packages, routers, and streams self-register — no manual tracking.
import { IRPC_STORE } from '@irpclib/irpc';
// Live snapshot
IRPC_STORE.packages; // Set of registered packages
IRPC_STORE.routers; // Set of active routers
IRPC_STORE.calls; // Set of active streamsSubscribe to lifecycle events:
const unsubscribe = IRPC_STORE.subscribe((event) => {
switch (event.type) {
case 'register': // Package registered
case 'route': // Router created
case 'queue': // Stream started
case 'dequeue': // Stream finished
}
});The store is a live window, not a log. It tracks what's active right now. Zero overhead when nobody subscribes. Build logging, metrics, dashboards, or alerting on top — the store has no opinions about what you do with the data.
Multiple Functions
Create multiple functions in the same package.
// rpc/users/index.ts
export const getUser = irpc.declare<GetUserFn>('getUser', () => ({}));
export const createUser = irpc.declare<CreateUserFn>('createUser', () => ({}));
export const updateUser = irpc.declare<UpdateUserFn>('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 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 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