state-mirror
Version:
Real-time cross-tab/device state synchronization library with plugin support.
1,273 lines (1,264 loc) • 42 kB
JavaScript
import { v4 } from 'uuid';
import jsonpatch from '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 = 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: 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: 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);
}
export { Broadcaster, ConflictEngine, DiffEngine, OfflineQueue, StateMirrorWatcher, ThrottleManager, debounce, stateMirror as default, stateMirror, throttle, watch };
//# sourceMappingURL=index.esm.js.map