UNPKG

state-mirror

Version:

Real-time cross-tab/device state synchronization library with plugin support.

1,287 lines (1,276 loc) 42.3 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var uuid = require('uuid'); var jsonpatch = require('fast-json-patch'); class DiffEngine { constructor() { this.previousState = null; } /** * Generate a patch between the previous state and the current state */ generatePatch(currentState, paths) { if (this.previousState === null) { this.previousState = this.deepClone(currentState); return { operations: [], hasChanges: false }; } let operations; if (paths && paths.length > 0) { // Only diff specific paths operations = this.generatePathPatch(this.previousState, currentState, paths); } else { // Diff entire object operations = jsonpatch.compare(this.previousState, currentState); } this.previousState = this.deepClone(currentState); return { operations, hasChanges: operations.length > 0 }; } /** * Generate patch for specific paths only */ generatePathPatch(previousState, currentState, paths) { const operations = []; for (const path of paths) { const previousValue = this.getPathValue(previousState, path); const currentValue = this.getPathValue(currentState, path); if (!this.isEqual(previousValue, currentValue)) { const pathOperations = jsonpatch.compare({ [path]: previousValue }, { [path]: currentValue }); // Adjust paths to be relative to the root const adjustedOperations = pathOperations.map((op) => ({ ...op, path: op.path === '' ? `/${path}` : `/${path}${op.path}` })); operations.push(...adjustedOperations); } } return operations; } /** * Get value at a specific path in an object */ getPathValue(obj, path) { return path.split('.').reduce((current, key) => { return current && current[key] !== undefined ? current[key] : undefined; }, obj); } /** * Deep equality check */ isEqual(a, b) { if (a === b) return true; if (a == null || b == null) return false; if (typeof a !== typeof b) return false; if (typeof a !== 'object') return false; const keysA = Object.keys(a); const keysB = Object.keys(b); if (keysA.length !== keysB.length) return false; for (const key of keysA) { if (!keysB.includes(key)) return false; if (!this.isEqual(a[key], b[key])) return false; } return true; } /** * Deep clone an object */ deepClone(obj) { if (obj === null || typeof obj !== 'object') return obj; if (obj instanceof Date) return new Date(obj.getTime()); if (obj instanceof Array) return obj.map(item => this.deepClone(item)); if (typeof obj === 'object') { const clonedObj = {}; for (const key in obj) { if (obj.hasOwnProperty(key)) { clonedObj[key] = this.deepClone(obj[key]); } } return clonedObj; } return obj; } /** * Reset the diff engine state */ reset() { this.previousState = null; } /** * Set the initial state for comparison */ setInitialState(state) { this.previousState = this.deepClone(state); } /** * Check if a path exists in the current state */ hasPath(state, path) { return this.getPathValue(state, path) !== undefined; } /** * Get all paths in an object (flattened) */ getAllPaths(obj, prefix = '') { const paths = []; if (obj === null || typeof obj !== 'object') { return paths; } for (const key in obj) { if (obj.hasOwnProperty(key)) { const currentPath = prefix ? `${prefix}.${key}` : key; paths.push(currentPath); if (typeof obj[key] === 'object' && obj[key] !== null) { paths.push(...this.getAllPaths(obj[key], currentPath)); } } } return paths; } } class Broadcaster { constructor(sourceId, storageKey = 'state-mirror-broadcast') { this.channel = null; this.messageHandlers = []; this._isConnected = false; this.fallbackInterval = null; this.sourceId = sourceId; this.storageKey = storageKey; } async connect() { try { // Try to use BroadcastChannel API if (typeof BroadcastChannel !== 'undefined') { this.channel = new BroadcastChannel(this.storageKey); this.channel.onmessage = (event) => { this.handleMessage(event.data); }; this._isConnected = true; console.log('StateMirror: Connected via BroadcastChannel'); } else { // Fallback to localStorage this.setupLocalStorageFallback(); this._isConnected = true; console.log('StateMirror: Connected via localStorage fallback'); } } catch (error) { console.error('StateMirror: Failed to connect broadcaster:', error); throw error; } } disconnect() { if (this.channel) { this.channel.close(); this.channel = null; } if (this.fallbackInterval) { clearInterval(this.fallbackInterval); this.fallbackInterval = null; } this._isConnected = false; this.messageHandlers = []; } async send(message) { if (!this._isConnected) { throw new Error('Broadcaster is not connected'); } // Add source and timestamp if not present const enrichedMessage = { ...message, source: message.source || this.sourceId, timestamp: message.timestamp || Date.now() }; if (this.channel) { // Use BroadcastChannel this.channel.postMessage(enrichedMessage); } else { // Use localStorage fallback await this.sendViaLocalStorage(enrichedMessage); } } onMessage(handler) { this.messageHandlers.push(handler); } isConnected() { return this._isConnected; } handleMessage(message) { // Ignore messages from self if (message.source === this.sourceId) { return; } // Validate message structure if (!this.isValidMessage(message)) { console.warn('StateMirror: Received invalid message:', message); return; } // Notify all handlers this.messageHandlers.forEach(handler => { try { handler(message); } catch (error) { console.error('StateMirror: Error in message handler:', error); } }); } setupLocalStorageFallback() { // Poll localStorage for new messages this.fallbackInterval = window.setInterval(() => { this.checkLocalStorageMessages(); }, 100); // Check every 100ms // Listen for storage events window.addEventListener('storage', (event) => { if (event.key === this.storageKey && event.newValue) { try { const message = JSON.parse(event.newValue); this.handleMessage(message); } catch (error) { console.error('StateMirror: Error parsing localStorage message:', error); } } }); } async sendViaLocalStorage(message) { try { const messageStr = JSON.stringify(message); localStorage.setItem(this.storageKey, messageStr); // Trigger storage event for other tabs window.dispatchEvent(new StorageEvent('storage', { key: this.storageKey, newValue: messageStr, oldValue: null, storageArea: localStorage })); } catch (error) { console.error('StateMirror: Error sending via localStorage:', error); throw error; } } checkLocalStorageMessages() { try { const messageStr = localStorage.getItem(this.storageKey); if (messageStr) { const message = JSON.parse(messageStr); // Only process if it's a recent message (within last 5 seconds) const messageAge = Date.now() - message.timestamp; if (messageAge < 5000) { this.handleMessage(message); } // Clean up old message localStorage.removeItem(this.storageKey); } } catch (error) { console.error('StateMirror: Error checking localStorage messages:', error); } } isValidMessage(message) { return (message && typeof message === 'object' && typeof message.type === 'string' && typeof message.source === 'string' && typeof message.timestamp === 'number' && message.data !== undefined); } /** * Send a ping message to test connectivity */ async ping() { await this.send({ type: 'ping', data: { timestamp: Date.now() }, source: this.sourceId, timestamp: Date.now() }); } /** * Get connected tabs count (approximate) */ async getConnectedTabsCount() { if (!this._isConnected) return 0; const pingId = `ping-${Date.now()}`; const responses = new Set(); // Send ping and collect responses const pingHandler = (message) => { if (message.type === 'pong' && message.data?.pingId === pingId) { responses.add(message.source); } }; this.onMessage(pingHandler); await this.send({ type: 'ping', data: { pingId, timestamp: Date.now() }, source: this.sourceId, timestamp: Date.now() }); // Wait for responses await new Promise(resolve => setTimeout(resolve, 1000)); // Clean up this.messageHandlers = this.messageHandlers.filter(h => h !== pingHandler); return responses.size; } } class ConflictEngine { constructor() { /** * Default conflict resolver: last-write-wins */ this.lastWriteWinsResolver = (local, incoming) => { return local.timestamp >= incoming.timestamp ? local : incoming; }; /** * Merge-based conflict resolver that combines operations */ this.mergeResolver = (local, incoming, instance) => { // Create a merged patch const mergedPatch = { id: `${local.id}-${incoming.id}-merged`, timestamp: Math.max(local.timestamp, incoming.timestamp), source: local.source, target: local.target, operations: [...local.operations, ...incoming.operations], version: Math.max(local.version, incoming.version) + 1, metadata: { ...local.metadata, ...incoming.metadata, merged: true, originalPatches: [local.id, incoming.id] } }; return mergedPatch; }; /** * Path-based conflict resolver that resolves conflicts per path */ this.pathBasedResolver = (local, incoming, instance) => { const localPaths = this.extractPaths(local.operations); const incomingPaths = this.extractPaths(incoming.operations); // Find conflicting paths const conflictingPaths = localPaths.filter(path => incomingPaths.includes(path)); if (conflictingPaths.length === 0) { // No conflicts, merge patches return this.mergeResolver(local, incoming, instance); } // For conflicting paths, use last-write-wins based on patch timestamps const resolvedOperations = [...local.operations]; for (const incomingOp of incoming.operations) { const incomingPath = this.getOperationPath(incomingOp); const hasConflict = conflictingPaths.includes(incomingPath); if (!hasConflict) { resolvedOperations.push(incomingOp); } else { // Check if local has a more recent operation for this path const localOp = local.operations.find(op => this.getOperationPath(op) === incomingPath); if (!localOp || incoming.timestamp > local.timestamp) { // Replace local operation with incoming const index = resolvedOperations.findIndex(op => this.getOperationPath(op) === incomingPath); if (index !== -1) { resolvedOperations[index] = incomingOp; } else { resolvedOperations.push(incomingOp); } } } } return { id: `${local.id}-${incoming.id}-path-resolved`, timestamp: Math.max(local.timestamp, incoming.timestamp), source: local.source, target: local.target, operations: resolvedOperations, version: Math.max(local.version, incoming.version) + 1, metadata: { ...local.metadata, pathResolved: true, conflictingPaths } }; }; this.defaultConflictResolver = this.lastWriteWinsResolver; } /** * Resolve conflicts between local and incoming patches */ resolveConflict(local, incoming, instance, customResolver) { const resolver = customResolver || instance.config.conflictResolver || this.defaultConflictResolver; try { return resolver(local, incoming, instance); } catch (error) { console.error('StateMirror: Error in conflict resolver:', error); // Fallback to last-write-wins return this.lastWriteWinsResolver(local, incoming, instance); } } /** * Timestamp-based conflict resolver with custom threshold */ timestampResolver(threshold = 1000) { return (local, incoming) => { const timeDiff = Math.abs(local.timestamp - incoming.timestamp); if (timeDiff <= threshold) { // If timestamps are close, prefer the one with more operations return local.operations.length >= incoming.operations.length ? local : incoming; } // Otherwise, use last-write-wins return local.timestamp >= incoming.timestamp ? local : incoming; }; } /** * Extract paths from operations */ extractPaths(operations) { return operations.map(op => this.getOperationPath(op)); } /** * Get the path from an operation */ getOperationPath(operation) { return operation.path || ''; } /** * Check if two patches have conflicts */ hasConflicts(local, incoming) { const localPaths = this.extractPaths(local.operations); const incomingPaths = this.extractPaths(incoming.operations); return localPaths.some(path => incomingPaths.includes(path)); } /** * Get conflicting paths between two patches */ getConflictingPaths(local, incoming) { const localPaths = this.extractPaths(local.operations); const incomingPaths = this.extractPaths(incoming.operations); return localPaths.filter(path => incomingPaths.includes(path)); } /** * Create a custom conflict resolver */ createCustomResolver(resolverFn) { return resolverFn; } /** * Validate a patch for conflicts */ validatePatch(patch) { const errors = []; if (!patch.id) { errors.push('Patch must have an id'); } if (!patch.timestamp || typeof patch.timestamp !== 'number') { errors.push('Patch must have a valid timestamp'); } if (!patch.source) { errors.push('Patch must have a source'); } if (!patch.target) { errors.push('Patch must have a target'); } if (!Array.isArray(patch.operations)) { errors.push('Patch must have operations array'); } if (patch.version === undefined || typeof patch.version !== 'number') { errors.push('Patch must have a version number'); } return { isValid: errors.length === 0, errors }; } } class ThrottleManager { constructor() { this.throttledFunctions = new Map(); this.debouncedFunctions = new Map(); } /** * Create a throttled function */ throttle(func, delay, config = { delay, leading: true, trailing: true }) { const key = `throttle-${func.toString()}-${delay}`; if (this.throttledFunctions.has(key)) { return this.throttledFunctions.get(key); } let lastCall = 0; let timeoutId = null; let lastArgs = null; const throttled = ((...args) => { const now = Date.now(); lastArgs = args; if (now - lastCall >= delay) { if (config.leading !== false) { lastCall = now; func.apply(this, args); } } else if (config.trailing !== false) { if (timeoutId) { clearTimeout(timeoutId); } timeoutId = window.setTimeout(() => { lastCall = Date.now(); if (lastArgs) { func.apply(this, lastArgs); lastArgs = null; } }, delay - (now - lastCall)); } }); this.throttledFunctions.set(key, throttled); return throttled; } /** * Create a debounced function */ debounce(func, delay, config = { delay, leading: false, trailing: true }) { const key = `debounce-${func.toString()}-${delay}`; if (this.debouncedFunctions.has(key)) { return this.debouncedFunctions.get(key); } let timeoutId = null; let lastArgs = null; let hasCalled = false; const debounced = ((...args) => { lastArgs = args; if (config.leading && !hasCalled) { hasCalled = true; func.apply(this, args); } if (timeoutId) { clearTimeout(timeoutId); } if (config.trailing !== false) { timeoutId = window.setTimeout(() => { if (config.leading) { hasCalled = false; } if (lastArgs) { func.apply(this, lastArgs); lastArgs = null; } }, delay); } }); this.debouncedFunctions.set(key, debounced); return debounced; } /** * Cancel a throttled function */ cancelThrottle(func) { const key = Array.from(this.throttledFunctions.keys()).find(k => k.includes(func.toString())); if (key) { this.throttledFunctions.delete(key); } } /** * Cancel a debounced function */ cancelDebounce(func) { const key = Array.from(this.debouncedFunctions.keys()).find(k => k.includes(func.toString())); if (key) { this.debouncedFunctions.delete(key); } } /** * Clear all throttled and debounced functions */ clear() { this.throttledFunctions.clear(); this.debouncedFunctions.clear(); } /** * Get the number of active throttled functions */ getThrottledCount() { return this.throttledFunctions.size; } /** * Get the number of active debounced functions */ getDebouncedCount() { return this.debouncedFunctions.size; } } /** * Simple throttle function */ function throttle(func, delay, leading = true, trailing = true) { let lastCall = 0; let timeoutId = null; let lastArgs = null; return ((...args) => { const now = Date.now(); lastArgs = args; if (now - lastCall >= delay) { if (leading) { lastCall = now; func(...args); } } else if (trailing) { if (timeoutId) { clearTimeout(timeoutId); } timeoutId = window.setTimeout(() => { lastCall = Date.now(); if (lastArgs) { func(...lastArgs); lastArgs = null; } }, delay - (now - lastCall)); } }); } /** * Simple debounce function */ function debounce(func, delay, leading = false, trailing = true) { let timeoutId = null; let lastArgs = null; let hasCalled = false; return ((...args) => { lastArgs = args; if (leading && !hasCalled) { hasCalled = true; func(...args); } if (timeoutId) { clearTimeout(timeoutId); } if (trailing) { timeoutId = window.setTimeout(() => { if (leading) { hasCalled = false; } if (lastArgs) { func(...lastArgs); lastArgs = null; } }, delay); } }); } class OfflineQueue { constructor(storageAdapter) { this.storageAdapter = storageAdapter; this.db = null; this.dbName = 'state-mirror-queue'; this.storeName = 'patches'; this.version = 1; this.isInitialized = false; this.flushInProgress = false; } /** * Initialize the IndexedDB database */ async initialize() { if (this.isInitialized) return; try { if (this.storageAdapter) { // Use custom storage adapter this.isInitialized = true; return; } // Use IndexedDB if (typeof indexedDB === 'undefined') { throw new Error('IndexedDB is not supported in this environment'); } return new Promise((resolve, reject) => { const request = indexedDB.open(this.dbName, this.version); request.onerror = () => reject(request.error); request.onsuccess = () => { this.db = request.result; this.isInitialized = true; resolve(); }; request.onupgradeneeded = (event) => { const db = event.target.result; if (!db.objectStoreNames.contains(this.storeName)) { const store = db.createObjectStore(this.storeName, { keyPath: 'id' }); store.createIndex('timestamp', 'timestamp', { unique: false }); store.createIndex('source', 'source', { unique: false }); } }; }); } catch (error) { console.error('StateMirror: Failed to initialize offline queue:', error); throw error; } } /** * Add a patch to the offline queue */ async enqueue(patch) { if (!this.isInitialized) { await this.initialize(); } try { if (this.storageAdapter) { await this.storageAdapter.set(`patch-${patch.id}`, patch); return; } if (!this.db) { throw new Error('Database not initialized'); } return new Promise((resolve, reject) => { const transaction = this.db.transaction([this.storeName], 'readwrite'); const store = transaction.objectStore(this.storeName); const request = store.put(patch); request.onerror = () => reject(request.error); request.onsuccess = () => resolve(); }); } catch (error) { console.error('StateMirror: Failed to enqueue patch:', error); throw error; } } /** * Get all patches from the queue */ async getAll() { if (!this.isInitialized) { await this.initialize(); } try { if (this.storageAdapter) { // This is a simplified implementation - in practice you'd need to list all keys return []; } if (!this.db) { throw new Error('Database not initialized'); } return new Promise((resolve, reject) => { const transaction = this.db.transaction([this.storeName], 'readonly'); const store = transaction.objectStore(this.storeName); const request = store.getAll(); request.onerror = () => reject(request.error); request.onsuccess = () => resolve(request.result || []); }); } catch (error) { console.error('StateMirror: Failed to get patches from queue:', error); return []; } } /** * Remove patches from the queue */ async remove(patches) { if (!this.isInitialized) { await this.initialize(); } try { if (this.storageAdapter) { for (const patch of patches) { await this.storageAdapter.remove(`patch-${patch.id}`); } return; } if (!this.db) { throw new Error('Database not initialized'); } return new Promise((resolve, reject) => { const transaction = this.db.transaction([this.storeName], 'readwrite'); const store = transaction.objectStore(this.storeName); let completed = 0; let hasError = false; patches.forEach(patch => { const request = store.delete(patch.id); request.onerror = () => { if (!hasError) { hasError = true; reject(request.error); } }; request.onsuccess = () => { completed++; if (completed === patches.length && !hasError) { resolve(); } }; }); }); } catch (error) { console.error('StateMirror: Failed to remove patches from queue:', error); throw error; } } /** * Clear all patches from the queue */ async clear() { if (!this.isInitialized) { await this.initialize(); } try { if (this.storageAdapter) { await this.storageAdapter.clear(); return; } if (!this.db) { throw new Error('Database not initialized'); } return new Promise((resolve, reject) => { const transaction = this.db.transaction([this.storeName], 'readwrite'); const store = transaction.objectStore(this.storeName); const request = store.clear(); request.onerror = () => reject(request.error); request.onsuccess = () => resolve(); }); } catch (error) { console.error('StateMirror: Failed to clear queue:', error); throw error; } } /** * Get queue status */ async getStatus() { if (!this.isInitialized) { await this.initialize(); } try { const patches = await this.getAll(); return { pending: patches.length, processing: this.flushInProgress ? patches.length : 0, failed: 0, // This would need to be tracked separately lastFlush: Date.now() // This would need to be tracked separately }; } catch (error) { console.error('StateMirror: Failed to get queue status:', error); return { pending: 0, processing: 0, failed: 0, lastFlush: 0 }; } } /** * Flush the queue by sending all patches */ async flush(sendFunction) { if (this.flushInProgress) { console.warn('StateMirror: Flush already in progress'); return; } this.flushInProgress = true; try { const patches = await this.getAll(); if (patches.length === 0) { return; } console.log(`StateMirror: Flushing ${patches.length} patches from queue`); // Sort patches by timestamp patches.sort((a, b) => a.timestamp - b.timestamp); const successfulPatches = []; const failedPatches = []; for (const patch of patches) { try { await sendFunction(patch); successfulPatches.push(patch); } catch (error) { console.error('StateMirror: Failed to send patch from queue:', error); failedPatches.push(patch); } } // Remove successful patches from queue if (successfulPatches.length > 0) { await this.remove(successfulPatches); } console.log(`StateMirror: Flushed ${successfulPatches.length} patches, ${failedPatches.length} failed`); } catch (error) { console.error('StateMirror: Error during queue flush:', error); throw error; } finally { this.flushInProgress = false; } } /** * Check if the queue is empty */ async isEmpty() { const patches = await this.getAll(); return patches.length === 0; } /** * Get the size of the queue */ async size() { const patches = await this.getAll(); return patches.length; } /** * Close the database connection */ close() { if (this.db) { this.db.close(); this.db = null; } this.isInitialized = false; } } class StateMirrorWatcher { constructor() { this.isConnected = false; this.isWatching = false; this.plugins = new Map(); this.eventHandlers = new Map(); this.version = 0; this.sourceId = uuid.v4(); this.diffEngine = new DiffEngine(); this.broadcaster = new Broadcaster(this.sourceId); this.conflictEngine = new ConflictEngine(); this.throttleManager = new ThrottleManager(); this.offlineQueue = new OfflineQueue(); // Set up throttled and debounced sync functions this.debouncedSync = this.throttleManager.debounce(this.performSync.bind(this), 100); this.throttledSync = this.throttleManager.throttle(this.performSync.bind(this), 1000); } /** * Start watching a state object */ watch(state, config) { this.state = state; this.config = { strategy: 'broadcast', debounce: 100, throttle: 1000, ...config }; this.id = config.id; // Initialize components this.diffEngine.setInitialState(state); this.initializePlugins(); this.connect(); this.isWatching = true; this.emit('update', { type: 'watch-started', state }); return this; } /** * Stop watching the state */ unwatch() { this.isWatching = false; this.disconnect(); this.diffEngine.reset(); this.throttleManager.clear(); this.emit('update', { type: 'watch-stopped' }); } /** * Update the state with operations */ update(operations) { if (!this.isWatching) return; try { // Apply operations to state jsonpatch.applyPatch(this.state, operations).newDocument; // Generate patch for broadcasting const patch = { id: uuid.v4(), timestamp: Date.now(), source: this.sourceId, target: this.id, operations, version: ++this.version, metadata: { applied: true } }; // Process through plugins let processedPatch = this.processPlugins('onSend', patch); if (processedPatch) { this.broadcastPatch(processedPatch); } this.emit('update', { type: 'state-updated', patch, operations }); } catch (error) { console.error('StateMirror: Error applying operations:', error); this.emit('error', { type: 'apply-error', error, operations }); } } /** * Sync the current state */ async sync() { if (!this.isWatching) return; const diffResult = this.diffEngine.generatePatch(this.state, this.config.paths); if (diffResult.hasChanges) { const patch = { id: uuid.v4(), timestamp: Date.now(), source: this.sourceId, target: this.id, operations: diffResult.operations, version: ++this.version }; await this.broadcastPatch(patch); } } /** * Use a plugin */ use(plugin) { if (this.plugins.has(plugin.id)) { console.warn(`StateMirror: Plugin ${plugin.id} already registered`); return this; } this.plugins.set(plugin.id, plugin); if (plugin.onInit) { try { plugin.onInit(this); } catch (error) { console.error(`StateMirror: Error initializing plugin ${plugin.id}:`, error); this.emit('plugin-error', { pluginId: plugin.id, error }); } } this.emit('plugin-loaded', { plugin }); return this; } /** * Remove a plugin */ removePlugin(pluginId) { const plugin = this.plugins.get(pluginId); if (plugin && plugin.onDestroy) { try { plugin.onDestroy(this); } catch (error) { console.error(`StateMirror: Error destroying plugin ${pluginId}:`, error); } } this.plugins.delete(pluginId); } /** * Add event listener */ on(event, handler) { if (!this.eventHandlers.has(event)) { this.eventHandlers.set(event, []); } this.eventHandlers.get(event).push(handler); } /** * Remove event listener */ off(event, handler) { const handlers = this.eventHandlers.get(event); if (handlers) { const index = handlers.indexOf(handler); if (index !== -1) { handlers.splice(index, 1); } } } /** * Emit an event */ emit(event, data) { const handlers = this.eventHandlers.get(event); if (handlers) { handlers.forEach(handler => { try { handler(data); } catch (error) { console.error(`StateMirror: Error in event handler for ${event}:`, error); } }); } } /** * Enable DevTools */ enableDevTools() { // This will be implemented in the devtools module console.log('StateMirror: DevTools enabled'); } /** * Disable DevTools */ disableDevTools() { // This will be implemented in the devtools module console.log('StateMirror: DevTools disabled'); } /** * Flush the offline queue */ async flushQueue() { await this.offlineQueue.flush(this.broadcastPatch.bind(this)); } /** * Get queue status */ async getQueueStatus() { return await this.offlineQueue.getStatus(); } async connect() { try { await this.broadcaster.connect(); this.broadcaster.onMessage(this.handleMessage.bind(this)); this.isConnected = true; this.emit('connect'); // Flush any queued patches await this.flushQueue(); } catch (error) { console.error('StateMirror: Failed to connect:', error); this.emit('error', { type: 'connection-error', error }); } } disconnect() { this.broadcaster.disconnect(); this.isConnected = false; this.emit('disconnect'); } async broadcastPatch(patch) { if (!this.isConnected) { // Queue for later if offline await this.offlineQueue.enqueue(patch); return; } try { const message = { type: 'patch', data: patch, source: this.sourceId, timestamp: Date.now() }; await this.broadcaster.send(message); } catch (error) { console.error('StateMirror: Failed to broadcast patch:', error); // Queue for retry await this.offlineQueue.enqueue(patch); } } handleMessage(message) { if (message.type === 'patch' && message.data) { this.handlePatch(message.data); } } handlePatch(patch) { // Ignore our own patches if (patch.source === this.sourceId) return; // Process through plugins let processedPatch = this.processPlugins('onReceive', patch); if (!processedPatch) return; // Check for conflicts const currentPatch = this.getCurrentPatch(); if (currentPatch && this.conflictEngine.hasConflicts(currentPatch, processedPatch)) { processedPatch = this.conflictEngine.resolveConflict(currentPatch, processedPatch, this); this.emit('conflict', { local: currentPatch, incoming: patch, resolved: processedPatch }); } // Apply the patch this.applyPatch(processedPatch); } applyPatch(patch) { try { const result = jsonpatch.applyPatch(this.state, patch.operations).newDocument; // Process through plugins this.processPlugins('onApply', patch); this.emit('sync', { type: 'patch-applied', patch, result }); } catch (error) { console.error('StateMirror: Error applying patch:', error); this.emit('error', { type: 'patch-error', error, patch }); } } processPlugins(hook, data) { let processedData = data; for (const plugin of this.plugins.values()) { const hookFn = plugin[hook]; if (typeof hookFn === 'function') { try { const result = hookFn(processedData, this); if (result !== undefined) { processedData = result; } } catch (error) { console.error(`StateMirror: Error in plugin ${plugin.id} hook ${hook}:`, error); this.emit('plugin-error', { pluginId: plugin.id, hook, error }); } } } return processedData; } initializePlugins() { if (this.config.plugins) { this.config.plugins.forEach(plugin => this.use(plugin)); } } getCurrentPatch() { // This would return the current patch being processed // For now, return null return null; } async performSync() { await this.sync(); } } /** * Create a new StateMirror instance */ function stateMirror() { return new StateMirrorWatcher(); } /** * Watch a state object with the given configuration */ function watch(state, config) { const mirror = stateMirror(); return mirror.watch(state, config); } exports.Broadcaster = Broadcaster; exports.ConflictEngine = ConflictEngine; exports.DiffEngine = DiffEngine; exports.OfflineQueue = OfflineQueue; exports.StateMirrorWatcher = StateMirrorWatcher; exports.ThrottleManager = ThrottleManager; exports.debounce = debounce; exports.default = stateMirror; exports.stateMirror = stateMirror; exports.throttle = throttle; exports.watch = watch; //# sourceMappingURL=index.js.map