PageSourceSearch

https://www.impactmojo.in/service-worker.js

js impactmojo.in collected 2026-09-29 07:13:40 UTC 10,898 bytes, 280 lines download raw bytes

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.