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 reconnectionRetrySocket 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 managementdispose() 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.