# PartyServer Rooms

When you need **client↔client** state — presence, live cursors, shared notes, concurrent editing — Quickback exposes [PartyServer](https://github.com/cloudflare/partykit) 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](/platform/realtime/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:

```ts
// 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.

```ts
// 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.

```ts
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+)

```ts
// 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, 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.
- **`access: { resource: { feature, table, key } }`** — same gate, but it matches the roomId against `table.<key>` instead of `table.id`. Use it when the room is addressed by a unique non-PK column — e.g. a Yjs editor keyed by `event_pages.yjsRoomId` (`ns.idFromName(row.yjsRoomId)`). `key` defaults to `'id'`, so existing pins are unchanged. The compiler validates the column exists at compile time. `key` also 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].resource` accepts 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. Emits `app.use('/realtime/<kebab>/*', …)` that 401s anonymous, 400s a missing segment, and 403s unless the decoded room segment equals `ctx.userId`. No schema import, no firewall, no DB query — a string compare against the server-resolved `ctx`. Use `'ctx.activeOrgId'` for a per-org room. This is the right gate for per-user gateways, presence, or inbox DOs keyed on `ns.idFromName(ctx.userId)`. Only `'ctx.userId'` and `'ctx.activeOrgId'` are accepted (validated at compile time); anything else is a build error. Distinct from `resource` because there's no row to firewall — it closes the cross-user hole that "no `access` declared" 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 `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. 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?":

1. **roomId is a table's row id** → `access: { resource: { feature, table } }`.
2. **roomId is a unique non-PK column** (a slug, a `yjsRoomId`) → add `key`: `access: { resource: { feature, table, key: 'yjsRoomId' } }`.
3. **one DO class serves several room kinds** (e.g. `conv-<id>` / `ticket-<id>`) → the multi-resource form with a `pattern` that captures the kind and id.
4. **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 *full* `buildFirewallConditions`, relationship subqueries included.
5. **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 an `x-user-id` header 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`:

```ts
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`](https://github.com/cloudflare/partykit) 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.

```ts
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.
