npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@jerrylum/wrpc

v1.2.0

Published

Typed WebSocket RPC for client↔server and server→client calls

Downloads

30

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, and notify with 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 ConnectionManager designed for environments like Cloudflare Durable Objects

Install

npm install @jerrylum/wrpc zod
# or
bun add @jerrylum/wrpc zod

zod 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, or notify), optional Zod input/output schemas, and a resolver.
  • Router: A tree of procedures (and nested routers). Built with initWRPC root and root.router.
  • Session: Provided to resolvers, enabling bidirectional calls.
    • On the server: session.getClient(clientId) and session.broadcast() call client procedures.
    • On the client: session.getServer() calls server procedures.
  • Handler: createWebSocketHandler drives message handling, connection lifecycle, and integrates storage (e.g., Durable Objects).
  • Client: createWRPCClient provides a typed wrpc proxy to call server procedures and a WebsocketClient for 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
  • procedure
    • .input(zod), .output(zod), .meta(meta)
    • .query(resolver), .mutation(resolver), .notify(resolver) where resolver({ input, session, ctx })
  • router
    • root.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 }
  • createWRPCClient(options, clientRouter)
    • Returns [WebsocketClient, wrpcProxy] where wrpcProxy mirrors server router structure
  • WRPCClientManager
    • getClient(), 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 void immediately after attempting ws.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 onError on the server (or console.error on 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=join as 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.