Editor Live Sync
The editor live sync system keeps widget and overlay editors in real-time sync across all connected sessions. When a mutation happens (upload, update, delete), every open editor tab sees the change immediately — without polling.
The system is built on top of the existing WebSocket + Redis pub/sub infrastructure. No additional services are required.
Architecture
Mutation (REST or GraphQL handler)
│
▼
Redis pub/sub publish
"lumio:sounds:{account_id}" ←─ channel helper in lo-cache
│
▼
Event Relay Worker (apps/api/src/workers/event_relay.rs)
PSUBSCRIBE "lumio:sounds:*" → forwards to WsServer
│
▼
WebSocket channel "sounds:{account_id}"
(gated by gate.rs: ChannelGate::AccountScoped + feature:sounds)
│
▼
useEditorPresence hook (apps/web/src/hooks/use-editor-presence.ts)
extraChannels: ["sounds:{accountId}"]
onEvent: (channel, eventType, data) => { ... }
│
▼
Version counter incremented
│
▼
Child component re-fetches data
Key files
| File | Role |
|---|---|
crates/lo-cache/src/pubsub.rs | Channel name helpers (channels::sounds, channels::events, …) |
apps/api/src/workers/event_relay.rs | Redis PSUBSCRIBE → WsServer relay; CHANNEL_MAPPINGS table |
crates/lo-websocket/src/gate.rs | channel_gate_for and channel_feature_for; controls who may subscribe |
apps/web/src/hooks/use-editor-presence.ts | Frontend WebSocket hook; extraChannels + onEvent API |
Data Flow in Detail
1. Backend publishes an event
After any mutation, the REST and GraphQL handlers call publish_json with the structured payload:
let channel = lo_cache::pubsub::channels::sounds(&account_id.to_string());
let _ = state.pubsub.publish_json(&channel, &serde_json::json!({
"event_type": "sound:list:updated",
"data": { "action": "created", "sound_id": row.id }
})).await;
The publish must happen in both the REST handler (routes/sounds.rs) and the GraphQL handler (graphql/sounds.rs). This is required by the GraphQL/REST parity rule — a mutation on either protocol must produce identical side-effects.
2. Event Relay Worker forwards to WebSocket
The relay worker runs a PSUBSCRIBE loop over all entries in CHANNEL_MAPPINGS:
const CHANNEL_MAPPINGS: &[(&str, &str)] = &[
("lumio:sounds:*", "sounds:"),
("lumio:events:*", "events:"),
// ...
];
When a message arrives on lumio:sounds:{account_id}, the worker maps it to the WsServer channel sounds:{account_id} and wraps it in the standard WebSocket envelope:
{
"type": "event",
"channel": "sounds:{account_id}",
"event_type": "sound:list:updated",
"data": { "action": "created", "sound_id": "..." }
}
The event_type and data fields are extracted from the Redis payload before wrapping. This is the same envelope format used by overlay channels.
3. Channel gate controls who can subscribe
channel_gate_for in gate.rs determines the authentication requirement for each channel type:
"sounds" => ChannelGate::AccountScoped,
AccountScoped — not Authenticated — because Widget and Overlay tokens carry an account but no account-level RBAC, and must still reach only their own account's sound events.
channel_feature_for adds an optional feature-flag check:
"sounds" => Some("feature:sounds"),
Subscriptions are rejected if the gate fails or the feature flag is disabled. An unregistered channel type falls through to ChannelGate::Unknown and is always rejected — a new channel that is not added to both functions simply does not work.
4. Frontend subscribes via useEditorPresence
The widget editor subscribes to extra channels alongside the presence channel:
const [soundsVersion, setSoundsVersion] = useState(0);
const handleEditorEvent = useCallback(
(_channel: string, eventType: string) => {
if (eventType === "sound:list:updated") {
setSoundsVersion((v) => v + 1);
}
},
[]
);
const presence = useEditorPresence({
resourceChannel: `widget:${initial.id}`,
extraChannels: [`sounds:${accountId}`],
onEvent: handleEditorEvent,
wsUrl,
wsToken,
});
The onEvent callback receives every event from every extra channel. Filter on eventType to handle only the events your component cares about.
5. Child components react to version changes
Components that display the synced data receive version as a prop and re-fetch when it changes:
useEffect(() => {
fetchSounds(0, search, false);
}, [version]);
Using a version counter rather than updating state directly from the event avoids stale-data and race-condition issues: the refetch always loads fresh data from the server.
useEditorPresence API
interface UseEditorPresenceOptions {
resourceChannel: string; // Primary presence channel (e.g. "widget:{id}")
extraChannels?: string[]; // Additional channels to subscribe for data sync
onEvent?: EditorEventHandler; // Called for every event on extra channels
wsUrl?: string;
wsToken?: string;
enabled?: boolean;
}
export type EditorEventHandler = (
channel: string,
eventType: string,
data: unknown
) => void;
interface UseEditorPresenceResult {
connections: PresenceConnection[];
isConnected: boolean;
}
Implementation notes:
extraChannelsandonEventare excluded from theuseEffectdependency array on purpose. Adding them would cause WebSocket reconnects every render cycle when callbacks are recreated. WraponEventinuseCallbackwith a stable dependency list.- The hook maintains a single WebSocket connection shared between presence tracking and data channels. No extra connections are created for
extraChannels.
Event naming convention
| Pattern | Meaning |
|---|---|
{resource}:list:updated | The collection changed (item created, updated, or deleted) |
{resource}:{action} | A specific action on a single resource |
The data field carries action ("created" | "updated" | "deleted") and the affected resource ID. Examples:
sound:list:updated— sound library changed; subscribers should refresh the listsound:play— a sound was triggered for playbacksound:stop— playback was stopped
Channel gate types
When adding a new channel, choose the appropriate gate in gate.rs:
| Gate | When to use |
|---|---|
Public | No authentication. Only for publicly readable data (e.g. login assignment codes) |
PublicWhenFeature("system:flag") | Public while the named system flag is on; rejects with FEATURE_DISABLED when off. The channel id is not parsed as an account UUID |
Authenticated | Any authenticated identity (not Anonymous). Note this does not check the account in the channel name — do not use it for account-scoped data |
AccountScoped | Caller must be able to access the account id encoded in the channel name, but needs no specific permission. This is the right gate for account-scoped channels that Widget/Overlay tokens must reach, since those carry an account but no account-level RBAC |
Permission("resource:read") | Account access plus the named account permission. Use for sensitive data visible only to team members with the right role |
UserPermission("resource:read") | The named permission in the caller's user-scoped permissions; the channel id is not an account UUID |
WidgetToken | Only widget tokens whose widget_id matches the channel |
OverlayToken | Only overlay tokens whose overlay_id matches the resolved overlay |
ExtensionOwner | Only the developer who owns the extension in the channel name |
Unknown | The fallthrough — an unregistered channel type is always rejected |
Authenticated is a common mis-pick for account-scoped data: any authenticated identity could subscribe to another account's channel. sounds was moved to AccountScoped for exactly that reason.
Pair the gate with a channel_feature_for entry if the channel is behind a plan feature flag. Keep that mapping conservative — gating a Free-tier channel by mistake bricks the dashboard for Free users; when in doubt return None and rely on the per-resource feature checks in the REST/GraphQL handlers.
Step-by-step: adding a new sync channel
Follow these steps to add real-time sync for a new resource type.
Step 1 — Add a channel helper
In crates/lo-cache/src/pubsub.rs, add a helper to the channels module:
/// My resource events: `lumio:my-resource:{account_id}`
pub fn my_resource(account_id: &str) -> String {
format!("lumio:my-resource:{}", account_id)
}
Step 2 — Register the relay mapping
In apps/api/src/workers/event_relay.rs, add an entry to CHANNEL_MAPPINGS:
const CHANNEL_MAPPINGS: &[(&str, &str)] = &[
// existing entries ...
("lumio:my-resource:*", "my-resource:"),
];
If the payload format is { "event_type": "...", "data": { ... } }, add a matching branch in run_relay_loop to extract event_type and data and re-wrap in the standard envelope. See the lumio:sounds:* branch as the reference implementation.
Step 3 — Add a channel gate
In crates/lo-websocket/src/gate.rs, add the channel type to both match arms:
// channel_gate_for
"my-resource" => ChannelGate::Authenticated, // or Permission("my-resource:read")
// channel_feature_for
"my-resource" => Some("feature:my_resource"), // omit if no feature flag
Step 4 — Broadcast from your handlers
In your REST handler (apps/api/src/routes/my_resource.rs) and GraphQL handler (apps/api/src/graphql/my_resource.rs), publish after every mutation:
let channel = lo_cache::pubsub::channels::my_resource(&account_id.to_string());
let _ = state.pubsub.publish_json(&channel, &serde_json::json!({
"event_type": "my-resource:list:updated",
"data": { "action": "created", "id": row.id }
})).await;
Do this in both REST and GraphQL handlers. Skipping one creates a parity gap where mutations via one protocol are not reflected in open editors.
Step 5 — Subscribe in the editor hook
In the editor component (widget-editor.tsx or overlay-editor.tsx), add the extra channel subscription:
const [myResourceVersion, setMyResourceVersion] = useState(0);
const handleEditorEvent = useCallback(
(_channel: string, eventType: string) => {
if (eventType === "my-resource:list:updated") {
setMyResourceVersion((v) => v + 1);
}
},
[]
);
const presence = useEditorPresence({
resourceChannel: `widget:${initial.id}`,
extraChannels: [`my-resource:${accountId}`],
onEvent: handleEditorEvent,
wsUrl,
wsToken,
});
Step 6 — Pass version to child components
Pass the version counter down to any component that displays the synced data:
<MyResourceContent
accountId={accountId}
version={myResourceVersion}
/>
Step 7 — Refetch on version change
In the child component, watch version with a useEffect:
useEffect(() => {
fetchMyResources(0, search, false);
}, [version]);
What NOT to use for sync
The extra field in WindowState (managed by getWindowExtra / setWindowExtra in shared/ui/src/floating-window.tsx) is stored in localStorage. It is designed for persisting UI preferences across page reloads (e.g. volume level, filter state, column widths) — not for propagating data changes between users or tabs.
For anything that must be reflected on another user's screen, use the pub/sub + WebSocket channel pattern described above.
All registered channels
The full set of Redis patterns registered in CHANNEL_MAPPINGS:
| Redis pattern | WsServer channel prefix | Gate | Feature flag |
|---|---|---|---|
lumio:events:* | events: | Permission("events:read") | — |
lumio:spotify:* | spotify: | Permission("spotify:read") | — |
lumio:chat:* | chat: | Permission("chat:read") | feature:multichat |
lumio:automations:* | automations: | Permission("automations:read") | feature:automation |
lumio:overlay:* | overlay: | OverlayToken | — |
lumio:widget:* | widget: | WidgetToken | feature:widgets |
lumio:ext-storage:* | ext-storage: | Permission("extension-store:read") | feature:extensions |
lumio:sounds:* | sounds: | AccountScoped | feature:sounds |
lumio:history:* | history: | Permission("history:read") | feature:stream_history |
gate.rs knows more channel types than the relay maps — these are subscribable but are broadcast to from inside the API process rather than relayed from a Redis pattern:
| Channel type | Gate | Feature flag |
|---|---|---|
presence | Authenticated | — |
login-assignments | Public | — |
ideas | PublicWhenFeature("system:ideas_hub_public") | system:ideas_hub |
ext-logs | ExtensionOwner | feature:extension_development |
ext-install-logs | Permission("bot-modules:read") | feature:bot_module_extensions |