Skip to content

Client

The RPC client provides a type-safe proxy for calling server procedures with automatic type inference.

Import

ts
import { createClient } from '@ereo/rpc/client'
import type { RPCClientOptions, RPCClientError } from '@ereo/rpc/client'

createClient

Creates a typed client proxy from a router type.

Signature

ts
function createClient<T extends Router<RouterDef>>(
  optionsOrEndpoint: string | RPCClientOptions
): InferClient<T['_def']>

Type Definitions

ts
interface RPCClientOptions {
  /** HTTP endpoint for queries/mutations (e.g., '/api/rpc') */
  httpEndpoint: string

  /** WebSocket endpoint for subscriptions (e.g., 'ws://localhost:3000/api/rpc') */
  wsEndpoint?: string

  /** Custom fetch function */
  fetch?: typeof fetch

  /** Custom headers (static or dynamic) */
  headers?: Record<string, string> | (() => Record<string, string>)

  /** WebSocket reconnect options */
  reconnect?: {
    enabled?: boolean      // Default: true
    maxAttempts?: number   // Default: 10
    delayMs?: number       // Default: 1000
    maxDelayMs?: number    // Default: 30000
  }

  /** Use POST for all requests instead of GET for queries */
  usePostForQueries?: boolean

  /** WebSocket heartbeat interval in milliseconds (default: 30000) */
  heartbeatInterval?: number

  /** Enable WebSocket heartbeat (default: true) */
  heartbeatEnabled?: boolean
}

Parameters

ParameterTypeDescription
optionsOrEndpointstring | RPCClientOptionsEndpoint URL string or full options object

Returns

A typed proxy object that mirrors the router structure with query(), mutate(), and subscribe() methods.

Examples

Minimal Setup

ts
import { createClient } from '@ereo/rpc/client'
import type { Api } from './api/router'

// Simple string endpoint (HTTP only, no subscriptions)
const rpc = createClient<Api>('/api/rpc')

Full Configuration

ts
const rpc = createClient<Api>({
  httpEndpoint: '/api/rpc',
  wsEndpoint: 'ws://localhost:3000/api/rpc',

  // Dynamic headers for auth
  headers: () => ({
    Authorization: `Bearer ${getAuthToken()}`,
  }),

  // Reconnection settings
  reconnect: {
    enabled: true,
    maxAttempts: 15,
    delayMs: 500,
    maxDelayMs: 60000,
  },

  // Heartbeat settings
  heartbeatEnabled: true,
  heartbeatInterval: 30000,
})

Production Setup

ts
const rpc = createClient<Api>({
  httpEndpoint: '/api/rpc',
  wsEndpoint: typeof window !== 'undefined'
    ? `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/api/rpc`
    : undefined,

  headers: () => {
    const token = localStorage.getItem('auth_token')
    return token ? { Authorization: `Bearer ${token}` } : {}
  },

  reconnect: {
    enabled: true,
    maxAttempts: 20,
    delayMs: 1000,
    maxDelayMs: 30000,
  },
})

Client Methods

query()

Executes a query procedure. Uses HTTP GET by default (cacheable).

Signature

ts
// Without input
query(): Promise<TOutput>

// With input
query(input: TInput): Promise<TOutput>

Examples

ts
// No input
const health = await rpc.health.query()
// health is typed as { status: string; timestamp: number }

// With input
const user = await rpc.users.get.query({ id: '123' })
// user is typed as User | null

// Nested path
const stats = await rpc.v1.admin.analytics.daily.query()

mutate()

Executes a mutation procedure. Always uses HTTP POST.

Signature

ts
// Without input
mutate(): Promise<TOutput>

// With input
mutate(input: TInput): Promise<TOutput>

Examples

ts
// No input
const result = await rpc.auth.logout.mutate()
// result is typed as { success: boolean }

// With input
const newPost = await rpc.posts.create.mutate({
  title: 'Hello World',
  content: 'My first post',
})
// newPost is typed as Post

// Nested path
await rpc.v1.admin.users.ban.mutate({ userId: '123' })

subscribe()

Subscribes to a subscription procedure via WebSocket.

Signature

ts
// Without input
subscribe(callbacks: SubscriptionCallbacks<TOutput>): Unsubscribe

// With input
subscribe(input: TInput, callbacks: SubscriptionCallbacks<TOutput>): Unsubscribe

Type Definitions

ts
interface SubscriptionCallbacks<T> {
  onData: (data: T) => void
  onError?: (error: Error) => void
  onComplete?: () => void
}

