213 lines
5.9 KiB
TypeScript
213 lines
5.9 KiB
TypeScript
/**
|
|
* BroadcastChannel State Synchronization
|
|
* FE-STATE-002: Synchronize Zustand state across browser tabs using BroadcastChannel
|
|
*
|
|
* This middleware allows Zustand stores to automatically sync their state
|
|
* across all open tabs/windows of the same origin.
|
|
*/
|
|
|
|
import { StateCreator, StoreMutatorIdentifier } from 'zustand';
|
|
|
|
/**
|
|
* Options for broadcast synchronization
|
|
*/
|
|
export interface BroadcastSyncOptions {
|
|
/** Channel name for BroadcastChannel (default: store name) */
|
|
channelName?: string;
|
|
/** Whether to enable synchronization (default: true) */
|
|
enabled?: boolean;
|
|
/** Function to filter which state changes should be synced */
|
|
shouldSync?: (state: any, prevState: any) => boolean;
|
|
}
|
|
|
|
/**
|
|
* BroadcastChannel message format
|
|
*/
|
|
interface BroadcastMessage {
|
|
type: 'state-update' | 'state-request' | 'state-response';
|
|
storeName: string;
|
|
state?: any;
|
|
timestamp: number;
|
|
}
|
|
|
|
/**
|
|
* Create a BroadcastChannel instance for a store
|
|
*/
|
|
function createBroadcastChannel(storeName: string): BroadcastChannel | null {
|
|
if (typeof window === 'undefined' || !window.BroadcastChannel) {
|
|
console.warn('[BroadcastSync] BroadcastChannel not supported in this environment');
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return new BroadcastChannel(`veza-store-${storeName}`);
|
|
} catch (error) {
|
|
console.warn(`[BroadcastSync] Failed to create BroadcastChannel for ${storeName}:`, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Zustand middleware for BroadcastChannel synchronization
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* export const useMyStore = create<MyState>()(
|
|
* broadcastSync(
|
|
* (set, get) => ({
|
|
* // store implementation
|
|
* }),
|
|
* { channelName: 'my-store' }
|
|
* )
|
|
* );
|
|
* ```
|
|
*/
|
|
export function broadcastSync<T extends object>(
|
|
config: StateCreator<T>,
|
|
options: BroadcastSyncOptions = {},
|
|
): StateCreator<T> {
|
|
return (set, get, api) => {
|
|
const storeName = options.channelName || 'default-store';
|
|
const enabled = options.enabled !== false;
|
|
const shouldSync = options.shouldSync || (() => true);
|
|
|
|
let channel: BroadcastChannel | null = null;
|
|
let isReceivingUpdate = false;
|
|
let lastState: T | null = null;
|
|
|
|
// Initialize BroadcastChannel
|
|
if (enabled) {
|
|
channel = createBroadcastChannel(storeName);
|
|
|
|
if (channel) {
|
|
// Listen for state updates from other tabs
|
|
channel.onmessage = (event: MessageEvent<BroadcastMessage>) => {
|
|
const message = event.data;
|
|
|
|
if (message.type === 'state-update' && message.state) {
|
|
// Ignore updates from the same tab
|
|
if (isReceivingUpdate) {
|
|
return;
|
|
}
|
|
|
|
// Check if we should sync this update
|
|
if (shouldSync(message.state, lastState)) {
|
|
isReceivingUpdate = true;
|
|
set(message.state as T);
|
|
lastState = message.state;
|
|
|
|
// Reset flag after a short delay
|
|
setTimeout(() => {
|
|
isReceivingUpdate = false;
|
|
}, 100);
|
|
}
|
|
} else if (message.type === 'state-request') {
|
|
// Another tab is requesting the current state
|
|
const currentState = get();
|
|
channel.postMessage({
|
|
type: 'state-response',
|
|
storeName,
|
|
state: currentState,
|
|
timestamp: Date.now(),
|
|
} as BroadcastMessage);
|
|
} else if (message.type === 'state-response' && message.state) {
|
|
// Received state from another tab (initial sync)
|
|
if (!lastState) {
|
|
isReceivingUpdate = true;
|
|
set(message.state as T);
|
|
lastState = message.state;
|
|
|
|
setTimeout(() => {
|
|
isReceivingUpdate = false;
|
|
}, 100);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Request initial state from other tabs on startup
|
|
channel.postMessage({
|
|
type: 'state-request',
|
|
storeName,
|
|
timestamp: Date.now(),
|
|
} as BroadcastMessage);
|
|
}
|
|
}
|
|
|
|
// Create the store with wrapped set function
|
|
const store = config(
|
|
(...args) => {
|
|
if (!isReceivingUpdate) {
|
|
set(...args);
|
|
|
|
// Broadcast state update to other tabs
|
|
if (channel && enabled) {
|
|
const newState = get();
|
|
|
|
if (shouldSync(newState, lastState)) {
|
|
channel.postMessage({
|
|
type: 'state-update',
|
|
storeName,
|
|
state: newState,
|
|
timestamp: Date.now(),
|
|
} as BroadcastMessage);
|
|
|
|
lastState = newState;
|
|
}
|
|
}
|
|
} else {
|
|
// If we're receiving an update, just set without broadcasting
|
|
set(...args);
|
|
}
|
|
},
|
|
get,
|
|
api,
|
|
);
|
|
|
|
return store;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Helper to create a synchronized store with both persistence and broadcast sync
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* export const useMyStore = create<MyState>()(
|
|
* persist(
|
|
* broadcastSync(
|
|
* (set, get) => ({
|
|
* // store implementation
|
|
* }),
|
|
* { channelName: 'my-store' }
|
|
* ),
|
|
* { name: 'my-store-storage' }
|
|
* )
|
|
* );
|
|
* ```
|
|
*/
|
|
export function createSynchronizedStore<T extends object>(
|
|
config: StateCreator<T>,
|
|
options: {
|
|
persist?: { name: string; partialize?: (state: T) => Partial<T> };
|
|
broadcast?: BroadcastSyncOptions;
|
|
} = {},
|
|
) {
|
|
let store = config;
|
|
|
|
// Apply broadcast sync if enabled
|
|
if (options.broadcast?.enabled !== false) {
|
|
store = broadcastSync(store, options.broadcast) as StateCreator<T>;
|
|
}
|
|
|
|
// Apply persistence if configured
|
|
if (options.persist) {
|
|
const { persist: persistMiddleware } = require('zustand/middleware');
|
|
store = persistMiddleware(store, {
|
|
name: options.persist.name,
|
|
partialize: options.persist.partialize,
|
|
}) as StateCreator<T>;
|
|
}
|
|
|
|
return store;
|
|
}
|
|
|