reactant-share
Version:
A framework for building shared web applications with Reactant
149 lines (138 loc) • 4.93 kB
text/typescript
import { createId } from './utils';
type LockName = string;
type LockId = string;
type LockCallBack = (lock: {
name: string;
mode: 'exclusive';
}) => Promise<void>;
type LockQueue = { tabId: string; lockId: string }[];
const lockMap: Map<LockName, Map<LockId, LockCallBack>> = new Map();
const tabId = createId();
const lockStorageKey = 'reactant:lock';
const tabStorageKey = 'reactant:tab';
let heartbeatTimer: number;
let isListenUnload = false;
let storage: Storage;
const clearTabLocks = (tabIds: string[], _storage: Storage) => {
if (tabIds.length === 0) return;
Object.keys(localStorage).forEach((key) => {
if (!key.indexOf(lockStorageKey)) {
const lockQueue: LockQueue = JSON.parse(
localStorage.getItem(key) ?? '[]'
);
const newValue = JSON.stringify(
lockQueue.filter(
(item) => tabIds.indexOf(item.tabId) === -1
// && localStorage.getItem(`${tabStorageKey}:${item.tabId}`)
)
);
_storage.setItem(key, newValue);
}
});
tabIds.forEach((_tabId) => _storage.removeItem(`${tabStorageKey}:${_tabId}`));
};
const addUnloadListener = () => {
if (isListenUnload) return;
isListenUnload = true;
/**
* After unload event, It is known that the clear of localStorage in some Firefox scenarios and Safari v10 does not execute properly.
*/
window.addEventListener('pagehide', () => {
// do not use `unload` event
// https://developer.chrome.com/docs/web-platform/deprecating-unload
clearTabLocks([tabId], localStorage);
});
};
const filterInvalidTabs = () => {
const invalidTabIds: string[] = [];
Object.keys(localStorage).forEach((key) => {
if (!key.indexOf(tabStorageKey)) {
const timestamp = localStorage.getItem(key);
// TODO: think about Wakeup
// Maximum is thread lock for 1 second + 1.99 second.
if (timestamp && Date.now() - Number(timestamp) > 2999) {
const expiredTabId = key.replace(`${tabStorageKey}:`, '');
invalidTabIds.push(expiredTabId);
}
} else if (!key.indexOf(lockStorageKey)) {
const lockQueue: LockQueue = JSON.parse(
localStorage.getItem(key) ?? '[]'
);
lockQueue.forEach((item) => {
if (!localStorage.getItem(`${tabStorageKey}:${item.tabId}`)) {
invalidTabIds.push(item.tabId);
}
});
}
});
return invalidTabIds;
};
const heartbeat = () => {
if (typeof heartbeatTimer === 'undefined') {
const tabHeartbeatKey = `${tabStorageKey}:${tabId}`;
storage.setItem(tabHeartbeatKey, Date.now().toString());
heartbeatTimer = window.setInterval(
() => storage.setItem(tabHeartbeatKey, Date.now().toString()),
1000
);
}
};
const createFrameStorage = () => {
const iframe = document.createElement('iframe');
iframe.src = 'about:blank';
iframe.setAttribute('style', 'display: none;');
document.body.appendChild(iframe);
storage = iframe!.contentWindow!.localStorage;
};
const simpleLock = (name: LockName, callback: LockCallBack) => {
createFrameStorage();
addUnloadListener();
heartbeat();
return new Promise((resolve, reject) => {
const lockId = createId();
lockMap.set(name, lockMap.get(name) ?? new Map());
lockMap.get(name)!.set(lockId, callback);
const storageKey = `${lockStorageKey}:${name}`;
const oldStorageValue = localStorage.getItem(storageKey);
const lockQueue: LockQueue = JSON.parse(oldStorageValue ?? '[]');
lockQueue.push({ tabId, lockId });
const listener = async (event: StorageEvent) => {
if (event.key === storageKey && event.newValue) {
const [lock]: LockQueue = JSON.parse(event.newValue);
if (lock?.tabId === tabId && lock?.lockId === lockId) {
window.removeEventListener('storage', listener);
try {
const result = await lockMap.get(name)!.get(lockId)!({
name,
mode: 'exclusive',
});
resolve(result);
} catch (e) {
reject(e);
}
lockMap.get(name)!.delete(lockId);
const currentLockQueue: LockQueue = JSON.parse(
localStorage.getItem(storageKey) ?? '[]'
);
currentLockQueue.splice(0, 1);
storage.setItem(storageKey, JSON.stringify(currentLockQueue));
}
}
};
window.addEventListener('storage', listener);
storage.setItem(storageKey, JSON.stringify(lockQueue));
clearTabLocks(filterInvalidTabs(), storage);
});
};
export const useLock = (name: LockName, callback: LockCallBack) => {
const isPrimitiveLock = !!(navigator as any).locks?.request;
if (isPrimitiveLock) {
return (navigator as any).locks.request(name, callback).catch((e: any) => {
if (e.code === 18 && e.toString().startsWith('SecurityError')) {
return simpleLock(name, callback);
}
return e;
});
}
return simpleLock(name, callback);
};