Skip to content

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:

bash
npx degit beerush-id/anchor/templates/irpc-bun-starter my-api
cd my-api
bun install
bun run serve

Server 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

bash
npm install @irpclib/irpc @irpclib/http @irpclib/ws

IRPC 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 usage

Step 1: Create Package

Create a package to namespace your IRPC functions.

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

typescript
import { HTTPTransport } from '@irpclib/http';

const transport = new HTTPTransport({
  endpoint: `/irpc/${irpc.href}`,
  timeout: 10000,
  maxRetries: 3,
});

WebSocket Transport (for persistent connections):

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

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

typescript
// 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, or ERROR).
  • 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.

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

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

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

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

typescript
// 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
});
typescript
// 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.

typescript
const call = getDashboard('user-123');
call.subscribe(state => {
  console.log('Hydrating partial UI fields...', state.data);
});

Caching

Cache responses to reduce network calls.

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

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

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

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

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

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

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

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

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

json
{
  "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.example

Build and Publish

bash
# Build client stubs
npm run build

# Publish to NPM
npm publish

Client Usage

Clients install your package and use the stubs:

bash
npm install my-api
typescript
import { 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