veza/apps/web/src/utils/broadcastSync.ts

409 lines
14 KiB
TypeScript
Raw Normal View History

/**
* 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.
*
* NOTE: This sync mechanism coexists with React Query cache synchronization
* (see reactQuerySync.ts). Both use BroadcastChannel but with different channel
* names and message formats, so they do not conflict:
* - Zustand sync: `veza-store-${storeName}` channels with BroadcastMessage format
* - React Query sync: `veza-react-query-sync` channel with ReactQuerySyncMessage format
*/
import { StateCreator } from 'zustand';
import { logger } from './logger';
/**
* 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?: <T>(state: T, prevState: T | null) => boolean;
/**
* Action 4.2.1.1: Callback to invalidate React Query cache when state is synced
* Called when state updates are broadcast or received from other tabs
*/
onStateSync?: <T>(state: T, prevState: T | null) => void;
}
/**
* BroadcastChannel message format
* CRITIQUE FIX #14: Ajout d'un ID unique pour chaque message pour éviter les doublons
*/
interface BroadcastMessage<T = unknown> {
type: 'state-update' | 'state-request' | 'state-response';
storeName: string;
state?: T;
timestamp: number;
messageId?: string; // CRITIQUE FIX #14: ID unique pour éviter les doublons
}
/**
* Create a BroadcastChannel instance for a store
*/
function createBroadcastChannel(storeName: string): BroadcastChannel | null {
if (typeof window === 'undefined' || !window.BroadcastChannel) {
logger.warn(
'[BroadcastSync] BroadcastChannel not supported in this environment',
);
return null;
}
try {
return new BroadcastChannel(`veza-store-${storeName}`);
} catch (error) {
logger.warn(
`[BroadcastSync] Failed to create BroadcastChannel for ${storeName}`,
{
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
storeName,
},
);
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);
const onStateSync = options.onStateSync;
let channel: BroadcastChannel | null = null;
let isReceivingUpdate = false;
let lastState: T | null = null;
// CRITIQUE FIX #14: Système de timestamps pour déterminer quelle mise à jour est la plus récente
let lastUpdateTimestamp = 0;
// CRITIQUE FIX #14: Set pour tracker les messages déjà traités (éviter les doublons)
const processedMessages = new Set<string>();
// CRITIQUE FIX #14: Queue pour les mises à jour en attente
const pendingUpdates: Array<{
state: T;
timestamp: number;
messageId: string;
}> = [];
// Initialize BroadcastChannel
if (enabled) {
channel = createBroadcastChannel(storeName);
if (channel) {
// CRITIQUE FIX #14: Fonction pour traiter les mises à jour en queue
const processPendingUpdates = () => {
if (pendingUpdates.length === 0 || isReceivingUpdate) {
return;
}
// Trier par timestamp (plus récent en premier)
pendingUpdates.sort((a, b) => b.timestamp - a.timestamp);
// Traiter la mise à jour la plus récente
const update = pendingUpdates.shift();
if (update && update.timestamp > lastUpdateTimestamp) {
isReceivingUpdate = true;
set(update.state);
lastState = update.state;
lastUpdateTimestamp = update.timestamp;
// Nettoyer les anciens messages traités (garder seulement les 100 derniers)
if (processedMessages.size > 100) {
const toDelete = Array.from(processedMessages).slice(0, 50);
toDelete.forEach((id) => processedMessages.delete(id));
}
// Reset flag après traitement
setTimeout(() => {
isReceivingUpdate = false;
// Traiter la prochaine mise à jour en queue
processPendingUpdates();
}, 50); // Réduire le délai pour une meilleure réactivité
}
};
// Listen for state updates from other tabs
channel.onmessage = (event: MessageEvent<BroadcastMessage>) => {
const message = event.data;
// Action 2.3.1.3: Type guard to ensure this is a valid BroadcastMessage
// This prevents processing messages from other sync mechanisms (e.g., React Query sync)
if (
!message ||
typeof message !== 'object' ||
!message.type ||
!message.storeName ||
typeof message.timestamp !== 'number'
) {
// Not a valid BroadcastMessage - ignore (likely from a different sync mechanism)
return;
}
// Verify message type is one of the expected Zustand sync types
if (
message.type !== 'state-update' &&
message.type !== 'state-request' &&
message.type !== 'state-response'
) {
// Not a Zustand sync message - ignore
return;
}
// Verify storeName matches (extra safety check)
if (message.storeName !== storeName) {
// Message is for a different store - ignore
return;
}
// CRITIQUE FIX #14: Générer un ID unique pour ce message s'il n'en a pas
const messageId =
message.messageId ||
`${message.type}-${message.timestamp}-${Math.random()}`;
// Ignorer les messages déjà traités (éviter les doublons)
if (processedMessages.has(messageId)) {
return;
}
if (message.type === 'state-update' && message.state) {
// Ignore updates from the same tab si on est déjà en train de recevoir
if (isReceivingUpdate) {
// CRITIQUE FIX #14: Ajouter à la queue au lieu de rejeter
pendingUpdates.push({
state: message.state as T,
timestamp: message.timestamp,
messageId,
});
// Trier la queue et traiter si possible
processPendingUpdates();
return;
}
// CRITIQUE FIX #14: Vérifier si cette mise à jour est plus récente que la dernière
if (message.timestamp <= lastUpdateTimestamp) {
// Mise à jour obsolète, l'ignorer
processedMessages.add(messageId);
return;
}
// Check if we should sync this update
if (shouldSync(message.state, lastState)) {
processedMessages.add(messageId);
isReceivingUpdate = true;
const prevState = lastState; // Save previous state before updating
set(message.state as T);
const newState = message.state as T;
lastState = newState;
lastUpdateTimestamp = message.timestamp;
// Action 4.2.1.1: Invalidate React Query cache when state is synced from another tab
if (onStateSync) {
try {
onStateSync(newState, prevState);
} catch (error) {
logger.warn(
'[BroadcastSync] Error in onStateSync callback',
{
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
storeName,
},
);
}
}
// Reset flag after a short delay
setTimeout(() => {
isReceivingUpdate = false;
// Traiter les mises à jour en queue après le délai
processPendingUpdates();
}, 50); // Réduire le délai pour une meilleure réactivité
} else {
processedMessages.add(messageId);
}
} else if (message.type === 'state-request') {
// Another tab is requesting the current state
const currentState = get();
// Only serialize data, not functions (functions can't be cloned)
const serializableState = JSON.parse(JSON.stringify(currentState));
if (channel) {
channel.postMessage({
type: 'state-response',
storeName,
state: serializableState,
timestamp: Date.now(),
} as BroadcastMessage);
}
} else if (message.type === 'state-response' && message.state) {
// CRITIQUE FIX #14: Received state from another tab (initial sync)
// Vérifier le timestamp pour éviter d'écraser un état plus récent
if (!lastState || message.timestamp > lastUpdateTimestamp) {
processedMessages.add(messageId);
isReceivingUpdate = true;
const prevState = lastState; // Save previous state before updating
set(message.state as T);
const newState = message.state as T;
lastState = newState;
lastUpdateTimestamp = message.timestamp;
// Action 4.2.1.1: Invalidate React Query cache when state is synced from another tab
if (onStateSync) {
try {
onStateSync(newState, prevState);
} catch (error) {
logger.warn(
'[BroadcastSync] Error in onStateSync callback',
{
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
storeName,
},
);
}
}
setTimeout(() => {
isReceivingUpdate = false;
processPendingUpdates();
}, 50);
} else {
processedMessages.add(messageId);
}
}
};
// 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)) {
// CRITIQUE FIX #14: Utiliser un timestamp précis et un ID unique pour chaque message
const timestamp = Date.now();
const messageId = `update-${timestamp}-${Math.random()}`;
// Only serialize data, not functions (functions can't be cloned)
const serializableState = JSON.parse(JSON.stringify(newState));
channel.postMessage({
type: 'state-update',
storeName,
state: serializableState,
timestamp,
messageId, // CRITIQUE FIX #14: Ajouter l'ID unique
} as BroadcastMessage);
// Action 4.2.1.1: Invalidate React Query cache when state is updated locally
if (onStateSync) {
try {
onStateSync(newState, lastState);
} catch (error) {
logger.warn(
'[BroadcastSync] Error in onStateSync callback',
{
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
storeName,
},
);
}
}
lastState = newState;
lastUpdateTimestamp = timestamp;
}
}
} 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;
}