UNPKG

broadcast-channel-state-sync

Version:

A TypeScript library for state synchronization across browser tabs using BroadcastChannel

519 lines (512 loc) 15.4 kB
// src/utils/index.ts function generateUUID() { return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function(c) { const r = Math.random() * 16 | 0; const v = c === "x" ? r : r & 3 | 8; return v.toString(16); }); } // src/core/BroadcastChannelManager.ts var MESSAGE_TYPES = { STATE_REQUEST: "STATE_REQUEST", STATE_RESPONSE: "STATE_RESPONSE", STATE_UPDATE: "STATE_UPDATE", STATE_SYNC_START: "STATE_SYNC_START", STATE_SYNC_END: "STATE_SYNC_END" }; var BroadcastChannelManager = class { constructor(stateManager, options = {}) { this.options = { channelName: options.channelName ?? "state_channel", syncTimeout: options.syncTimeout ?? 5e3, retryAttempts: options.retryAttempts ?? 3, retryDelay: options.retryDelay ?? 1e3, instanceId: options.instanceId ?? generateUUID() }; this.bc = new BroadcastChannel(this.options.channelName); this.stateManager = stateManager; this.isReceivingBroadcast = false; this.pendingSyncs = /* @__PURE__ */ new Map(); this.messageQueue = []; this.processingQueue = false; this.setupEventListeners(); this.initializeState(); } setupEventListeners() { this.bc.onmessage = this.handleMessage.bind(this); window.addEventListener("unload", () => this.destroy()); } async handleMessage(event) { const { data } = event; if (data.source === this.options.instanceId) { return; } this.messageQueue.push({ message: data, timestamp: Date.now() }); if (!this.processingQueue) { await this.processMessageQueue(); } } async processMessageQueue() { this.processingQueue = true; const messageHandlers = { [MESSAGE_TYPES.STATE_REQUEST]: this.handleStateRequest.bind(this), [MESSAGE_TYPES.STATE_RESPONSE]: this.handleStateResponse.bind(this), [MESSAGE_TYPES.STATE_UPDATE]: this.handleStateUpdate.bind(this), [MESSAGE_TYPES.STATE_SYNC_START]: this.handleSyncStart.bind(this), [MESSAGE_TYPES.STATE_SYNC_END]: this.handleSyncEnd.bind(this) }; while (this.messageQueue.length > 0) { const { message } = this.messageQueue.shift(); try { const handler = messageHandlers[message.type]; if (handler) { await handler(message); } } catch (error) { console.error("Error processing message:", error); } } this.processingQueue = false; } async handleStateRequest(message) { const currentState = this.stateManager.getState(); this.bc.postMessage({ type: MESSAGE_TYPES.STATE_RESPONSE, state: currentState, id: message.id, timestamp: Date.now(), source: this.options.instanceId }); } async handleStateResponse(message) { const pendingSync = this.pendingSyncs.get(message.id); if (pendingSync) { clearTimeout(pendingSync.timer); this.pendingSyncs.delete(message.id); pendingSync.resolve(message.state); } } async handleStateUpdate(message) { if (this.isReceivingBroadcast) return; try { this.isReceivingBroadcast = true; await this.updateState(message.state); } finally { this.isReceivingBroadcast = false; } } async handleSyncStart(message) { } async handleSyncEnd(message) { } async updateState(state) { this.stateManager.setState(state); } async broadcastState(newState) { if (this.isReceivingBroadcast) return; const message = { type: MESSAGE_TYPES.STATE_UPDATE, state: newState, timestamp: Date.now(), source: this.options.instanceId }; this.bc.postMessage(message); } async getInitialState() { let attempts = 0; while (attempts < this.options.retryAttempts) { try { const state = await this.requestState(); return state; } catch (error) { attempts++; if (attempts === this.options.retryAttempts) { return this.stateManager.getState(); } await new Promise( (resolve) => setTimeout(resolve, this.options.retryDelay) ); } } return this.stateManager.getState(); } async requestState() { return new Promise((resolve, reject) => { const syncId = generateUUID(); const timer = setTimeout(() => { this.pendingSyncs.delete(syncId); reject(new Error("State sync timeout")); }, this.options.syncTimeout); this.pendingSyncs.set(syncId, { resolve, reject, timer }); this.bc.postMessage({ type: MESSAGE_TYPES.STATE_REQUEST, id: syncId, timestamp: Date.now(), source: this.options.instanceId }); }); } async initializeState() { try { const syncId = generateUUID(); this.bc.postMessage({ type: MESSAGE_TYPES.STATE_SYNC_START, id: syncId, timestamp: Date.now(), source: this.options.instanceId }); const initialState = await this.getInitialState(); this.isReceivingBroadcast = true; await this.updateState(initialState); this.bc.postMessage({ type: MESSAGE_TYPES.STATE_SYNC_END, id: syncId, timestamp: Date.now(), source: this.options.instanceId }); } finally { this.isReceivingBroadcast = false; } } destroy() { this.pendingSyncs.forEach(({ timer }) => clearTimeout(timer)); this.pendingSyncs.clear(); this.messageQueue = []; this.bc.onmessage = null; this.bc.close(); } }; var BroadcastChannelManager_default = BroadcastChannelManager; // src/core/BaseAdapter.ts var BaseAdapter = class { constructor({ getState, setState, options = {} }) { this.manager = new BroadcastChannelManager_default( { getState, setState }, { channelName: options.channelName ?? "broadcast-channel", syncTimeout: options.syncTimeout ?? 3e3, retryAttempts: options.retryAttempts ?? 5, retryDelay: options.retryDelay ?? 1e3, instanceId: options.instanceId ?? generateUUID() } ); } async broadcastState(newState) { try { await this.manager.broadcastState(newState); } catch (error) { console.error("Error broadcasting state:", error); throw error; } } destroy() { this.manager.destroy(); } }; // src/adapters/redux.ts var BroadcastAdapter = class extends BaseAdapter { constructor({ store, slices, options = {} }) { if (!store) { throw new Error("Store cannot be empty"); } if (!slices || Object.keys(slices).length === 0) { throw new Error("Slices cannot be empty"); } super({ getState: () => { try { const currentState = store.getState(); const state = {}; Object.keys(slices).forEach((key) => { if (key in currentState) { state[key] = currentState[key]; } }); return state; } catch (error) { console.error("Error getting state:", error); throw error; } }, setState: (state) => { try { Object.entries(state).forEach(([key, value]) => { if (slices[key]) { const slice = slices[key]; const actions = Object.values(slice.actions); const setStateAction = actions.find( (action) => action.type.endsWith("/setState") || action.type.endsWith("/replaceState") ); if (setStateAction) { store.dispatch(setStateAction(value)); } else { console.warn( `No setState or replaceState action found for slice ${key}` ); } } }); } catch (error) { console.error("Error setting state:", error); throw error; } }, options: { ...options, channelName: options.channelName ?? "redux-channel" } }); this.unsubscribeCallback = null; this.store = store; this.slices = slices; this.prevState = store.getState(); this.setupSubscription(); } setupSubscription() { this.unsubscribeCallback = this.store.subscribe(() => { try { const newState = this.store.getState(); const changedSlices = {}; Object.entries(this.slices).forEach(([key, slice]) => { const prevSliceState = this.prevState[key]; const newSliceState = newState[key]; if (prevSliceState !== newSliceState) { changedSlices[key] = newSliceState; } }); if (Object.keys(changedSlices).length > 0) { this.broadcastState(changedSlices).catch((error) => { console.error("Error broadcasting changed slices:", error); }); } this.prevState = newState; } catch (error) { console.error("Error in store subscription:", error); } }); } destroy() { if (this.unsubscribeCallback) { this.unsubscribeCallback(); } super.destroy(); } }; // src/adapters/pinia.ts var BroadcastAdapter2 = class extends BaseAdapter { constructor({ slices, options = {} }) { if (!slices || Object.keys(slices).length === 0) { throw new Error("Slices cannot be empty"); } super({ getState: () => { try { const state = {}; Object.entries(slices).forEach(([key, slice]) => { state[key] = JSON.parse(JSON.stringify(slice.$state)); }); return state; } catch (error) { console.error("Error getting state:", error); throw error; } }, setState: (state) => { try { Object.entries(state).forEach(([key, value]) => { if (slices[key]) { slices[key].$patch(value); } }); } catch (error) { console.error("Error setting state:", error); throw error; } }, options: { ...options, channelName: options.channelName ?? "pinia-state" } }); this.unsubscribeCallbacks = []; this.slices = slices; this.setupSubscriptions(); } setupSubscriptions() { Object.entries(this.slices).forEach(([key, slice]) => { const unsubscribe = slice.$subscribe(() => { const stateToBroadcast = { [key]: JSON.parse(JSON.stringify(slice.$state)) }; this.broadcastState(stateToBroadcast).catch((error) => { console.error(`Error broadcasting state for ${key}:`, error); }); }); this.unsubscribeCallbacks.push(unsubscribe); }); } destroy() { this.unsubscribeCallbacks.forEach((unsubscribe) => unsubscribe()); super.destroy(); } }; // src/adapters/zustand.ts var BroadcastAdapter3 = class extends BaseAdapter { constructor({ slices, options = {} }) { if (!slices || Object.keys(slices).length === 0) { throw new Error("Slices cannot be empty"); } super({ getState: () => { try { const state = {}; Object.entries(slices).forEach(([key, slice]) => { state[key] = slice.getState(); }); return state; } catch (error) { console.error("Error getting state:", error); throw error; } }, setState: (state) => { try { Object.entries(state).forEach(([key, value]) => { if (slices[key]) { slices[key].setState(value); } }); } catch (error) { console.error("Error setting state:", error); throw error; } }, options: { ...options, channelName: options.channelName ?? "zustand-channel" } }); this.unsubscribeCallbacks = []; this.slices = slices; this.setupSubscriptions(); } setupSubscriptions() { Object.entries(this.slices).forEach(([key, slice]) => { const unsubscribe = slice.subscribe((state) => { this.broadcastState({ [key]: state }).catch((error) => { console.error(`Error broadcasting state for ${key}:`, error); }); }); this.unsubscribeCallbacks.push(unsubscribe); }); } destroy() { this.unsubscribeCallbacks.forEach((unsubscribe) => unsubscribe()); super.destroy(); } }; // src/adapters/mobx.ts import { runInAction, autorun } from "mobx"; var BroadcastAdapter4 = class extends BaseAdapter { constructor({ store, stateKeys, options = {} }) { if (!store) { throw new Error("Store cannot be empty"); } if (!stateKeys || stateKeys.length === 0) { throw new Error("State keys cannot be empty"); } const getState = () => { try { const state = {}; stateKeys.forEach((key) => { if (key in store) { if (Array.isArray(store[key])) { state[key] = JSON.parse(JSON.stringify(store[key])); } else { state[key] = store[key]; } } }); return state; } catch (error) { console.error("Error getting state:", error); throw error; } }; super({ getState, setState: (state) => { try { runInAction(() => { Object.entries(state).forEach(([key, value]) => { if (stateKeys.includes(key) && key in store) { if (Array.isArray(value)) { store[key] = JSON.parse(JSON.stringify(value)); } else { store[key] = value; } } }); }); } catch (error) { console.error("Error setting state:", error); throw error; } }, options: { ...options, channelName: options.channelName ?? "mobx-channel" } }); this.disposer = null; this.store = store; this.stateKeys = stateKeys; this.getStateFn = getState; this.prevState = getState(); this.setupSubscription(); } setupSubscription() { this.disposer = autorun(() => { try { const newState = this.getStateFn(); const changedKeys = {}; this.stateKeys.forEach((key) => { const prevValue = this.prevState[key]; const newValue = newState[key]; if (Array.isArray(prevValue) && Array.isArray(newValue)) { if (JSON.stringify(prevValue) !== JSON.stringify(newValue)) { changedKeys[key] = newValue; } } else if (prevValue !== newValue) { changedKeys[key] = newValue; } }); if (Object.keys(changedKeys).length > 0) { this.broadcastState(changedKeys).catch((error) => { console.error("Error broadcasting changed state:", error); }); } this.prevState = newState; } catch (error) { console.error("Error in store subscription:", error); } }); } destroy() { if (this.disposer) { this.disposer(); } super.destroy(); } }; export { BroadcastChannelManager_default as BroadcastChannelManager, BroadcastAdapter4 as MobxAdapter, BroadcastAdapter2 as PiniaAdapter, BroadcastAdapter as ReduxAdapter, BroadcastAdapter3 as ZustandAdapter, generateUUID };