@vibedout/realtime
v0.1.0
Published
Realtime rooms for Cloudflare Workers: a RealtimeRoom Durable Object base class on the WebSocket Hibernation API (Zod message protocol, presence derived from the sockets' attachments, numbered events in SQLite with catch-up, rate limits, broadcast over RP
Maintainers
Readme
@vibedout/realtime
Realtime rooms for Cloudflare Workers: any page can be shared live by many people. A Durable Object room holds the sockets, the
people, and a short numbered history; a Worker helper decides who may connect; a React client keeps a page in step with it.
Workers + WebCrypto on the server, React 19 and TanStack Query on the client. partysocket is the only runtime dependency;
Zod is a peer (the schemas you write are checked by the same copy the package uses). The room is our own class on the Durable
Objects WebSocket Hibernation API and partysocket, not PartyServer. Its socket plumbing (quiet send and close, fan-out, the
token bucket, the size-capped JSON reader, the keepalive auto-response) is bundled into dist/, so it adds no dependency.
| Entry point | For | Runs in |
|---|---|---|
| @vibedout/realtime / /protocol | the message schemas, defineRoom, roles, close codes | anywhere |
| @vibedout/realtime/server | RealtimeRoom, the Durable Object base class | Workers |
| @vibedout/realtime/connect | who may connect: session hook, access hook, 60-second room tickets | Workers |
| @vibedout/realtime/hono | the connect helper as a ready-made Hono app | Workers |
| @vibedout/realtime/client | one connection to one room, no UI framework | browser, Node |
| @vibedout/realtime/react | <Realtime.Room> and the hooks | browser (React 19) |
Three kinds of traffic
| | Durable events | Ephemeral signals | Presence |
|---|---|---|---|
| For | things that changed (a task moved, a message sent) | cursors, typing | who is here |
| Stored | yes, in the room's SQLite, with a sequence number | never | in the sockets' attachments, never in storage |
| Replayed after a reconnect | yes, in order | no | the welcome carries the current list |
| Sent to | everyone, including the sender (its clientId is the confirmation) | everyone except the sender | everyone |
| Billing that matters | row writes (about $1 per million) | incoming messages at 20:1 | none |
Only durable events write to storage, so a board with twenty cursors at 20 Hz costs almost nothing.
The room (/server)
import { z } from "zod";
import { defineRoom } from "@vibedout/realtime/protocol";
import { type Participant, RealtimeRoom, RoomError } from "@vibedout/realtime/server";
export const board = defineRoom({
events: { "task.move": z.object({ id: z.string(), to: z.string() }) }, // durable, validated, numbered
signals: { cursor: z.object({ x: z.number(), y: z.number() }) }, // ephemeral
minRole: { "task.move": "member", cursor: "viewer" }, // default is "member" for everything
});
export class BoardRoom extends RealtimeRoom<Env, typeof board> {
readonly definition = board;
// Optional hooks: refuse or reshape an event before it is stored, react to people arriving and leaving.
protected override onEvent(type: string, payload: unknown, who: Participant) {
if (type === "task.move" && /* some rule */ false) throw new RoomError("locked", "that task is locked");
return undefined; // or return a replacement payload
}
}wrangler.jsonc:
{
"compatibility_date": "2026-09-26",
"durable_objects": { "bindings": [{ "name": "BOARD", "class_name": "BoardRoom" }] },
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["BoardRoom"] }]
}What the room guarantees
| Behaviour | How |
|---|---|
| Hibernation, no timers | Hibernation API only (acceptWebSocket, attachments, tags). A ping is answered by the runtime (setWebSocketAutoResponse) without waking the room. The one alarm prunes the log and closes sockets whose session ended; it re-arms itself only while something older than the newest event remains or a socket still has an end to wait for |
| Presence survives hibernation | Derived from the sockets' attachments, one entry per user however many tabs; join is announced for a user's first socket, leave for their last. A user can set status and meta (16 flat keys); it applies to all of their tabs |
| Numbered events and catch-up | Each event is a row in rt_events with a seq. A client connecting with ?since=<last seq> receives the welcome, then every later event in order, in one synchronous step, so nothing can slip between the snapshot and the replay. If the gap cannot be replayed (pruned, more than maxReplay, or the client is ahead of the room) it gets resync and refetches over HTTP |
| Per-connection rate limit | Three token buckets in memory: a coarse flood bucket checked before parsing, one for events (30 per 10 s by default), one for signals and presence updates (60 per s). Refused events get an error with their clientId; signals over the bucket are dropped silently, and a signal the role may not send (or the room does not know) is answered once per type, then dropped, without counting against the connection. 20 refused messages (bad frames, invalid payloads, events over the limit) within one 10-second window close the socket (1008); a slow trickle of mistakes never adds up. An oversized frame (maxMessageChars, 64 K characters by default) closes it (1009) |
| Order | The events of one connection are handled one at a time, in the order they were sent, even when onEvent waits on a database; events from different connections are numbered in the order the room handles them |
| Roles | viewer, member, admin, from the access hook. Each event or signal names the weakest role that may send it (default member, so a viewer can watch and set their presence but not write) |
| Session end | The identity carries exp. The first message after it closes the socket with 4001, and the alarm closes a socket that only listens (it wakes the room once, at the earliest session end); the client fetches a fresh ticket and reconnects. No timer |
| Removing someone | disconnectUser(userId) over RPC closes all of their sockets with 4003, which tells their client not to reconnect on its own. The access hook runs only at connect, so call this when you revoke access |
| Limits on sockets | maxConnections per room (the next upgrade answers 503), maxConnectionsPerUser (the user's oldest is closed with 4009) |
| Broadcast from Worker code | Durable Object RPC: publish(type, payload, { actor, clientId }) (durable, validated like a client's event), broadcast(type, payload) (ephemeral, to every socket), invalidate(queryKey), patch(queryKey, op, entity), disconnectUser(userId), getPresence(), getLastSeq(). A published event may not serialize longer than maxMessageChars (a replay of 500 of them stays bounded) |
// An ordinary HTTP mutation: write to D1, then tell the room.
app.post("/api/projects/:id/tasks", async (c) => {
const task = await createTask(c);
await c.env.BOARD.getByName(`board:${c.req.param("id")}`).publish("task.moved", task, { actor: c.get("userId") });
return c.json(task);
});Room names are <type>:<id> (board:proj_9, chat:org_12:general), the same names the connect helper derives from the URL.
Trust and cost notes
- The room trusts one header,
x-rt-identity, which only the Worker's connect helper sets. Every method on a Durable Object class is callable over RPC by whoever holds its namespace binding,protectedones included (TypeScript'sprotecteddoes not exist at runtime:commitstores an event with no schema check,fanOutsends anything,limitsreads the configuration), so never hand the room's namespace to code that serves untrusted callers; keep it in the Worker that has the connect routes and your own server code. - The room's storage uses one table,
rt_events. A subclass that stores its own data (a chat'smessagestable, say) uses other names, and a subclass that uses its own alarm must callsuper.alarm(). - The log keeps 1,000 events or 7 days (
retention), whichever is smaller, and never the newest event, so numbering does not restart and a client's savedsincestays meaningful. Pruning is soft: the table can pass the limit until the next alarm (every 6 hours by default). - Presence is as stale as the runtime's idea of the socket. A user is announced as gone when the runtime reports their last socket closed. A connection that dies
without a close (a network cut) lingers until the runtime notices; the client's
pingevery 30 s and its own silence check make that rare, but there is no server-side sweep of quiet sockets. ws.sendfailures are ignored (a closing socket is cleaned up by its close handler).webSocketClosecompletes the close handshake for compatibility dates before2026-04-07.
Connecting and auth (/connect, /hono)
The upgrade is verified in the Worker before it is forwarded to the room. A refused request never creates the room.
import { realtimeHandlers, TICKET_PATH, UPGRADE_PATH } from "@vibedout/realtime/connect";
const realtime = realtimeHandlers<Env>({
rooms: { board: (env) => env.BOARD }, // room type -> Durable Object namespace
authenticate: (request, env) => sessionFrom(request), // { userId, name, expiresAt } or null: your session, your auth library
authorize: (identity, room, env) => roleFor(identity.userId, room), // "viewer" | "member" | "admin" | null
ticket: { secret: (env) => env.REALTIME_TICKET_SECRET }, // optional: enables room tickets
allowedOrigins: ["https://widget.example"], // optional: other origins that may use the cookie
});
app.get(UPGRADE_PATH, realtime.upgrade); // GET /parties/:type/:room
app.post(TICKET_PATH, realtime.ticket); // POST /realtime/ticket { type, room } -> { ticket, expiresAt, role }@vibedout/realtime/hono gives the same as one call, app.route("/", realtimeRoutes({ ... })). It returns a Hono of the version this package
resolved, so if your app resolves another version TypeScript will object; realtimeHandlers has no such coupling (it is typed on a small structural context) and is
the one to use in an app.
| Step | Result |
|---|---|
| Not a GET / no Upgrade: websocket | 405 / 426 |
| Unknown room type, or an id that is not [A-Za-z0-9][A-Za-z0-9_.:-]{0,127} | 404 / 400 |
| Cookie path: the browser's Origin is neither this Worker's own (scheme, host, port) nor allowed | 403 origin_not_allowed (blocks cross-site WebSocket hijacking) |
| authenticate returns nothing, or a session that has ended | 401 unauthorized / session_expired |
| authorize returns null | 403 forbidden |
| Otherwise | the request is rebuilt (the cookie, Authorization, the ticket and every x-rt-* header the browser sent are dropped; the identity header is set from what the Worker proved) and forwarded |
A display name longer than 80 characters is cut, and a colour that is not a short string is replaced by a generated one; a userId or an end time that is not usable is a
bug in the hook and answers 500 (and is logged), not a silent 401.
A route of your own that serves a room's data: admit
A page often loads a room's data over plain HTTP too (the pins a board shows before the live events, a room's history). That route must let in exactly the people the socket lets in, so it does not repeat the checks; it asks for the same admission:
app.get("/boards/:room/pins", async (c) => {
const admitted = await realtime.admit(c, { type: "board", room: c.req.param("room") });
if (!admitted.ok) return admitted.response; // or answer in your own error shape from admitted.status / admitted.error
const { identity, room, role } = admitted; // who, which room (room.name is the Durable Object name), and as what
return c.json(await c.env.BOARD.getByName(room.name).listPins());
});The checks and their order are the upgrade's, with the ticket route's rule that who is asking comes before anything about rooms: Origin (403 origin_not_allowed), the session
(401 unauthorized, 401 session_expired), the room type and id (404 unknown_room_type, 400 invalid_room_id), then authorize (403 forbidden). admitted.response is the
same { "error": "<code>" } JSON the upgrade answers. A ?ticket= is for opening the socket and is not read here. Doing the steps by hand (authenticate then authorize) is the
mistake this exists to prevent: it skips the origin check and the end of the session.
authenticate and authorize are your functions: this package knows nothing about Better Auth or any session store. With Better Auth, authenticate is
(request, env) => getSession(request.headers) mapped to { userId, name, expiresAt }, and authorize is a membership query.
Two ways to prove who you are
- Same origin (the default). The browser sends the session cookie on the upgrade. Nothing else to do on the client.
- A room ticket for a widget on another origin, a native app, or any client that cannot send the cookie. The app's
POST /realtime/ticketauthenticates the user and checks access, then returns a ticket good for 60 seconds (5 minutes at most) and one room; the client puts it in the URL (?ticket=), which is why it is short-lived. The ticket proves who the user is and when their session ends; the role is decided again byauthorizeat connect, so a removed member is out at the next reconnect. A ticket can be used again within its minute (that is how a dropped connection reconnects); it is not single-use.
Tickets are v1.<payload>.<signature>, HMAC-SHA256 over the payload with the secret (at least 32 characters; pass an array to rotate: the first signs, all verify).
The ticket endpoint authenticates the caller before it reads the request, so an anonymous caller learns nothing about which rooms exist, and it refuses bodies over 1 KB.
mintTicket(secret, claims, { ttlSeconds, now }) and verifyTicket(secret, ticket, { room }, { now }) are exported for custom flows; verifyTicket checks the signature
first and returns a reason (malformed, bad_signature, expired, not_yet_valid, wrong_room) instead of throwing. Cross-origin use of the ticket route needs Hono's own
cors() in front of it; the cookie is never sent cross-origin.
Add the Workers Rate Limiting binding in front of UPGRADE_PATH for a per-user or per-IP cap on upgrades; the room's own limits start only after a socket is open.
The client (/client, /react)
import { useQuery } from "@tanstack/react-query";
import { Realtime, useBroadcast, usePresence, useRoomMutation, useSignal } from "@vibedout/realtime/react";
export function BoardPage({ projectId }: { projectId: string }) {
return (
<Realtime.Room type="board" id={projectId} ticketUrl="/realtime/ticket"> {/* omit ticketUrl for same-origin cookie auth */}
<Avatars />
<Tasks projectId={projectId} />
</Realtime.Room>
);
}
function Avatars() {
const people = usePresence(); // one entry per user; re-renders only when the list changes
return <>{people.map((p) => <span key={p.userId} style={{ color: p.color }}>{p.name}</span>)}</>;
}
function Tasks({ projectId }: { projectId: string }) {
const tasks = useQuery({ queryKey: ["tasks", projectId], queryFn: () => api.tasks(projectId) }); // a plain query
const move = useRoomMutation<{ id: string; to: string }>("task.move", { queryKey: ["tasks", projectId] }); // refetches when anyone moves a task
const cursor = useBroadcast<{ x: number; y: number }>("cursor"); // ~20 per second at most
useSignal<{ x: number; y: number }>("cursor", (point, from) => showCursor(from, point));
// ...
}| Export | What it does |
|---|---|
| <Realtime.Room type id> | Connects after mount (server rendering reads idle), reconnects with catch-up, closes on unmount, switches rooms when type or id changes. Props: ticketUrl or getTicket (called on every connect and reconnect), host, protocol, prefix, query, patch, resyncQueryKeys, queryClient, advanced |
| useRoom() | { status, connId, userId, role, lastSeq, presence, error, send, signal, setPresence, client }. Re-renders on every change; the narrower hooks avoid that |
| usePresence() | Presence[], deduplicated by user |
| useBroadcast(type, { intervalMs }) | A stable function that sends an ephemeral signal, throttled (leading edge, and the last value is always delivered) |
| useSignal(type, handler), useRoomEvent(type, handler) | Receive signals / durable events of one type (handlers may change identity freely) |
| useRoomMutation(type, { queryKey, mode, apply }) | Sends a durable event and keeps a query in step: mode: "refetch" (default) invalidates the query when the event arrives, from anyone; mode: "patch" with apply updates the cache at once for your own send (rolled back, then refetched, if the server refuses) and on arrival for everyone else's |
Every context hook throws useX must be used inside <Realtime.Room> outside a room, naming the hook.
How it stays correct (all in RealtimeClient, tested with a fake socket):
- It remembers the newest event number and reconnects with
?since=; an event it has seen is dropped, a hole in the numbering makes it reconnect to be replayed, and the welcome does not skip it ahead over the replay that follows. resyncresets it to the room's number and refetches[type, id](orresyncQueryKeys).- With tickets, a ticket is requested for each attempt. A 401 or 403 from the ticket endpoint (
RealtimeAuthError) stops it with statusunauthorized; any other failure is retried with backoff. sendresolves when the server echoes the event (matched byclientId) with itsseq, and rejects withRealtimeMutationError(forbidden,invalid_payload, ...,not_connected,disconnected,timeout). Nothing is queued while offline; an app that wants an outbox builds it on that rejection.- A close with code
4009(this tab was the oldest of too many from one user) ends in statusreplaced, and4003(disconnectUser) inunauthorized; neither reconnects on its own. Any other drop reconnects with backoff. - A signal the room said this role may not send (
errorwithtype) is not sent again on that connection, so a viewer's cursor costs one reply. - An event the client cannot read is skipped and refetched (
resync) rather than mistaken for a hole; a second hole within two seconds of a hole-triggered reconnect is also a resync, so a broken stream cannot loop. - Server
invalidatemessages refetch the query. Serverpatchmessages refetch too, unless<Realtime.Room patch>opts in, in which case a list in the cache is upserted or deleted byid(and anything that is not a list is refetched instead). - A
pingevery 30 s keeps idle sockets alive through proxies; silence for 2.5 intervals reconnects.
Without React, RealtimeClient from @vibedout/realtime/client is the same thing (connect(), close(), send, onEvent, getSnapshot...), and works in Node with
WebSocket: require("ws").
Developing and testing
vite devwith@cloudflare/vite-plugincarries the WebSockets, the hibernation API and the auto-response with no extra setup. One dev-only difference: an upgrade the Worker refuses is answered by dropping the connection, not by relaying the 401/403 (wrangler devrelays it).- Tests use vitest projects (
pnpm test):workersruns the room, the connect helper and the protocol in workerd with@cloudflare/vitest-pool-workers(includingevictDurableObjectfor hibernation), anddomruns the client and hooks in jsdom with a fakeWebSocket. The test Worker config pins an oldercompatibility_datethan production (the pool's workerd is older than the Vite plugin's). Test refusals of RPC methods withrunInDurableObject: an error thrown across the RPC boundary fails the pool's run. pnpm typecheckruns two configs (Workers types for the server half, DOM for the client half; they cannot share globals).pnpm buildwritesdist/withtsdown.
Not included
Shared text editing (Yjs) and a chat layer.
Changelog
- 0.1.0: the room, the connect helper (with
admit, the socket's admission for an HTTP route of your own that serves a room's data) and the React client.
