strata-storage
Version:
Zero-dependency universal storage plugin providing a unified API for all storage operations across web, Android, and iOS platforms
234 lines (233 loc) • 7.5 kB
JavaScript
/**
* Cross-tab Synchronization Feature
* Zero-dependency implementation using BroadcastChannel and storage events
*/
import { EventEmitter } from "../utils/index.js";
/**
* Cross-tab synchronization manager
*/
export class SyncManager extends EventEmitter {
config;
channel;
origin;
listeners = new Map();
debounceTimers = new Map();
constructor(config = {}) {
super();
this.config = {
enabled: config.enabled ?? true,
channelName: config.channelName || 'strata-sync',
storages: config.storages || ['localStorage', 'sessionStorage'],
conflictResolution: config.conflictResolution || 'latest',
debounceMs: config.debounceMs || 50,
};
this.origin = this.generateOrigin();
}
/**
* Initialize sync manager
*/
async initialize() {
if (!this.config.enabled)
return;
// Set up BroadcastChannel if available
if (this.isBroadcastChannelAvailable()) {
this.setupBroadcastChannel();
}
// Set up storage event listeners
this.setupStorageEvents();
}
/**
* Broadcast a change to other tabs
*/
broadcast(message) {
if (!this.config.enabled)
return;
const fullMessage = {
...message,
origin: this.origin,
};
// Debounce broadcasts
const debounceKey = `${message.type}:${message.key || '*'}`;
if (this.debounceTimers.has(debounceKey)) {
clearTimeout(this.debounceTimers.get(debounceKey));
}
this.debounceTimers.set(debounceKey, setTimeout(() => {
this.sendMessage(fullMessage);
this.debounceTimers.delete(debounceKey);
}, this.config.debounceMs));
}
/**
* Subscribe to sync events
*/
subscribe(callback) {
const handler = (change) => {
callback(change);
};
this.on('change', handler);
return () => {
this.off('change', handler);
};
}
/**
* Close sync manager
*/
close() {
// Clear all debounce timers
for (const timer of this.debounceTimers.values()) {
clearTimeout(timer);
}
this.debounceTimers.clear();
// Close BroadcastChannel
if (this.channel) {
this.channel.close();
this.channel = undefined;
}
// Remove storage event listeners
this.listeners.forEach((listener) => {
window.removeEventListener('storage', listener);
});
this.listeners.clear();
// Remove all event listeners
this.removeAllListeners();
}
/**
* Check if BroadcastChannel is available
*/
isBroadcastChannelAvailable() {
return typeof window !== 'undefined' && 'BroadcastChannel' in window;
}
/**
* Set up BroadcastChannel for cross-tab communication
*/
setupBroadcastChannel() {
try {
this.channel = new BroadcastChannel(this.config.channelName);
this.channel.onmessage = (event) => {
const message = event.data;
// Ignore messages from self
if (message.origin === this.origin)
return;
// Emit change event
this.emitChange({
key: message.key || '*',
oldValue: undefined,
newValue: message.value,
source: 'remote',
storage: message.storage,
timestamp: message.timestamp,
});
};
this.channel.onmessageerror = (event) => {
console.error('Sync message error:', event);
};
}
catch (error) {
console.warn('Failed to set up BroadcastChannel:', error);
}
}
/**
* Set up storage event listeners for fallback sync
*/
setupStorageEvents() {
if (typeof window === 'undefined')
return;
const listener = (event) => {
// Only process events from other windows
if (event.storageArea !== window.localStorage)
return;
// Parse the storage type from key prefix
let storageType = 'localStorage';
if (event.key?.startsWith('strata:session:')) {
storageType = 'sessionStorage';
}
// Check if this storage type is enabled for sync
if (!this.config.storages.includes(storageType))
return;
let oldValue;
let newValue;
try {
oldValue = event.oldValue ? JSON.parse(event.oldValue) : undefined;
newValue = event.newValue ? JSON.parse(event.newValue) : undefined;
}
catch {
// If parsing fails, use raw values
oldValue = event.oldValue;
newValue = event.newValue;
}
this.emitChange({
key: event.key || '*',
oldValue,
newValue,
source: 'remote',
storage: storageType,
timestamp: Date.now(),
});
};
window.addEventListener('storage', listener);
// Create a SubscriptionCallback wrapper for the storage event listener
const subscriptionCallback = (_change) => {
// This is handled by the listener itself
};
this.listeners.set(subscriptionCallback, listener);
}
/**
* Send message through available channels
*/
sendMessage(message) {
// Send through BroadcastChannel if available
if (this.channel) {
try {
this.channel.postMessage(message);
}
catch (error) {
console.error('Failed to send sync message:', error);
}
}
// Storage events are automatic when using localStorage
// No need to manually trigger them
}
/**
* Emit a change event
*/
emitChange(change) {
this.emit('change', change);
}
/**
* Generate unique origin ID
*/
generateOrigin() {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
/**
* Resolve conflicts between values
*/
resolveConflict(values) {
if (values.length === 0)
return null;
if (values.length === 1)
return values[0];
if (typeof this.config.conflictResolution === 'function') {
return this.config.conflictResolution(values);
}
switch (this.config.conflictResolution) {
case 'latest':
// Return the last value (most recent)
return values[values.length - 1];
case 'merge':
// Simple merge for objects
if (values.every((v) => typeof v === 'object' && v !== null && !Array.isArray(v))) {
return Object.assign({}, ...values);
}
// For non-objects, use latest
return values[values.length - 1];
default:
return values[values.length - 1];
}
}
}
/**
* Create a sync manager instance
*/
export function createSyncManager(config) {
return new SyncManager(config);
}