Quickback Docs

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>
DirectionServer → clientsClient ↔ clients
ShardingOne DO per orgOne DO per room
Use casePostgres-changes, app-wide eventsLive cursors, shared edits, presence
CatchupEphemeralPer-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_ROOMinterview-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-key header
  • ?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' } },
    },

    // 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, queries chats with the resource's buildFirewallConditions(ctx) and eq(chats.id, rowId), and 403s when the row is invisible to the caller (out-of-org, soft-deleted, etc.). The DO's onLoad / onConnect then 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 pinned table instead 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.
  • 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].resource accepts either form (string shorthand or { feature, table }).
  • 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 access declared — falls through to the catchall /realtime/* gate that just checks ctx.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.

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.

On this page