Skip to main content

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

FileRole
crates/lo-cache/src/pubsub.rsChannel name helpers (channels::sounds, channels::events, …)
apps/api/src/workers/event_relay.rsRedis PSUBSCRIBE → WsServer relay; CHANNEL_MAPPINGS table
crates/lo-websocket/src/gate.rschannel_gate_for and channel_feature_for; controls who may subscribe
apps/web/src/hooks/use-editor-presence.tsFrontend 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:

  • extraChannels and onEvent are excluded from the useEffect dependency array on purpose. Adding them would cause WebSocket reconnects every render cycle when callbacks are recreated. Wrap onEvent in useCallback with 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

PatternMeaning
{resource}:list:updatedThe 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 list
  • sound:play — a sound was triggered for playback
  • sound:stop — playback was stopped

Channel gate types

When adding a new channel, choose the appropriate gate in gate.rs:

GateWhen to use
AuthenticatedAny 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
WidgetTokenOnly widget tokens. Use for channels tied to a specific widget instance
OverlayTokenOnly overlay tokens. Use for channels tied to a specific overlay
PublicNo 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 patternWsServer channel prefixGateFeature 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:WidgetTokenfeature:widgets
lumio:ext-storage:*ext-storage:Permission("extension-store:read")feature:extensions
lumio:sounds:*sounds:Authenticatedfeature:sounds