Skip to content

Getting started

Source RPC is TypeScript RPC for a network of peers — a browser tab, a Node service, and a plant full of devices — over socket.io and MQTT 5, with one programming model across all of them.

A class is the contract: the server hands one live instance to exposeClassInstance, the client gets a typed proxy of the same class, and calling a method on the proxy runs it on that instance. No code generation and no schema files required, though there is a schema when you want arguments checked at runtime.

npm install @source-repo/rpc

ESM only, Node 22 or later, and it runs in the browser.

If all you need is a browser talking to a Node server, use tRPC. It is very good at that and far more widely used. This is for the case it does not cover: more than two parties, not all on the same kind of link, and commands where sending one twice is not free.

The class both sides share

Nothing here is Source RPC-specific — no decorators, no base class:

typescript
// calculator.ts
export class Calculator {
    private memory = 0

    async square(n: number) {
        return n * n
    }

    async add(n: number) {
        this.memory += n
        return this.memory
    }
}

The server

With no transports configured it listens on WebSocket (socket.io) port 7843:

typescript
import { RpcServer } from '@source-repo/rpc'
import { Calculator } from './calculator.js'

const server = new RpcServer()
server.exposeClassInstance(new Calculator(), 'calculator')
await server.ready()

A client

In another Node process, a browser page, or the same process for testing:

typescript
import { RpcClient } from '@source-repo/rpc'
import type { Calculator } from './calculator.js'    // the type only - see below

const client = new RpcClient('http://localhost:7843')
await client.ready()

const calculator = await client.proxy<Calculator>('calculator')
console.log(await calculator.square(3))      // 9
console.log(await calculator.add(10))        // 10
console.log(await calculator.add(5))         // 15 - one instance, holding its own state

await client.close()

Three things worth noticing:

The instance is a real, long-lived object. It is constructed once, by you, and every call runs against that same object — which is why add accumulates. State lives where you would put it anyway, in fields.

import type on the client is the point of the whole design: the client compiles against the class but imports none of its code, so the implementation never reaches the browser bundle. In a monorepo, put the class in a package both sides depend on. If the client cannot see the class at all, describe the surface with an interface instead and pass that to proxy<T>().

proxy() hands back the instance, so calling a remote method reads like calling a local one. Two names are reserved on a proxy: $with attaches per-call options — an idempotency key, a timeout, an owner fence — and then answers undefined so await does not adopt the proxy as a promise.

The other half: state

Methods are what a peer can be told. State is what it is, and it is first-class rather than a getStatus() you have to remember to call. Extend RpcComponent<Props, State> and the instance publishes a snapshot every time it changes:

typescript
// oven.ts
import { RpcComponent, rpc, rpcNamespace } from '@source-repo/rpc'

type OvenProps = { unit: string; maximum: number }
type OvenState = { celsius: number; mode: string }

@rpcNamespace('oven')
export class Oven extends RpcComponent<OvenProps, OvenState> {
    constructor() {
        super({ unit: '°C', maximum: 300 }, { celsius: 20, mode: 'idle' })
    }

    @rpc({ semantics: 'idempotent-command' })
    async setMode(mode: string) {
        this.setState({ mode })
        return mode
    }
}

Observers get it as a local cache:

typescript
const oven = await client.component<Oven>('oven', 'ovenServer')

oven.state.celsius              // synchronous, no network hop, always the last published value
oven.props.maximum              // the host's inputs, beside the state they bound
await oven.setMode('heating')   // changing it is a call, exactly as before

Reading is a property access; changing is a method call, and the asymmetry is deliberate. oven.state.celsius costs nothing and never blocks, because the snapshot is already here — twenty screens watching one oven cost one subscription. An assignment back the other way would have nowhere to put a refusal, a timeout, or a plant that said no, so writing stays a call with an await on it.

Every view also carries a status, so a cache that has stopped being current says so instead of quietly lying — see State and observable components.

Where to go next

This page is deliberately the ten-minute version.

MIT licensed