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.version— contract-v2 projects mount the same surface at/broadcast/v2(no hidden v1 alias). 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.)
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 (contract v2) + AsyncAPI (any contract)
On the v2 contract, realtime data frames are delivered as CloudEvents 1.0
envelopes on /broadcast/v2 (control frames stay raw). 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). The document is
contract-version-independent: it is emitted for any project with realtime
or webhooks (v1 included); only the message payload schemas are shaped by
the pinned contract — raw frames on v1, CloudEvents envelopes on v2.
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.