210 lines
5.5 KiB
JavaScript
210 lines
5.5 KiB
JavaScript
// 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 };
|
|
})();
|