Type-safe RPC and event streaming over WebSockets. Define procedures on the server, get fully-typed send and on on the client — no codegen, no schema duplication.
npm install @frsty/wsrpcSupports any schema library that implements Standard Schema (zod, valibot, arktype, etc.).
import { z } from 'zod'
import { createProcedure, WebsocketHandler } from '@frsty/wsrpc/server'
type Ctx = { userId: string; room: string }
const base = createProcedure<Ctx>()
const router = {
sendMessage: base
.input(z.object({ text: z.string() }))
.handler(function* (c) {
yield c.replyAll('message', { text: c.input.text, from: c.userId })
yield c.reply('ack', { delivered: true })
return { ok: true }
}),
}
export const handler = new WebsocketHandler(router, {
*onOpen({ ctx }) {
yield ctx.replyAll('presence', { userId: ctx.userId, status: 'online' })
},
*onClose({ ctx }) {
yield ctx.replyAll('presence', { userId: ctx.userId, status: 'offline' })
},
})
export type AppHandler = typeof handlerimport { handler } from './router'
type WsData = { userId: string; room: string; conn: unknown }
const server = Bun.serve<WsData>({
port: 3000,
fetch(req, srv) {
const url = new URL(req.url)
srv.upgrade(req, {
data: {
userId: url.searchParams.get('userId') ?? 'anon',
room: url.searchParams.get('room') ?? 'lobby',
conn: null,
},
})
},
websocket: {
open(ws) {
ws.subscribe(`room:${ws.data.room}`)
ws.data.conn = handler.connection({
ctx: { userId: ws.data.userId, room: ws.data.room },
send: (data) => ws.send(data),
broadcast: (data) => server.publish(`room:${ws.data.room}`, data),
})
ws.data.conn.handleOpen()
},
message(ws, raw) { ws.data.conn.handleMessage(raw) },
close(ws) {
ws.data.conn.handleClose()
ws.unsubscribe(`room:${ws.data.room}`)
},
},
})import { createClient } from '@frsty/wsrpc/client'
import type { AppHandler } from './router'
const client = createClient<AppHandler>('ws://localhost:3000?userId=alice&room=lobby', {
reconnect: true,
})
// Typed event listeners
client.on('presence', (data) => console.log(data.userId, data.status))
client.on('message', (data) => console.log(data.from, data.text))
// Typed RPC calls
const result = await client.send('sendMessage', { text: 'hello' })
// result: { ok: true }Handlers are generator functions. yield emits events to connected clients, return sends the RPC response back to the caller.
.handler(function* (c) {
yield c.reply('progress', { pct: 50 }) // → only the caller
yield c.replyAll('announcement', { msg: '…' }) // → everyone (via broadcast)
return { done: true } // → RPC response to caller
})Pass any Standard Schema-compatible validator to .input():
import { z } from 'zod'
import * as v from 'valibot'
import { type } from 'arktype'
base.input(z.object({ text: z.string() }))
base.input(v.object({ text: v.string() }))
base.input(type({ text: 'string' }))onOpen, onClose, and onError are generator functions on WebsocketHandler. They receive the typed ctx and can yield events the same way procedures do.
ctx is the typed object your adapter passes into handler.connection(). It's merged with reply and replyAll before being handed to each handler.
type Ctx = { userId: string; role: 'admin' | 'user' }
const base = createProcedure<Ctx>()
base.handler(function* (c) {
if (c.role === 'admin') yield c.replyAll('notice', { msg: 'admin here' })
return { ok: true }
})| Export | Description |
|---|---|
createProcedure<Ctx>() |
Creates a procedure builder bound to a context type |
WebsocketHandler |
Wraps a router and lifecycle hooks, exposes .connection() |
ConnectionOpts |
Type for the argument to .connection() — wire your framework here |
Router, Lifecycle, HandlerCtx |
Supporting types |
| Export | Description |
|---|---|
createClient<Handler>(url, opts) |
Creates a typed client |
ClientOptions |
Reconnect, lifecycle callbacks |
Low-level types for building framework adapters: ConnectionOpts, Router, LifecycleFn, AllGenerator, safeJsonParse.
Low-level types for building custom transports: InferHandler, InferRouterTypes, EventMap, AnyWebsocketHandler, safeJsonParse.
MIT