UNPKG

solo-tab-enforcer

Version:

Cross-browser solution for enforcing single tab usage in web applications

480 lines (422 loc) 11.9 kB
/** * Solo Tab Enforcer - Core Module * Cross-browser solution for enforcing single tab usage */ class TabEnforcer { constructor(options = {}) { this.options = { storageKey: 'solo-tab-enforcer', checkInterval: 1000, warningMessage: 'Another tab is already open. Please close other tabs to continue.', redirectUrl: null, allowMultipleTabs: false, debug: false, onTabConflict: null, onTabActivated: null, onTabDeactivated: null, useVisibilityAPI: true, useBroadcastChannel: true, useStorageEvents: true, tabTimeoutMs: 5000, ...options, }; this.tabId = this.generateTabId(); this.isActive = false; this.isInitialized = false; this.broadcastChannel = null; this.storageEventListener = null; this.visibilityChangeListener = null; this.beforeUnloadListener = null; this.focusListener = null; this.blurListener = null; this.checkTimer = null; this.heartbeatTimer = null; this.supportedFeatures = this.detectFeatures(); this.log('TabEnforcer initialized with options:', this.options); } /** * Generate unique tab identifier */ generateTabId() { return `tab_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } /** * Detect browser features */ detectFeatures() { return { broadcastChannel: typeof BroadcastChannel !== 'undefined', visibilityAPI: typeof document.visibilityState !== 'undefined', localStorage: typeof localStorage !== 'undefined', sessionStorage: typeof sessionStorage !== 'undefined', storageEvents: typeof window.addEventListener !== 'undefined', }; } /** * Initialize the tab enforcer */ init() { if (this.isInitialized) { this.log('TabEnforcer already initialized'); return; } this.log('Initializing TabEnforcer...'); if (this.options.allowMultipleTabs) { this.log('Multiple tabs allowed, enforcer disabled'); return; } this.setupEventListeners(); this.registerTab(); this.startHeartbeat(); this.startTabCheck(); this.isInitialized = true; this.log('TabEnforcer initialized successfully'); } /** * Setup event listeners for different browser APIs */ setupEventListeners() { // BroadcastChannel for modern browsers if ( this.options.useBroadcastChannel && this.supportedFeatures.broadcastChannel ) { this.setupBroadcastChannel(); } // Storage events for cross-tab communication if (this.options.useStorageEvents && this.supportedFeatures.storageEvents) { this.setupStorageEvents(); } // Visibility API for tab focus detection if (this.options.useVisibilityAPI && this.supportedFeatures.visibilityAPI) { this.setupVisibilityAPI(); } // Window focus/blur events (fallback) this.setupFocusEvents(); // Cleanup on page unload this.setupUnloadEvents(); } /** * Setup BroadcastChannel communication */ setupBroadcastChannel() { try { this.broadcastChannel = new BroadcastChannel(this.options.storageKey); this.broadcastChannel.onmessage = (event) => { this.handleBroadcastMessage(event.data); }; this.log('BroadcastChannel initialized'); } catch (error) { this.log('BroadcastChannel failed to initialize:', error); } } /** * Setup storage events for cross-tab communication */ setupStorageEvents() { this.storageEventListener = (event) => { if (event.key === this.options.storageKey) { this.handleStorageEvent(event); } }; window.addEventListener('storage', this.storageEventListener); this.log('Storage events initialized'); } /** * Setup Visibility API */ setupVisibilityAPI() { this.visibilityChangeListener = () => { if (document.visibilityState === 'visible') { this.handleTabActivated(); } else { this.handleTabDeactivated(); } }; document.addEventListener( 'visibilitychange', this.visibilityChangeListener ); this.log('Visibility API initialized'); } /** * Setup focus/blur events */ setupFocusEvents() { this.focusListener = () => this.handleTabActivated(); this.blurListener = () => this.handleTabDeactivated(); window.addEventListener('focus', this.focusListener); window.addEventListener('blur', this.blurListener); this.log('Focus events initialized'); } /** * Setup unload events */ setupUnloadEvents() { this.beforeUnloadListener = () => { this.unregisterTab(); }; window.addEventListener('beforeunload', this.beforeUnloadListener); window.addEventListener('unload', this.beforeUnloadListener); this.log('Unload events initialized'); } /** * Register current tab */ registerTab() { const tabData = { id: this.tabId, timestamp: Date.now(), url: window.location.href, userAgent: navigator.userAgent, isActive: document.visibilityState === 'visible', }; this.setStorageData(tabData); this.broadcastMessage({ type: 'tab-registered', tabId: this.tabId }); this.log('Tab registered:', this.tabId); } /** * Unregister current tab */ unregisterTab() { this.removeStorageData(); this.broadcastMessage({ type: 'tab-unregistered', tabId: this.tabId }); this.log('Tab unregistered:', this.tabId); } /** * Start heartbeat to maintain tab presence */ startHeartbeat() { this.heartbeatTimer = setInterval(() => { this.updateHeartbeat(); }, this.options.checkInterval); } /** * Update heartbeat timestamp */ updateHeartbeat() { const existingData = this.getStorageData(); if (existingData && existingData.id === this.tabId) { existingData.timestamp = Date.now(); this.setStorageData(existingData); } } /** * Start tab checking routine */ startTabCheck() { this.checkTimer = setInterval(() => { this.checkForConflicts(); }, this.options.checkInterval); } /** * Check for tab conflicts */ checkForConflicts() { const existingData = this.getStorageData(); if (!existingData) { // No existing tab, register this one this.registerTab(); return; } // Check if existing tab is still alive const timeDiff = Date.now() - existingData.timestamp; if (timeDiff > this.options.tabTimeoutMs) { // Existing tab is dead, take over this.registerTab(); return; } // Check if this is a different tab if (existingData.id !== this.tabId) { this.handleTabConflict(existingData); } } /** * Handle tab conflict */ handleTabConflict(existingTab) { this.log('Tab conflict detected:', existingTab); if (this.options.onTabConflict) { this.options.onTabConflict(existingTab); } else { this.showDefaultWarning(); } } /** * Show default warning message */ showDefaultWarning() { if (this.options.redirectUrl) { window.location.href = this.options.redirectUrl; } else { alert(this.options.warningMessage); window.close(); } } /** * Handle tab activation */ handleTabActivated() { this.isActive = true; this.log('Tab activated'); if (this.options.onTabActivated) { this.options.onTabActivated(); } // Re-register tab when activated this.registerTab(); } /** * Handle tab deactivation */ handleTabDeactivated() { this.isActive = false; this.log('Tab deactivated'); if (this.options.onTabDeactivated) { this.options.onTabDeactivated(); } } /** * Handle broadcast messages */ handleBroadcastMessage(data) { this.log('Received broadcast message:', data); switch (data.type) { case 'tab-registered': if (data.tabId !== this.tabId) { this.checkForConflicts(); } break; case 'tab-unregistered': // Another tab closed, we might be able to take over if (data.tabId !== this.tabId) { setTimeout(() => this.registerTab(), 100); } break; } } /** * Handle storage events */ handleStorageEvent(event) { this.log('Storage event:', event); if (event.newValue && event.newValue !== event.oldValue) { const newData = JSON.parse(event.newValue); if (newData.id !== this.tabId) { this.checkForConflicts(); } } } /** * Broadcast message to other tabs */ broadcastMessage(data) { if (this.broadcastChannel) { try { this.broadcastChannel.postMessage(data); } catch (error) { this.log('Failed to broadcast message:', error); } } } /** * Get data from storage */ getStorageData() { try { const data = localStorage.getItem(this.options.storageKey); return data ? JSON.parse(data) : null; } catch (error) { this.log('Failed to get storage data:', error); return null; } } /** * Set data to storage */ setStorageData(data) { try { localStorage.setItem(this.options.storageKey, JSON.stringify(data)); } catch (error) { this.log('Failed to set storage data:', error); } } /** * Remove data from storage */ removeStorageData() { try { localStorage.removeItem(this.options.storageKey); } catch (error) { this.log('Failed to remove storage data:', error); } } /** * Get current tab information */ getTabInfo() { return { id: this.tabId, isActive: this.isActive, isInitialized: this.isInitialized, supportedFeatures: this.supportedFeatures, options: this.options, }; } /** * Destroy the tab enforcer */ destroy() { this.log('Destroying TabEnforcer...'); // Clear timers if (this.checkTimer) { clearInterval(this.checkTimer); } if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); } // Remove event listeners if (this.storageEventListener) { window.removeEventListener('storage', this.storageEventListener); } if (this.visibilityChangeListener) { document.removeEventListener( 'visibilitychange', this.visibilityChangeListener ); } if (this.beforeUnloadListener) { window.removeEventListener('beforeunload', this.beforeUnloadListener); window.removeEventListener('unload', this.beforeUnloadListener); } if (this.focusListener) { window.removeEventListener('focus', this.focusListener); } if (this.blurListener) { window.removeEventListener('blur', this.blurListener); } // Close broadcast channel if (this.broadcastChannel) { this.broadcastChannel.close(); } // Unregister tab this.unregisterTab(); this.isInitialized = false; this.log('TabEnforcer destroyed'); } /** * Log messages if debug is enabled */ log(...args) { if (this.options.debug) { console.log(`[TabEnforcer:${this.tabId}]`, ...args); } } } // Export for different environments if (typeof module !== 'undefined' && module.exports) { module.exports = TabEnforcer; } if (typeof window !== 'undefined') { window.TabEnforcer = TabEnforcer; }