# 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](/stack/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' } },
    },

    // 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`:

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