99 lines
3.5 KiB
JavaScript
99 lines
3.5 KiB
JavaScript
function createOutboxCoordinator({
|
|
storage,
|
|
locks = globalThis.navigator?.locks,
|
|
channelFactory = typeof globalThis.BroadcastChannel === 'function' ?
|
|
() => new globalThis.BroadcastChannel('stackchain-outbox-v1') : null,
|
|
addStorageListener = globalThis.addEventListener?.bind(globalThis),
|
|
removeStorageListener = globalThis.removeEventListener?.bind(globalThis),
|
|
tabId = globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
|
|
now = () => Date.now(),
|
|
leaseMs = 15000,
|
|
} = {}) {
|
|
const changeKey = 'stackchain.outbox-change.v1';
|
|
const subscribers = new Set();
|
|
const channel = channelFactory ? channelFactory() : null;
|
|
let sequence = 0;
|
|
|
|
function validChange(value) {
|
|
return value && (value.queue === 'issue' || value.queue === 'authored') ? value : null;
|
|
}
|
|
|
|
function publish(change) {
|
|
subscribers.forEach(listener => listener(change));
|
|
}
|
|
|
|
if (channel) {
|
|
channel.onmessage = event => {
|
|
const change = validChange(event?.data);
|
|
if (change && change.tabId !== tabId) publish(change);
|
|
};
|
|
}
|
|
|
|
const onStorage = event => {
|
|
if (event?.key !== changeKey || !event.newValue) return;
|
|
try {
|
|
const change = validChange(JSON.parse(event.newValue));
|
|
if (change && change.tabId !== tabId) publish(change);
|
|
} catch (_error) { /* Ignore malformed cross-tab signals. */ }
|
|
};
|
|
if (addStorageListener) addStorageListener('storage', onStorage);
|
|
|
|
function notify(queue) {
|
|
const change = { queue, tabId, changedAt: Number(now()), sequence: ++sequence };
|
|
if (!validChange(change)) return;
|
|
try { storage?.setItem(changeKey, JSON.stringify(change)); } catch (_error) { /* Broadcast may still work. */ }
|
|
try { channel?.postMessage(change); } catch (_error) { /* Storage events remain available. */ }
|
|
}
|
|
|
|
function skipped() {
|
|
return { confirmed: [], remaining: [], blocked: 0, lease_skipped: true };
|
|
}
|
|
|
|
async function withFallbackLease(queue, work) {
|
|
const key = 'stackchain.outbox-lease.' + queue + '.v1';
|
|
const token = tabId + ':' + (++sequence);
|
|
const timestamp = Number(now());
|
|
try {
|
|
const current = JSON.parse(storage?.getItem(key) || 'null');
|
|
if (current?.token && Number(current.expiresAt) > timestamp) return skipped();
|
|
storage?.setItem(key, JSON.stringify({ token, expiresAt: timestamp + leaseMs }));
|
|
const claimed = JSON.parse(storage?.getItem(key) || 'null');
|
|
if (claimed?.token !== token) return skipped();
|
|
} catch (_error) {
|
|
return work();
|
|
}
|
|
try {
|
|
return await work();
|
|
} finally {
|
|
try {
|
|
const current = JSON.parse(storage?.getItem(key) || 'null');
|
|
if (current?.token === token) storage?.removeItem(key);
|
|
} catch (_error) { /* An expired lease will be reclaimed. */ }
|
|
}
|
|
}
|
|
|
|
async function runExclusive(queue, work) {
|
|
if (locks?.request) {
|
|
return locks.request('stackchain-outbox-' + queue + '-v1', { mode: 'exclusive', ifAvailable: true },
|
|
lock => lock ? work() : skipped());
|
|
}
|
|
return withFallbackLease(queue, work);
|
|
}
|
|
|
|
function subscribe(listener) {
|
|
if (typeof listener !== 'function') return () => {};
|
|
subscribers.add(listener);
|
|
return () => subscribers.delete(listener);
|
|
}
|
|
|
|
function close() {
|
|
subscribers.clear();
|
|
try { channel?.close(); } catch (_error) { /* No-op. */ }
|
|
if (removeStorageListener) removeStorageListener('storage', onStorage);
|
|
}
|
|
|
|
return { runExclusive, notify, subscribe, close };
|
|
}
|
|
|
|
if (typeof module !== 'undefined' && module.exports) module.exports = createOutboxCoordinator;
|