type Unsubscribe = () => void

Examples

Basic Subscription
ts
const unsubscribe = rpc.posts.onCreated.subscribe({
  onData: (post) => {
    console.log('New post:', post.title)
    addToList(post)
  },
  onError: (error) => {
    console.error('Subscription error:', error)
    showNotification('Connection lost')
  },
  onComplete: () => {
    console.log('Subscription ended')
  },
})

// Later: cleanup
unsubscribe()
Subscription with Input
ts
const unsubscribe = rpc.chat.messages.subscribe(
  { roomId: 'general' },
  {
    onData: (message) => {
      appendMessage(message)
    },
    onError: (error) => {
      console.error('Chat error:', error)
    },
  }
)
Multiple Subscriptions
ts
// Subscribe to multiple streams
const unsub1 = rpc.notifications.onNew.subscribe({
  onData: (notification) => showNotification(notification),
})

const unsub2 = rpc.presence.onlineUsers.subscribe({
  onData: (users) => updateOnlineUsers(users),
})

// Cleanup all
function cleanup() {
  unsub1()
  unsub2()
}

Auto-Reconnection

The client automatically reconnects WebSocket connections when they drop.

Behavior

  1. On connection loss, waits for delayMs before first retry
  2. Each subsequent retry doubles the delay (exponential backoff)
  3. Delay is capped at maxDelayMs
  4. After maxAttempts, stops retrying and calls onError for all subscriptions
  5. On successful reconnect, automatically resubscribes to all active subscriptions

Configuration

ts
const rpc = createClient<Api>({
  httpEndpoint: '/api/rpc',
  wsEndpoint: 'ws://localhost:3000/api/rpc',
  reconnect: {
    enabled: true,      // Enable auto-reconnect
    maxAttempts: 10,    // Max retry attempts
    delayMs: 1000,      // Initial delay (1 second)
    maxDelayMs: 30000,  // Max delay (30 seconds)
  },
})

Reconnection Timeline Example

Connection lost
├── Wait 1s   → Attempt 1
├── Wait 2s   → Attempt 2
├── Wait 4s   → Attempt 3
├── Wait 8s   → Attempt 4
├── Wait 16s  → Attempt 5
├── Wait 30s  → Attempt 6 (capped)
├── Wait 30s  → Attempt 7
├── Wait 30s  → Attempt 8
├── Wait 30s  → Attempt 9
└── Wait 30s  → Attempt 10 (final)

Heartbeat (Ping/Pong)

The client maintains connection health using heartbeat messages.

Behavior

  1. Client sends ping messages at heartbeatInterval (default: 30s)
  2. Server responds with pong
  3. If 2 consecutive pongs are missed, connection is considered dead
  4. Client closes the connection and triggers reconnection

Configuration

ts
const rpc = createClient<Api>({
  httpEndpoint: '/api/rpc',
  wsEndpoint: 'ws://localhost:3000/api/rpc',
  heartbeatEnabled: true,     // Enable heartbeat (default)
  heartbeatInterval: 30000,   // 30 seconds
})

Disabling Heartbeat

ts
const rpc = createClient<Api>({
  httpEndpoint: '/api/rpc',
  wsEndpoint: 'ws://localhost:3000/api/rpc',
  heartbeatEnabled: false,  // Disable heartbeat
})

Custom Headers

Headers can be static or dynamic (useful for auth tokens).

Static Headers

ts
const rpc = createClient<Api>({
  httpEndpoint: '/api/rpc',
  headers: {
    'X-API-Key': 'my-api-key',
    'X-Client-Version': '1.0.0',
  },
})

Dynamic Headers

ts
const rpc = createClient<Api>({
  httpEndpoint: '/api/rpc',
  headers: () => ({
    Authorization: `Bearer ${getAuthToken()}`,
    'X-Request-ID': generateRequestId(),
  }),
})

Auth Token Pattern

ts
let authToken: string | null = null

export function setAuthToken(token: string | null) {
  authToken = token
}

export const rpc = createClient<Api>({
  httpEndpoint: '/api/rpc',
  wsEndpoint: 'ws://localhost:3000/api/rpc',
  headers: () => {
    const headers: Record<string, string> = {}
    if (authToken) {
      headers.Authorization = `Bearer ${authToken}`
    }
    return headers
  },
})

// Usage
setAuthToken(await login(email, password))
const user = await rpc.users.me.query()

Custom Fetch

Replace the built-in fetch for testing or custom networking.

