Getting Started
wRPC (@alexify/wrpc) is a Web RPC protocol for Node.js and browsers: you declare a router of procedures on the server, and the client builds a typed object out of it at runtime. Calls, server → client events, subscriptions, rooms and binary streams ride on whichever transport the client picks — a WebSocket, HTTP, Server-Sent Events, WebTransport, a WebRTC data channel or a message broker — and the package has no runtime dependencies at all.
How it fits together
One router serves every feature on this site, over every transport, and every box on the server side is replaceable without touching your handlers:
The client reaches the server over a transport: a WebSocket, HTTP or SSE through the engine, WebTransport through an injected HTTP/3 host, a WebRTC data channel, or a message broker. Each hands its frames to a ServerTransport, the dispatcher turns them into packets, and the router decides which procedure runs. Swap the engine (uWebSockets.js), add a transport, or mount the whole thing inside Fastify or Express — the router and the wire protocol do not change. Client › Transports has what each transport carries.
Installation
pnpm add @alexify/wrpcRequires Node.js ≥ 22 (the client also runs in any modern browser through a bundler). The package is CommonJS and ships as-is — no build step, no transpile. require('@alexify/wrpc') and import { Server } from '@alexify/wrpc' both work.
About the snippets
The examples use top-level await, which a .mjs file or an ESM bundle gives you for free. In a CommonJS script, wrap them in an async function.
Your first server
A router maps unit names to procedures. A procedure is a handler plus the policy around it — who may call it, what its arguments must look like, how long it may run:
const { Server, defineRouter, procedure } = require('@alexify/wrpc');
const router = defineRouter({
greeting: {
hello: procedure({
access: 'public',
handler: async (context, { name }) => `Hello, ${name}`,
}),
},
});
const server = new Server({ router, host: '127.0.0.1', port: 8000, protocol: 'http' });
await server.listen();protocol: 'http'
Server creates an HTTPS listener unless you ask for 'http' — pass key and cert for TLS. In production, terminating TLS at a proxy and running the server on 'http' behind it is just as valid.
That server answers under basePath (/api by default): a WebSocket upgrade, plain HTTP and Server-Sent Events. WebTransport, WebRTC and broker clients reach the same router through their own hosts. See Server for everything the shell takes.
Your first client
const { WrpcClient } = require('@alexify/wrpc');
const client = await WrpcClient.connect('ws://127.0.0.1:8000/api');
await client.load('greeting');
console.log(await client.api.greeting.hello({ name: 'World' })); // Hello, Worldload() asks the server what the unit contains (through the built-in system/introspect procedure) and scaffolds client.api.greeting from the answer. Nothing is generated ahead of time, and the server is the single source of truth about what exists.
Typing it costs four lines — a contract interface and connect<Api>() — or none at all, generated from the running server:
import { connect } from '@alexify/wrpc';
interface Api {
greeting: { hello(args: { name: string }): Promise<string> };
}
const client = await connect<Api>('ws://127.0.0.1:8000/api');
await client.load('greeting');
await client.api.greeting.hello({ name: 'World' }); // typed end to endnpx wrpc types http://127.0.0.1:8000/api --out api.d.tsNo build step and no TypeScript at runtime — see Typed client.
The same call works over plain HTTP — connect to http://127.0.0.1:8000/api and every call becomes a POST carrying the same JSON packet. What HTTP cannot carry is anything that needs the connection to stay open: events, subscriptions, cancellation and binary streams. See Client.
Adding a session
Access control is one word on a procedure. access: 'public' is callable by anyone; anything else needs a session:
const router = defineRouter({
auth: {
login: procedure({
access: 'public',
handler: async (context, { user }) => {
context.client.startSession(undefined, { user });
return { ok: true };
},
}),
},
profile: {
// 'session' is the default, spelled out here for clarity
whoami: procedure({
access: 'session',
handler: async (context) => ({ user: context.session.state.user }),
}),
},
});The session token travels in an HttpOnly; Secure; SameSite=Lax cookie that is read back on both HTTP requests and the WebSocket upgrade, so a reconnect restores the session without a round trip. See Sessions.
Adding realtime
Two things make a connection worth keeping open. Events are fire-and-forget messages in either direction, and rooms are named groups of clients to send them to:
const router = defineRouter({
chat: {
join: procedure({
access: 'public',
handler: async (context, { room }) => {
context.client.join(room);
return { ok: true };
},
}),
shout: procedure({
access: 'public',
handler: async (context, { room, text }) =>
({ sent: context.server.to(room).emit('chat/message', { text }) }),
}),
},
});await client.load('chat');
client.api.chat.on('message', ({ text }) => console.log(text));
await client.api.chat.join({ room: 'lobby' });
await client.api.chat.shout({ room: 'lobby', text: 'hi' });See Rooms, and Scaling for making a room span more than one process.
Adding a subscription
A subscription is a procedure that answers with many values instead of one. Write it as an async generator; the client consumes it with a callback or a for await loop:
const router = defineRouter({
chat: {
ticks: procedure.subscription({
access: 'public',
handler: async function* (context, { to = 3 }) {
for (let i = 1; i <= to; i++) yield { n: i };
},
}),
},
});for await (const value of client.api.chat.ticks.iterate({ to: 3 })) {
console.log(value); // { n: 1 }, { n: 2 }, { n: 3 }
}Backpressure is real in both directions: the server pump waits for the transport to drain before pulling the next value, so a slow consumer stops the producer instead of filling memory. See Subscriptions.
Types
The typed variant above is the whole story in miniature: declare the contract (or generate it with wrpc types, --openapi included) and connect<Api>() types every call, subscription and server-push event. The details — subscription contracts, typed events, static introspection — live in Typed client.
Where to go next
| If you want to… | Read |
|---|---|
| know every option the server takes | Server |
| write procedures, validators, versions | Router & procedures |
| expose procedures as REST endpoints | Declarative REST |
| run code around every call | Hooks |
| authenticate users | Sessions · Authentication |
| broadcast to groups | Rooms · Scaling |
| push a feed of values | Subscriptions |
| move files over the connection | Binary streams |
| tune reconnect, batching, heartbeat | Client |
| run inside fastify / express / uWebSockets.js | Adapters |
| pick a transport, or several | Client › Transports · Multiple backends |
| serve realtime where WebSockets can't go | Server-Sent Events |
| run over HTTP/3, with streams that do not block each other | WebTransport |
| connect browsers to each other, with no server in the data path | WebRTC · identity and trust |
| call between services, or feed clients from a durable log | Message brokers |
| span more than one process | Cluster |
| carry per-call metadata | Metadata |
| cache and invalidate in React | TanStack Query |
| save bandwidth, or seal what TLS does not cover | Compression · Encryption |
| get ready for production | Security · Running in production · Rate limiting |
| see calls in your traces | OpenTelemetry |
| know exactly what goes over the wire | Wire protocol |