@jerrylum/wrpc
v1.2.0
Published
Typed WebSocket RPC for client↔server and server→client calls
Downloads
30
Maintainers
Readme
WRPC — Typed WebSocket RPC for Client↔Server and Server→Client calls
WRPC is a lightweight, type-safe RPC layer over WebSockets, inspired by tRPC's developer ergonomics but designed for bidirectional communication. It provides:
- Typed procedures:
query,mutation, andnotifywith Zod-driven input/output validation - Routers: nestable, composable routers mirroring your API structure
- Sessions: symmetric APIs for server→client, client→server, and server→all-clients
- WebSocket hibernation support: a
ConnectionManagerdesigned for environments like Cloudflare Durable Objects
Install
npm install @jerrylum/wrpc zod
# or
bun add @jerrylum/wrpc zodzod is a peer dependency (^4).
Entry points:
@jerrylum/wrpc/client— WebSocket client, client manager, shared router types@jerrylum/wrpc/server— WebSocket handler, connection manager, procedure builder
Concepts
- Procedure: A callable endpoint with a type (
query,mutation, ornotify), optional Zod input/output schemas, and a resolver. - Router: A tree of procedures (and nested routers). Built with
initWRPCroot androot.router. - Session: Provided to resolvers, enabling bidirectional calls.
- On the server:
session.getClient(clientId)andsession.broadcast()call client procedures. - On the client:
session.getServer()calls server procedures.
- On the server:
- Handler:
createWebSocketHandlerdrives message handling, connection lifecycle, and integrates storage (e.g., Durable Objects). - Client:
createWRPCClientprovides a typedwrpcproxy to call server procedures and aWebsocketClientfor connection lifecycle.
Server Setup
Define your server-side router and WebSocket handler.
// server/router.ts
import { z } from 'zod';
import { initWRPC } from '@jerrylum/wrpc/server';
// 1) Initialize WRPC root (optionally provide default meta)
const root = initWRPC.createServer<{ userId?: string }>();
// 2) Define procedures using the builder
const hello = root.procedure
.input(z.object({ name: z.string().min(1) }))
.output(z.object({ greeting: z.string() }))
.query(async ({ input }) => {
return { greeting: `Hello, ${input.name}!` };
});
const notifyClient = root.procedure.input(z.object({ clientId: z.string(), message: z.string() })).mutation(async ({ input, session }) => {
// Server → specific client one-way push (client must implement a matching notify procedure)
session.getClient<typeof clientRouter>(input.clientId).notifications.push.notify({
message: input.message
});
return { ok: true };
});
// 3) Compose your router
export const appRouter = root.router({
hello,
admin: {
notifyClient
}
});
export type AppRouter = typeof appRouter;Integrate the WebSocket handler (example: Cloudflare Durable Object with hibernation).
// server/handler.ts
import { createWebSocketHandler } from '@jerrylum/wrpc/server';
import { appRouter } from './router';
// Storage helpers for Durable Object (shape is app-defined)
const storage = { data: null as unknown };
export const handler = createWebSocketHandler({
router: appRouter,
loadData: async () => storage.data,
saveData: async (data) => {
storage.data = data;
},
destroy: async () => {
storage.data = null;
},
getWebSocket: (clientId) => {
// Return the active WebSocket for a clientId (from your DO connection map)
return activeSockets.get(clientId) ?? null;
},
getClientIdByWebSocket: (ws) => reverseSocketIndex.get(ws) ?? null,
onError: ({ error, req }) => {
console.error('WRPC Server Error', error, req);
}
});
// Your DO constructor should call:
// await handler.initialize();
// And your DO lifecycle should call:
// handler.handleMessage(ws, message, ctx)
// handler.handleConnection(ws, { roomId, clientId, deviceId, deviceName })
// handler.handleClose(ws, code, reason)Client Setup
Define the client router (procedures the server may call on the client), then create the client and call server procedures via the typed proxy.
// client/index.ts
import { z } from 'zod';
import { createWRPCClient } from '@jerrylum/wrpc/client';
import type { AppRouter } from '../server/router';
import { initWRPC } from '@jerrylum/wrpc/server';
// 1) Client router (procedures the server can call on the client)
const root = initWRPC.createClient<AppRouter>();
export const clientRouter = root.router({
notifications: {
push: root.procedure.input(z.object({ message: z.string() })).notify(async ({ input }) => {
// Handle a server → client notification
console.log('Notification:', input.message);
})
}
});
// 2) Create client and a typed WRPC proxy for calling server
const [client, wrpc] = createWRPCClient<AppRouter, typeof clientRouter>(
{
wsUrl: 'wss://example.com/ws',
roomId: 'room-1',
clientId: 'client-123',
deviceId: 'device-xyz',
deviceName: 'My Laptop',
onContext: async () => ({}),
onOpen: () => console.log('connected'),
onClosed: (code, reason) => console.log('closed', code, reason),
onConnectionStateChange: (s) => console.log('state:', s)
},
clientRouter
);
// 3) Call server procedures with full type safety
async function run() {
const res = await wrpc.hello.query({ name: 'Jerry' });
// ^? { greeting: string }
console.log(res.greeting);
}
run();You can also manage a singleton client instance with the built-in manager:
import { createClientManager } from '@jerrylum/wrpc/client';
export const wrpcManager = createClientManager<AppRouter, typeof clientRouter>(
() => ({
wsUrl: 'wss://example.com/ws',
roomId: 'room-1',
clientId: 'client-123',
deviceId: 'device-xyz',
onContext: async () => ({}),
onOpen: () => {},
onClosed: () => {},
onConnectionStateChange: () => {}
}),
clientRouter
);
const [client, wrpc] = wrpcManager.getClient();Bidirectional Calls via Session
Server resolver receives a session:
// Server → one client
session.getClient<typeof clientRouter>(clientId).notifications.push.notify({ message: 'Hello!' });
// Server → all clients (broadcast)
session.broadcast<typeof clientRouter>().notifications.push.notify({ message: 'Hi everyone!' });Client resolver receives a session:
// Client → server
const data = await session.getServer().admin.notifyClient.mutation({ clientId, message: 'pong' });API Reference (selected)
initWRPC.createServer(opts?)/initWRPC.createClient<ServerRouter>(opts?)- Produces
{ procedure, router, mergeRouters, _config }bound to your root types
- Produces
procedure.input(zod),.output(zod),.meta(meta).query(resolver),.mutation(resolver),.notify(resolver)whereresolver({ input, session, ctx })
routerroot.router({ ... })creates a nested router.mergeRouters(a, b)merges routers.
createWebSocketHandler({ router, loadData, saveData, destroy, getWebSocket, getClientIdByWebSocket, onError? })- Returns
{ connectionManager, initialize, handleMessage, handleConnection, handleClose, handleError, getClient, broadcast }
- Returns
createWRPCClient(options, clientRouter)- Returns
[WebsocketClient, wrpcProxy]wherewrpcProxymirrors server router structure
- Returns
WRPCClientManagergetClient(),resetClient(),isConnected(),getConnectionState()
Message Shapes
Validated with Zod:
type WRPCRequest = {
kind: 'request';
id: string;
type: 'query' | 'mutation';
path: string; // e.g. "admin.notifyClient"
input: unknown;
};
type WRPCResponse =
| { kind: 'response'; id: string; result: { type: 'data'; data: unknown } }
| { kind: 'response'; id: string; result: { type: 'error'; error: { message: string; code?: string } } };
type WRPCNotification = {
kind: 'notification';
path: string; // e.g. "notifications.push"
input?: unknown;
};Notify semantics
- Fire-and-forget: the sender returns
voidimmediately after attemptingws.send; no response is sent or awaited. - Best-effort delivery: if the WebSocket is disconnected, the notification is silently dropped.
- Broadcast-friendly:
session.broadcast().someProcedure.notify(input)fans out without collecting responses. - Receiver-only errors: handler failures are logged via
onErroron the server (orconsole.erroron the client); the sender never learns about failures.
Client .notify() does not connect
Unlike query and mutation, client-side notify() does not call connect(). It only sends when the WebSocket is already open; otherwise the notification is dropped with no error.
| | query / mutation | notify |
|---|---|---|
| Return type | Promise<T> | void |
| Connects if offline | Yes (await connect()) | No |
| Waits for handler | Yes (response round-trip) | No |
| Offline / not open | Connect or reject | Silent drop |
Use query or mutation when delivery must happen. Use notify for optional, low-stakes updates (e.g. server pushes, heartbeats, UI hints).
If you need to notify only after the socket is up, wait for onOpen / connectionState === 'connected', or guard with client.isConnected():
if (client.isConnected()) {
wrpc.room.heartbeat.notify({ ts: Date.now() });
}The first query or mutation in a session establishes the connection; any notify calls made before that (while still offline or connecting) are intentionally dropped.
Notes
- Input/output validation is optional; omit
.input()/.output()to skip runtime validation. - Server and client routers are independent and can evolve separately; only the paths actually invoked need to match.
- The connection manager supports Cloudflare WebSocket hibernation patterns but can be adapted to other environments by implementing the required handler options.
- On connect, the client sends
action=joinas a WebSocket query param (required by app-level handshake validation in consuming workers). Create vs join room semantics are handled by your own RPC procedures, not by that query param.
