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::Authenticated)
│
▼
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::Authenticated,
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.
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;
}
type EditorEventHandler = (
channel: string,
eventType: string,
data: unknown
) => void;
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 |
|---|---|
Authenticated | Any valid auth token (JWT, WidgetToken, OverlayToken). Use for non-sensitive metadata where RBAC is not required (e.g. sound library sync) |
Permission("resource:read") | Requires a specific RBAC permission on the account. Use for sensitive data visible only to team members with the right role |
WidgetToken | Only widget tokens. Use for channels tied to a specific widget instance |
OverlayToken | Only overlay tokens. Use for channels tied to a specific overlay |
Public | No authentication. Use only for publicly readable data (e.g. login assignment codes) |
Pair the gate with a channel_feature_for entry if the channel is behind a plan feature flag.
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 channels currently 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") | — |
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: | Authenticated | feature:sounds |