openvibers/openvibe.shared notification-live.js
notification-live.js
| 1 | /* |
| 2 | * openvibe-shared/notification-live.js — the notification badge's realtime feed (roadmap WS-E task 3, WS-F task 1; |
| 3 | * ADR-005 amendment 2). notification-ui.js loads it on demand when a site turns it on (navbar notificationsRealtime). |
| 4 | * For every (re)connect it asks Network for a realtime ticket (POST <api>/api/v1/realtime/ticket: Bearer token, or the |
| 5 | * cookie on openvibe.network itself) and opens Events' stream with it, without credentials: |
| 6 | * <stream_url>?topics=network.notification.*&ticket=<ticket>[&last_event_id=<cursor>] |
| 7 | * Events streams only the person's own network.notification.created events (subject visibility); one whose subject is |
| 8 | * not the ticket's is ignored all the same. Each calls onNotification(payload); `event: gap` calls onGap. |
| 9 | * The cursor is the last SSE id Events sent (opaque, handed back as it came); an event already heard (by event_id) is |
| 10 | * skipped. A ticket opens one stream: errors close it and reconnect with a fresh ticket from the cursor, backing off 2 s to 15 min |
| 11 | * with jitter. Stops (the caller's polling is all there is) after 10 failures in a row, on a Content-Security-Policy |
| 12 | * refusal, when signed out (401/403) or when Network has realtime off (404/503). Hidden 5 min: closed; shown: resumed. |
| 13 | * OVNotificationLive.create({ ticketUrl, token, credentials, onNotification, onGap, onState }) → { start, stop, restart, state } |
| 14 | */ |
| 15 | (function (root) { |
| 16 | ; |
| 17 | if (typeof document === 'undefined' || root.OVNotificationLive) return; |
| 18 | const TYPE_RE = /^network\.notification\./; |
| 19 | const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/; |
| 20 | const MAX_FAILURES = 10; // 2 s, 4 s, … 512 s: about 9 to 17 minutes of retries before polling only |
| 21 | const SEEN_MAX = 256; // event ids remembered for dedupe |
| 22 | |
| 23 | function create(o) { |
| 24 | const rt = { state: 'off', failures: 0, lastId: null, subject: null, origin: null, events: 0, ignored: 0, gaps: 0, tickets: 0 }; |
| 25 | let es = null; let stopped = true; let gen = 0; |
| 26 | const seen = new Set(); |
| 27 | const t = {}; // timers: retry, hide |
| 28 | const later = (k, f, ms) => { root.clearTimeout(t[k]); t[k] = root.setTimeout(() => { t[k] = null; f(); }, ms); }; |
| 29 | const call = (f, ...a) => { try { if (typeof f === 'function') f(...a); } catch { /* the page's handler must not break the feed */ } }; |
| 30 | function set(s) { if (rt.state !== s) { rt.state = s; call(o.onState, s); } } |
| 31 | function close() { const s = es; es = null; if (s) try { s.close(); } catch { /* */ } } |
| 32 | function halt(s) { close(); root.clearTimeout(t.retry); t.retry = null; set(s); } |
| 33 | |
| 34 | async function open() { |
| 35 | if (stopped || es || rt.state === 'ticket') return; |
| 36 | if (document.hidden) { set('hidden'); return; } |
| 37 | const g = gen; |
| 38 | set('ticket'); |
| 39 | let tk; |
| 40 | try { |
| 41 | const tok = typeof o.token === 'function' ? await o.token() : o.token; |
| 42 | const r = await root.fetch(o.ticketUrl, { method: 'POST', credentials: o.credentials || 'omit', cache: 'no-store', headers: tok ? { Authorization: `Bearer ${tok}` } : {} }); |
| 43 | if (g !== gen) return; |
| 44 | if (r.status === 401 || r.status === 403) return halt('signed-out'); |
| 45 | if (r.status === 404 || r.status === 501 || r.status === 503) return halt('unavailable'); |
| 46 | if (!r.ok) throw new Error('ticket'); |
| 47 | tk = await r.json(); |
| 48 | if (!tk || typeof tk.ticket !== 'string' || !SUBJECT_RE.test(String(tk.subject)) || !Array.isArray(tk.topics)) throw new Error('ticket'); |
| 49 | } catch { |
| 50 | if (g === gen) fail(); |
| 51 | return; |
| 52 | } |
| 53 | if (g !== gen) return; |
| 54 | let u; |
| 55 | try { |
| 56 | u = new URL(tk.stream_url); |
| 57 | if (!/^https?:$/.test(u.protocol)) throw new Error('url'); |
| 58 | u.searchParams.set('topics', tk.topics.join(',')); |
| 59 | u.searchParams.set('ticket', tk.ticket); |
| 60 | if (rt.lastId != null) u.searchParams.set('last_event_id', rt.lastId); |
| 61 | } catch { fail(); return; } |
| 62 | rt.tickets++; rt.origin = u.origin; |
| 63 | rt.subject = tk.subject; |
| 64 | set('connecting'); |
| 65 | let s; |
| 66 | try { s = es = new root.EventSource(u.href); } catch { es = null; fail(); return; } |
| 67 | s.onopen = () => { if (es === s) { rt.failures = 0; set('open'); } }; |
| 68 | s.onmessage = (e) => { if (es === s) heard(e); }; |
| 69 | s.onerror = () => { if (es === s) fail(); }; |
| 70 | s.addEventListener('gap', (e) => { |
| 71 | if (es !== s) return; |
| 72 | let gap = null; try { gap = JSON.parse(e.data); } catch { /* */ } |
| 73 | rt.gaps++; |
| 74 | call(o.onGap, gap); |
| 75 | }); |
| 76 | } |
| 77 | function heard(e) { |
| 78 | let m; try { m = JSON.parse(e.data); } catch { return; } |
| 79 | const ev = m && m.event; |
| 80 | const id = ev && typeof ev.event_id === 'string' ? ev.event_id : null; |
| 81 | if (id) { |
| 82 | if (seen.has(id)) return; // already heard (a replay after a reconnect) |
| 83 | seen.add(id); |
| 84 | if (seen.size > SEEN_MAX) seen.delete(seen.values().next().value); |
| 85 | } |
| 86 | if (e.lastEventId) rt.lastId = String(e.lastEventId); |
| 87 | // Events already sends only this person's own; anything else is never shown. |
| 88 | if (!ev || !TYPE_RE.test(String(ev.event_type)) || !ev.subject || ev.subject.type !== 'user' || ev.subject.id !== rt.subject) { rt.ignored++; return; } |
| 89 | rt.events++; |
| 90 | call(o.onNotification, ev.payload && typeof ev.payload === 'object' ? ev.payload : {}, ev); |
| 91 | } |
| 92 | // A ticket opens one stream, so the browser's own reconnect (same URL) is never used: close, back off, new ticket. |
| 93 | function fail() { |
| 94 | close(); |
| 95 | if (stopped || rt.state === 'blocked') return; |
| 96 | if (++rt.failures >= MAX_FAILURES) { set('failed'); return; } |
| 97 | set('backoff'); |
| 98 | later('retry', () => { if (!stopped) { if (document.hidden) set('hidden'); else open(); } }, Math.min(9e5, 2e3 * 2 ** (rt.failures - 1)) * (0.5 + Math.random() / 2)); |
| 99 | } |
| 100 | |
| 101 | // A Content-Security-Policy whose connect-src leaves Events out: one refusal, then polling only. |
| 102 | document.addEventListener('securitypolicyviolation', (e) => { |
| 103 | if (stopped || !rt.origin || String(e.blockedURI || '').indexOf(rt.origin) !== 0) return; |
| 104 | halt('blocked'); |
| 105 | }); |
| 106 | document.addEventListener('visibilitychange', () => { |
| 107 | if (stopped) return; |
| 108 | if (document.hidden) { if (es) later('hide', () => { if (document.hidden && es) { close(); set('hidden'); } }, 5 * 60 * 1000); return; } |
| 109 | root.clearTimeout(t.hide); t.hide = null; |
| 110 | if (rt.state === 'hidden') open(); |
| 111 | }); |
| 112 | root.addEventListener('online', () => { if (!stopped && (rt.state === 'failed' || rt.state === 'backoff')) { rt.failures = 0; root.clearTimeout(t.retry); set('off'); open(); } }); |
| 113 | |
| 114 | const api = { |
| 115 | start() { if (!stopped) return api; stopped = false; set('off'); open(); return api; }, |
| 116 | stop() { stopped = true; gen++; close(); Object.keys(t).forEach((k) => { root.clearTimeout(t[k]); t[k] = null; }); set('off'); return api; }, |
| 117 | /** Another account (or a new token): forget the cursor and the subject, start again. */ |
| 118 | restart() { api.stop(); rt.lastId = null; seen.clear(); rt.subject = null; rt.failures = 0; return api.start(); }, |
| 119 | state: () => ({ ...rt }), |
| 120 | }; |
| 121 | return api; |
| 122 | } |
| 123 | |
| 124 | root.OVNotificationLive = { create }; |
| 125 | if (typeof module !== 'undefined' && module.exports) module.exports = root.OVNotificationLive; |
| 126 | })(typeof window !== 'undefined' ? window : globalThis); |
| 127 |