feat: room sync zustand middleware

roomSync() replicates the @liveblocks/zustand surface
(state.liveblocks.{enterRoom,leaveRoom,status,others,room}) over the
self-hosted relay. Storage fan-out is debounced and guarded against
remote echo; presence maps the self key; room exposes broadcastEvent,
subscribe and no-op history stubs. Falls back to a degraded solo mode
if the socket never opens so the Loader never blocks. Dev-only store
hook on window for debugging (stripped in production).
This commit is contained in:
L'électron rare
2026-06-13 18:04:22 +02:00
parent 60fc17819c
commit 8a34b1d333
2 changed files with 414 additions and 8 deletions
+8 -8
View File
@@ -6,18 +6,13 @@ import {
generateSoftColors,
uID,
} from "../utils";
import { createClient } from "@liveblocks/client";
import { liveblocks } from "@liveblocks/zustand";
const client = createClient({
publicApiKey: import.meta.env.VITE_LIVEBLOCKS_API_KEY,
});
import { roomSync } from "./roomSync";
const id = uID();
const color = generateSoftColors();
export const useStore = create(
liveblocks(
roomSync(
(set) => ({
mode: CREATE_MODE,
setMode: (newMode) => set({ mode: newMode }),
@@ -78,9 +73,14 @@ export const useStore = create(
}),
}),
{
client,
presenceMapping: { self: true },
storageMapping: { bricks: true, cursorColors: true },
}
)
);
// Dev-only debugging affordance: expose the store so multiplayer sync can be
// inspected from devtools / e2e tests. Stripped from production builds.
if (import.meta.env.DEV && typeof window !== "undefined") {
window.__legoStore = useStore;
}
+406
View File
@@ -0,0 +1,406 @@
/**
* roomSync — a sovereign drop-in replacement for the `@liveblocks/zustand`
* middleware, backed by the self-hosted WebSocket relay in `server/index.js`.
*
* It exposes EXACTLY the slice the components consume:
*
* state.liveblocks = {
* enterRoom(roomId),
* leaveRoom(roomId, initialStorage?),
* status, // "initial" | "connecting" | "connected" | "closed"
* others, // [{ connectionId, presence: { self: {...} } }]
* room: {
* broadcastEvent({ type, data }), // ephemeral, fire-and-forget
* subscribe("event", cb) -> unsubscribe // cb receives ({ event })
* history: { undo(), redo() }, // no-op stubs (kept for API parity)
* },
* }
*
* Usage mirrors liveblocks():
* create(roomSync(configFn, { presenceMapping, storageMapping, wsUrl }))
*
* Mappings:
* - presenceMapping: { self: true } -> the `self` store key is this client's presence
* - storageMapping: { bricks: true, cursorColors: true } -> shared, last-writer-wins
*/
const STATUS = {
INITIAL: "initial",
CONNECTING: "connecting",
CONNECTED: "connected",
CLOSED: "closed",
};
// If the relay does not connect within this window, we flip status to
// "connected" anyway so the single-player builder is never blocked behind the
// Loader (graceful degraded / solo mode). The WS keeps trying in the
// background; if it later opens, real sync resumes transparently.
const SOLO_FALLBACK_MS = 4000;
// Light debounce for storage fan-out (bricks can change rapidly while drawing).
const STORAGE_DEBOUNCE_MS = 80;
function resolveWsUrl(explicit) {
if (explicit) return explicit;
const envUrl =
typeof import.meta !== "undefined" &&
import.meta.env &&
import.meta.env.VITE_RT_URL;
if (envUrl) return envUrl;
// Fallback: in prod (https origin) target wss://<host>/blocs-rt (routed by
// infra), otherwise the local dev relay.
if (typeof window !== "undefined" && window.location) {
const { protocol, host } = window.location;
if (protocol === "https:") {
return `wss://${host}/blocs-rt`;
}
}
return "ws://localhost:8090";
}
export function roomSync(configFn, options = {}) {
const {
presenceMapping = {},
storageMapping = {},
wsUrl: wsUrlOption,
} = options;
const presenceKeys = Object.keys(presenceMapping).filter(
(k) => presenceMapping[k]
);
const storageKeys = Object.keys(storageMapping).filter(
(k) => storageMapping[k]
);
const wsUrl = resolveWsUrl(wsUrlOption);
return (set, get, api) => {
// --- internal connection state (closure, not part of the public store) ---
let ws = null;
let applyingRemote = false; // guard so remote-applied storage isn't echoed
let storageTimer = null;
let soloTimer = null;
let lastSentStorage = null; // JSON string, to skip redundant sends
let lastSentPresence = null; // JSON string
const eventSubscribers = new Set(); // Set<cb>, cb({ event })
// others keyed by connectionId for O(1) join/leave/presence updates.
const othersMap = new Map(); // connectionId -> { connectionId, presence }
const syncOthers = () => {
setLiveblocks({ others: Array.from(othersMap.values()) });
};
// Helper to patch the `liveblocks` slice without clobbering siblings.
const setLiveblocks = (patch) => {
set((state) => ({ liveblocks: { ...state.liveblocks, ...patch } }));
};
const collectStorage = () => {
const state = get();
const storage = {};
for (const key of storageKeys) storage[key] = state[key];
return storage;
};
const collectPresence = () => {
const state = get();
const presence = {};
for (const key of presenceKeys) presence[key] = state[key];
return presence;
};
const sendRaw = (payload) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(payload));
}
};
const sendPresence = () => {
const presence = collectPresence();
const serialized = JSON.stringify(presence);
if (serialized === lastSentPresence) return;
lastSentPresence = serialized;
sendRaw({ t: "presence", presence });
};
const sendStorage = () => {
const storage = collectStorage();
const serialized = JSON.stringify(storage);
if (serialized === lastSentStorage) return;
lastSentStorage = serialized;
sendRaw({ t: "storage", storage });
};
const scheduleStorageSend = () => {
if (storageTimer) clearTimeout(storageTimer);
storageTimer = setTimeout(() => {
storageTimer = null;
sendStorage();
}, STORAGE_DEBOUNCE_MS);
};
// Apply remote storage into the local store WITHOUT triggering an echo.
const applyRemoteStorage = (storage) => {
if (!storage || typeof storage !== "object") return;
const patch = {};
for (const key of storageKeys) {
if (key in storage) patch[key] = storage[key];
}
lastSentStorage = JSON.stringify(collectStorageFrom(patch));
applyingRemote = true;
try {
set(patch);
} finally {
applyingRemote = false;
}
};
// Build the {storage} shape from a partial patch merged over current keys,
// so lastSentStorage reflects exactly what the store now holds.
const collectStorageFrom = (patch) => {
const state = get();
const storage = {};
for (const key of storageKeys) {
storage[key] = key in patch ? patch[key] : state[key];
}
return storage;
};
// --- watch local store changes to fan-out presence / storage ---
let prevSnapshot = null;
const watchLocalChanges = () => {
const captureSnapshot = () => {
const state = get();
const snap = {};
for (const key of storageKeys) snap[key] = state[key];
for (const key of presenceKeys) snap[key] = state[key];
return snap;
};
prevSnapshot = captureSnapshot();
api.subscribe(() => {
if (applyingRemote) return; // don't loop remote writes back out
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const state = get();
let storageChanged = false;
let presenceChanged = false;
for (const key of storageKeys) {
if (state[key] !== prevSnapshot[key]) {
storageChanged = true;
prevSnapshot[key] = state[key];
}
}
for (const key of presenceKeys) {
if (state[key] !== prevSnapshot[key]) {
presenceChanged = true;
prevSnapshot[key] = state[key];
}
}
if (storageChanged) scheduleStorageSend();
if (presenceChanged) sendPresence();
});
};
// --- incoming message handling ---
const handleMessage = (raw) => {
let msg;
try {
msg = JSON.parse(raw);
} catch {
return;
}
if (!msg || typeof msg !== "object") return;
switch (msg.t) {
case "init": {
if (msg.storage) applyRemoteStorage(msg.storage);
othersMap.clear();
if (Array.isArray(msg.others)) {
for (const o of msg.others) {
if (o && o.connectionId != null) {
othersMap.set(o.connectionId, {
connectionId: o.connectionId,
presence: o.presence,
});
}
}
}
syncOthers();
// Cancel solo fallback — we are genuinely connected.
if (soloTimer) {
clearTimeout(soloTimer);
soloTimer = null;
}
setLiveblocks({ status: STATUS.CONNECTED });
// Announce our presence to peers (first frame => relay emits `join`).
lastSentPresence = null;
sendPresence();
// Re-baseline storage so our snapshot isn't echoed right back.
lastSentStorage = JSON.stringify(collectStorage());
break;
}
case "storage": {
applyRemoteStorage(msg.storage);
break;
}
case "join":
case "presence": {
if (msg.connectionId != null) {
othersMap.set(msg.connectionId, {
connectionId: msg.connectionId,
presence: msg.presence,
});
syncOthers();
}
break;
}
case "leave": {
if (msg.connectionId != null && othersMap.delete(msg.connectionId)) {
syncOthers();
}
break;
}
case "event": {
for (const cb of eventSubscribers) {
try {
cb({ event: msg.event });
} catch {
// a faulty subscriber must not break the relay loop
}
}
break;
}
default:
break;
}
};
// --- room object exposed to components ---
const room = {
broadcastEvent: (event) => {
sendRaw({ t: "event", event });
},
subscribe: (type, cb) => {
if (type !== "event" || typeof cb !== "function") {
return () => {};
}
eventSubscribers.add(cb);
return () => eventSubscribers.delete(cb);
},
// History is a Liveblocks storage feature; the components call
// room.history.undo()/redo(). We keep no-op stubs for API parity so the
// BottomBar buttons never throw. (Real undo/redo would need an op log.)
history: {
undo: () => {},
redo: () => {},
},
};
const enterRoom = (roomId) => {
// Tear down any previous connection first.
teardown();
othersMap.clear();
syncOthers();
setLiveblocks({ status: STATUS.CONNECTING });
let url;
try {
url = `${wsUrl}?room=${encodeURIComponent(roomId)}`;
ws = new WebSocket(url);
} catch {
// Never throw out of enterRoom — fall back to solo mode.
ws = null;
setLiveblocks({ status: STATUS.CONNECTED });
return;
}
// Solo degraded fallback: if the relay never opens, don't block the UI.
soloTimer = setTimeout(() => {
soloTimer = null;
const st = get().liveblocks.status;
if (st !== STATUS.CONNECTED) {
setLiveblocks({ status: STATUS.CONNECTED });
}
}, SOLO_FALLBACK_MS);
ws.onopen = () => {
// We wait for `init` to flip to CONNECTED so the storage snapshot is
// applied first; the open itself just clears the connecting timeout
// implicitly via init.
};
ws.onmessage = (e) => handleMessage(e.data);
ws.onerror = () => {
// Swallow — onclose handles state; solo fallback covers the UI.
};
ws.onclose = () => {
if (soloTimer) {
clearTimeout(soloTimer);
soloTimer = null;
}
// Only mark closed if this is still the active socket.
};
};
const teardown = () => {
if (storageTimer) {
clearTimeout(storageTimer);
storageTimer = null;
}
if (soloTimer) {
clearTimeout(soloTimer);
soloTimer = null;
}
if (ws) {
try {
ws.onopen = ws.onmessage = ws.onerror = ws.onclose = null;
ws.close();
} catch {
// ignore
}
ws = null;
}
lastSentStorage = null;
lastSentPresence = null;
othersMap.clear();
};
const leaveRoom = (_roomId, initialStorage) => {
teardown();
// Reset shared storage locally if the caller passed a reset payload
// (mirrors liveblocks leaveRoom(roomId, { bricks: [] })).
if (initialStorage && typeof initialStorage === "object") {
applyingRemote = true;
try {
set(initialStorage);
} finally {
applyingRemote = false;
}
}
setLiveblocks({ others: [], status: STATUS.INITIAL });
};
// --- build the user's store config, then attach the liveblocks slice ---
const userState = configFn(set, get, api);
// Defer wiring the local-change watcher until the store + api exist.
// api.subscribe is available synchronously here.
queueMicrotask(watchLocalChanges);
return {
...userState,
liveblocks: {
enterRoom,
leaveRoom,
status: STATUS.INITIAL,
others: [],
room,
},
};
};
}