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.