PartyServer Rooms
Per-room collaborative editing with presence, live state, and bidirectional WebSockets.
When you need client↔client state — presence, live cursors, shared notes, concurrent editing — Quickback exposes PartyServer rooms at /realtime/<binding>/<room-id>. Each room is its own Durable Object instance, so isolation between rooms is automatic and the runtime hibernates idle rooms for free.
This is separate from /broadcast/v1/* (the org-wide server fan-out documented in Using Realtime). They share the worker and the auth model but solve different problems:
| Concern | /broadcast/v1/* | /realtime/<binding>/<room-id> |
|---|---|---|
| Direction | Server → clients | Client ↔ clients |
| Sharding | One DO per org | One DO per room |
| Use case | Postgres-changes, app-wide events | Live cursors, shared edits, presence |
| Catchup | Ephemeral | Per-room state survives reconnects |
When to use rooms vs broadcast
Reach for rooms when the messages flow between users in the same context (interview, document, game match). Reach for broadcast when the server is publishing facts that any client in the org may want to react to (a record was updated, a webhook fired, a role changed).
You can use both in the same project. Cost is the same.
Define a room class
The PartyServer Server class extends DurableObject, so any in-worker DO you wire up via bindings.durableObjects qualifies. The lifecycle hooks below are what PartyServer adds on top:
// quickback/features/interviews/lib/InterviewRoom.ts
import { Server, type Connection, type WSMessage } from 'partyserver';
export class InterviewRoom extends Server {
static options = { hibernate: true };
presence = new Map<string, { userId?: string; name?: string }>();
onConnect(connection: Connection) {
this.presence.set(connection.id, {});
this.broadcastPresence();
}
onMessage(connection: Connection, message: WSMessage) {
if (typeof message !== 'string') return;
const parsed = JSON.parse(message);
if (parsed.type === 'note') {
this.broadcast(JSON.stringify({ type: 'note', from: connection.id, body: parsed.body }), [connection.id]);
}
}
onClose(connection: Connection) {
this.presence.delete(connection.id);
this.broadcastPresence();
}
private broadcastPresence() {
const peers = Array.from(this.presence.entries()).map(([id, meta]) => ({ id, ...meta }));
this.broadcast(JSON.stringify({ type: 'presence', peers }));
}
}Wire it into your config
One entry under bindings.durableObjects is everything the compiler needs. The Server class lives under a lib/ directory inside the feature (the same path the compiler uses for any in-worker shared code) — author it at quickback/features/interviews/lib/InterviewRoom.ts. The from value is the corresponding module path, no extension.
// quickback/quickback.config.ts
export default defineConfig({
// ...
bindings: {
durableObjects: [
{
name: 'INTERVIEW_ROOM',
className: 'InterviewRoom',
from: 'features/interviews/lib/InterviewRoom',
},
],
},
build: {
dependencies: {
partyserver: '^0.5.5',
partysocket: '^1.1.18',
},
},
});The compiler emits the [[durable_objects.bindings]] block, the [[migrations]] entry (SQLite-backed by default), and the class re-export from src/index.ts. It also auto-mounts the hono-party middleware at /realtime/* the moment any in-worker DO is declared, so URL routing follows PartyServer's own convention.
Connect from the client
The partysocket package handles reconnection, buffering, and resilience. The party field is the kebab-cased binding name (INTERVIEW_ROOM → interview-room); the room field is any opaque ID.
import PartySocket from 'partysocket';
const socket = new PartySocket({
host: window.location.host,
party: 'interview-room',
room: 'interview-abc123',
prefix: 'realtime',
});
socket.addEventListener('message', (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'presence') updatePresence(msg.peers);
if (msg.type === 'note') appendNote(msg.from, msg.body);
});
socket.send(JSON.stringify({ type: 'note', body: 'asked about tenure' }));Authentication
The auto-mounted middleware is secure by default — anonymous WebSocket upgrades are rejected with 401 Unauthorized before partyserverMiddleware sees the request. The four auth mechanisms the rest of the worker honors all unlock connections:
- session cookie
Authorization: Bearer <jwt>x-api-keyheader?api_key=/?key=query parameter
(For the hand-written mount form below, you can also accept signed ?ws_ticket= query params using the same issuer the Broadcaster exposes at /broadcast/v1/ws-ticket.)
The default gate only checks whether the caller is authenticated, not whether they're allowed in this room. For per-room authorization, the access field on the binding lets the compiler emit a path-scoped gate that runs your existing resource firewall + read.access pipeline before the WebSocket upgrade — no hand-written code, same status semantics as GET /api/v1/<resource>/:id.
Per-room authorization via access (v0.12+)
// quickback/quickback.config.ts
bindings: {
durableObjects: [
// Single-resource: roomId IS the row id of `chats`.
// Auto-emits a gate at /realtime/chat/* that runs `chats`'s firewall.
{
name: 'Chat', className: 'Chat', from: 'lib/Chat',
access: { resource: 'chats' },
},
// Multi-resource: roomId pattern dispatches kind → resource.
// Auto-emits a gate at /realtime/notepad/* that parses
// `day-note:<rowId>` | `interaction:<rowId>` | `record:<rowId>`
// and runs the matched resource's firewall on the rowId.
{
name: 'Notepad', className: 'Notepad', from: 'lib/Notepad',
access: {
roomId: { pattern: '^(day-note|interaction|record):([A-Za-z0-9_-]+)$', kindGroup: 1, idGroup: 2 },
resources: {
'day-note': { resource: 'day-notes' },
'interaction': { resource: 'interactions' },
'record': { resource: 'records' },
},
},
},
// Multi-table feature: pin the gate to a specific table so the
// firewall + id query is unambiguous (v0.16.1+).
{
name: 'NotepadInteractions', className: 'NotepadInteractions', from: 'lib/NotepadInteractions',
access: { resource: { feature: 'interactions', table: 'interactions' } },
},
// Room keyed by a unique non-PK column (v0.36+). A Yjs page editor is
// addressed by `event_pages.yjsRoomId`, not the row id — `key` tells the
// gate which column the roomId matches.
{
name: 'PageRoom', className: 'PageRoom', from: 'lib/PageRoom',
access: { resource: { feature: 'event-pages', table: 'eventPages', key: 'yjsRoomId' } },
},
// Identity-gated room — the roomId IS the caller's own id, with no backing
// table. A per-user gateway / presence / inbox keyed on `ctx.userId`:
// auto-emits a gate at /realtime/user-gateway/* that 403s unless the room
// segment equals the authenticated user's id. Use `ctx.activeOrgId` for a
// per-org room. No DB query — it's a string compare against ctx.
{
name: 'UserGateway', className: 'UserGateway', from: 'lib/UserGateway',
access: { roomIdEquals: 'ctx.userId' },
},
// Genuinely public room (no auth, no row lookup) — opt out explicitly.
{ name: 'PublicLobby', className: 'PublicLobby', from: 'lib/PublicLobby',
access: 'public' },
],
}What the compiler emits:
access: { resource: 'chats' }—app.use('/realtime/chat/*', …)that 401s anonymous, parses the URL segment as a row id, querieschatswith the resource'sbuildFirewallConditions(ctx)andeq(chats.id, rowId), and 403s when the row is invisible to the caller (out-of-org, soft-deleted, etc.). The DO'sonLoad/onConnectthen runs only for rows the user can already read.access: { resource: { feature, table } }— same gate, but the schema import + firewall alias are keyed off the pinnedtableinstead of the feature name. Required when a feature owns more than one resourceful table; the access gate emit can't pick which table's firewall to query otherwise.access: { resource: { feature, table, key } }— same gate, but it matches the roomId againsttable.<key>instead oftable.id. Use it when the room is addressed by a unique non-PK column — e.g. a Yjs editor keyed byevent_pages.yjsRoomId(ns.idFromName(row.yjsRoomId)).keydefaults to'id', so existing pins are unchanged. The compiler validates the column exists at compile time.keyalso works inside each multi-resource branch.- Multi-resource form — same gate but with a regex parser dispatching to per-kind firewall lookups. Unknown kinds 400 at the gate. Each
resources[kind].resourceaccepts either form (string shorthand or{ feature, table, key? }). access: { roomIdEquals: 'ctx.userId' }— an identity gate for rooms with no backing table, where the roomId is the caller's own id. Emitsapp.use('/realtime/<kebab>/*', …)that 401s anonymous, 400s a missing segment, and 403s unless the decoded room segment equalsctx.userId. No schema import, no firewall, no DB query — a string compare against the server-resolvedctx. Use'ctx.activeOrgId'for a per-org room. This is the right gate for per-user gateways, presence, or inbox DOs keyed onns.idFromName(ctx.userId). Only'ctx.userId'and'ctx.activeOrgId'are accepted (validated at compile time); anything else is a build error. Distinct fromresourcebecause there's no row to firewall — it closes the cross-user hole that "noaccessdeclared" leaves open (any authenticated user could otherwise open another user's room via the catchall).access: 'public'—app.use('/realtime/<kebab>/*', …)that just passes through. Use for anonymous game lobbies / public collab pads where the room itself is the unit of access.- No
accessdeclared — falls through to the catchall/realtime/*gate that just checksctx.authenticated. (v0.11.1 behavior.)
Compile-time validation: every resource / resources[].resource value is checked against the project's feature names so typos surface as build errors, not as runtime 400s on the first WS upgrade. The string shorthand requires the feature to have exactly one resourceful table; multi-table features must use the long form { feature, table } to pin a specific table. When key is set, the column is checked against the pinned table's schema.
Migrating a hand-rolled onConnect room to a declarative gate
If a room currently enforces access by hand inside onConnect / onBeforeConnect — typically because the room sits on a multi-table feature and an earlier compiler refused the ambiguous string shorthand — you can almost always replace it with a declarative access block. The decision is just "what is the roomId?":
- roomId is a table's row id →
access: { resource: { feature, table } }. - roomId is a unique non-PK column (a slug, a
yjsRoomId) → addkey:access: { resource: { feature, table, key: 'yjsRoomId' } }. - one DO class serves several room kinds (e.g.
conv-<id>/ticket-<id>) → the multi-resource form with apatternthat captures the kind and id. - access depends on membership, not just tenant (e.g. "is this user an attendee of the event that owns this page") → you do not need hand-written code. Declare the pinned table's firewall with a
via:relationship; the gate runs the table's fullbuildFirewallConditions, relationship subqueries included. - roomId is the caller's own identity (a per-user gateway/presence/inbox where the room is
ctx.userId, with no backing table) →access: { roomIdEquals: 'ctx.userId' }(or'ctx.activeOrgId'). Do not enforce this inside the DO from request data — a Durable Object has no session and only sees client-settable headers, so trusting anx-user-idheader lets any authenticated user open another user's room. The gate belongs at the Worker edge, which is exactly what this arm emits.
The declarative gate runs the exact firewall the CRUD GET /:id handler enforces, with the same 401/403 semantics, before the WebSocket upgrade — so by the time onConnect fires the caller is already authorized. Dropping the hand-rolled check also closes a common gap: a room with no access block is reachable by any authenticated user who can guess a room id, regardless of tenant.
Hand-written mount with onBeforeConnect
PartyServer also accepts an onBeforeConnect hook that runs before the WebSocket upgrade. Reject the upgrade by returning a Response; otherwise return undefined (or a modified Request) and the connection proceeds. Use this when you want a different auth shape than the default — for example, requiring ?ws_ticket= exclusively (and rejecting raw header / query api-keys), or running per-room checks at the gate instead of in onConnect:
import { partyserverMiddleware } from 'hono-party';
app.use('/realtime/*', partyserverMiddleware({
options: {
prefix: 'realtime',
onBeforeConnect: async (req) => {
const token = new URL(req.url).searchParams.get('ws_ticket');
if (!token || !await verifyTicket(token)) {
return new Response('Unauthorized', { status: 401 });
}
},
},
}));The matching ws_ticket issuer is the same one the Broadcaster already uses at /broadcast/v1/ws-ticket — generate a ticket from your authenticated UI, pass it as a query param on the WebSocket URL, and verify it inside onBeforeConnect. To use this hand-written form, declare a custom route at /realtime/* before the auto-mount runs (Hono dispatches the first matching middleware) — that lets you opt out of the default auth gate or layer per-room access checks at the upgrade step.
CRDT-grade document collab (Yjs)
Drop in Y-PartyServer when you need true concurrent text/document editing. It's a Server subclass that wraps Yjs's sync protocol; everything else above stays the same.
import { YServer } from 'y-partyserver';
export class DocumentRoom extends YServer {
// Yjs doc lives on `this.doc`; sync protocol is automatic.
// Override `onLoad()` / `onSave()` to persist to R2 / KV / your DB.
}Why this and not the Broadcaster
The Broadcaster is a one-DO-per-org fan-out. It can't shard by document, and it doesn't model client→client messages. PartyServer's per-room model is the right primitive for collab; the Broadcaster's per-org model is the right primitive for "tell everyone in this org that X happened." Run both.