broadcast-channel-state-sync
Version:
A TypeScript library for state synchronization across browser tabs using BroadcastChannel
519 lines (512 loc) • 15.4 kB
JavaScript
// 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
};