A fast, zero-dependency WebSocket-based RPC protocol for Node.js and browsers. Router and procedures, subscriptions that resume, rooms that scale across processes, and binary streams with backpressure that reaches all the way into TCP — under 15 KB min+gzip in a browser bundle, and nothing at all in your lockfile.
const { Server, WrpcClient, 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();
const client = await WrpcClient.connect('ws://127.0.0.1:8000/api');
await client.load('greeting');
await client.api.greeting.hello({ name: 'World' }); // → 'Hello, World'📖 Full documentation · Getting started · Wire protocol
Nothing extra in the process handling your RPC traffic. package.json has
no dependencies field at all — and no peerDependencies or
optionalDependencies either, because those are dependencies with extra steps.
The WebSocket engine is written from scratch (RFC 6455 + 7692), and the handful
of utilities borrowed from elsewhere were copied in and adapted rather than
installed.
Optional integrations are injected, not depended on. uWebSockets.js, Redis,
fastify, express and TanStack Query all work — you require them and hand them
over, and wrpc duck-types the injection at the boundary. So the integrations
you do not use cost you nothing, and the ones you do use are on your version,
not ours.
One protocol, both sides. The browser build contains zero Node builtins, so the client that talks to your server is the same implementation, not a reimplementation.
| wrpc | tRPC | Socket.IO | |
|---|---|---|---|
| Transport | WebSocket, HTTP, SSE, worker port | HTTP, WebSocket | WebSocket + long-poll fallback |
| Types | contract-first + codegen, no build step | TypeScript inference | none built in |
| Realtime | subscriptions, events, rooms, acks | subscriptions | events, rooms, acks |
| Binary | streams with backpressure | ❌ | ❌ (messages only) |
| Scaling | rooms backplane (any pub/sub) | your own | adapters |
| Cluster ops | fetchClients, commands, presence (local read) | your own | fetchSockets (round-trip) |
| Runtime deps | 0 | a few | several |
| Needs TypeScript | ❌ | effectively yes | ❌ |
- You want a public HTTP API. wrpc has a REST mode, but it is a convenience for reaching procedures, not an API design. If third parties consume it, write an HTTP API and document it.
- Your stack is polyglot. The protocol is documented and small enough to reimplement, but the only implementation today is JavaScript. gRPC exists for a reason.
- You need guaranteed delivery. Rooms are at-most-once fan-out, not a
queue. Subscriptions with
tracked()values and an event log let a client catch up; if you need a broker's guarantees, use a broker. - You want end-to-end type inference from server code. tRPC's model — where the client's types come from the server's implementation with nothing written twice — is genuinely nicer if your whole stack is TypeScript. wrpc's contract is declared or generated, deliberately, because it must also work for JavaScript users and across a network boundary the compiler cannot see.
- Installation · Bundle size · Browser usage
- Quick Start
- Features
- Exports
- Documentation
- Contributing · Changelog · Security · License
npm install @alexify/wrpcRequires Node.js ≥ 22 (or any modern browser through a bundler). The
package is CommonJS and ships as-is — no build step and no transpile;
require('@alexify/wrpc') and import { Server } from '@alexify/wrpc' both
work.
Measured with pnpm size (esbuild, fully minified with identifier mangling,
then gzipped):
| Entry | min | min+gzip | budget |
|---|---|---|---|
@alexify/wrpc — browser (client, streams, chunks) |
44.3 KB | 14.9 KB | 15.0 KB |
@alexify/wrpc — node (client + server) |
158.2 KB | 52.2 KB | — |
@alexify/wrpc/ws (WebSocket engine) |
20.7 KB | 7.5 KB | — |
@alexify/wrpc/engine (engine port) |
21.2 KB | 7.7 KB | — |
@alexify/wrpc/uws (uWebSockets.js adapter) |
16.0 KB | 6.4 KB | — |
@alexify/wrpc/fastify |
135.3 KB | 46.2 KB | — |
@alexify/wrpc/express |
122.9 KB | 41.3 KB | — |
@alexify/wrpc/scaling (rooms backplane) |
4.1 KB | 1.7 KB | — |
@alexify/wrpc/sse — browser (client transport) |
47.2 KB | 15.9 KB | 16.0 KB |
@alexify/wrpc/sse — node |
86.1 KB | 28.6 KB | — |
@alexify/wrpc/query (TanStack bindings) |
2.7 KB | 1.1 KB | 2.0 KB |
@alexify/wrpc/auth (token strategies) |
3.3 KB | 1.5 KB | 2.0 KB |
The Node-only rows are reported for visibility into what each subpath pulls in — they never ship to a browser, and the adapter rows include the whole core because they bundle it. Only the browser-reachable entries carry a budget, and exceeding one fails CI: it is a ratchet against accidental bloat, raised deliberately in the same change that earns the bytes.
Injected packages (uWebSockets.js, ioredis, fastify, express,
@tanstack/query-core) are never bundled — you install what you use.
The package ships a browser entry (browser.js), resolved automatically by any
bundler that honours the package.json browser field and the browser
condition in exports — webpack 5, Vite, esbuild (platform: 'browser'),
Parcel, Bun. Rollup users need
@rollup/plugin-node-resolve
with browser: true.
It contains WrpcClient/WrpcClientProxy, the stream and chunk helpers, and
zero Node builtins — the server half is not in it. The two platform-specific
pairs (chunks, runtime) are swapped automatically, and scripts/size.js
fails the build if any browser entry ever resolves a node: import or a
package.
A router of procedures, a server, a client:
const { Server, WrpcClient, defineRouter, procedure, tracked } = require('@alexify/wrpc');
const router = defineRouter({
chat: {
// a call
send: procedure({
access: 'session',
input: (args) => { if (!args?.text) throw new Error('text is required'); },
handler: async (context, { text }) => {
const sent = context.server.to('lobby').emit('chat/message', { text });
return { sent };
},
}),
// a subscription: many values, one connection
onMessage: procedure.subscription({
access: 'session',
handler: async function* (context, args, { lastEventId, signal }) {
for await (const message of feed({ signal })) yield tracked(message.id, message);
},
}),
// an inbound, fire-and-forget event
on: {
typing: procedure({ handler: async (context, data) => announce(data) }),
},
},
auth: {
login: procedure({
access: 'public',
handler: async (context, { user }) => {
context.client.startSession(undefined, { user });
return { ok: true };
},
}),
},
});
const server = new Server({ router, host: '127.0.0.1', port: 8000, protocol: 'http' });
await server.listen();const client = await WrpcClient.connect('ws://127.0.0.1:8000/api');
await client.load('chat', 'auth');
await client.api.auth.login({ user: 'ada' });
client.api.chat.on('message', ({ text }) => console.log(text));
client.sendEvent('chat/typing', { who: 'ada' });
for await (const message of client.api.chat.onMessage.iterate()) {
console.log(message);
}| Area | What you get |
|---|---|
| RPC | Router and procedures with access control, Standard Schema validators, timeouts, concurrency queues and versioned units |
| Realtime | Events both ways, rooms, acks (client.ask, to(room).ask), subscriptions with resume, cancellation, call batching |
| Hooks | Lifecycle phases in the fastify tradition — router, unit and procedure levels flattened into one pipeline; no (ctx, next) middleware |
| Streams | Binary upload/download interleaved on one connection, with end-to-end backpressure |
| REST | Declarative endpoints on the same procedures — verb, path, status, fastify-shaped schemas, /vN version paths, OpenAPI via wrpc types --openapi |
| Sessions & auth | Cookie-backed sessions restored on reconnect, pluggable store, CSRF-aware REST dispatch; bearer/payload token carriers, client stores and the authenticate/refresh lifecycle |
| Scaling | Rooms backplane over any pub/sub; Redis and in-memory adapters included; cluster layer — replicated presence (count with no round-trip), fetchClients, cross-instance commands, node-to-node ask |
| Transports | WebSocket, plain HTTP, Server-Sent Events, Service Worker MessagePort; pluggable wire codec and request metadata (x-wrpc-meta-<key>) |
| Hosts | Batteries-included server, or fastify / express / uWebSockets.js / bare node:http |
| Client | Exponential backoff with full jitter, app-level heartbeat, automatic re-load() and re-subscribe, offline/online |
| Observability | Structured logging into your pino, OpenTelemetry spans and metrics, W3C trace context across the wire |
| Operations | Security hardening, graceful drain and shutdown, benchmarks you can reproduce, testing patterns |
| DX | Contract-first typed client, wrpc types codegen, TanStack Query bindings, hand-maintained .d.ts for every subpath |
Both are injected, never depended on — the package still has no dependencies. Your logger and your OpenTelemetry SDK are duck-typed.
const pino = require('pino');
const api = require('@opentelemetry/api');
const server = new Server({ router, logger: pino(), telemetry: { api } });logger takes a structured logger (pino, bunyan, winston), a Console, or
false to go silent. wrpc binds children for you — component, peer and a
per-call callId — and context.log inside a handler is already scoped to
that call.
telemetry takes the @opentelemetry/api module or your own
{ tracer, meter }. Spans follow the OTel rpc.* convention, fourteen metrics
cover calls, connections, subscriptions, broadcasts and streams, and call
packets carry W3C trace context so a client span parents the server's across
the network hop.
Neither can break a request: a logger that throws, a broken exporter or a meter that dies is contained at the call site.
See Logging and OpenTelemetry.
| Subpath | Exports | Docs |
|---|---|---|
@alexify/wrpc |
Server, RpcServer, WrpcClient, connect, defineRouter, procedure, tracked, createEventLog, createEventStream, MemorySessionStore, WrpcReadable, WrpcWritable, WrpcError, chunkEncode/chunkDecode |
Server · Client |
@alexify/wrpc/ws |
WebsocketServer, Connection, Frame, FrameParser, OPCODES, CLOSE_CODES |
Wire format |
@alexify/wrpc/engine |
createNodeEngine, isEngine, the Engine/WrpcSocket contracts |
Engine port |
@alexify/wrpc/uws |
createUwsEngine, UwsSocket |
uWebSockets.js |
@alexify/wrpc/fastify |
wrpcFastify, findUwsApp |
Fastify |
@alexify/wrpc/express |
createWrpc |
Express |
@alexify/wrpc/scaling |
MemoryBackplane, createRedisAdapter, isBackplane |
Scaling |
@alexify/wrpc/sse |
SseChannels, ServerSseTransport, ClientSseTransport, SseParser |
Server-Sent Events |
@alexify/wrpc/query |
createQueryUtils |
TanStack Query |
@alexify/wrpc/auth |
bearerAuth, memoryStore, webStorage, cookieStorage, bearerTransport, payloadTransport |
Authentication |
wrpc (bin) |
wrpc types <url> --out api.d.ts |
Codegen CLI |
Every subpath ships hand-maintained TypeScript declarations — no generation, no
any where a real type belongs.
- Start here — getting started, why wrpc?.
- Server — server, router, hooks, sessions, rooms, subscriptions, streams, scaling, cluster, REST, authentication, metadata.
- Client — client, typed client, browser & bundling, CLI, TanStack Query.
- Transports & hosts — SSE, wire codec, uWebSockets.js, fastify, express.
- Operations — security, running in production, testing, performance, logging, OpenTelemetry.
- Reference — wire protocol (frozen at 1.0), wire format, engine port, errors & close codes.
- Types —
index.d.tsis the full public surface, plus one.d.tsper subpath.
See CONTRIBUTING.md for the development workflow, the
house rules (zero dependencies, .d.ts and tsd in the same change) and the
release checklist.
pnpm test # node --test, recursive
pnpm test:coverage # c8, thresholds 95/95/90/95
pnpm test:types # tsd
pnpm lint # oxlint
pnpm size # bundle-size budgets
pnpm docs:dev # the documentation siteSee CHANGELOG.md.
See SECURITY.md for how to report a vulnerability.
MIT — see LICENSE.