openvibers/openvibe.shared openvibe-sw.js

openvibe-sw.js

main 12.7 KB · 224 lines · javascript

1/* OpenVibe — the one worker every site runs (the owner, 2026-11-09: "how come when you change sites it doesn't load more
2 * asynchronously and dynamically, so it's faster across sites? … better caching on the client, cross-site and smarter, while
3 * staying SEO friendly and crawlable?").
4 *
5 * Served by every OpenVibe site at /openvibe-sw.js (same origin, scope "/"), from openvibe-shared. Two jobs in one file:
6 *
7 * 1. Web Push (the job it always had): the network sends a notification, the worker shows it.
8 * 2. The cache of content-addressed files: a shared or site file served under its content hash (?v=<hash> or a hash in
9 * the name: what serve.url() hands out) is kept and answered from the cache without asking the network again. Its
10 * bytes cannot change under that URL, and it is public, so this is safe for everyone this worker runs for.
11 *
12 * The rules that keep it honest (review 11-09):
13 *
14 * - Only content-addressed files are answered by the worker. Pages and every other file go to the network exactly as
15 * the browser sends them: no copy of a page is ever kept (a signed-in page could outlive a sign-out on a shared
16 * device), nothing cuts a slow page off, and a file without a hash is never served stale against new HTML.
17 * - Nothing private is stored: a response with Set-Cookie, Cache-Control: private or no-store, or a Vary on Cookie or
18 * Authorization passes through and is never written to the cache.
19 * - The API is never touched: /api/ and anything that is not a plain GET go to the network untouched.
20 * - The kill switch: GET <kill switch> says { "enabled": false } and every worker on every site unregisters itself and
21 * drops its caches. A worker that cannot reach the switch keeps working.
22 * - A new VERSION is a new cache, and the cache keeps the newest MAX_ENTRIES files.
23 *
24 * This file is also the Web Push worker the notification bell registers for anyone who turns push on, so whatever it does
25 * reaches them without any other opt-in: that is why it only keeps immutable public bytes.
26 * The site that wants this says so: OpenVibeNavbar.init({ sw: true }). A site can also point the worker elsewhere
27 * ({ sw: { path: '/my-sw.js' } }) or leave it alone.
28 */
29'use strict';
30
31const VERSION = '4.164.0'; // the release this worker ships in: a bump replaces the caches
32const CACHE_PREFIX = 'ov-shell-';
33const CACHE_NAME = CACHE_PREFIX + VERSION;
34const KILL_SWITCH = 'https://openvibe.network/api/v1/sw-config';
35const STATIC_TYPES = ['javascript', 'ecmascript', 'css', 'woff2', 'woff', 'ttf', 'otf', 'svg+xml', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'ico'];
36
37const self_ = typeof self !== 'undefined' ? self : globalThis;
38
39/** The shared files every OpenVibe page loads. The site's own manifest (below) replaces this list when it has one. */
40const SHELL = [
41 '/shared/navbar.js',
42 '/shared/theme-loader.js',
43 '/shared/web-runtime.js',
44 '/shared/footer.js',
45 '/shared/nav-icons.js',
46 '/shared/kit.css',
47 '/shared/kit-layout.css',
48 '/shared/ov-brand.woff2',
49 '/shared/ov-glyphs.woff2',
50 '/shared/ov-glyphs.svg',
51];
52
53// The switch may be pointed elsewhere by the registration URL: /openvibe-sw.js?cfg=https://…
54const params = new URL(self_.location.href).searchParams;
55const KILL_URL = params.get('cfg') || KILL_SWITCH;
56
57// ── The cache's own rules ────────────────────────────────────────────────────────────────────────────────────────────────
58
59/** A URL is content-addressed when it carries ?v=<hash> or a hash in the file name: its bytes cannot change. */
60function isHashed(url) {
61 if (/[?&]v=[0-9a-f]{8,64}\b/.test(url.pathname + url.search)) return true;
62 return /[.\-_][0-9a-f]{8,64}(?=[.\-_/]|$)/.test(url.pathname);
63}
64
65/** True when the response may be written to the cache: public, not private, no cookie, no Vary on the reader. */
66function cacheable(res) {
67 if (!res || !res.ok || res.type === 'opaque' || res.type === 'opaqueredirect') return false;
68 const cc = String(res.headers.get('cache-control') || '');
69 if (/\bprivate\b/i.test(cc) || /\bno-store\b/i.test(cc)) return false;
70 if (res.headers.get('set-cookie')) return false;
71 const vary = String(res.headers.get('vary') || '').toLowerCase();
72 if (/(^|,|\s)\s*(cookie|authorization)\s*(,|$)/.test(vary)) return false;
73 return true;
74}
75
76
77/** True when the response is a file the shell keeps (a script, a stylesheet, a font, an image). */
78function staticType(res) {
79 const type = String(res.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
80 const sub = type.indexOf('/') >= 0 ? type.slice(type.indexOf('/') + 1) : type;
81 return STATIC_TYPES.indexOf(sub) >= 0;
82}
83
84// ── The strategies ─────────────────────────────────────────────────────────────────────────────────────────────────────
85
86/** Hashed assets: the cache first, the network only when the cache does not have it. */
87async function cacheFirst(req, cache) {
88 const hit = await cache.match(req);
89 if (hit) return hit;
90 const res = await fetch(req);
91 if (cacheable(res) && staticType(res)) { try { await cache.put(req, res.clone()); await trim(cache); } catch { /* full or gone */ } }
92 return res;
93}
94
95/** Each release adds files under new hashes: the oldest go once the cache holds more than MAX_ENTRIES. */
96const MAX_ENTRIES = 400;
97async function trim(cache) {
98 const keys = await cache.keys();
99 for (let i = 0; i < keys.length - MAX_ENTRIES; i++) await cache.delete(keys[i]);
100}
101
102
103
104// ── The shell's own files ────────────────────────────────────────────────────────────────────────────────────────────────
105
106/** The site's manifest of the shell it serves, by content hash: the site's own answer, when it has one. */
107async function shellUrls() {
108 try {
109 const res = await fetch('/ov-sw-manifest.json', { cache: 'no-store' });
110 if (!res.ok) return SHELL;
111 const body = await res.json();
112 const list = Array.isArray(body && body.shell) ? body.shell.filter((u) => typeof u === 'string' && u.startsWith('/')) : [];
113 return list.length ? list : SHELL;
114 } catch { return SHELL; }
115}
116
117/** Put the shell in the cache so the next page does not wait for it. One file that fails never stops the rest. */
118async function warmShell(cache) {
119 // Only the content-addressed ones: the worker never answers for any other file (see the fetch handler).
120 const urls = (await shellUrls()).filter((u) => { try { return isHashed(new URL(u, self_.location.origin)); } catch { return false; } });
121 await Promise.all(urls.map(async (url) => {
122 try {
123 const res = await fetch(new Request(url, { cache: 'reload' }));
124 if (cacheable(res) && staticType(res)) await cache.put(url, res);
125 } catch { /* that file is not on this site */ }
126 }));
127}
128
129// ── The kill switch ────────────────────────────────────────────────────────────────────────────────────────────────────────
130
131/** Ask the network whether the worker may keep working. No answer means yes: a hiccup must not take the cache away. */
132async function checkKillSwitch() {
133 try {
134 const res = await fetch(KILL_URL, { cache: 'no-store' });
135 if (!res.ok) return false;
136 const cfg = await res.json();
137 if (!cfg || cfg.enabled !== false) return false;
138 } catch { return false; }
139 try {
140 await self_.registration.unregister();
141 for (const name of await caches.keys()) if (name.startsWith(CACHE_PREFIX)) await caches.delete(name);
142 } catch { /* nothing more to do */ }
143 return true;
144}
145
146// ── The worker's life ───────────────────────────────────────────────────────────────────────────────────────────────────
147
148self_.addEventListener('install', () => self_.skipWaiting());
149
150self_.addEventListener('activate', (event) => {
151 event.waitUntil((async () => {
152 await self_.clients.claim();
153 for (const name of await caches.keys()) if (name.startsWith(CACHE_PREFIX) && name !== CACHE_NAME) await caches.delete(name);
154 await checkKillSwitch();
155 try { await warmShell(await caches.open(CACHE_NAME)); } catch { /* the shell warms on its next visit */ }
156 })());
157});
158
159self_.addEventListener('fetch', (event) => {
160 const req = event.request;
161 if (req.method !== 'GET') return; // never touches a write
162 if (req.headers.get('range')) return; // a partial request is the browser's business
163 let url;
164 try { url = new URL(req.url); } catch { return; }
165 if (url.origin !== self_.location.origin) return; // another site's bytes are another site's cache
166 if (url.pathname.startsWith('/api/')) return; // the API is never cached
167 if (url.pathname === '/ov-sw.js' || url.pathname === '/openvibe-sw.js') return;
168
169 // Only content-addressed files (a hash in the name, or ?v=<hash>): their bytes never change and are public, so the
170 // cache answers first, for good. Pages and every other file go to the network exactly as the browser sends them.
171 // (Review 11-09: this file is also the Web Push worker every signed-in person with push already runs, so whatever it
172 // does reaches people who never opted in. Kept copies of pages could outlive a sign-out on a shared device, a 3 s
173 // cut-off turned slow pages into "offline", and stale unhashed scripts could run against new HTML after a deploy.)
174 if (req.destination === 'document' || req.mode === 'navigate' || !isHashed(url)) return;
175 event.respondWith((async () => cacheFirst(req, await caches.open(CACHE_NAME)))());
176});
177
178self_.addEventListener('message', (event) => {
179 const data = (event.data && typeof event.data === 'object') ? event.data : {};
180 if (data.type === 'skipWaiting') self_.skipWaiting();
181 if (data.type === 'checkKill') event.waitUntil(checkKillSwitch());
182 if (data.type === 'clearCaches') event.waitUntil((async () => {
183 for (const name of await caches.keys()) if (name.startsWith(CACHE_PREFIX)) await caches.delete(name);
184 })());
185});
186
187// ── Web Push (the job this worker always had) ───────────────────────────────────────────────────────────────────────
188
189self_.addEventListener('push', (event) => {
190 let data = {};
191 try { data = event.data ? event.data.json() : {}; } catch { data = { title: 'OpenVibe', body: event.data ? event.data.text() : '' }; }
192 const title = data.title || 'OpenVibe';
193 const options = {
194 body: data.body || '',
195 icon: data.icon || 'https://openvibe.network/assets/logo-192.png',
196 badge: data.badge || 'https://openvibe.network/assets/logo-72.png',
197 tag: data.tag || 'openvibe',
198 renotify: !!data.renotify,
199 data: { url: data.url || 'https://openvibe.network/notifications', id: data.id || null },
200 requireInteraction: false,
201 };
202 event.waitUntil(self_.registration.showNotification(title, options));
203});
204
205self_.addEventListener('notificationclick', (event) => {
206 event.notification.close();
207 const url = (event.notification.data && event.notification.data.url) || 'https://openvibe.network/notifications';
208 event.waitUntil((async () => {
209 const all = await self_.clients.matchAll({ type: 'window', includeUncontrolled: true });
210 let target = null;
211 try { target = new URL(url); } catch { /* */ }
212 for (const c of all) {
213 try {
214 if (target && new URL(c.url).origin === target.origin && 'focus' in c) {
215 await c.focus();
216 if ('navigate' in c) { try { await c.navigate(url); } catch { /* cross-origin navigate blocked */ } }
217 return;
218 }
219 } catch { /* */ }
220 }
221 await self_.clients.openWindow(url);
222 })());
223});
224