import type { SharedWorkerControlMessage, UserEventMessage, UserEventType, WorkerEventMessage, WorkerInboundMessage, } from '../types.ts'; const {appSubUrl, sharedWorkerUri} = window.config; type EventOf = Extract; type Subscriber = (msg: EventOf) => void; const subscribers = new Map>(); let fallbackSignalled = false; let sharedWorker: SharedWorker | null = null; function dispatch(msg: UserEventMessage) { if (msg.eventType === 'worker-connected') { document.documentElement.setAttribute('data-user-events-connected', 'true'); } const set = subscribers.get(msg.eventType); if (!set) return; for (const cb of set) cb(msg); } function signalFallback() { if (fallbackSignalled) return; fallbackSignalled = true; dispatch({eventType: 'worker-unavailable'}); } function init() { try { sharedWorker = new SharedWorker(sharedWorkerUri, {type: 'module', name: 'user-events'}); } catch (err) { console.warn('SharedWorker unavailable, falling back to periodic polling', err); queueMicrotask(signalFallback); return; } // Browsers without module-SharedWorker support fail at parse time, before the WebSocket opens. sharedWorker.addEventListener('error', (event) => { console.error('worker error', event); signalFallback(); }); sharedWorker.port.addEventListener('messageerror', () => { console.error('unable to deserialize message'); }); sharedWorker.port.addEventListener('error', (e) => { console.error('worker port error', e); }); const postSharedWorkerControlMessage = (sw: SharedWorker, msg: SharedWorkerControlMessage) => { sw.port.postMessage(msg); }; const handleWorkerEvent = (msg: WorkerEventMessage) => { if (msg.workerEvent === 'error') { console.error('worker port event error', msg); } else if (msg.workerEvent === 'close') { postSharedWorkerControlMessage(sharedWorker!, {type: 'close'}); sharedWorker!.port.close(); } else { console.error('unknown worker port event', msg); } }; const handleLogout = () => { postSharedWorkerControlMessage(sharedWorker!, {type: 'close'}); sharedWorker!.port.close(); // slightly delay our "logout" for a short while, in case there are other logout requests in-flight. // * if the logout is triggered by a page redirection (e.g.: user clicks "/user/logout") // * "beforeunload" event is triggered, this code path won't execute // * if the logout is triggered by a fetch call // * "beforeunload" event is not triggered until JS does the redirection. // * in this case, the logout fetch call already completes and has sent the "logout" message to the worker // * there can be a data-race between the fetch call's redirection and the "logout" message from the worker // * the fetch call's logout redirection should always win over the worker message, because it might have a custom location setTimeout(() => { window.location.assign(`${appSubUrl}/`) }, 1000); }; sharedWorker.port.addEventListener('message', (event: MessageEvent) => { const msg = event.data; if (msg?.msgType === 'worker-event') { handleWorkerEvent(msg.msgData); } else if (msg?.msgType === 'user-event') { if (msg.msgData?.eventType === 'logout') { handleLogout(); return; } dispatch(msg.msgData); } else { console.error('unknown inbound message', msg); } }); sharedWorker.port.start(); const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; postSharedWorkerControlMessage(sharedWorker, { type: 'start', url: `${wsProtocol}//${window.location.host}${appSubUrl}/-/ws`, showDebugLog: !window.config.runModeIsProd, }); window.addEventListener('beforeunload', () => { // FIXME: this logic is not quite right. // "beforeunload" can be canceled by some actions like "are-you-sure" and the navigation can be cancelled. // In this case: the worker port is incorrectly closed while the page is still there. postSharedWorkerControlMessage(sharedWorker!, {type: 'close'}); sharedWorker!.port.close(); }); } let initialized = false; export function onUserEvent(type: T, cb: Subscriber): () => void { if (!initialized) { initialized = true; if (window.WebSocket && window.SharedWorker) { init(); } else { queueMicrotask(signalFallback); } } let set = subscribers.get(type); if (!set) { set = new Set(); subscribers.set(type, set); } const wrapped: Subscriber = (msg) => cb(msg as EventOf); set.add(wrapped); return () => { set.delete(wrapped) }; }