Realtime WebSocket Implementation
This document describes how to complete the WebSocket integration in the current codebase. The repository already has the initial foundation:
- Upgrade route:
apps/cms/src/routes/realtime.ts - Durable Object hub:
apps/cms/src/realtime/site-room.ts - Publish path from item mutations:
apps/cms/src/services/item-service.ts - SDK client:
packages/sdk/src/realtime/index.ts
The remaining implementation goal is to add explicit enablement, collection-level opt-in, complete permission checks and operator-facing docs.
Configuration decision
Do not choose only env or only DB settings. Use three layers:
- Env/binding: infrastructure capability and kill switch.
- Site setting: site-level enablement and runtime limits.
- Collection meta: explicit collection opt-in.
Check order:
SITE_ROOM binding exists
-> LUMIBASE_REALTIME_ENABLED !== "false"
-> settings["realtime.enabled"].value.enabled === true
-> collections.meta.realtime.enabled === true
-> user has read permission on collection
If any step fails, the server returns a clear error and does not open the subscription.
Data contract
Env
Add to apps/cms/src/env.ts:
LUMIBASE_REALTIME_ENABLED?: string;
Meaning:
false: disables all realtime for the deployment.- unset or
true: allows realtime if the site setting is enabled.
Site setting
Key: realtime.enabled
Value:
type RealtimeSiteSetting = {
enabled: boolean;
maxConnectionsPerUser?: number;
maxSubscriptionsPerConnection?: number;
heartbeatSeconds?: number;
idleTimeoutSeconds?: number;
};
Runtime defaults:
{
enabled: false,
maxConnectionsPerUser: 5,
maxSubscriptionsPerConnection: 50,
heartbeatSeconds: 30,
idleTimeoutSeconds: 90
}
Collection meta
No new migration is required because collections.meta is already JSONB.
type CollectionRealtimeMeta = {
realtime?: {
enabled?: boolean;
events?: Array<'create' | 'update' | 'delete'>;
presence?: boolean;
};
};
Default: disabled.
Backend implementation
1. Add a realtime config service
Create a small helper, for example apps/cms/src/services/realtime-config.ts:
getRealtimeSiteConfig(db, siteId)isRealtimeDeployEnabled(env)isCollectionRealtimeEnabled(collection)assertCollectionRealtimeEnabled(db, siteId, collectionName, action?)
This helper reads the settings table and collections.meta. It should not check user permissions by itself.
2. Update /api/v1/realtime
In apps/cms/src/routes/realtime.ts:
- If
Upgradeis not websocket, return status JSON includingenabledand a disabled reason when applicable. - Before forwarding to the Durable Object, check:
SITE_ROOMbinding exists.- Env kill switch is not disabled.
- Site setting is enabled.
- Do not read
siteIdfrom the query string. Usec.get('siteId'). - Pass the required internal config to the Durable Object through internal query params or headers.
Suggested errors:
| Code | HTTP/WS close | When |
|---|---|---|
REALTIME_NOT_AVAILABLE | HTTP 501 | Missing SITE_ROOM binding |
REALTIME_DISABLED | HTTP 403 | Env or site setting disabled |
UNAUTHORIZED | HTTP 401 | Invalid token |
3. Update SiteRoom
In apps/cms/src/realtime/site-room.ts:
- Store
siteId,userIdand subscription limits on the session. - When receiving
subscribe, call an internal route/service or receive an allowlist from the Worker to check:- collection realtime opt-in
readpermission- subscription count limit
- Send acknowledgement:
{ "type": "subscribed", "collection": "posts" }
- Send errors:
{ "type": "error", "code": "COLLECTION_REALTIME_DISABLED", "message": "Realtime is disabled for this collection." }
The Durable Object should avoid querying the database when possible. The Worker route has better DB/runtime context; the DO should remain the connection and fan-out hub.
4. Update ItemService.publishRealtimeEvent
In apps/cms/src/services/item-service.ts:
- Before publishing, check:
- deployment realtime is enabled
- site setting is enabled
- collection meta is enabled
- action is included in
meta.realtime.eventsif specified
- Use the same shard key as the realtime route. The route currently supports multi-region shard keys, while publish uses
idFromName(siteId). Move DO resolution into a shared helper so connections and publish requests reach the same room. - Publish after the DB commit and after data has been decrypted/masked as appropriate.
Realtime errors must never fail the primary mutation.
5. Permissions and field masks
The MVP can check read permission on subscribe. The complete version needs to mask payloads per subscriber before delivery:
- Load the subscriber permission.
- Re-evaluate the row rule against the item after mutation.
- If it no longer matches, send a virtual
delete. - If it matches, mask fields by permission and send the
event.
Because the Durable Object does not have convenient DB context, there are two paths:
| Path | Pros | Cons |
|---|---|---|
| Worker pre-computes payloads by permission group and publishes multiple envelopes | Simple DO, no DB query inside DO | Requires grouping subscribers by permission fingerprint |
| DO calls a Worker internal API to authorize each event | Accurate and easy to reason about | More roundtrips and latency |
Recommended Phase 1: check permission on subscribe and only publish payloads that have passed basic service masking. Phase 2: permission-aware fan-out per subscriber.
6. Studio UI
Add UI in the Data Model collection detail:
Realtime enabledtoggle.- Event checkboxes: create, update, delete.
Presence enabledtoggle.
Use PATCH /api/v1/collections/:name and merge meta.realtime; do not overwrite unrelated meta keys.
Add a settings page section:
- Site
realtime.enabledtoggle. maxConnectionsPerUsernumber input.maxSubscriptionsPerConnectionnumber input.
Test plan
Backend tests:
GET /api/v1/realtimewithout upgrade returns health JSON.- Missing
SITE_ROOMreturnsREALTIME_NOT_AVAILABLE. - Env kill switch returns
REALTIME_DISABLED. - Disabled site setting returns
REALTIME_DISABLED. - Subscribing to a collection without opt-in returns
COLLECTION_REALTIME_DISABLED. - Subscribing to an opted-in collection without read access returns
FORBIDDEN. - Mutation for a collection without opt-in does not call DO publish.
- Mutation for an opted-in collection uses the correct DO shard key.
SDK tests:
- Reconnect restores subscriptions.
pingsendspong.subscribe()returns an unsubscribe function.- Presence listener receives the expected payload shape.
Manual verification:
- Deploy Worker with
SITE_ROOM. - Enable
realtime.enabledfor the site. - Enable
meta.realtime.enabledfor thepostscollection. - Open two SDK clients.
- Client A subscribes to
posts. - Client B updates an item.
- Client A receives the correct action/itemId event.
- Disable collection realtime and confirm new subscriptions are rejected.
Rollout
- Ship env kill switch with a safe default.
- Ship site setting default disabled.
- Ship collection opt-in UI.
- Enable staging for one non-sensitive collection.
- Watch connection count, error rate and DO logs.
- Enable production per site and collection.
Open items
Docker realtime adapter does not exist yet— shipped inrealtime-audience-channels(see below).Publish and connect need a shared shard resolver— shipped asresolveRoomName()inapps/cms/src/realtime/shard-config.ts.- Permission-aware per-subscriber payload masking should be a Phase 2 task if Phase 1 needs to ship quickly.
Audience plane (end-user frontend realtime)
The sections above cover the studio plane — admin users (users table) subscribing by collection. The realtime-audience-channels spec (.kiro/specs/realtime-audience-channels/) adds a second, isolated audience plane for frontend end-users who live in an app-owned table (keyed by e.g. a citizenID), not users.
Key pieces:
- Protocol (
@lumibase/sharedrealtime/protocol.ts): the sharedlumibase-sync-v1Zod schema, extended withjoin/leaveframes, anotificationframe,welcome.plane, and a targetedRealtimeEventenvelope{ plane, target: { userId | subjectId | channel } }. Studio frames are byte-compatible. - Runtime provider (
RealtimeProvider, ADR-002):runtime.realtime.publish(siteId, event). Cloudflare forwards to theSiteRoomDO; Docker publishes into an in-process hub backing awsserver (apps/cms/src/realtime/node-hub.ts) attached inserve.ts. Multi-node Docker (PostgresLISTEN/NOTIFYor Redis) is future work. - Fan-out (
apps/cms/src/realtime/fan-out.ts,shouldDeliver): plane → target (userId/subjectId/channel) → collection subscription. Studio-only skip-echo. Strict plane isolation. - Tickets (
POST /api/v1/realtime/audience-ticket): authz lives at the route viaresolveAudienceGrant— it maps the authenticated frontend principal to asubjectIdand a channel allowlist from verified claims. The signed ticket carriesplane:'public',subjectId,channels; the DO/hub trusts these and never reads identity from the client. - Notifications (
NotificationService): persists anotificationsrow and fans it out to the recipient's sessions (userIdfor studio,subjectIdfor public), flipspushed, and exposeslistUndelivered/markPushedfor reconnect replay. - SDK (
AudienceClient): fetches the audience ticket, joins/leaves channels, surfaces channel events + notifications, reconnects with backoff and re-joins channels. - Scale: per-session rate-limit/heartbeat/idle apply to both planes;
LUMIBASE_REALTIME_MAX_CONNECTIONS_PER_SUBJECTcaps per-subject sessions;resolveRoomName/subjectBucketsupport audience bucket sharding (v1 defaults to a single{siteId}:audroom).
Design rationale: the end-user table being separate from users is intentional and correct. The fix for "realtime can't filter end-users" is a logical subject address decoupled from the user source, resolved server-side from a verified ticket — not merging tables.