-
Notifications
You must be signed in to change notification settings - Fork 2
RPC
RPC + pub/sub framework over WebSocket. Server + Client with
id-correlated request/response, Koa-style middleware on both ends,
channels with pluggable adapters, and a stable JSON wire protocol.
No Browser badge: Server needs a request-driven WebSocket upgrade,
which no browser has. Client alone would be genuinely browser-safe
(it talks to the native global WebSocket directly), but mod.ts
re-exports Server and Client from the same barrel with no separate
./client subpath, so importing @tundralibs/rpc at all pulls
Server in regardless of which one you use.
Built on top of @tundralibs/compat/websocket's
WebSocketServer primitive — this package adds:
-
Command router — typed map of
cmd→ handler with optional payload validation. Handlers return values that flow back to the client as aresultframe. - Koa-style middleware — composes around every command. Use it for auth, logging, rate limits, request-scoped state.
-
Channels & pub/sub —
subscribe/unsubscribe/publishover named channels. Default adapter is in-memory; plug in Redis or another cross-process adapter when you need fan-out across instances.
It is transport-agnostic: the same Server instance can be mounted
via server.handlers(), run standalone via server.listen(), or
answer one upgrade at a time via server.handleUpgrade(request).
Server runs wherever it can get at a WebSocket upgrade. On Deno, Bun
and Node that is a listening socket (listen() / handlers()); on
Cloudflare Workers it is handleUpgrade(request) from a fetch
handler — see Cloudflare Workers. A browser has
neither, so Server cannot run there; Client is what a browser uses.
Two things do not travel to Workers, both verified on workerd rather than inferred:
-
Cross-connection fan-out. Each connection belongs to the I/O
context of the request that upgraded it, and workerd refuses I/O
across contexts, so
publish()reaches only the subscriber whose request is currently on the stack — every other subscriber's send throws. That throw is no longer silent: the connection is stillOPENwhen it happens, so it's routed toServerOptions.onSendErrorinstead of being swallowed like an ordinary closed-socket send. The hook only makes the drop observable — real fan-out on Workers needs a Durable Object owning the sockets, which this package does not provide. -
backpressureThreshold. Workerd'sWebSockethas nobufferedAmount, so the hook never fires.
The ./conformance sub-path is excluded from the public barrel because
it pulls in a test framework that browser and edge-worker bundlers
cannot resolve — keep it in test files only.
| Module | Description | Documentation |
|---|---|---|
Server |
Command router + channels + middleware | This page |
| Wire protocol | JSON envelope frames, decodeFrame / encodeFrame
|
docs/Rpc-Protocol |
| Middleware | Koa-style middleware patterns and recipes | docs/Rpc-Middleware |
| Pub/Sub adapters | Adapter contract, capability flags, in-memory + Redis sketch, conformance test harness | docs/Rpc-PubSub |
| Extending | Subclass overrides — pattern subscribe, frame inspection | docs/Rpc-Extending |
./pubsub |
PubSubAdapter base + MemoryPubSubAdapter
|
docs/Rpc-PubSub |
./conformance |
runAdapterConformance — adapter contract test harness (test files only) |
docs/Rpc-PubSub |
Deno:
deno add @tundralibs/rpcBun:
bunx jsr add @tundralibs/rpcNode.js:
npx jsr add @tundralibs/rpcimport { Server } from '@tundralibs/rpc';
import { MemoryPubSubAdapter } from '@tundralibs/rpc/pubsub';The adapter conformance harness ships on its own sub-path, and belongs in test files only — it imports a test framework, which browser and edge-worker bundlers cannot resolve:
import { runAdapterConformance } from '@tundralibs/rpc/conformance';JSON envelopes, one frame per WebSocket text message.
Client → server:
Server → client:
// Successful command result
{ "id": "1", "type": "result", "ok": true, "data": { "id": "u-1" } }
// Failed command
{ "id": "1", "type": "result", "ok": false, "error": { "code": "VALIDATION", "message": "name required" } }
// Subscribe / unsubscribe confirmation
{ "id": "2", "type": "subscribed", "channel": "chat:room1" }
{ "id": "3", "type": "unsubscribed", "channel": "chat:room1" }
// Broadcast on a subscribed channel
{ "type": "msg", "channel": "chat:room1", "data": { "from": "u-1", "text": "hi" } }
// Out-of-band protocol error — the offending frame's id is echoed back
// when recoverable, so the client can fail the call fast (see Rpc-Protocol)
{ "id": "1", "type": "error", "code": "BAD_FORMAT", "message": "invalid frame" }The id field correlates request / response. Server-initiated msg
frames omit it; error frames carry it when it can be recovered from
the offending inbound frame. Full reference:
Rpc-Protocol.
import { Server } from '@tundralibs/rpc';
const server = new Server();
// Register a command
server.command('echo', undefined, (ctx) => ctx.payload);
// Register a channel
server.channel('news', {});
// Run standalone
await server.listen({ port: 8080 });
// Later, broadcast
await server.publish('news', { headline: 'Hello world' });
// Shut down
await server.close();Every option is optional; this spells all of them out at once.
import { Server } from '@tundralibs/rpc';
import { MemoryPubSubAdapter } from '@tundralibs/rpc/pubsub';
type Conn = { userId: string };
const server = new Server<Conn>({
// Pub/sub adapter — this is the default when omitted.
pubsub: new MemoryPubSubAdapter(),
// Decides what `T` becomes for each connection.
upgrade: (req, info) => {
const userId = req.headers.get('x-user-id');
if (!userId) return false; // refuse — falls through to HTTP
console.log('upgrade from', info.remoteAddress);
return { data: { userId } };
},
// Incoming frames over this many bytes are rejected before
// decoding. Default 1 MB; 0 disables the cap.
maxFrameSize: 1_048_576,
// Outbound-buffer soft cap in bytes. Omit to disable observation.
backpressureThreshold: 4 * 1_048_576,
onBackpressure: (ws, bufferedAmount) => {
console.warn('slow consumer', ws.data.userId, bufferedAmount);
},
// Fires when a server-initiated send throws on a connection that is
// still OPEN — not the ordinary closed-mid-flight case, which stays
// silent. The one place this fires today: `publish()` fan-out on
// Cloudflare Workers (see Browser / Worker compatibility above).
onSendError: (ws, err) => {
console.error('send failed on live connection', ws.data.userId, err);
},
});T is the per-connection state type. The upgrade hook (same shape
as WebSocketHandler.upgrade in compat/webserver) decides what T
becomes for each connection — return false to refuse, true to
accept with default data, or { data, protocol?, headers? } to
attach typed state.
Frame size: incoming frames over maxFrameSize bytes are rejected
before decoding — the client receives an error frame with code
FRAME_TOO_LARGE. Default is 1 MB; raise it for binary uploads or
drop to a smaller cap for stricter shapes.
Backpressure: when backpressureThreshold is set, after every
server-side send (publish, command result, subscribed, msg),
if ws.bufferedAmount exceeds the threshold, the configured
onBackpressure handler fires for that connection. Implement your
own policy — close, log, or drop further sends.
use, command, and channel all return the server, so registration
chains:
import { Server } from '@tundralibs/rpc';
const server = new Server()
// Koa-style middleware — wraps every command.
.use(async (ctx, next) => {
console.log('->', ctx.cmd);
await next();
})
// Command with no validator — the handler gets the raw payload.
.command('echo', undefined, (ctx) => ctx.payload)
// Command with a validator — `ctx.payload` is the validator's return type.
.command(
'greet',
(input) => input as { name: string },
(ctx) => `hello ${ctx.payload.name}`,
)
// Channel registration — `{}` is subscribe-only, no hooks.
.channel('news', {});import { Server } from '@tundralibs/rpc';
// Needs a separate install: deno add @tundralibs/compat
import type { WebSocketHandler } from '@tundralibs/compat/webserver';
const server = new Server();
server.channel('news', {});
// Either — mount into a WebServer you already run…
const handlers: WebSocketHandler<unknown> = server.handlers();
console.log('mount these on WebServer.websocket:', handlers);
// …or answer upgrades from a `fetch` handler — see the Cloudflare
// Workers scenario below.
const upgrade = (req: Request): Promise<Response> => server.handleUpgrade(req);
console.log(typeof upgrade);
// …or run standalone on an internally-managed WebServer.
await server.listen({ port: 8080 });
await server.publish('news', { headline: 'Hello' }); // broadcast
await server.close(); // tear downThe most common shape — REST + realtime sharing one server, one port, one TLS config.
// Needs a separate install: deno add @tundralibs/compat
import { WebServer } from '@tundralibs/compat/webserver';
import { Server } from '@tundralibs/rpc';
const rpc = new Server();
rpc.command('ping', undefined, () => 'pong');
const handleUsers = (_req: Request): Response => new Response('[]');
const web = new WebServer('API', {
mode: 'TCP',
port: 8080,
handler: (req) => {
const url = new URL(req.url);
if (url.pathname === '/health') return new Response('ok');
if (url.pathname === '/users') return handleUsers(req);
return new Response('Not Found', { status: 404 });
},
websocket: rpc.handlers(),
});
await web.start();import { Server } from '@tundralibs/rpc';
const server = new Server();
server.command('echo', undefined, (ctx) => ctx.payload);
await server.listen({ port: 8080 });
// Later
await server.close();Workers can accept a WebSocket but can never listen(). Build the
Server once at module scope and hand each upgrade to it — commands,
validators, middleware and channels all behave exactly as they do
behind listen().
import { Server } from '@tundralibs/rpc';
const rpc = new Server();
rpc.command('add', undefined, (ctx) => {
const { a, b } = ctx.payload as { a: number; b: number };
return a + b;
});
rpc.channel('news', {});
export default {
fetch: (request: Request): Promise<Response> =>
rpc.handleUpgrade(request, {
remoteAddress: request.headers.get('CF-Connecting-IP'),
}),
};A request that is not an upgrade gets a 426 back, and one the
upgrade hook refused gets a 403 — check response.status if you
would rather answer either yourself. Read
Browser / Worker compatibility
first: publish() does not fan out across connections on Workers.
The same method works inside a Deno.serve you already own:
import { Server } from '@tundralibs/rpc';
const rpc = new Server();
rpc.command('ping', undefined, () => 'pong');
Deno.serve({ port: 8080 }, (request, info) =>
rpc.handleUpgrade(request, {
remoteAddress: info.remoteAddr.transport === 'tcp'
? info.remoteAddr.hostname
: null,
}));Bun and Node cannot answer an upgrade with a Response at all, so
handleUpgrade rejects with compat's UnsupportedRuntimeError there —
use handlers() or listen(), which do work.
Client is the matching counterpart to Server — same wire protocol,
parallel middleware mental model. Use it from browsers, Node, Deno,
or Bun via the platform's built-in WebSocket.
import { Client } from '@tundralibs/rpc';
const client = new Client({ url: 'ws://localhost:8080' });
await client.connect();
// Command
const reply = await client.command<{ text: string }>('echo', {
text: 'hello',
});
// Subscribe to a server-side channel
const sub = await client.subscribe('news', (data) => {
console.log('news:', data);
});
// …later
await sub.unsubscribe();
// Publish to a client-publishable channel. The server's channel must
// have `onPublish` configured, and — because a client `pub` inherits
// the subscribe-time authorization — you must be subscribed to that
// channel first, or the publish is rejected with `NOT_SUBSCRIBED`.
await client.subscribe('chat:room1', (data) => {
console.log('chat:', data);
});
await client.publish('chat:room1', { from: 'me', text: 'hi' });
await client.close();url is the only required option; the rest are shown here with their
defaults.
import { Client } from '@tundralibs/rpc';
const client = new Client({
url: 'ws://localhost:8080', // ws:// or wss://
// Sub-protocol(s) offered on the handshake. Nothing in this package
// requires one — the server picks via its upgrade hook's `protocol`.
protocols: ['my-app-v1'],
defaultTimeoutMs: 30_000, // 0 disables command timeouts
reconnect: {
enabled: true,
maxAttempts: 10,
initialDelayMs: 500,
backoffFactor: 2,
maxDelayMs: 30_000,
},
// A subscription replayed after a reconnect was refused (the
// connection lost its authorization, or the channel is gone). The
// dead subscription has already been dropped when this fires.
onSubscriptionError: (channel, error) => {
console.warn('re-subscribe refused:', channel, error.message);
},
// Auto-reconnect exhausted `reconnect.maxAttempts`. The client now
// stays DISCONNECTED and will not retry on its own.
onReconnectFailed: (attempts) => {
console.error(`gave up after ${attempts} attempts`);
},
});When reconnect.enabled is true (default), an unexpected close
triggers exponential-backoff retries up to maxAttempts. Active
subscriptions are re-established on reconnect. In-flight command()
calls reject with CONNECTION_LOST. While the client is parked between
retries its state reads DISCONNECTED; calling connect() in that
window cancels the pending retry and connects immediately rather than
racing the backoff into a second concurrent socket — this is the
intended way to recover after onReconnectFailed.
A manual connect() during backoff supersedes the parked retry but
never disables auto-reconnect: if that connect() itself fails (the
server is still down) the backoff schedule is re-armed, so the client
keeps retrying and reconnects on its own once the server returns — the
failed call still rejects so you can log or react to it. Only close()
stops reconnecting, and it does so even when called during backoff
(where state reads DISCONNECTED): it cancels the pending retry and
latches the closed state, so the client will not silently reconnect
later. A subsequent connect() clears that and reconnects normally.
Two middleware chains — one per direction:
import { Client } from '@tundralibs/rpc';
const client = new Client({ url: 'ws://localhost:8080' });
const getAuthToken = (): string => 'token';
client.useSend(async (ctx, next) => {
// ctx.frame is the OUTBOUND frame (cmd / sub / unsub / pub).
// Mutate, log, retry — then call next() to write the frame.
if (ctx.frame.type === 'cmd') {
ctx.frame.payload = {
...(ctx.frame.payload as object | undefined),
token: getAuthToken(),
};
}
await next();
});
client.useReceive(async (ctx, next) => {
// ctx.frame is the INBOUND frame from the server. Skip next() to
// drop the frame entirely.
console.log('server frame:', ctx.frame.type);
await next();
});Send middleware runs in registration order; the last next() writes
the frame to the wire. Receive middleware runs in registration order;
the last next() performs the built-in dispatch (id correlation for
result frames, channel routing for msg frames). Errors thrown in
send middleware reject the awaiting caller of
command() / subscribe() / etc.
import { Client } from '@tundralibs/rpc';
import type { ClientState, ClientSubscription } from '@tundralibs/rpc';
const client = new Client({ url: 'ws://localhost:8080' });
// 'DISCONNECTED' | 'CONNECTING' | 'CONNECTED' | 'CLOSING'
const state: ClientState = client.state;
console.log(state);
await client.connect(); // open WebSocket, resolves on 'open'
// Request/response — the type parameter types the resolved value.
const reply = await client.command<{ text: string }>('echo', { text: 'hi' });
console.log(reply.text);
// Returns a subscription handle.
const sub: ClientSubscription = await client.subscribe('chat:room1', (data) => {
console.log('msg:', data);
});
// Ack'd publish — needs `onPublish` on the server's channel, and the
// active subscription above (see "Client publish via channel onPublish").
await client.publish('chat:room1', { text: 'hi' });
await sub.unsubscribe();
await client.close(); // graceful close, stops reconnectsimport { Client, Server } from '@tundralibs/rpc';
// Server side
const server = new Server();
server.command('echo', undefined, (ctx) => ctx.payload);
server.channel('news', {});
await server.listen({ port: 8080, hostname: '127.0.0.1' });
// Client side (same process here for the demo; in practice the
// client lives in a browser / different process / different host)
const client = new Client({ url: 'ws://127.0.0.1:8080' });
await client.connect();
const echo = await client.command('echo', { text: 'hi' });
// → { text: 'hi' }
const sub = await client.subscribe('news', (data) => {
console.log('news:', data);
});
// Server-initiated push reaches the client's handler.
await server.publish('news', { headline: 'Hello world' });
await sub.unsubscribe();
await client.close();
await server.close();You can pass a httpHandler if you want non-WS requests to do
something other than 404:
import { Server } from '@tundralibs/rpc';
const server = new Server();
await server.listen({
port: 8080,
httpHandler: () => new Response('Use the WebSocket endpoint'),
});import { Server } from '@tundralibs/rpc';
type Conn = { userId: string; subscriptions: Set<string> };
const verifyToken = (_token: string | null): string | undefined => undefined;
const server = new Server<Conn>({
upgrade: (req) => {
const token = req.headers.get('authorization');
const userId = verifyToken(token);
if (!userId) return false; // 401-equivalent — falls through to HTTP
return {
data: { userId, subscriptions: new Set() },
};
},
});
server.command('whoami', undefined, (ctx) => ({
userId: ctx.ws.data.userId, // typed as `Conn`
}));
await server.listen({ port: 8080 });Validator<T> is a function (input: unknown) => T | Promise<T> —
any validator that throws on invalid input fits.
// Needs a separate install: deno add @tundralibs/guardian
import { Guardian } from '@tundralibs/guardian';
import { Server } from '@tundralibs/rpc';
const server = new Server();
const db = {
users: {
create: (_user: { name: string; email: string }) => Promise.resolve('u-1'),
},
};
const CreateUser = Guardian.object({
name: Guardian.string(),
email: Guardian.string().email(),
});
server.command(
'createUser',
(input) => CreateUser.parse(input),
async (ctx) => {
// ctx.payload is typed as { name: string; email: string }
const id = await db.users.create(ctx.payload);
return { id };
},
);Plain hand-written validators work too:
import { Server } from '@tundralibs/rpc';
const server = new Server();
server.command(
'sendMessage',
(input) => {
if (typeof input !== 'object' || input === null) {
throw new Error('expected object');
}
const text = (input as { text?: unknown }).text;
if (typeof text !== 'string' || text.length === 0) {
throw new Error('text required');
}
return { text };
},
(ctx) => ({ ok: true, length: ctx.payload.text.length }),
);import { Server } from '@tundralibs/rpc';
const server = new Server();
server.use(async (ctx, next) => {
const start = performance.now();
try {
await next();
console.log(`${ctx.cmd} ok ${performance.now() - start | 0}ms`);
} catch (err) {
console.error(`${ctx.cmd} fail ${performance.now() - start | 0}ms`, err);
throw err; // rethrow so the framework reports it as a result error
}
});A middleware can short-circuit by throwing — the framework converts
the throw into a result frame with ok: false. Custom error codes
flow through via err.code.
import { Server } from '@tundralibs/rpc';
const server = new Server();
server.use(async (ctx, next) => {
if (!(ctx.ws.data as { userId?: string }).userId) {
const err = new Error('not authenticated') as Error & { code: string };
err.code = 'UNAUTHENTICATED';
throw err;
}
await next();
});Or short-circuit silently (returns ok: true with no data):
import { Server } from '@tundralibs/rpc';
// Needs a separate install: deno add @tundralibs/compat
import type { ServerWebSocket } from '@tundralibs/compat/webserver';
const server = new Server();
const rateLimited = (_ws: ServerWebSocket<unknown>) => false;
server.use(async (ctx, next) => {
if (rateLimited(ctx.ws)) {
return; // skip handler, but ack the request
}
await next();
});import { Server } from '@tundralibs/rpc';
const server = new Server();
const canJoin = (_userId: string, _room: string): boolean => true;
server.channel('chat:room1', {
authorize: (ctx) => {
const userId = (ctx.ws.data as { userId?: string }).userId;
return Boolean(userId && canJoin(userId, 'room1'));
},
onSubscribe: (ctx) => {
console.log(`user joined chat:room1`);
},
onUnsubscribe: (ctx) => {
console.log(`user left chat:room1`);
},
});import { Server } from '@tundralibs/rpc';
const server = new Server();
// Anywhere on the server
await server.publish('chat:room1', {
from: 'system',
text: 'maintenance in 5 minutes',
});Every subscriber on chat:room1 (across all connections served by
this Server) receives a msg frame.
By default, clients can only subscribe to channels — they can't
publish into them. Add an onPublish to opt in:
import { Server } from '@tundralibs/rpc';
const server = new Server();
const parseChatMessage = (payload: unknown) => payload as { text: string };
server.channel('chat:room1', {
authorize: (ctx) => Boolean((ctx.ws.data as { userId?: string }).userId),
onPublish: async (ctx, payload) => {
// Validate, then re-broadcast via the server's publish so every
// subscriber (including the sender) sees the message.
const msg = parseChatMessage(payload);
await server.publish(ctx.channel, {
from: (ctx.ws.data as { userId: string }).userId,
text: msg.text,
at: Date.now(),
});
},
});On the client, publishing requires an active subscription to the same
channel — the pub inherits the authorization decision made at
subscribe time, so a publish without a prior subscribe() is rejected
with NOT_SUBSCRIBED:
import { Client } from '@tundralibs/rpc';
const client = new Client({ url: 'ws://localhost:8080' });
const render = (_msg: unknown) => {};
// Subscribe first, then publish to the same channel.
await client.subscribe('chat:room1', (msg) => render(msg));
await client.publish('chat:room1', { text: 'hi' });When a channel doesn't have onPublish, client pub frames are
refused with a PUBLISH_REFUSED error.
onPublish is fire-and-forget by design — the handler doesn't see
the request frame's id and the server's ack carries no data.
That's intentional: clients use pub when they want to push and
move on.
If you need request/response with custom ack data (delivered-at
timestamp, generated message ID, validation result, anything tied
to the specific publish call), use a command instead. Commands
already give you ctx.id, schema validation, structured ack via
return value, custom error codes via thrown errors:
import { Server } from '@tundralibs/rpc';
type Conn = { userId: string };
const server = new Server<Conn>();
const ChatMessageSchema = {
parse: (input: unknown) => input as { channel: string; message: string },
};
const canSendTo = (_userId: string, _channel: string): boolean => true;
server.command(
'publishChat',
(input) => ChatMessageSchema.parse(input),
async (ctx) => {
if (!canSendTo(ctx.ws.data.userId, ctx.payload.channel)) {
throw Object.assign(new Error('forbidden'), { code: 'FORBIDDEN' });
}
await server.publish(ctx.payload.channel, {
from: ctx.ws.data.userId,
text: ctx.payload.message,
});
return { delivered: true, ts: Date.now() };
},
);Client sends a cmd frame, gets a typed result back with the same
id. Reach for pub when fire-and-forget is what you actually want.
The default MemoryPubSubAdapter is in-process only. For broadcast
across multiple node instances, write an adapter against the same
PubSubAdapter interface and pass it to the server:
import {
type AdapterCapabilities,
PubSubAdapter,
Server,
type Subscription,
} from '@tundralibs/rpc';
// The surface your Redis client of choice has to provide — `cacher`
// already speaks Redis, or use a direct driver.
declare class RedisClient {
constructor(opts: { url: string });
on(event: 'message', cb: (topic: string, raw: string) => void): void;
subscribe(topic: string): void;
unsubscribe(topic: string): void;
publish(topic: string, payload: string): Promise<void>;
quit(): Promise<void>;
}
class RedisPubSubAdapter extends PubSubAdapter {
override readonly capabilities: AdapterCapabilities = {
patternSubscribe: true,
presence: false,
replay: false,
guaranteedOrder: true,
guaranteedDelivery: false, // Redis pub/sub is at-most-once
crossProcess: true,
backpressureVisibility: false,
};
// Two connections: a Redis connection in subscribe mode can't issue
// normal commands, so PUBLISH needs its own.
private __pub: RedisClient;
private __sub: RedisClient;
private __handlers = new Map<string, Set<(data: unknown) => void>>();
private __closed = false;
constructor(opts: { url: string }) {
super();
this.__pub = new RedisClient(opts);
this.__sub = new RedisClient(opts);
this.__sub.on('message', (topic, raw) => {
const handlers = this.__handlers.get(topic);
if (!handlers) return;
let data: unknown;
try {
data = JSON.parse(raw);
} catch {
return; // malformed — drop
}
// One throwing subscriber must not stop the rest of the fan-out.
for (const fn of [...handlers]) {
try {
fn(data);
} catch { /* swallow */ }
}
});
}
override subscribe(
topic: string,
handler: (data: unknown) => void,
): Subscription {
if (this.__closed) throw new Error('adapter closed');
let set = this.__handlers.get(topic);
if (!set) {
set = new Set();
this.__handlers.set(topic, set);
this.__sub.subscribe(topic); // SUBSCRIBE on the first local handler
}
set.add(handler);
return {
unsubscribe: () => {
const current = this.__handlers.get(topic);
if (!current) return;
current.delete(handler); // idempotent
if (current.size === 0) {
this.__handlers.delete(topic);
this.__sub.unsubscribe(topic); // UNSUBSCRIBE on the last one
}
},
};
}
override async publish(topic: string, data: unknown): Promise<void> {
if (this.__closed) return; // publish after close must not deliver
await this.__pub.publish(topic, JSON.stringify(data));
}
override async close(): Promise<void> {
if (this.__closed) return; // idempotent
this.__closed = true;
this.__handlers.clear();
await Promise.all([this.__sub.quit(), this.__pub.quit()]);
}
}
const server = new Server({
pubsub: new RedisPubSubAdapter({ url: 'redis://localhost:6379' }),
});Full adapter contract and capability flags: Rpc-PubSub.
The framework uses a small fixed set of error codes for protocol-level
issues; user code can throw with custom .code to route through the
same channel.
| Code | Sent by | Meaning |
|---|---|---|
BAD_FORMAT |
server | Malformed frame (invalid JSON, missing fields, unknown type) |
FRAME_TOO_LARGE |
server | Incoming frame exceeded maxFrameSize (rejected before decoding) |
UNKNOWN_COMMAND |
server |
cmd frame referenced a command that wasn't registered |
UNKNOWN_CHANNEL |
server |
sub/pub frame referenced an unregistered channel |
VALIDATION |
server | Validator threw. The thrown error's .message is forwarded |
FORBIDDEN |
server | Channel authorize returned false
|
AUTHZ_ERROR |
server | Channel authorize itself threw |
PUBLISH_REFUSED |
server | Client tried to publish on a channel without onPublish
|
NOT_SUBSCRIBED |
server | Client sent a pub frame for a channel it is not currently subscribed to |
PUBLISH_ERROR |
server |
onPublish handler threw |
HANDLER_ERROR |
server | Command handler threw without a custom .code
|
| custom | userland | Throw Object.assign(new Error(msg), { code: 'YOUR_CODE' }) from a handler or middleware |
A thrown error may also carry a data property. It rides along on the
result frame's error object and reaches the caller as .data on
the rejection, so a failure can return structure rather than only a
string:
import { Client, Server } from '@tundralibs/rpc';
const server = new Server();
server.command('createUser', undefined, () => {
throw Object.assign(new Error('invalid user'), {
code: 'VALIDATION',
data: { fields: { email: 'must be an email address' } },
});
});
await server.listen({ port: 8080, hostname: '127.0.0.1' });
const client = new Client({ url: 'ws://127.0.0.1:8080' });
await client.connect();
try {
await client.command('createUser', { email: 'nope' });
} catch (err) {
const { code, data } = err as Error & { code?: string; data?: unknown };
console.error(code, data); // 'VALIDATION' { fields: { … } }
}
await client.close();
await server.close();error.data is sent verbatim to the caller — keep internals out of it.
Three more codes are raised client-side, with no wire frame behind
them, when a command() cannot be completed: REQUEST_TIMEOUT (no
result arrived within defaultTimeoutMs), CONNECTION_LOST (the
socket dropped with the call in flight) and CLOSED (client.close()
was called with the call in flight).
Every failure that arrives from the server attaches a .code property
to the rejection — both the result frame path above and a rejection
caused by a correlated out-of-band error frame (e.g. BAD_FORMAT
for a malformed frame whose id the server could recover). Only the
result path can also carry .data; the error frame has no field
for it.
The three client-side codes above are the exception: they have no wire
frame behind them and carry their code in the Error's message
only, so branch on err.message for those.
Everything above is a wire error — something a remote peer reported
back over the protocol. Both Server and Client also throw plain
JS errors synchronously for local misuse, before any frame is sent.
All four classes extend RpcError (itself extending
@tundralibs/utils's BaseError), so a single instanceof RpcError
catches any of them:
| Class | Thrown by | When |
|---|---|---|
RpcConfigError |
new Client(opts) |
opts.url is missing or not a string |
RpcRegistrationError |
server.command(name, …) / server.channel(name, …)
|
name is already registered |
RpcStateError |
server.use() / .command() / .channel() / .handleUpgrade()
|
called after server.close()
|
RpcStateError |
client.command() / .subscribe() / .publish()
|
called while client.state !== 'CONNECTED'
|
RpcStateError |
any Middleware / ClientSendMiddleware / ClientReceiveMiddleware
|
its next() is called more than once |
import { Client, RpcConfigError, RpcStateError, Server } from '@tundralibs/rpc';
try {
new Client({ url: '' });
} catch (err) {
console.error(err instanceof RpcConfigError, (err as Error).message);
// true 'Client: `url` is required and must be a string'
}
const server = new Server();
await server.close();
try {
server.command('late', undefined, () => 'never runs');
} catch (err) {
console.error(err instanceof RpcStateError); // true
}These never reach a remote peer — a duplicate command() registration
or a closed-server call is a bug in the process calling it, not
something the other end of the wire could observe or recover from.
Pure JS on top of @tundralibs/compat/websocket — this package inherits whatever
that primitive supports. Today that means:
- Bun: native WebSocket
-
Deno: native WebSocket via
Deno.upgradeWebSocket() -
Node: WebSocket via the
wsnpm package -
Cloudflare Workers:
WebSocketPair, reached throughhandleUpgrade()only
Server itself has no runtime branches — the wire-up you pick is what
decides where it runs:
| Wire-up | Bun | Deno | Node | Workers |
|---|---|---|---|---|
listen() |
✅ | ✅ | ✅ | ❌ |
handlers() |
✅ | ✅ | ✅ | ❌ |
handleUpgrade() |
❌ | ✅ | ❌ | ✅ |
listen() and handlers() both need a listening socket, which Workers
never has — listen() rejects there with compat's
UnsupportedRuntimeError, and handlers() still returns handlers but
has no WebServer to mount them on. handleUpgrade() needs an upgrade
that answers with a Response, which Bun and Node do not have, so it
rejects there with the same typed error naming the two that do work —
never a raw TypeError.
- Wire Protocol Reference — frame shapes, field validation, and codec error modes
- Middleware Patterns — auth, rate-limiting, timing, error-routing, heartbeat recipes
- Pub/Sub Adapters — adapter contract, capability flags, in-memory implementation, Redis sketch, conformance test harness
- Extending — the override surface, pattern subscribe via subclass, custom frame inspection
-
Tracing middleware —
ready-made
@tundralibs/tracermiddleware for RPC's generic chain (a span per message, parented from inboundtraceparent) - Examples — runnable demos
-
@tundralibs/compat/websocket— the underlying middleware-aware WebSocket primitive this package is built on