openvibers/openvibe.shared notification-live.js

notification-live.js

main 7.7 KB · 127 lines · javascript

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 'use strict';
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