Skip to content

Commit fb458fe

Browse files
Update timeout-related variables to use Number for better type safety and introduce safe storage access methods. Enhance session renewal logic and improve error handling for BroadcastChannel creation. This ensures more robust session management and clearer code structure.
1 parent 59ef917 commit fb458fe

5 files changed

Lines changed: 159 additions & 66 deletions

File tree

resources/js/bootstrap.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -353,10 +353,11 @@ if (window.Processmaker && window.Processmaker.broadcasting) {
353353

354354
if (userID) {
355355
const timeoutScript = document.head.querySelector("meta[name=\"timeout-worker\"]")?.content;
356-
const accountTimeoutLength = parseInt(eval(document.head.querySelector("meta[name=\"timeout-length\"]")?.content));
357-
const warnSeconds = parseInt(document.head.querySelector("meta[name=\"timeout-warn-seconds\"]")?.content);
356+
const timeoutEnabledMeta = document.head.querySelector("meta[name=\"timeout-enabled\"]")?.content;
357+
const accountTimeoutLength = Number(document.head.querySelector("meta[name=\"timeout-length\"]")?.content);
358+
const warnSeconds = Number(document.head.querySelector("meta[name=\"timeout-warn-seconds\"]")?.content);
358359
const accountTimeoutWarnSeconds = Number.isNaN(warnSeconds) ? 0 : warnSeconds;
359-
const accountTimeoutEnabled = document.head.querySelector("meta[name=\"timeout-enabled\"]") ? parseInt(document.head.querySelector("meta[name=\"timeout-enabled\"]")?.content) : 1;
360+
const accountTimeoutEnabled = timeoutEnabledMeta ? Number(timeoutEnabledMeta) : 1;
360361

361362
const sessionSyncState = initSessionSync({
362363
userId: userID.content,

resources/js/common/sessionSync.js

Lines changed: 146 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)