112 lines
4.2 KiB
JavaScript
112 lines
4.2 KiB
JavaScript
(() => {
|
|
"use strict";
|
|
const SEND_INTERVAL = 60_000;
|
|
const RETRY_INTERVAL = 30_000;
|
|
let lastSentAt = -Infinity;
|
|
let pendingActivityAt = null;
|
|
let activityTimer = null;
|
|
let expiryTimer = null;
|
|
let activityInFlight = false;
|
|
let statusInFlight = false;
|
|
let stopped = false;
|
|
let idleLimit = 2 * 60 * 60 * 1000;
|
|
let lastStatusAt = 0;
|
|
|
|
async function sessionRequest(path, options = {}) {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), 15_000);
|
|
try {
|
|
const response = await fetch(path, { credentials: "same-origin", cache: "no-store", ...options, signal: controller.signal });
|
|
const payload = response.ok ? await response.json() : null;
|
|
return { status: response.status, ok: response.ok, payload };
|
|
} finally { clearTimeout(timer); }
|
|
}
|
|
|
|
function expired() {
|
|
if (stopped) return;
|
|
stopped = true;
|
|
clearTimeout(activityTimer);
|
|
clearTimeout(expiryTimer);
|
|
window.location.replace("/login?reason=inactive");
|
|
}
|
|
|
|
function acceptStatus(payload) {
|
|
if (stopped) return;
|
|
const seconds = Number(payload.expires_in_seconds);
|
|
if (!Number.isFinite(seconds)) throw new Error("Invalid session deadline");
|
|
idleLimit = Math.max(1, Number(payload.idle_timeout_seconds) || 7200) * 1000;
|
|
lastStatusAt = Date.now();
|
|
clearTimeout(expiryTimer);
|
|
// Check the shared server session before redirecting: another tab may have
|
|
// renewed it. Passive polling here must not extend its deadline.
|
|
expiryTimer = setTimeout(checkSession, Math.max(250, seconds * 1000));
|
|
}
|
|
|
|
async function checkSession() {
|
|
if (stopped || statusInFlight) return;
|
|
statusInFlight = true;
|
|
try {
|
|
const response = await sessionRequest("/api/hockey/auth/session");
|
|
if (response.status === 401) return expired();
|
|
if (!response.ok) throw new Error("Session status unavailable");
|
|
acceptStatus(response.payload);
|
|
} catch (_) {
|
|
// A temporary network/WFL failure must not log an operator out.
|
|
if (!stopped) {
|
|
clearTimeout(expiryTimer);
|
|
expiryTimer = setTimeout(checkSession, RETRY_INTERVAL);
|
|
}
|
|
} finally { statusInFlight = false; }
|
|
}
|
|
|
|
function scheduleActivity(delay = 0) {
|
|
if (stopped || activityInFlight || activityTimer !== null || pendingActivityAt === null) return;
|
|
activityTimer = setTimeout(sendActivity, Math.max(delay, SEND_INTERVAL - (Date.now() - lastSentAt), 0));
|
|
}
|
|
|
|
async function sendActivity() {
|
|
activityTimer = null;
|
|
if (stopped || activityInFlight || pendingActivityAt === null) return;
|
|
const activityAt = pendingActivityAt;
|
|
const idleMs = Math.max(0, Date.now() - activityAt);
|
|
pendingActivityAt = null;
|
|
if (idleMs >= idleLimit) { void checkSession(); return; }
|
|
activityInFlight = true;
|
|
lastSentAt = Date.now();
|
|
let retry = 0;
|
|
try {
|
|
const response = await sessionRequest("/api/hockey/auth/activity", {
|
|
method: "POST", credentials: "same-origin", cache: "no-store",
|
|
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ idle_ms: idleMs }),
|
|
});
|
|
if (response.status === 401) return expired();
|
|
if (!response.ok) throw new Error("Session renewal unavailable");
|
|
acceptStatus(response.payload);
|
|
} catch (_) {
|
|
pendingActivityAt = Math.max(pendingActivityAt ?? activityAt, activityAt);
|
|
retry = RETRY_INTERVAL;
|
|
} finally {
|
|
activityInFlight = false;
|
|
scheduleActivity(retry);
|
|
}
|
|
}
|
|
|
|
function recordActivity(event) {
|
|
if (stopped || !event.isTrusted || document.visibilityState === "hidden") return;
|
|
pendingActivityAt = Date.now();
|
|
scheduleActivity();
|
|
}
|
|
|
|
["pointerdown", "pointermove", "keydown", "wheel", "touchstart", "input"].forEach((type) => {
|
|
window.addEventListener(type, recordActivity, { capture: true, passive: true });
|
|
});
|
|
const checkOnReturn = () => {
|
|
if (document.visibilityState !== "hidden" && Date.now() - lastStatusAt >= SEND_INTERVAL) void checkSession();
|
|
};
|
|
window.addEventListener("focus", checkOnReturn);
|
|
window.addEventListener("pageshow", checkOnReturn);
|
|
window.addEventListener("online", () => { void checkSession(); scheduleActivity(); });
|
|
document.addEventListener("visibilitychange", checkOnReturn);
|
|
void checkSession();
|
|
})();
|