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 ConnectionContext, type WSMessage } from 'partyserver';
export class InterviewRoom extends Server {
static options = { hibernate: true };
presence = new Map<string, { userId?: string; name?: string }>();
onConnect(connection: Connection, ctx: ConnectionContext) {
// Verified by the Worker gate; never sent by the client (see "Who connected").
this.presence.set(connection.id, { userId: ctx.request.headers.get("x-quickback-user-id") ?? undefined });
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 an opaque ID that must be URL-safe as stored (letters, digits, -, _, ., ~, and : for the multi-resource form). The gates refuse a percent-encoded room segment with 404 rather than decode it: PartyServer names the Durable Object from the raw segment, so decoding would authorise one id (ab) while opening another (a%62). Generate room ids (and key column values) from a URL-safe alphabet; cuid2, nanoid and UUIDs all qualify.
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. A path with an empty segment (/realtime//interview-room/abc) is refused with 404 first, because PartyServer would route it to the same room while it matched none of the per-room gates below. 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 before the WebSocket upgrade — no hand-written code, same status semantics as GET /api/v1/<resource>/:id. Who may connect and who may write are declared on the binding itself with roles and writeRoles (Room roles); without them the gate falls back to the table's CRUD read.access.
Per-room authorization via access
// 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.
{
name: 'NotepadInteractions', className: 'NotepadInteractions', from: 'lib/NotepadInteractions',
access: { resource: { feature: 'interactions', table: 'interactions' } },
},
// Room keyed by a unique non-PK column. A Yjs page editor is
// addressed by `event_pages.yjsRoomId`, not the row id — `key` tells the
// gate which column the roomId matches. `roles` / `writeRoles` declare
// the room's own audience, independent of the table's CRUD read access.
{
name: 'PageRoom', className: 'PageRoom', from: 'lib/PageRoom',
access: {
resource: { feature: 'event-pages', table: 'eventPages', key: 'yjsRoomId' },
roles: ['admin', 'member'], // who may connect
writeRoles: ['admin'], // who may edit; members connect read-only
},
},
// 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, runs the role gate (the binding'sroles, or the table'sread.accesswhenrolesis omitted) before any query, 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 caller's firewall admits.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.
Every gate above compares the room segment exactly as it appears in the URL; a segment containing % is a 404 before any lookup (see Connect from the client).
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. The resource-backed access objects are strict: an unknown key (a misspelled writeRole) is a build error, never an ignored narrowing.
Room roles
A resource-backed room declares its own audience, independent of the backing table:
access: {
resource: { feature: 'event-pages', table: 'eventPages', key: 'yjsRoomId' },
roles: ['admin', 'member', 'scope:event:staff'], // connect (read)
writeRoles: ['admin', 'scope:event:staff'], // mutate
},rolesgates the WebSocket upgrade. When declared it replaces the table's CRUDread.accessin the gate, so you can narrow (or widen) the room without touching who may read the table. The table's firewall still runs: tenant isolation is not a role decision, and a caller still has to see the row.writeRolesgates mutations. Everyonerolesadmits may connect; callers outsidewriteRolesconnect read-only. A writer has to be able to connect, so list writers in both: when both are role lists, awriteRolesentry thatrolesdoesn't admit is a build error.
Both take the same role vocabulary as resource access: org roles, the role+ hierarchy, pseudo-roles (AUTHENTICATED, SESSION, SCOPED, SYSADMIN), scoped roles (scope:<kind>:<role>, or their sugar), and declared authz.roles. They're lowered by the same expansion step as read.access, so member+ means the same thing in a room and on a CRUD route. A role list is sugar for { roles: [...] }; the object form adds userRole and or / and:
roles: { or: [{ roles: ['admin'] }, { userRole: ['appmanager'] }] },Refused at compile time, all fail-closed: an empty list, PUBLIC (use access: 'public'), record: predicates (put row conditions in the firewall), and anything that lowers to a relationship: or fga: arm. roles / writeRoles are accepted only on the resource-backed forms — roomIdEquals and 'public' rooms have no row to firewall.
How writes are enforced. The gate evaluates writeRoles and forwards the verdict to the Durable Object as x-quickback-room-access: write | read (inbound copies are stripped; see Who connected). The compiler then re-exports the room class from src/index.ts through withRoomWriteAccess (src/lib/realtime-room-access.ts), which turns the verdict into a connection tag (tags survive hibernation) and refuses a reader's mutations:
| Room class | Reader connection |
|---|---|
Yjs (YServer / withYjs from y-partyserver 2.x) | isReadOnly(connection) is true: document updates are refused, sync and awareness keep working. Custom messages never reach onCustomMessage. |
| Any other PartyServer room | Messages never reach onMessage. |
Either kind, over HTTP (onRequest) | GET, HEAD and OPTIONS pass; any other method gets 403 unless the request carries the write verdict. |
To accept some reader messages (presence pings, cursors), define onReadOnlyMessage(connection, message) on the room; otherwise they're dropped. The HTTP counterpart is onReadOnlyRequest(request), which receives a reader's unsafe request instead of the 403. Anything other than an explicit write verdict is read-only, so a missing header fails closed. DO RPC methods called from an action are not affected; server code that calls the room with stub.fetch() directly (bypassing the Worker gate) sets x-quickback-room-access: write itself. The writer verdict is stored as a connection tag, so it uses one of PartyServer's tag slots: a write-gated room can return at most 8 tags of its own from getConnectionTags (10 per connection, including the connection id and the writer tag). A room that overrides isReadOnly keeps its own answer for writers; readers are always read-only. Use isRoomWriter(connection) from src/lib/realtime-room-access.ts if the room needs the verdict itself.
The room id is masked for non-writers. When a room with writeRoles is keyed by a non-PK column (key), that column is masked ('redact') for everyone outside writeRoles on every read path masking covers: GET /, GET /:id, views, and auto-CRUD realtime emits. Through those generated read surfaces, a caller cannot learn a room id it cannot write to. Values your own code returns or sends are not covered: an action that returns the row, or a hand-written broadcast that includes the column, needs its own projection. The binding owns that column's audience, so a hand-written masking.<key> on the table is a build error, and writeRoles must then be a plain role list (masking audiences can't express userRole or or / and). A reader who is not a writer therefore needs the room id delivered some other way; rooms keyed by the row id (no key) mask nothing, because the id is the row's identity on every surface.
The backing table declares nothing about the room — no masking on yjsRoomId, and its read.access can stay as wide as CRUD needs:
// quickback/features/event-pages/event-pages.ts
import { feature, q } from "@quickback/compiler";
export default feature("eventPages", {
columns: {
id: q.id(),
title: q.text().required(),
yjsRoomId: q.text().required().unique(),
organizationId: q.scope("organization"),
...q.audit(),
...q.softDelete(),
},
read: { access: { roles: ["member", "admin"] } },
create: { access: { roles: ["admin"] } },
update: { access: { roles: ["admin"] } },
});Omitting them warns. A resource-backed room with no roles (gated on the table's CRUD read.access) or no writeRoles (every admitted caller may write) compiles with a warning:
[quickback:authz] AUTHZ_ROOM_ACCESS_INHERITED: realtime room "PageRoom" declares no access.roles or access.writeRoles: …See Diagnostics.
Who connected
Every upgrade that passes the gate reaches the Durable Object with the caller's server-resolved identity stamped on the request. The Worker strips any inbound copy of these headers first, so a client cannot forge them:
| Header | Value |
|---|---|
x-quickback-user-id | ctx.userId |
x-quickback-org-id | ctx.activeOrgId, when the session has an active organization |
x-quickback-roles | ctx.roles joined with , |
x-quickback-room-access | write or read — only on rooms whose binding declares writeRoles (Room roles) |
Read them in onConnect(connection, ctx) or onBeforeConnect(request) from ctx.request.headers / request.headers. A display name or avatar is not stamped; look it up from the user id if the room needs one.
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 client-sent request data — a Durable Object has no session, so trusting a client'sx-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 Worker then stamps the verified identity for the room to read (see "Who connected").
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.
Add y-partyserver and yjs to build.dependencies alongside partyserver. The cloud compiler image ships all three — plus y-prosemirror and the prosemirror-* packages for rich-text docs — so hosted complete compiles can bundle-check the room.
import { YServer } from 'y-partyserver';
import * as Y from 'yjs';
export class DocumentRoom extends YServer {
static options = { hibernate: true };
// The Yjs doc lives on `this.document` (a Y.Doc). There is no `this.doc` —
// reading it returns undefined and throws on first use.
// Sync protocol is automatic; override `onLoad()` / `onSave()` to persist.
async onSave() {
await this.ctx.storage.put('doc', Y.encodeStateAsUpdate(this.document));
}
async onLoad() {
const saved = await this.ctx.storage.get<Uint8Array>('doc');
if (saved) Y.applyUpdate(this.document, saved);
}
}With writeRoles on the binding, readers of a YServer room are read-only through isReadOnly with no code in the class (Room roles).
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.