PageSourceSearch

https://smrtbld.ai/js/notifications/CrossTabSync.js

js smrtbld.ai collected 2026-10-05 15:08:20 UTC 16,036 bytes, 537 lines download raw bytes

1/**
2 * CrossTabSync - BroadcastChannel Wrapper for Cross-Tab Synchronization
3 *
4 * Keeps notification state synchronized across browser tabs.
5 * Uses BroadcastChannel API (with localStorage fallback for older browsers).
6 *
7 * Message types:
8 * - notification:added
9 * - notification:removed
10 * - notification:markedRead
11 * - notification:tabChanged
12 * - notification:bulkSync
13 * - ping/pong (for leadership election)
14 */
15
16import { pubsub } from '../utils/pubsub.js';
17import { offlineStore } from '../offline/offline-store.js';
18
19const CHANNEL_NAME = 'sb-notifications';
20// Un-scoped key: pre-CA-548 tabs still read it, so the leader keeps writing it.
21const LEADER_KEY = 'sb_notif_leader';
22const LEADER_HEARTBEAT_MS = 5000;
23const LEADER_TIMEOUT_MS = 10000;
24
25// =============================================================================
26// STATE
27// =============================================================================
28
29let channel = null;
30let isLeader = false;
31let leaderId = null;
32let heartbeatInterval = null;
33let leaderHealthInterval = null;
34let tabId = null;
35let active = false;
36let partitionKey = null;
37let _initGeneration = 0;
38let _electionAbort = null;
39let _releaseLock = null;
40let frozen = false;
41
42// =============================================================================
43// INITIALIZATION
44// =============================================================================
45
46/**
47 * Initialize cross-tab sync
48 */
49export function init() {
50    if (active) return;
51    active = true;
52    const generation = ++_initGeneration;
53    tabId = generateTabId();
54    partitionKey = currentPartitionKey();
55
56    if (typeof BroadcastChannel !== 'undefined') {
57        initBroadcastChannel();
58    } else {
59        initStorageFallback();
60    }
61
62    // One leader per signed-in partition; no partition, no hubs (CA-548).
63    if (partitionKey) electLeader(generation);
64
65    console.debug('[CrossTabSync] Initialized, tabId:', tabId);
66}
67
68/**
69 * Cleanup on destroy. Idempotent — the one release path for the leader lock.
70 */
71export function destroy() {
72    ++_initGeneration;
73    unbindPageLifecycle();
74
75    if (heartbeatInterval) {
76        clearInterval(heartbeatInterval);
77        heartbeatInterval = null;
78    }
79
80    if (leaderHealthInterval) {
81        clearInterval(leaderHealthInterval);
82        leaderHealthInterval = null;
83    }
84
85    // Withdraw a pending lock request and release a held one.
86    _electionAbort?.abort();
87    _electionAbort = null;
88    const release = _releaseLock;
89    _releaseLock = null;
90    release?.();
91
92    // Relinquish leadership
93    if (isLeader) {
94        try { localStorage.removeItem(leaderKey()); } catch { /* storage blocked */ }
95        isLeader = false;
96    }
97    leaderId = null;
98    frozen = false;
99
100    if (channel) {
101        channel.close();
102        channel = null;
103    }
104
105    window.removeEventListener('storage', handleStorageEvent);
106    partitionKey = null;
107    active = false;
108}
109
110// =============================================================================
111// BROADCAST CHANNEL IMPLEMENTATION
112// =============================================================================
113
114function initBroadcastChannel() {
115    channel = new BroadcastChannel(CHANNEL_NAME);
116
117    channel.onmessage = (event) => {
118        handleMessage(event.data);
119    };
120
121    channel.onmessageerror = (event) => {
122        console.warn('[CrossTabSync] Message error:', event);
123    };
124}
125
126// =============================================================================
127// LOCALSTORAGE FALLBACK (for older browsers)
128// =============================================================================
129
130const STORAGE_MSG_KEY = 'sb_notif_msg';
131
132function initStorageFallback() {
133    window.addEventListener('storage', handleStorageEvent);
134}
135
136function handleStorageEvent(event) {
137    if (event.key !== STORAGE_MSG_KEY) return;
138    if (!event.newValue) return;
139
140    try {
141        const message = JSON.parse(event.newValue);
142
143        // Ignore our own messages
144        if (message.senderId === tabId) return;
145
146        handleMessage(message);
147    } catch (e) {
148        console.warn('[CrossTabSync] Failed to parse storage message:', e);
149    }
150}
151
152// =============================================================================
153// MESSAGING
154// =============================================================================
155
156/**
157 * Broadcast a message to all tabs
158 * @param {string} type - Message type
159 * @param {Object} payload - Message data
160 */
161export function broadcast(type, payload = {}) {
162    const message = {
163        type,
164        payload,
165        senderId: tabId,
166        partitionKey: currentPartitionKey(),
167        timestamp: Date.now()
168    };
169
170    if (channel) {
171        channel.postMessage(message);
172    } else if (typeof BroadcastChannel === 'undefined') {
173        // localStorage fallback
174        localStorage.setItem(STORAGE_MSG_KEY, JSON.stringify(message));
175        // Clear immediately (we just need the storage event)
176        setTimeout(() => localStorage.removeItem(STORAGE_MSG_KEY), 100);
177    }
178    // BroadcastChannel browser with no open channel (destroyed): send nothing, never via localStorage (CA-548).
179}
180
181/**
182 * Handle incoming message from another tab
183 * @param {Object} message
184 */
185function handleMessage(message) {
186    if (!message?.type) return;
187
188    // Ignore own messages
189    if (message.senderId === tabId) return;
190
191    // Another signed-in user's tab, or a pre-CA-548 sender: never delivered here.
192    const ownPartition = currentPartitionKey();
193    if (!message.partitionKey || message.partitionKey !== ownPartition) return;
194
195    console.debug('[CrossTabSync] Received:', message.type);
196
197    switch (message.type) {
198        case 'notification:added':
199            pubsub.publish('crosstab:notification:added', message.payload);
200            break;
201
202        case 'notification:removed':
203            pubsub.publish('crosstab:notification:removed', message.payload);
204            break;
205
206        case 'notification:markedRead':
207            pubsub.publish('crosstab:notification:markedRead', message.payload);
208            break;
209
210        case 'notification:tabChanged':
211            pubsub.publish('crosstab:notification:tabChanged', message.payload);
212            break;
213
214        case 'notification:bulkSync':
215            pubsub.publish('crosstab:notification:bulkSync', message.payload);
216            break;
217
218        case 'leader:ping':
219            // Another tab is checking if leader is alive
220            if (isLeader) {
221                broadcast('leader:pong', { leaderId: tabId });
222            }
223            break;
224
225        case 'leader:pong':
226            // Leader responded, update our knowledge
227            leaderId = message.payload.leaderId;
228            break;
229
230        case 'leader:claimed':
231            // Another tab claimed leadership. A lock holder is the leader by definition (CA-548).
232            if (message.senderId !== tabId && !_releaseLock) {
233                // A non-holder that believed it led (heartbeat fallback) stops its hubs.
234                if (isLeader) {
235                    isLeader = false;
236                    pubsub.publish('crosstab:leaderDemoted', { tabId });
237                }
238                leaderId = message.senderId;
239                // Let every follower immediately replay its live group interests. The new
240                // leader may not have its hub connection yet; TaskGroupSync retains the
241                // relayed union and joins it on signalr:notification:connected.
242                pubsub.publish('crosstab:leaderElected', {
243                    tabId: message.senderId,
244                    isThisTab: false
245                });
246            }
247            break;
248
249        default:
250            // Shared group owners intentionally use this generic path. In particular,
251            // markup-processing-interest/event stays payload-transparent so TaskGroupSync
252            // can enforce the active offline {DatabaseName,userId} partition itself.
253            pubsub.publish(`crosstab:${message.type}`, message.payload);
254    }
255}
256
257// =============================================================================
258// CONVENIENCE METHODS
259// =============================================================================
260
261/**
262 * Broadcast that a notification was added
263 * @param {Object} notification
264 */
265export function broadcastAdded(notification) {
266    broadcast('notification:added', notification);
267}
268
269/**
270 * Broadcast that a notification was removed
271 * @param {string} threadId
272 */
273export function broadcastRemoved(threadId) {
274    broadcast('notification:removed', { threadId });
275}
276
277/**
278 * Broadcast that notifications were marked read
279 * @param {string[]} threadIds
280 */
281export function broadcastMarkedRead(threadIds) {
282    broadcast('notification:markedRead', { threadIds });
283}
284
285/**
286 * Broadcast tab change
287 * @param {string} tab
288 */
289export function broadcastTabChanged(tab) {
290    broadcast('notification:tabChanged', { tab });
291}
292
293/**
294 * Broadcast full sync (when leader fetches from server)
295 * @param {Object[]} notifications
296 */
297export function broadcastBulkSync(notifications) {
298    broadcast('notification:bulkSync', { notifications });
299}
300
301/**
302 * Relay a Microsoft Teams chat message to the other tabs.
303 *
304 * Needed because only the LEADER tab holds the SignalR connection, so a `teamsMessage` hub
305 * event lands in exactly one tab — but the Teams panel may be open in any of them.
306 * Received by the others as `crosstab:teams:message` via the generic default: branch
307 * in handleMessage().
308 *
309 * @param {{ chatId: string, messageId: string, changeType?: string }} payload
310 */
311export function broadcastTeamsMessage(payload) {
312    broadcast('teams:message', payload);
313}
314
315// =============================================================================
316// LEADERSHIP ELECTION
317// =============================================================================
318
319/**
320 * Attempt to become the leader tab
321 * Leader is responsible for:
322 * - SignalR connection
323 * - Periodic server sync
324 * - Push notification handling
325 */
326function electLeader(generation) {
327    if (navigator.locks?.request) {
328        bindPageLifecycle();
329        requestLeaderLock(generation);
330        return;
331    }
332    electByHeartbeat();
333}
334
335/**
336 * Web Locks election: the grant is held for the tab's life and the browser hands it to the
337 * next waiter when this tab dies. Withdrawn by destroy(), handed off on freeze (CA-548).
338 */
339function requestLeaderLock(generation) {
340    const abort = new AbortController();
341    _electionAbort = abort;
342    navigator.locks.request(leaderKey(), { mode: 'exclusive', signal: abort.signal }, () => {
343        if (generation !== _initGeneration || _electionAbort !== abort) return;
344        let release;
345        const held = new Promise(resolve => { release = resolve; });
346        _releaseLock = release;
347        if (!claimLeadership()) {
348            _releaseLock = null;
349            return;
350        }
351        startHeartbeat();
352        return held;
353    }).catch(() => { /* aborted: a withdrawn request */ });
354}
355
356// No Web Locks: the heartbeat election, partition-scoped.
357function electByHeartbeat() {
358    const leaderData = getLeaderData();
359
360    // Check if existing leader is still alive
361    if (leaderData && Date.now() - leaderData.heartbeat < LEADER_TIMEOUT_MS) {
362        // Leader exists and is active
363        leaderId = leaderData.tabId;
364        isLeader = false;
365    } else {
366        // No active leader - claim it
367        claimLeadership();
368    }
369
370    // Start heartbeat if we're leader
371    if (isLeader) {
372        startHeartbeat();
373    }
374
375    // Periodically check leader health
376    leaderHealthInterval = setInterval(checkLeaderHealth, LEADER_HEARTBEAT_MS);
377}
378
379/** @returns {boolean} false when the leader key cannot be written — this tab does not lead. */
380function claimLeadership() {
381    if (!setLeaderData({ tabId, heartbeat: Date.now() })) return false;
382    isLeader = true;
383    leaderId = tabId;
384
385    broadcast('leader:claimed', { leaderId: tabId });
386    console.debug('[CrossTabSync] Claimed leadership');
387
388    pubsub.publish('crosstab:leaderElected', { tabId, isThisTab: true });
389    return true;
390}
391
392// Under Web Locks this is a writer only, so pre-CA-548 tabs see a live leader and never claim.
393function startHeartbeat() {
394    if (heartbeatInterval) {
395        clearInterval(heartbeatInterval);
396    }
397
398    heartbeatInterval = setInterval(() => {
399        if (isLeader) {
400            setLeaderData({
401                tabId: tabId,
402                heartbeat: Date.now()
403            });
404        }
405    }, LEADER_HEARTBEAT_MS);
406}
407
408function checkLeaderHealth() {
409    if (isLeader) return; // We're the leader, no need to check
410
411    const leaderData = getLeaderData();
412
413    if (!leaderData || Date.now() - leaderData.heartbeat > LEADER_TIMEOUT_MS) {
414        // Leader is dead, attempt to claim
415        console.debug('[CrossTabSync] Leader timeout, attempting to claim');
416        if (claimLeadership()) startHeartbeat();
417    }
418}
419
420function getLeaderData() {
421    try {
422        const raw = localStorage.getItem(leaderKey());
423        return raw ? JSON.parse(raw) : null;
424    } catch {
425        return null;
426    }
427}
428
429/** @returns {boolean} whether both keys were written */
430function setLeaderData(data) {
431    try {
432        const value = JSON.stringify(data);
433        localStorage.setItem(leaderKey(), value);
434        localStorage.setItem(LEADER_KEY, value);
435        return true;
436    } catch {
437        return false;
438    }
439}
440
441function leaderKey() {
442    return `sb_notif_leader:${partitionKey}`;
443}
444
445// =============================================================================
446// FREEZE / BACK-FORWARD CACHE HAND-OFF (Web Locks only)
447// =============================================================================
448
449let pageLifecycleBound = false;
450
451function bindPageLifecycle() {
452    if (pageLifecycleBound) return;
453    pageLifecycleBound = true;
454    document.addEventListener('freeze', handleFreeze);
455    document.addEventListener('resume', handleResume);
456    window.addEventListener('pagehide', handleFreeze);
457    window.addEventListener('pageshow', handleResume);
458}
459
460function unbindPageLifecycle() {
461    if (!pageLifecycleBound) return;
462    pageLifecycleBound = false;
463    document.removeEventListener('freeze', handleFreeze);
464    document.removeEventListener('resume', handleResume);
465    window.removeEventListener('pagehide', handleFreeze);
466    window.removeEventListener('pageshow', handleResume);
467}
468
469// A frozen tab's socket is dead, so it hands the lock to the next waiter.
470function handleFreeze(event) {
471    if (event.type === 'pagehide' && !event.persisted) return;
472    if (!active || frozen) return;
473    frozen = true;
474    _electionAbort?.abort();
475    _electionAbort = null;
476    const release = _releaseLock;
477    _releaseLock = null;
478    if (isLeader) {
479        isLeader = false;
480        if (heartbeatInterval) {
481            clearInterval(heartbeatInterval);
482            heartbeatInterval = null;
483        }
484        pubsub.publish('crosstab:leaderDemoted', { tabId });
485    }
486    release?.();
487}
488
489function handleResume(event) {
490    if (event.type === 'pageshow' && !event.persisted) return;
491    if (!active || !frozen) return;
492    frozen = false;
493    requestLeaderLock(_initGeneration);
494}
495
496function currentPartitionKey() {
497    return offlineStore.getActivePartition()?.key || null;
498}
499
500// =============================================================================
501// UTILITIES
502// =============================================================================
503
504function generateTabId() {
505    return `tab_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
506}
507
508/**
509 * Check if this tab is the leader
510 * @returns {boolean}
511 */
512export function isThisTabLeader() {
513    return isLeader;
514}
515
516/**
517 * Get current tab ID
518 * @returns {string}
519 */
520export function getTabId() {
521    return tabId;
522}
523
524// Default export
525export default {
526    init,
527    destroy,
528    broadcast,
529    broadcastAdded,
530    broadcastRemoved,
531    broadcastMarkedRead,
532    broadcastTabChanged,
533    broadcastBulkSync,
534    broadcastTeamsMessage,
535    isThisTabLeader,
536    getTabId
537};

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.