Realtime
Real-time updates via WebSockets with Cloudflare Durable Objects.
Quickback ships two parallel realtime primitives, both backed by Cloudflare Durable Objects:
/broadcast/v1/*— server fan-out. One DO instance per subscription scope. Postgres-changes events from CRUD routes plus custom broadcast events flow out to every subscriber, with role filtering and per-role field masking applied. Best for "tell every client in this shared scope that something changed." The scope can be an organization, a specific user, or a compiler-resolved resource key likeevents:evt_123. The/broadcast/v1version segment trackscontract.routes— set it to"v2"and the same surface mounts at/broadcast/v2(no hidden v1 alias). Framing is independent of the path: frames are CloudEvents 1.0 envelopes regardless of which prefix you serve them on. Documented on this page. For typed, targeted "this changed — refresh" signals fired from an action after commit (with optional per-recipient narrowing), see Named invalidations./realtime/<binding>/<room-id>— bidirectional rooms. One DO instance per room (per document, per interview, per game). Client↔client messages, presence, and CRDT-friendly state. Use these for collaborative editing. See PartyServer rooms.
Both share the same auth model and ride on the same compiled worker; they solve different problems.
Architecture
┌──────────────────────────────────────────┐
│ Quickback Worker │
│ │
│ ┌──────────┐ DO binding ┌────────────────────┐
│ │ Hono API │ ──────────────► │ Broadcaster (DO) │
│ │ │ │ WebSocket manager │
│ └──────────┘ └─────────┬──────────┘
│ │
└─────────────────────────────────────────┼┘
│ WebSocket
│
┌────────▼─────────┐
│ Browser Clients │
│ (CMS, Account, │
│ Admin, Custom) │
└──────────────────┘The Broadcaster Durable Object runs inline in your main Quickback worker — no separate worker deployment needed. The API calls the DO directly via its binding (in-process, no network hop).
- Quickback Worker — Your compiled API with the Broadcaster DO class exported from the same worker.
- Broadcaster (Durable Object) — Manages WebSocket connections per subscription scope. One instance per
scopeKey, organization, or user lane. - Browser Clients — CMS, Account, Admin, and custom frontends connect via WebSocket on the same origin.
Key Features
| Feature | Description |
|---|---|
| Scope-aware fan-out | Routes by scopeKey, organizationId, or userId |
| Role-based filtering | Only send events to users with matching roles |
| Per-role masking | Different users see different field values based on their role |
| User-specific targeting | Send events to a specific user within an org |
| Resource-scoped tickets | Mint scope-bound ws tickets gated by org-membership roles, authz relationship roles, or FGA relations |
| Custom broadcasts | Arbitrary events beyond CRUD |
| Custom namespaces | defineRealtime() for type-safe event helpers |
| Ticket-based auth | HMAC-signed tickets verified at WebSocket upgrade — no HTTP round-trip |
Enabling Realtime
Broadcasting is opt-in per resource, exactly like CRUD — a table broadcasts
only if it declares its own realtime block. Add realtime to individual table
definitions:
// quickback/features/applications/applications.ts
import { feature, q } from "@quickback/compiler";
export default feature("applications", {
columns: {
id: q.id(),
candidateId: q.text().required(),
stage: q.text().required(),
organizationId: q.scope("organization"),
...q.audit(),
...q.softDelete(),
},
firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }],
realtime: {
enabled: true,
onInsert: true,
onUpdate: true,
onDelete: true,
// Audience — WHO may receive these live rows. Same { roles } shape as
// read.access / crud.*.access. MANDATORY and fail-closed: a broadcast
// reaches every subscriber in the room, so the audience is not optional.
// Omit it (or leave it empty) and compilation fails. Roles may be concrete
// membership/scope roles or UPPERCASE pseudo-roles.
access: { roles: ["recruiter", "hiring-manager"] },
fields: ["id", "candidateId", "stage"],
},
});There is no implicit fanout. A resource with a broadcasting realtime block
must declare a non-empty access.roles, and an empty audience delivers to no
one. To broadcast to everyone in the room, say so deliberately with
access: { roles: ["PUBLIC"] }. (requiredRoles is the deprecated spelling of
access.roles — it still compiles but should be migrated.)
Omitting it fails the compile:
Resource "conversations" enables realtime broadcasts but declares no audience.
A broadcast fans out to every subscriber in the room, so the audience is not
optional. Declare realtime.access: { roles: [...] } naming who may receive it
(use roles: ['PUBLIC'] to broadcast to everyone in the room, deliberately).The audience is a second read surface, and it does not inherit read.access.
Whatever gates the REST read must gate the broadcast too, or the live channel
publishes rows the REST route would have withheld — private messages being the
usual casualty. Copy the read rule across, and where the read is gated by a
relationship (participants of a conversation, accepted followers), use
access: { authzRole: "<relationship-role>" } here rather than falling back to
roles: ["AUTHENTICATED"], which reaches every signed-in subscriber.
And enable the realtime transport in your database config. This is a
project-level switch that stands up the Broadcaster Durable Object + the
connection route; it does not by itself make any resource broadcast
(that's the per-resource realtime block above). The compiler generates the
Durable Object class, helper functions, and wrangler bindings — all within
your main worker.
For most apps — including org-scoped apps like chat, feeds, and dashboards —
the boolean form is all you need. Session-authenticated members subscribe
directly; audiences come from each resource's realtime.access.roles:
providers: {
database: defineDatabase("cloudflare-d1", {
realtime: true,
}),
}The object form is the advanced variant, only needed when you mint
resource-scoped tickets (e.g. an external attendee who may join one event's
channel, or an org member who may join one project's room). The mint gate is
declared with wsTicket.access, a discriminated union — the arm names the
authorization vocabulary, so a bare name can never silently mean two things:
providers: {
database: defineDatabase("cloudflare-d1", {
realtime: {
wsTicket: {
// Pick EXACTLY ONE arm:
access: { roles: ["member+"] }, // org-membership roles (hierarchy expands)
// access: { authzRole: "attendee" }, // authz relationship role
// access: { fga: { relation: "viewer", object: "event:{id}" } }, // FGA relation on the scope row
requestField: "eventId",
scopeTable: "events",
},
},
}),
}{ roles }— org-membership roles."member+"expands viaauth.roleHierarchy(compile error without it). Firewall-consistent: the route verifies the caller's org role and that the requestedscopeTablerow belongs to the caller's active organization — an org-A member cannot mint a ticket to org B's room. The scope table must therefore carry an organization column (compile error otherwise). UPPERCASE pseudo-roles are rejected here: aPUBLIC/AUTHENTICATEDticket factory would hand signed room credentials to callers with no org standing.{ authzRole }— a declaredauthz.rolesrelationship role ({ via }). The route probes the relationship table againstbody.eventId; the ticket carries the relationship role (e.g.attendee). The legacyrole: "attendee"string is the back-compat shorthand for this arm and compiles identically. Org-membership names (member/admin/owner) are still rejected in this arm — spell thoseaccess: { roles: [...] }.{ fga }— an FGA relation. The route loads thescopeTablerow through its own firewall (tenant bind), derives the object from the template ("event:{id}"—{field}tokens read from the row, the same template language as the read path'saccess: { fga }), and requires the caller to holdrelationon it via the same org-scoped check evaluator the read path uses. Requiresauthz.fga; the object type and relation are validated against the model at compile time.
Whichever arm you pick, /broadcast/v1/ws-ticket authenticates the caller,
runs that gate against body.eventId, and mints a short-lived ticket scoped to
the resolved events:<id> channel. Everything fails closed at compile time:
a bare name that exists in more than one vocabulary is an ambiguity error
demanding the explicit arm, and a missing hierarchy, missing FGA model, or
missing organization column fails the build with an actionable message.
Pages
- Durable Objects Setup — Broadcaster configuration, wrangler bindings, event formats, masking, and custom namespaces
- Using Realtime — Subscribing to
/broadcast/v1/*: WebSocket connection, ticket auth, client-side handling - Named Invalidations — Typed, targeted "this changed — refresh" signals fired from actions after commit, with optional per-recipient narrowing
- Live Views — Keep an open view's full join-surface (root + related children) live with hybrid-delta updates and resync-on-reconnect
- PartyServer Rooms — Per-room collab via
/realtime/<binding>/<room-id>: presence, live notes, room IDs
There are three declarative emission surfaces — pick by what did the write:
| The write is… | Emit with | Where |
|---|---|---|
| A CRUD row change | the table's realtime block (postgres_changes frames) | this page |
| A custom action | invalidates: on the action | Named invalidations |
An aggregate changeset (owns) | afterCommit on the aggregate root — one CloudEvent after commit | changesets → afterCommit |
CloudEvents + AsyncAPI
Realtime data frames are delivered as CloudEvents 1.0 envelopes (control
frames stay raw), on whatever path contract.routes mounts. Match frames on
the qbframe extension, never on type. See
Using Realtime
for the frame mapping.
The project's event surface — realtime channels, named-invalidation events,
and outbound webhook messages — is published as an AsyncAPI 3.0 document at
GET /asyncapi.json (same auth gating as /openapi.json), for any project
with realtime or webhooks.
Realtime emit from actions
Tables that lock auto-CRUD but mutate through custom actions no longer need
hand-written realtime.insert/update/delete blocks. Opt the table in:
realtime: {
enabled: true,
access: { roles: ["member+"] },
scopeKeyFrom: "events:{eventId}", // sugar for scopeTable + scopeField
emitFromActions: true, // scoped-db writes auto-emit after commit
}Writes to the table through the scoped db inside record-bound and bulk
actions emit frames byte-compatible with auto-CRUD (same envelope, masking,
audience), after commit, best-effort. For projections, standalone actions, or
cross-feature targets, declare an action-level emit: to get a pre-bound
emitCrud(row, oldRow?) helper. Full details and the capture caveats live in
the realtime docs.