1/* ImpactMojo â Service Worker 2 --------------------------------------------------------------------------- 3 Goal: real offline support WITHOUT the stale-file serving that forced the 4 previous service worker to be retired. 5 6 Strategy 7 - Navigations / HTML â network-first. Always fresh when online; falls back 8 to a cached copy (or /offline.html) only when the network fails. This is 9 what prevents the "stale page" bug that killed the old SW. 10 - Static assets (CSS/JS/img/font/json, same-origin + known CDNs) 11 â stale-while-revalidate. Served instantly from cache 12 but refreshed in the background, so a cached asset is at most one load old. 13 - On activate, every cache that is not the current version is deleted, so a 14 deploy can never leave stale files behind. User-downloaded course caches 15 (impactmojo-course-*) are preserved across versions. 16 - On-demand course download (postMessage 'CACHE_COURSE') and background sync 17 of queued progress ('sync-progress') back the js/offline.js module. 18 19 Bump VERSION on a deploy that changes cached static assets to force a clean 20 purge. Even without a bump, HTML is always network-first and assets 21 self-heal via revalidation, so staleness is bounded. */ 22 23'use strict'; 24 25const VERSION = 'v2-bb11aae7ba4f'; 26const RUNTIME = 'im-runtime-' + VERSION; 27const OFFLINE_URL = '/offline.html'; 28const COURSE_PREFIX = 'impactmojo-course-'; 29 30// Flagship course id â entry URL (mirrors FLAGSHIP_COURSES in js/offline.js). 31// Per-course URL lists: entry page + lexicon; shared assets appended in cacheCourse. 32const COURSE_URLS = { 33 causal: ['/courses/causal/', '/courses/causal/lexicon.html'], 34 dataviz: ['/courses/dataviz/', '/courses/dataviz/lexicon.html'], 35 devai: ['/courses/devai/', '/courses/devai/lexicon.html'], 36 devecon: ['/courses/devecon/', '/courses/devecon/lexicon.html'], 37 gandhi: ['/courses/gandhi/', '/courses/gandhi/lexicon.html'], 38 gender: ['/courses/gender/', '/courses/gender/lexicon.html'], 39 intervention: ['/courses/intervention/', '/courses/intervention/lexicon.html'], 40 esg: ['/courses/esg/', '/courses/esg/lexicon.html'], 41 'gender-mel': ['/courses/gender-mel/', '/courses/gender-mel/lexicon.html'], 42 law: ['/courses/law/', '/courses/law/lexicon.html'], 43 livelihoods: ['/courses/livelihoods/', '/courses/livelihoods/lexicon.html'], 44 media: ['/courses/media/', '/courses/media/lexicon.html'], 45 mel: ['/courses/mel/', '/courses/mel/lexicon.html'], 46 "nothing-about-us": ['/courses/nothing-about-us/', '/courses/nothing-about-us/lexicon.html'], 47 "nvc-rj": ['/courses/nvc-rj/', '/courses/nvc-rj/lexicon.html'], 48 poa: ['/courses/poa/', '/courses/poa/lexicon.html'], 49 powerBI: ['/courses/powerBI/powerbi.html', '/courses/powerBI/lexicon.html'], 50 pubchoice: ['/courses/pubchoice/', '/courses/pubchoice/lexicon.html'], 51 pubpol: ['/courses/pubpol/', '/courses/pubpol/lexicon.html'], 52 sel: ['/courses/sel/', '/courses/sel/lexicon.html'], 53}; 54 55const STATIC_RE = /\.(?:css|js|mjs|png|jpe?g|gif|svg|webp|ico|woff2?|ttf|eot|json)$/i; 56// Fonts are self-hosted under /assets/fonts/ (same-origin, matched by STATIC_RE); 57// only the Sargam icon CDN remains cross-origin cacheable. 58const CDN_RE = /(?:cdn\.jsdelivr\.net)/; 59 60// ââ Install: precache only the offline fallback, then take over. ââ 61self.addEventListener('install', (event) => { 62 event.waitUntil( 63 caches.open(RUNTIME) 64 .then((cache) => cache.add(OFFLINE_URL)) 65 .catch(() => {}) 66 .then(() => self.skipWaiting()) 67 ); 68}); 69 70// ââ Activate: drop old versioned caches (keep downloaded courses), claim clients. ââ 71self.addEventListener('activate', (event) => { 72 event.waitUntil( 73 caches.keys() 74 .then((keys) => Promise.all(keys.map((key) => { 75 if (key === RUNTIME) return null; 76 if (key.startsWith(COURSE_PREFIX)) return null; // user downloads persist 77 return caches.delete(key); 78 }))) 79 .then(() => self.clients.claim()) 80 ); 81}); 82 83// ââ Fetch routing. ââ 84self.addEventListener('fetch', (event) => { 85 const req = event.request; 86 if (req.method !== 'GET') return; // never touch POST/PUT/etc. 87 88 let url; 89 try { url = new URL(req.url); } catch (e) { return; } 90 if (!url.protocol.startsWith('http')) return;
90 // skip chrome-extension:, etc. 91 92 // Always go straight to the network for backend / dynamic endpoints. 93 if (url.hostname.includes('supabase') || 94 url.hostname.includes('instantdb') || 95 url.pathname.startsWith('/.netlify/') || 96 url.hostname.includes('google-analytics') || 97 url.hostname.includes('googletagmanager') || 98 url.hostname.includes('gtag')) { 99 return; 100 } 101 102 const isHTML = req.mode === 'navigate' || 103 (req.headers.get('accept') || '').includes('text/html'); 104 105 if (isHTML) { 106 event.respondWith(networkFirst(req)); 107 return; 108 } 109 110 const sameOrigin = url.origin === self.location.origin; 111 if ((sameOrigin && STATIC_RE.test(url.pathname)) || CDN_RE.test(url.hostname)) { 112 event.respondWith(staleWhileRevalidate(req)); 113 } 114 // Everything else falls through to the network (browser default). 115}); 116 117// Network-first: fresh online, cached (or offline page) when the network fails. 118function networkFirst(req) { 119 return fetch(req) 120 .then((res) => { 121 if (res && res.ok) { 122 const copy = res.clone(); 123 caches.open(RUNTIME).then((c) => c.put(req, copy)).catch(() => {}); 124 } 125 return res; 126 }) 127 .catch(() => caches.match(req).then((cached) => cached || caches.match(OFFLINE_URL))); 128} 129 130// Stale-while-revalidate: serve cache, refresh in the background. 131function staleWhileRevalidate(req) { 132 return caches.open(RUNTIME).then((cache) => 133 cache.match(req).then((cached) => { 134 const network = fetch(req) 135 .then((res) => { 136 if (res && (res.ok || res.type === 'opaque')) cache.put(req, res.clone()); 137 return res; 138 }) 139 .catch(() => cached); 140 return cached || network; 141 }) 142 ); 143} 144 145// ââ Messages from the page (js/offline.js). ââ 146self.addEventListener('message', (event) => { 147 const data = event.data || {}; 148 if (data.type === 'SKIP_WAITING') { 149 self.skipWaiting(); 150 } else if (data.type === 'CACHE_COURSE' && data.courseId) { 151 event.waitUntil(cacheCourse(data.courseId)); 152 } 153}); 154 155function postAll(message) { 156 return self.clients.matchAll().then((clients) => { 157 clients.forEach((client) => client.postMessage(message)); 158 }); 159} 160 161// Download a course's entry page for offline reading. Shared CSS/JS are picked 162// up by stale-while-revalidate while the user browses online, and caches.match 163// in networkFirst searches every cache (including this one) when offline. 164const COURSE_SHARED_ASSETS = ["/js/course-loader.js", "/js/course-progress.js", "/js/site-chrome.js", "/js/theme.js", "/js/config.js", "/js/offline.js", "/css/fonts.css"]; 165 166function cacheCourse(courseId) { 167 const base = COURSE_URLS[courseId]; 168 if (!base) { 169 return postAll({ type: 'COURSE_CACHE_ERROR', courseId: courseId, error: 'Unknown course' }); 170 } 171 return caches.open(COURSE_PREFIX + courseId).then((cache) => { 172 const courseUrls = Array.isArray(base) ? base : [base]; 173 const urls = courseUrls.concat(COURSE_SHARED_ASSETS, [OFFLINE_URL]); 174 let completed = 0; 175 return urls.reduce((chain, u) => chain.then(() => 176 fetch(u, { cache: 'reload' }) 177 .then((res) => { if (res && res.ok) return cache.put(u, res.clone()); }) 178 .catch(() => {}) 179 .then(() => { 180 completed++; 181 return postAll({ 182 type: 'COURSE_CACHE_PROGRESS', 183 courseId: courseId, 184 completed: completed, 185 total: urls.length, 186 progress: Math.round((completed / urls.length) * 100) 187 }); 188 }) 189 ), Promise.resolve()) 190 .then(() => postAll({ type: 'COURSE_CACHE_COMPLETE', courseId: courseId })) 191 .catch((err) => postAll({ type: 'COURSE_CACHE_ERROR', courseId: courseId, error: String(err) })); 192 }); 193} 194 195// ââ Web push: show a notification, and focus/open on click. ââ 196self.addEventListener('push', (event) => { 197 let data = {}; 198 try { data = event.data ? event.data.json() : {}; } 199 catch (e) { data = { title: 'ImpactMojo', body: event.data ? event.data.text() : '' }; } 200 201 const title = data.title || 'ImpactMojo'; 202 const options = { 203 body: data.body || '', 204 icon: '/assets/images/android-chrome-192x192.png', 205 badge: '/assets/images/favicon-32x32.png', 206 data: { link: data.link || '/' }, 207 tag: data.tag || undefined, 208 renotify: !!data.tag 209 }; 210 event.waitUntil(self.registration.showNotification(title, options)); 211}); 212 213self.addEventListener('notificationclick', (event) => { 214 event.notification.close(); 215 const link = (event.notification.data && event.notification.data.link) || '/'; 216 const target = new URL(link, self.location.origin).href; 217 event.waitUntil( 218 self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => { 219 for (const client of clients) {
220 if (client.url === target && 'focus' in client) return client.focus(); 221 } 222 return self.clients.openWindow ? self.clients.openWindow(target) : undefined; 223 }) 224 ); 225}); 226 227// ââ Background sync: replay progress POSTs queued by js/offline.js. ââ 228self.addEventListener('sync', (event) => { 229 if (event.tag === 'sync-progress') { 230 event.waitUntil(replayQueuedProgress()); 231 } 232}); 233 234function idbOpen() { 235 return new Promise((resolve, reject) => { 236 const r = indexedDB.open('ImpactMojoOffline', 1); 237 r.onupgradeneeded = () => { 238 const db = r.result; 239 if (!db.objectStoreNames.contains('pendingSync')) { 240 db.createObjectStore('pendingSync', { keyPath: 'id', autoIncrement: true }); 241 } 242 }; 243 r.onsuccess = () => resolve(r.result); 244 r.onerror = () => reject(r.error); 245 }); 246} 247 248function idbGetAll(db) { 249 return new Promise((resolve, reject) => { 250 const tx = db.transaction('pendingSync', 'readonly'); 251 const req = tx.objectStore('pendingSync').getAll(); 252 req.onsuccess = () => resolve(req.result || []); 253 req.onerror = () => reject(req.error); 254 }); 255} 256 257function idbDelete(db, id) { 258 return new Promise((resolve) => { 259 const tx = db.transaction('pendingSync', 'readwrite'); 260 tx.objectStore('pendingSync').delete(id); 261 tx.oncomplete = () => resolve(); 262 tx.onerror = () => resolve(); 263 }); 264} 265 266function replayQueuedProgress() { 267 return idbOpen().then((db) => 268 idbGetAll(db).then((items) => 269 items.reduce((chain, item) => chain.then(() => 270 fetch(item.url, { 271 method: item.method || 'POST', 272 headers: { 'Content-Type': 'application/json' }, 273 body: JSON.stringify(item.data) 274 }) 275 .then((res) => { if (res && res.ok) return idbDelete(db, item.id); }) 276 .catch(() => {}) // keep queued for the next sync 277 ), Promise.resolve()) 278 ) 279 ); 280}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.