ts
const rpc = createClient<Api>({
  httpEndpoint: '/api/rpc',
  fetch: async (url, init) => {
    console.log('Fetching:', url)
    const response = await fetch(url, init)
    console.log('Response:', response.status)
    return response
  },
})

Testing with Mock Fetch

ts
const mockFetch = jest.fn().mockResolvedValue({
  json: () => Promise.resolve({ ok: true, data: { id: '1', name: 'Test' } }),
})

const rpc = createClient<Api>({
  httpEndpoint: '/api/rpc',
  fetch: mockFetch,
})

// Test
await rpc.users.get.query({ id: '1' })
expect(mockFetch).toHaveBeenCalledWith(
  expect.stringContaining('path=users.get'),
  expect.any(Object)
)

POST for Queries

By default, queries use GET requests for cacheability. Enable POST for all requests:

ts
const rpc = createClient<Api>({
  httpEndpoint: '/api/rpc',
  usePostForQueries: true,  // All requests use POST
})

When to use POST for queries:

  • Input data is large (URL length limits)
  • Sensitive data shouldn't appear in URLs/logs
  • Server doesn't support GET with query params

Error Handling

RPCClientError

Errors thrown by the client include additional context:

ts
interface RPCClientError extends Error {
  code: string        // Error code from server
  path: string        // Procedure path (e.g., "users.get")
  details?: unknown   // Additional error details
}

Handling Errors

ts
try {
  const user = await rpc.users.get.query({ id: 'invalid' })
} catch (error) {
  if (error.code === 'NOT_FOUND') {
    showMessage('User not found')
  } else if (error.code === 'UNAUTHORIZED') {
    redirectToLogin()
  } else if (error.code === 'VALIDATION_ERROR') {
    showValidationErrors(error.details)
  } else {
    showMessage('An error occurred')
    console.error('RPC Error:', error.path, error.code, error.message)
  }
}

Error Handling Pattern

ts
async function safeQuery<T>(
  fn: () => Promise<T>,
  fallback: T
): Promise<T> {
  try {
    return await fn()
  } catch (error) {
    console.error('Query failed:', error)
    return fallback
  }
}

// Usage
const posts = await safeQuery(
  () => rpc.posts.list.query(),
  []  // Fallback to empty array
)

Type Inference

The client provides full type inference from the router:

ts
// Server
const api = createRouter({
  users: {
    get: procedure.query(
      z.object({ id: z.string() }),
      async ({ input }): Promise<User | null> => {
        return db.users.findUnique({ where: { id: input.id } })
      }
    ),
    list: procedure.query(async (): Promise<User[]> => {
      return db.users.findMany()
    }),
  },
})

export type Api = typeof api

// Client
const rpc = createClient<Api>({ httpEndpoint: '/api/rpc' })

// All types are inferred:
const user = await rpc.users.get.query({ id: '123' })
//    ^? User | null

const users = await rpc.users.list.query()
//    ^? User[]

// TypeScript errors for wrong input:
await rpc.users.get.query({ id: 123 }) // Error: number not assignable to string
await rpc.users.get.query({})          // Error: missing required property 'id'

Connection States

WebSocket Lifecycle

┌─────────────────────────────────────────────────────────────┐
│                    Connection States                         │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  [Disconnected] ──subscribe()──> [Connecting]               │
│        ▲                              │                      │
│        │                              │ connected            │
│        │                              ▼                      │
│        │                         [Connected]                 │
│        │                              │                      │
│        │ all unsub'd                  │ connection lost      │
│        │                              ▼                      │
│        └──────────────────── [Reconnecting] ◄───┐            │
│                                       │         │            │
│                                       │ retry   │ fail       │
│                                       └─────────┘            │
│                                                              │
└─────────────────────────────────────────────────────────────┘

Connection Behavior

  • WebSocket connects lazily on first subscribe() call
  • Connection closes automatically when all subscriptions are unsubscribed
  • Reconnection only happens if there are active subscriptions
  • Multiple subscribe() calls share the same WebSocket connection

Best Practices

  1. Create a single client instance - Reuse across your application
  2. Use dynamic headers for auth - Tokens may change during session
  3. Handle errors gracefully - Check error codes and show appropriate UI
  4. Clean up subscriptions - Always call unsubscribe() when done
  5. Configure reconnection - Adjust based on your use case
  6. Type your router - Export type Api = typeof api for full inference
  • Router - Server-side router
  • React Hooks - useQuery, useMutation, useSubscription
  • Protocol - HTTP and WebSocket protocol specification

Released under the MIT License.