From e1dac186ac6b5ef88d38ec0f71fe4b82f6bd15c1 Mon Sep 17 00:00:00 2001 From: fs Date: Sat, 14 Mar 2026 15:57:36 +0100 Subject: [PATCH] fix: add background keepalive ping to sync frontend idle timer with server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevents session timeout for users who stay active on a single page (scrolling, reading) without making requests. Pings every 5 min only when interaction was detected since last sync — no false keep-alive. Co-Authored-By: Claude Opus 4.6 --- web/js/components/app-session-warning.js | 39 ++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/web/js/components/app-session-warning.js b/web/js/components/app-session-warning.js index c038838..77551e5 100644 --- a/web/js/components/app-session-warning.js +++ b/web/js/components/app-session-warning.js @@ -12,6 +12,7 @@ const WARNING_LEAD_SECONDS = 120; const TICK_INTERVAL_MS = 1000; const MIN_IDLE_TIMEOUT = 180; // Don't show warning if timeout < 3 min +const KEEPALIVE_INTERVAL_MS = 300_000; // Sync with server every 5 min (if active) const INTERACTION_EVENTS = ['click', 'keydown', 'scroll', 'mousemove', 'touchstart']; const INTERACTION_THROTTLE_MS = 30_000; // Only reset every 30s to limit overhead @@ -235,6 +236,7 @@ const init = () => { // ── User interaction tracking ────────────────────────────── let lastInteractionReset = Date.now(); + let hasInteractionSinceLastSync = false; const onUserInteraction = () => { const now = Date.now(); @@ -246,10 +248,47 @@ const init = () => { // Only reset timer when dialog is NOT showing. if (!isDialogOpen) { lastActivity = now; + hasInteractionSinceLastSync = true; scheduleWarning(); } }; + // ── Background keepalive ──────────────────────────────────── + // Periodically syncs the server-side idle timer when user interaction + // was detected since the last sync. Without this, a user who stays on + // a single page (scrolling, reading, moving the mouse) would have + // their server-side timer expire even though the frontend shows "active". + + const keepaliveTimerId = setInterval(async () => { + if (!hasInteractionSinceLastSync || isDialogOpen) { + return; + } + hasInteractionSinceLastSync = false; + + try { + const body = new URLSearchParams({ + [config.csrfKey]: config.csrfToken, + }); + + const response = await fetch(config.pingUrl, { + method: 'POST', + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + body, + }); + + if (!response.ok) { + return; + } + + const data = await response.json(); + if (typeof data.idle_timeout_seconds === 'number' && data.idle_timeout_seconds > 0) { + config.idleSeconds = data.idle_timeout_seconds; + } + } catch { + // Network error — silently ignore, warning dialog will handle expiry. + } + }, KEEPALIVE_INTERVAL_MS); + // ── Focus trap (keyboard) ────────────────────────────────── const onKeyDown = (event) => {