openvibers/openvibe.shared openvibe-sw.js
openvibe-sw.js
| 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 | ; |
| 30 | |
| 31 | const VERSION = '4.164.0'; // the release this worker ships in: a bump replaces the caches |
| 32 | const CACHE_PREFIX = 'ov-shell-'; |
| 33 | const CACHE_NAME = CACHE_PREFIX + VERSION; |
| 34 | const KILL_SWITCH = 'https://openvibe.network/api/v1/sw-config'; |
| 35 | const STATIC_TYPES = ['javascript', 'ecmascript', 'css', 'woff2', 'woff', 'ttf', 'otf', 'svg+xml', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'ico']; |
| 36 | |
| 37 | const 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. */ |
| 40 | const 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://… |
| 54 | const params = new URL(self_.location.href).searchParams; |
| 55 | const 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. */ |
| 60 | function 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. */ |
| 66 | function 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). */ |
| 78 | function 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. */ |
| 87 | async 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. */ |
| 96 | const MAX_ENTRIES = 400; |
| 97 | async 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. */ |
| 107 | async 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. */ |
| 118 | async 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. */ |
| 132 | async 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 | |
| 148 | self_.addEventListener('install', () => self_.skipWaiting()); |
| 149 | |
| 150 | self_.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 | |
| 159 | self_.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 | |
| 178 | self_.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 | |
| 189 | self_.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 | |
| 205 | self_.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 |