@@ -17,34 +17,64 @@ export const initSessionSync = ({
1717 return null ;
1818 }
1919
20- const sessionChannelName = "pm-session-sync" ;
21- const sessionLeaderKey = "pm:session:leader" ;
22- const sessionStateKey = "pm:session:state" ;
23- const sessionWarningKey = "pm:session:warning" ;
20+ const safeStorageGetItem = ( key ) => {
21+ try {
22+ return localStorage . getItem ( key ) ;
23+ } catch ( error ) {
24+ return null ;
25+ }
26+ } ;
27+
28+ const safeTimeoutLength = Number ( accountTimeoutLength ) ;
29+ const safeTimeoutWarnSeconds = Number ( accountTimeoutWarnSeconds ) ;
30+ const safeTimeoutEnabled = Number ( accountTimeoutEnabled ) ;
31+ const normalizedTimeoutLength = Number . isFinite ( safeTimeoutLength ) && safeTimeoutLength > 0
32+ ? safeTimeoutLength
33+ : 0 ;
34+ const normalizedTimeoutWarnSeconds = Number . isFinite ( safeTimeoutWarnSeconds ) && safeTimeoutWarnSeconds > 0
35+ ? safeTimeoutWarnSeconds
36+ : 0 ;
37+ const normalizedTimeoutEnabled = Number . isFinite ( safeTimeoutEnabled ) ? safeTimeoutEnabled : 1 ;
38+ const sessionOrigin = window . location ?. origin || "unknown-origin" ;
39+ const sessionScope = encodeURIComponent ( `${ sessionOrigin } :${ userId } ` ) ;
40+ const sessionKeyPrefix = `pm:session:${ sessionScope } ` ;
41+ const sessionChannelName = `${ sessionKeyPrefix } :sync` ;
42+ const sessionLeaderKey = `${ sessionKeyPrefix } :leader` ;
43+ const sessionStateKey = `${ sessionKeyPrefix } :state` ;
44+ const sessionWarningKey = `${ sessionKeyPrefix } :warning` ;
2445 // Track keep-alive progress across tabs.
25- const sessionRenewingKey = "pm:session: renewing" ;
26- const sessionSuppressKey = "pm:session: suppress-warning" ;
27- const sessionMessageKey = "pm:session: message" ;
46+ const sessionRenewingKey = ` ${ sessionKeyPrefix } : renewing` ;
47+ const sessionSuppressKey = ` ${ sessionKeyPrefix } : suppress-warning` ;
48+ const sessionMessageKey = ` ${ sessionKeyPrefix } : message` ;
2849 const sessionTabId = `${ Date . now ( ) } -${ Math . random ( ) . toString ( 16 ) . slice ( 2 ) } ` ;
2950 const leaderHeartbeatMs = 4000 ;
3051 const leaderTtlMs = 8000 ;
31- const sessionDebugEnabled = localStorage . getItem ( "pm:session:debug" ) === "1" ;
52+ const sessionDebugEnabled = safeStorageGetItem ( "pm:session:debug" ) === "1" ;
3253 const sessionDebugLog = ( ...args ) => {
3354 if ( sessionDebugEnabled && ! isProd ) {
3455 console . info ( "[SessionSync]" , `[tab:${ sessionTabId } ]` , ...args ) ;
3556 }
3657 } ;
3758
3859 sessionDebugLog ( "worker:init" , { timeoutScript } ) ;
39- const AccountTimeoutWorker = new Worker ( timeoutScript ) ;
40- sessionDebugLog ( "worker:created" ) ;
60+ let AccountTimeoutWorker = null ;
61+ if ( timeoutScript && normalizedTimeoutEnabled && normalizedTimeoutLength > 0 ) {
62+ try {
63+ AccountTimeoutWorker = new Worker ( timeoutScript ) ;
64+ sessionDebugLog ( "worker:created" ) ;
65+ } catch ( error ) {
66+ sessionDebugLog ( "worker:create-failed" , error ) ;
67+ }
68+ } else {
69+ sessionDebugLog ( "worker:disabled" , { timeoutScript, normalizedTimeoutEnabled, normalizedTimeoutLength } ) ;
70+ }
4171
4272 const resolveSessionModal = ( ) => ( typeof getSessionModal === "function" ? getSessionModal ( ) : null ) ;
4373 const resolveCloseSessionModal = ( ) => ( typeof getCloseSessionModal === "function" ? getCloseSessionModal ( ) : null ) ;
4474
4575 const readStorageJson = ( key ) => {
4676 try {
47- const raw = localStorage . getItem ( key ) ;
77+ const raw = safeStorageGetItem ( key ) ;
4878 return raw ? JSON . parse ( raw ) : null ;
4979 } catch ( error ) {
5080 return null ;
@@ -68,7 +98,7 @@ export const initSessionSync = ({
6898 } ;
6999
70100 let sessionState = {
71- timeout : accountTimeoutLength ,
101+ timeout : normalizedTimeoutLength ,
72102 startedAt : Date . now ( ) ,
73103 } ;
74104
@@ -94,8 +124,13 @@ export const initSessionSync = ({
94124 refreshSessionStateFromStorage ( ) ;
95125
96126 const setSessionState = ( timeoutMinutes ) => {
127+ const normalizedTimeout = Number ( timeoutMinutes ) ;
128+ if ( ! Number . isFinite ( normalizedTimeout ) || normalizedTimeout <= 0 ) {
129+ sessionDebugLog ( "session-state:invalid-timeout" , { timeoutMinutes } ) ;
130+ return ;
131+ }
97132 sessionState = {
98- timeout : timeoutMinutes ,
133+ timeout : normalizedTimeout ,
99134 startedAt : Date . now ( ) ,
100135 } ;
101136 writeStorageJson ( sessionStateKey , sessionState ) ;
@@ -180,7 +215,14 @@ export const initSessionSync = ({
180215 writeStorageJson ( sessionSuppressKey , suppressWarningState ) ;
181216 } ;
182217
183- const sessionChannel = "BroadcastChannel" in window ? new BroadcastChannel ( sessionChannelName ) : null ;
218+ let sessionChannel = null ;
219+ if ( "BroadcastChannel" in window ) {
220+ try {
221+ sessionChannel = new BroadcastChannel ( sessionChannelName ) ;
222+ } catch ( error ) {
223+ sessionDebugLog ( "broadcast-channel:create-failed" , error ) ;
224+ }
225+ }
184226 const recentMessageIds = new Map ( ) ;
185227 const recentMessageTtlMs = 5000 ;
186228 const maxRecentMessageIds = 100 ;
@@ -258,11 +300,11 @@ export const initSessionSync = ({
258300 } ;
259301
260302 const markActivity = ( source ) => {
261- setSessionState ( accountTimeoutLength ) ;
303+ setSessionState ( normalizedTimeoutLength ) ;
262304 clearWarningState ( ) ;
263305 setSuppressWarning ( 2000 ) ;
264- broadcastSessionEvent ( "activity" , { timeout : accountTimeoutLength , source } ) ;
265- sessionDebugLog ( "activity" , { source, timeout : accountTimeoutLength } ) ;
306+ broadcastSessionEvent ( "activity" , { timeout : normalizedTimeoutLength , source } ) ;
307+ sessionDebugLog ( "activity" , { source, timeout : normalizedTimeoutLength } ) ;
266308 const closeSessionModal = resolveCloseSessionModal ( ) ;
267309 if ( closeSessionModal ) {
268310 closeSessionModal ( ) ;
@@ -273,6 +315,22 @@ export const initSessionSync = ({
273315 }
274316 } ;
275317
318+ const renewSession = ( timeoutMinutes = normalizedTimeoutLength ) => {
319+ const timeout = Number ( timeoutMinutes ) || normalizedTimeoutLength ;
320+ clearWarningState ( ) ;
321+ setRenewingState ( false ) ;
322+ setSuppressWarning ( 1000 ) ;
323+ setSessionState ( timeout ) ;
324+ broadcastSessionEvent ( "renewed" , { timeout } ) ;
325+ const closeSessionModal = resolveCloseSessionModal ( ) ;
326+ if ( closeSessionModal ) {
327+ closeSessionModal ( ) ;
328+ }
329+ if ( isLeader ( ) ) {
330+ startTimeoutWorker ( timeout ) ;
331+ }
332+ } ;
333+
276334 const getRemainingTimeout = ( timeoutMinutes ) => {
277335 const elapsedMinutes = ( Date . now ( ) - sessionState . startedAt ) / 60000 ;
278336 const remaining = timeoutMinutes - elapsedMinutes ;
@@ -288,6 +346,10 @@ export const initSessionSync = ({
288346 } ;
289347
290348 const startTimeoutWorker = ( timeoutMinutes ) => {
349+ if ( ! AccountTimeoutWorker || ! normalizedTimeoutEnabled || normalizedTimeoutLength <= 0 ) {
350+ sessionDebugLog ( "worker:start:skip" , { hasWorker : ! ! AccountTimeoutWorker , normalizedTimeoutEnabled, normalizedTimeoutLength } ) ;
351+ return ;
352+ }
291353 const remaining = getRemainingTimeout ( timeoutMinutes ) ;
292354 sessionDebugLog ( "worker:start" , { timeoutMinutes, remaining } ) ;
293355 if ( remaining <= 0 ) {
@@ -300,8 +362,8 @@ export const initSessionSync = ({
300362 method : "start" ,
301363 data : {
302364 timeout : remaining ,
303- warnSeconds : accountTimeoutWarnSeconds ,
304- enabled : accountTimeoutEnabled ,
365+ warnSeconds : normalizedTimeoutWarnSeconds ,
366+ enabled : normalizedTimeoutEnabled ,
305367 } ,
306368 } ) ;
307369 } ;
@@ -330,11 +392,15 @@ export const initSessionSync = ({
330392 const sessionModal = resolveSessionModal ( ) ;
331393 // Guard for layouts that don't include the session modal.
332394 if ( typeof sessionModal === "function" ) {
395+ const warningMessage = [
396+ "<p>Your user session is expiring. If your session expires, all of your unsaved data will be lost.</p>" ,
397+ "<p>Would you like to stay connected?</p>" ,
398+ ] . join ( "" ) ;
333399 sessionModal (
334400 "Session Warning" ,
335- "<p>Your user session is expiring. If your session expires, all of your unsaved data will be lost.</p><p>Would you like to stay connected?</p>" ,
401+ warningMessage ,
336402 remainingTime ,
337- accountTimeoutWarnSeconds ,
403+ normalizedTimeoutWarnSeconds ,
338404 ) ;
339405 }
340406 } ;
@@ -373,7 +439,7 @@ export const initSessionSync = ({
373439 }
374440
375441 if ( message . type === "renewed" || message . type === "started" || message . type === "activity" ) {
376- const timeout = Number ( message . data ?. timeout ) || accountTimeoutLength ;
442+ const timeout = Number ( message . data ?. timeout ) || normalizedTimeoutLength ;
377443 clearWarningState ( ) ;
378444 setRenewingState ( false ) ;
379445 setSuppressWarning ( 1000 ) ;
@@ -401,19 +467,28 @@ export const initSessionSync = ({
401467 }
402468 } ;
403469
470+ const handleBroadcastMessage = ( event ) => handleSessionMessage ( event . data ) ;
404471 if ( sessionChannel ) {
405- sessionChannel . onmessage = ( event ) => handleSessionMessage ( event . data ) ;
472+ sessionChannel . onmessage = handleBroadcastMessage ;
406473 }
407474
408- window . addEventListener ( "storage" , ( event ) => {
475+ const handleStorageMessage = ( event ) => {
409476 if ( event . key !== sessionMessageKey || ! event . newValue ) {
410477 return ;
411478 }
412479
413480 handleSessionMessage ( readStorageJson ( sessionMessageKey ) ) ;
414- } ) ;
481+ } ;
482+ window . addEventListener ( "storage" , handleStorageMessage ) ;
483+
484+ const handleStoredTerminalSessionMessage = ( ) => {
485+ const message = readStorageJson ( sessionMessageKey ) ;
486+ if ( message ?. type === "logout" || message ?. type === "expired" ) {
487+ handleSessionMessage ( message ) ;
488+ }
489+ } ;
415490
416- AccountTimeoutWorker . onmessage = ( e ) => {
491+ const handleWorkerMessage = ( e ) => {
417492 if ( ! isLeader ( ) ) {
418493 return ;
419494 }
@@ -438,6 +513,9 @@ export const initSessionSync = ({
438513 window . location = "/logout?timeout=true" ;
439514 }
440515 } ;
516+ if ( AccountTimeoutWorker ) {
517+ AccountTimeoutWorker . onmessage = handleWorkerMessage ;
518+ }
441519
442520 let wasLeader = false ;
443521 const updateLeadership = ( ) => {
@@ -468,9 +546,12 @@ export const initSessionSync = ({
468546 if ( leaderNow ) {
469547 ensureWorkerRunning ( "leadership-change" ) ;
470548 } else {
549+ workerStarted = false ;
550+ if ( AccountTimeoutWorker ) {
551+ AccountTimeoutWorker . postMessage ( { method : "stop" } ) ;
552+ }
471553 const closeSessionModal = resolveCloseSessionModal ( ) ;
472554 if ( closeSessionModal ) {
473- workerStarted = false ;
474555 closeSessionModal ( ) ;
475556 }
476557 }
@@ -482,8 +563,9 @@ export const initSessionSync = ({
482563 markActivity ( "load" ) ;
483564 ensureWorkerRunning ( "load" ) ;
484565 }
485- setInterval ( updateLeadership , leaderHeartbeatMs ) ;
486- window . addEventListener ( "visibilitychange" , ( ) => {
566+ const leadershipInterval = setInterval ( updateLeadership , leaderHeartbeatMs ) ;
567+ const handleVisibilityChange = ( ) => {
568+ handleStoredTerminalSessionMessage ( ) ;
487569 updateLeadership ( ) ;
488570 // Keep warning state in sync when switching tabs.
489571 refreshWarningStateFromStorage ( ) ;
@@ -493,17 +575,20 @@ export const initSessionSync = ({
493575 refreshSessionStateFromStorage ( ) ;
494576 startTimeoutWorker ( sessionState . timeout ) ;
495577 }
496- } ) ;
578+ } ;
579+ window . addEventListener ( "visibilitychange" , handleVisibilityChange ) ;
580+ window . addEventListener ( "focus" , handleStoredTerminalSessionMessage ) ;
497581
498582 // Broadcast manual logout so all tabs close warning and redirect.
499- document . addEventListener ( "click" , ( event ) => {
500- const logoutLink = event . target . closest ( ' a[href="/logout"], a[href^="/logout?"]' ) ;
583+ const handleDocumentClick = ( event ) => {
584+ const logoutLink = event . target . closest ( " a[href=\ "/logout\ "], a[href^=\ "/logout?\"]" ) ;
501585 if ( ! logoutLink ) {
502586 return ;
503587 }
504588 clearWarningState ( ) ;
505589 broadcastSessionEvent ( "logout" ) ;
506- } ) ;
590+ } ;
591+ document . addEventListener ( "click" , handleDocumentClick ) ;
507592
508593 const isSameDevice = ( e ) => {
509594 const localDeviceId = Vue . $cookies . get ( e . device_variable ) ;
@@ -532,10 +617,14 @@ export const initSessionSync = ({
532617 }
533618 } )
534619 . listen ( ".SessionStarted" , ( e ) => {
535- const lifetime = parseInt ( eval ( e . lifetime ) ) ;
620+ const lifetime = Number ( e . lifetime ) ;
536621 if ( ! isSameDevice ( e ) ) {
537622 return ;
538623 }
624+ if ( ! Number . isFinite ( lifetime ) || lifetime <= 0 ) {
625+ sessionDebugLog ( "event:session-started:invalid-lifetime" , { lifetime : e . lifetime } ) ;
626+ return ;
627+ }
539628
540629 sessionDebugLog ( "event:session-started" , { lifetime } ) ;
541630 setSessionState ( lifetime ) ;
@@ -557,7 +646,8 @@ export const initSessionSync = ({
557646 const newDeviceId = Vue . $cookies . get ( e . device_variable ) ;
558647 if ( localDeviceId !== newDeviceId ) {
559648 clearInterval ( redirectLogoutinterval ) ;
560- window . location . href = "/logout" ;
649+ // eslint-disable-next-line no-undef
650+ globalThis . location . href = "/logout" ;
561651 }
562652 } , 100 ) ;
563653 }
@@ -574,14 +664,31 @@ export const initSessionSync = ({
574664 }
575665
576666 return {
577- AccountTimeoutLength : accountTimeoutLength ,
578- AccountTimeoutWarnSeconds : accountTimeoutWarnSeconds ,
579- AccountTimeoutWarnMinutes : accountTimeoutWarnSeconds / 60 ,
580- AccountTimeoutEnabled : accountTimeoutEnabled ,
667+ AccountTimeoutLength : normalizedTimeoutLength ,
668+ AccountTimeoutWarnSeconds : normalizedTimeoutWarnSeconds ,
669+ AccountTimeoutWarnMinutes : normalizedTimeoutWarnSeconds / 60 ,
670+ AccountTimeoutEnabled : normalizedTimeoutEnabled ,
581671 AccountTimeoutWorker,
582672 sessionSync : {
583673 broadcast : broadcastSessionEvent ,
674+ destroy ( ) {
675+ clearInterval ( leadershipInterval ) ;
676+ // eslint-disable-next-line no-undef
677+ globalThis . removeEventListener ( "storage" , handleStorageMessage ) ;
678+ // eslint-disable-next-line no-undef
679+ globalThis . removeEventListener ( "visibilitychange" , handleVisibilityChange ) ;
680+ // eslint-disable-next-line no-undef
681+ globalThis . removeEventListener ( "focus" , handleStoredTerminalSessionMessage ) ;
682+ document . removeEventListener ( "click" , handleDocumentClick ) ;
683+ if ( sessionChannel ) {
684+ sessionChannel . close ( ) ;
685+ }
686+ if ( AccountTimeoutWorker ) {
687+ AccountTimeoutWorker . terminate ( ) ;
688+ }
689+ } ,
584690 isLeader,
691+ renewSession,
585692 setSessionState,
586693 clearWarningState,
587694 setRenewingState,
0 commit comments