diff --git a/src/store/index.js b/src/store/index.js index 3b0b15f..d7535fc 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -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; +} diff --git a/src/store/roomSync.js b/src/store/roomSync.js new file mode 100644 index 0000000..0374bd2 --- /dev/null +++ b/src/store/roomSync.js @@ -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:///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({ 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, + }, + }; + }; +}