Skip to content

TS RPC

TS RPC is a TypeScript library for building type-safe remote procedure calls over WebSockets. It solves a common pain point in real-time applications: getting strong typing across the network boundary between clients and services, without code generation or a build step.

The library uses a relay server architecture where a central WebSocket hub routes messages between peers using topic-based pub/sub or direct peer-to-peer addressing. Discriminated unions with Zod schemas ensure that every request and response is validated at both compile time and runtime.

Clients and services connect to a shared relay server. Messages are routed either by broadcasting to a topic or by targeting a specific peer’s clientId:

┌─────────┐ ┌─────────────┐ ┌─────────┐
│ Client │ ◄────────► │ Relay Server│ ◄────────► │ Service │
│ (Peer) │ WebSocket │ (Hub) │ WebSocket │ (Peer) │
└─────────┘ └─────────────┘ └─────────┘

The relay server is built on Bun.serve() with native WebSocket support, handling authentication, session persistence, rate limiting, and message size enforcement — all configurable through a clean options object.

  • End-to-end type safety — Define request/response contracts as Zod discriminated unions. TypeScript narrows the response type based on the request’s type field, so switch statements are exhaustive and typos are caught at compile time.
  • Relay server with dual routing — Topic-based broadcast for pub/sub patterns, plus direct peer-to-peer messaging via clientId. One server handles both use cases.
  • Bidirectional streaming — Send AsyncIterable streams over a single WebSocket connection using StreamManager. Built-in backpressure monitoring pauses the sender when the WebSocket buffer exceeds a configurable threshold (default 1MB).
  • Automatic reconnection — RetrySocket wraps the native WebSocket with exponential backoff (configurable from 1s up to 30s cap), message queueing during disconnects, and session ID restoration so clients keep their identity across reconnects.
  • Auth & authorization — Pluggable AuthValidator interface for token verification, plus AuthorizationRules for per-topic subscription/publish control and per-user rate limiting. Ships with permissive defaults for development and strict implementations for production.
  • Zod schema validation — All RPC message categories (request, response, welcome, ping/pong, error, stream data/end/error) are validated against Zod schemas at the boundary. Invalid messages are rejected before hitting application logic.
  • Heartbeat monitoring — Optional ping/pong heartbeat with configurable intervals to detect zombie connections early.
  • Clean resource management — dispose() tears down sockets, clears timers, and aborts active streams in one call.

Define your API as a pair of Zod discriminated unions in a shared file:

shared/my-api.ts
import { z } from "zod";
export const RequestSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("get-user"), id: z.string() }),
z.object({ type: z.literal("update-score"), points: z.number() }),
]);
export const ResponseSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("get-user"), name: z.string(), age: z.number() }),
z.object({ type: z.literal("update-score"), newScore: z.number() }),
z.object({ type: z.literal("error"), message: z.string() }),
]);

The service connects to the relay and uses match() to handle requests:

const service = RpcPeer.FromOptions({
url: "ws://localhost:8080",
requestSchema: RequestSchema,
responseSchema: ResponseSchema,
});
service.match(async (request) => {
switch (request.type) {
case "get-user":
return { type: "get-user", name: "Alice", age: 25 };
case "update-score":
return { type: "update-score", newScore: request.points + 100 };
}
});

The client gets fully-typed responses with no extra ceremony:

const client = RpcPeer.FromOptions({
url: "ws://localhost:8080",
requestSchema: RequestSchema,
responseSchema: ResponseSchema,
});
await client.waitForWelcome();
const res = await client.request({ type: "get-user", id: "1" });
// res.data is typed — TypeScript knows the possible shapes

The library is ~7,000 lines across five core modules:

ModuleLinesPurpose
RpcPeer1,220Core RPC client — request/response, match handlers, event dispatch
RetrySocket601WebSocket wrapper with auto-reconnect, message queueing, exponential backoff
RpcStream586Stream multiplexing over WebSocket — backpressure, abort, pending buffers
Authorization571Auth tokens, authorization rules, rate limiting, session management
WebSocketCloseCodes276RFC 6455 close codes with descriptions and type-safe constants

Every module has a co-located test file plus integration tests that start a real Bun WebSocket server and exercise the full client-server flow: connection handshake, request/response round-trips, timeout handling, multi-client scenarios, and peer-to-peer routing. All tests run with bun test.

LanguageTypeScript
RuntimeBun
ValidationZod 4
TransportNative WebSocket API
ArchitectureRelay server (pub/sub + peer-to-peer)
LintingBiome
Package@jadujoel/ts-rpc on npm

The source code is available on the project’s GitHub repository.