import { useStorage, useTimeoutFn } from '@vueuse/core' interface SharedExecutionOptions { timeout?: number // Maximum time a lock can be held before it's considered stale - default 5000ms storageDelay?: number // Delay before reading from storage to allow for changes to propagate - default 50ms debug?: boolean // Enable or disable debug logging } const tabId = `tab-${Math.random().toString(36).slice(2, 9)}` /** * Creates a composable that ensures a function is executed only once across all tabs * @param key Unique key to identify the function * @param fn Function to be executed * @param options Optional configuration (timeout, storageDelay) * @returns A wrapped function that ensures single execution across tabs */ export function useSharedExecutionFn(key: string, fn: () => Promise | T, options: SharedExecutionOptions = {}) { const { timeout = 5000, storageDelay = 50, debug = false } = options const storageResultKey = `nc-shared-execution-${key}-result` const storageLockKey = `nc-shared-execution-${key}-lock` const storageResultState = useStorage<{ status?: 'success' | 'error' result?: T error?: any }>(storageResultKey, {}) const debugLog = (...args: any[]) => { if (debug) console.log(`[${tabId}]`, ...args) } debugLog(`Tab initialized with ID: ${tabId}`) const getLock = (): { timestamp: number; tabId: string } | null => { try { return JSON.parse(localStorage.getItem(storageLockKey) || 'null') } catch (error) { debugLog(`Error reading lock:`, error) return null } } const acquireLock = async (): Promise => { let currentLock = getLock() const now = Date.now() if (!currentLock) { localStorage.setItem(storageLockKey, JSON.stringify({ timestamp: now, tabId })) // Allow storage updates to propagate - which will determine strictness of lock await new Promise((resolve) => setTimeout(resolve, storageDelay)) currentLock = getLock() if (currentLock?.tabId === tabId) { debugLog(`Lock acquired successfully`) return true } debugLog(`Lock acquired by ${currentLock?.tabId}`) return false } const lockIsStale = now - currentLock.timestamp > timeout if (lockIsStale) { localStorage.setItem(storageLockKey, JSON.stringify({ timestamp: now, tabId })) // Allow storage updates to propagate - which will determine strictness of lock await new Promise((resolve) => setTimeout(resolve, storageDelay)) currentLock = getLock() if (currentLock?.tabId === tabId) { debugLog(`Stale lock acquired successfully`) return true } debugLog(`Stale lock acquired by ${currentLock?.tabId}`) return false } debugLog(`Lock is held by ${currentLock?.tabId}`) return false } const releaseLock = (): void => { const currentLock = getLock() if (currentLock?.tabId === tabId) { debugLog(`Releasing lock.`) localStorage.removeItem(storageLockKey) } } const sharedExecutionFn = async (): Promise => { debugLog(`sharedExecutionFn called`) if (!(await acquireLock())) { const currentLock = getLock() return new Promise((resolve, reject) => { let timedOut = false const { start: startTimeout, stop: stopTimeout } = useTimeoutFn( () => { timedOut = true localStorage.removeItem(storageLockKey) reject(new Error(`Timeout waiting for result on key ${key}`)) }, currentLock?.timestamp ? timeout - (Date.now() - currentLock.timestamp) : timeout, ) startTimeout() if (storageResultState.value.status) { storageResultState.value = { ...storageResultState.value, status: undefined } } until(() => storageResultState.value) .toMatch((v) => v.status === 'success' || v.status === 'error') .then((res) => { if (timedOut) return stopTimeout() const { result, error } = res result ? resolve(result) : reject(error) }) }) } try { storageResultState.value = { ...storageResultState.value, status: undefined } const result = await fn() storageResultState.value = { status: 'success', result } return result } catch (error) { storageResultState.value = { status: 'error', error } throw error } finally { releaseLock() debugLog(`Function execution completed (success or failure).`) } } // Make sure to release lock on page unload onBeforeMount(() => { window.addEventListener('beforeunload', releaseLock) }) // Remove listener on component unmount to avoid leaks onBeforeUnmount(() => { window.removeEventListener('beforeunload', releaseLock) }) return sharedExecutionFn }