
rpc
Updated 12d agoby source-repo
Source RPC
TypeScript RPC for a network of peers — a browser tab, a Node service, and a plant full of devices — over WebSocket and MQTT 5, with one programming model across all of them.
A class is the contract. There is no code generation, no schema language and no .proto file: the server hands one live instance to exposeClassInstance, the client compiles against the same class with import type, and calling a method on the typed proxy runs it on that instance.
npm install @source-repo/rpc
If all you need is a browser and a Node server
Use tRPC. It is very good at exactly that, it is far more widely used, and you will find more answers to your questions.
Come here when the shape is different — when there are more than two parties and they are not all on the same kind of link:
- devices on an MQTT broker and operator screens on a WebSocket, calling each other without either end knowing which transport the other is on
- peers that cannot listen — a browser tab hosting a service, a box behind NAT — that still need to be addressable by name
- commands that change something physical, where sending one twice is not free
The problem it is built around
Most RPC libraries make it easy to call a function. Rather fewer distinguish the call failed from I lost the answer to a command that may well have run — and when the call opens a valve, that is the distinction that decides whether an operator sends a second start.
Retrying a read costs a round trip. Retrying a start costs a second start.
A class is the contract
// pump.ts — shared by both sides. No base class, and the decorators are optional.
export class Pump {
private bar = 0
@rpc({ semantics: 'query' }) async pressure() { return this.bar }
@rpc({ semantics: 'idempotent-command' }) async setSetpoint(bar: number) { this.bar = bar }
@rpc({ semantics: 'non-repeatable-command' }) async dispense() { /* … */ }
}
The server holds one long-lived instance. State lives in fields, where you would have put it anyway; nothing is constructed or discarded per call.
const server = new RpcServer({ name: 'plantServer', transports: [{ brokerurl: 'mqtt://plant:1883' }] })
server.exposeClassInstance(new Pump(), 'pump')
await server.ready()
The caller imports the type and none of the code, so the implementation never reaches a browser bundle.
import type { Pump } from './pump.js'
const pump = await client.proxy<Pump>('pump', 'plantServer')
await pump.setSetpoint(4)
The network
A peer is anything with a name, and a frame is addressed to a name, not to a socket. Once that is true, a server can call as well as answer, and a server that exposes nothing and forwards everything is a bus — so there is no separate broker implementation, and there should not be.
browser tab browser tab
hosting a service running the console
│ │
└───────────┬───────────┘
│ WebSocket (socket.io)
┌─────┴──────┐
│ bus │ :7843 relays; exposes nothing
└─────┬──────┘
│ MQTT 5
┌───────────────┼───────────────┐
│ │ │
plantServer cellSrv ovenSrv
The console calls ovenSrv without knowing it is on MQTT. ovenSrv calls the browser tab without knowing it cannot listen. Peers announce themselves, so discovery is free — no scanning, no configured host list — and a peer is never advertised back down the link it came from, so brokers joined in a ring settle rather than storm.
What you get that is hard to find elsewhere
Related servers

n8n
Updated todayby n8n-io
Fair-code workflow automation platform with native AI capabilities. Combine visual building with custom code, self-host or cloud, 400+ integrations.

mcp-server-fetch
OfficialUpdated 14d agoA Model Context Protocol server providing tools to fetch and convert web content for usage by LLMs

@modelcontextprotocol/server-everything
OfficialUpdated 14d agoMCP server that exercises all the features of the MCP protocol