TS RPC
Overview
Section titled “Overview”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.
Architecture
Section titled “Architecture”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.
Features
Section titled “Features”- End-to-end type safety — Define request/response contracts as Zod discriminated unions. TypeScript narrows the response type based on the request’s
typefield, soswitchstatements 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
AsyncIterablestreams over a single WebSocket connection usingStreamManager. Built-in backpressure monitoring pauses the sender when the WebSocket buffer exceeds a configurable threshold (default 1MB). - Automatic reconnection —
RetrySocketwraps 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
AuthValidatorinterface for token verification, plusAuthorizationRulesfor 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.
How It Works
Section titled “How It Works”Define your API as a pair of Zod discriminated unions in a shared file:
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 shapesKey Components
Section titled “Key Components”The library is ~7,000 lines across five core modules:
| Module | Lines | Purpose |
|---|---|---|
RpcPeer | 1,220 | Core RPC client — request/response, match handlers, event dispatch |
RetrySocket | 601 | WebSocket wrapper with auto-reconnect, message queueing, exponential backoff |
RpcStream | 586 | Stream multiplexing over WebSocket — backpressure, abort, pending buffers |
Authorization | 571 | Auth tokens, authorization rules, rate limiting, session management |
WebSocketCloseCodes | 276 | RFC 6455 close codes with descriptions and type-safe constants |
Testing
Section titled “Testing”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.
Tech Stack
Section titled “Tech Stack”| Language | TypeScript |
| Runtime | Bun |
| Validation | Zod 4 |
| Transport | Native WebSocket API |
| Architecture | Relay server (pub/sub + peer-to-peer) |
| Linting | Biome |
| Package | @jadujoel/ts-rpc on npm |
Source Code
Section titled “Source Code”The source code is available on the project’s GitHub repository.