// public/assets/sse.js /* English comments: shared SSE client with lease + cache */ (function () { const SSE_LEASE_KEY = 'scmedia_sse_lease'; const SSE_LAST = 'scmedia_sse_last'; const SSE_LAST_TYPE = 'scmedia_sse_last_type'; const SSE_CONNECTED = 'scmedia_sse_connected'; const SSE_RECONNECTS = 'scmedia_sse_reconnects'; const SSE_KEY_EXP = 'scmedia_sse_key_exp'; let client = null; let retryMs = 5000; let tabId = ''; let leaseTimer = null; let opening = false; const listeners = {}; function emit(type, payload) { const list = listeners[type] || []; list.forEach((cb) => { try { cb(payload); } catch (e) { // ignore } }); } function on(type, cb) { if (!type || typeof cb !== 'function') return; listeners[type] = listeners[type] || []; listeners[type].push(cb); } function loadKeyExp() { return Number(sessionStorage.getItem(SSE_KEY_EXP) || 0); } function cacheKeyExp(ttlSeconds) { const exp = Date.now() + Math.max(10, ttlSeconds) * 1000; sessionStorage.setItem(SSE_KEY_EXP, String(exp)); return exp; } function isKeyFresh() { const exp = loadKeyExp(); return exp > Date.now() + 1000; } function setConnected(ok) { localStorage.setItem(SSE_CONNECTED, ok ? '1' : '0'); if (ok) { localStorage.setItem(SSE_LAST, String(Date.now())); } } function noteEvent(type) { setConnected(true); localStorage.setItem(SSE_LAST, String(Date.now())); if (type) { localStorage.setItem(SSE_LAST_TYPE, String(type)); } refreshLease(); } function claimLease() { if (!tabId) { const existing = sessionStorage.getItem('scmedia_sse_tab_id'); tabId = existing || Math.random().toString(36).slice(2); sessionStorage.setItem('scmedia_sse_tab_id', tabId); } const now = Date.now(); const ttlMs = 15000; const raw = localStorage.getItem(SSE_LEASE_KEY) || ''; let lease = null; try { lease = raw ? JSON.parse(raw) : null; } catch (e) { lease = null; } if (lease && lease.ts && (now - lease.ts) < ttlMs && lease.id !== tabId) { return false; } localStorage.setItem(SSE_LEASE_KEY, JSON.stringify({ ts: now, id: tabId })); return true; } function refreshLease() { localStorage.setItem(SSE_LEASE_KEY, JSON.stringify({ ts: Date.now(), id: tabId })); } function releaseLease() { const raw = localStorage.getItem(SSE_LEASE_KEY) || ''; try { const lease = raw ? JSON.parse(raw) : null; if (lease && lease.id && lease.id !== tabId) return; } catch (e) { // ignore } localStorage.removeItem(SSE_LEASE_KEY); } async function open() { if (opening) return; opening = true; if (!claimLease()) { opening = false; return; } if (window.Auth?.isAccessExpired?.() && window.Auth?.refreshTokens) { await window.Auth.refreshTokens(); } const token = window.Auth?.getAccessToken?.(); if (!token) { opening = false; return; } if (!isKeyFresh()) { const res = await fetch('/api/auth/sse-key', { method: 'POST', headers: { Authorization: `Bearer ${token}` }, }); const data = await res.json().catch(() => null); const key = data?.data?.key || ''; const ttl = Number(data?.data?.expires_in || 60); if (!data?.ok || !key) { releaseLease(); opening = false; return; } cacheKeyExp(ttl); await new Promise((r) => setTimeout(r, 50)); } const es = new EventSource('/api/events', { withCredentials: true }); client = es; es.addEventListener('open', () => { retryMs = 5000; setConnected(true); refreshLease(); if (leaseTimer) clearInterval(leaseTimer); leaseTimer = setInterval(refreshLease, 5000); emit('open'); }); const handle = (type) => { noteEvent(type); emit(type); }; es.addEventListener('message', () => handle('message')); es.addEventListener('tick', () => handle('tick')); es.addEventListener('sources', (e) => { noteEvent('sources'); let payload = null; try { payload = JSON.parse(e.data); } catch (e2) { payload = null; } emit('sources', payload); }); es.addEventListener('jobs', (e) => { noteEvent('jobs'); let payload = null; try { payload = JSON.parse(e.data); } catch (e2) { payload = null; } emit('jobs', payload); }); es.addEventListener('error', () => { if (client) { client.close(); client = null; } if (leaseTimer) { clearInterval(leaseTimer); leaseTimer = null; } const prev = Number(localStorage.getItem(SSE_RECONNECTS) || 0); localStorage.setItem(SSE_RECONNECTS, String(prev + 1)); releaseLease(); opening = false; const delay = retryMs; retryMs = Math.min(retryMs * 2, 60000); setTimeout(open, delay); }); opening = false; } function start() { if (!window.EventSource) return; if (client) return; open().catch(() => {}); } function stop() { if (client) { client.close(); client = null; } if (leaseTimer) { clearInterval(leaseTimer); leaseTimer = null; } localStorage.removeItem(SSE_CONNECTED); localStorage.removeItem(SSE_LAST); localStorage.removeItem(SSE_LAST_TYPE); localStorage.removeItem(SSE_LEASE_KEY); sessionStorage.removeItem(SSE_KEY_EXP); document.cookie = 'sse_key=; path=/; max-age=0'; } window.Sse = { start, stop, on }; })();