1/* 2 * SecPlus Mastery offline service worker. 3 * 4 * Hand-written and dependency-free. Strategy by request type: 5 * - cross-origin (Supabase, Stripe, Plausible, Sentry, fonts) ......... untouched 6 * - same-origin API / functions (/api/*, /.netlify/*) ................. untouched 7 * - non-GET (POST/PUT/...) ........................................... untouched 8 * - HTML navigations ................... network-first, then offline fallback 9 * - A+ practice exam scripts (/aplus-practice-exam/*) ..... network-first, then cache 10 * - immutable hashed build assets (/assets/*, /aplus/core-N/assets/*) .. cache-first 11 * - other same-origin GET (icons, manifest, images, pdfs) .... stale-while-revalidate 12 * 13 * Online users therefore always get fresh HTML, fresh exam code, and live data; 14 * the cache only serves when the network is unavailable. Authenticated API and 15 * payment traffic is never cached. 16 * 17 * Update policy: NO skipWaiting / NO clientsClaim. A new worker waits and takes 18 * over only once existing tabs are gone, so a deploy can never swap assets out 19 * from under an in-progress action such as Stripe checkout. Bump VERSION to roll 20 * a fresh cache generation; older generations are deleted on activate. 21 */ 22// Bumped to v3 with the A+ exam routing fix below: installs that are already out 23// there hold a STATIC_CACHE full of the previous build's exam scripts, and the 24// new rule alone would never evict them. A new generation drops that cache on 25// activate. Existing tabs keep the old worker until they are closed (no 26// skipWaiting), which is fine: the worst they do is what they already do. 27const VERSION = 'v3'; 28const STATIC_CACHE = `secplus-static-${VERSION}`; 29const PAGE_CACHE = `secplus-pages-${VERSION}`; 30const OFFLINE_URL = '/offline.html'; 31 32// App-shell essentials worth having before the first offline navigation. 33const PRECACHE_URLS = [ 34 OFFLINE_URL, 35 '/favicon.svg?v=2', 36 '/apple-touch-icon.png?v=2', 37 '/icon-192.png?v=2', 38 '/icon-512.png?v=2', 39 '/site.webmanifest', 40]; 41 42self.addEventListener('install', (event) => { 43 event.waitUntil( 44 caches.open(STATIC_CACHE).then((cache) => cache.addAll(PRECACHE_URLS)), 45 ); 46}); 47 48self.addEventListener('activate', (event) => { 49 // Drop caches from previous versions. Without clientsClaim, pages already open 50 // keep their current controller; new loads pick up this worker. 51 event.waitUntil( 52 caches.keys().then((keys) => 53 Promise.all( 54 keys 55 .filter((key) => key !== STATIC_CACHE && key !== PAGE_CACHE) 56 .map((key) => caches.delete(key)), 57 ), 58 ), 59 ); 60}); 61 62// True for anything the worker must not touch: it falls through to the network 63// exactly as if no worker were installed. 64function isBypassed(url, request) { 65 if (request.method !== 'GET') return true; 66 if (url.origin !== self.location.origin) return true; // Supabase/Stripe/Plausible/fonts 67 if (url.pathname.startsWith('/.netlify/')) return true; // serverless functions 68 if (url.pathname.startsWith('/api/')) return true; // function proxy (netlify.toml) 69 return false; 70} 71 72// Only ever cache a same-origin 200. Skips opaque/cross-origin and partial (206) 73// range responses, which throw on Cache.put or would poison the cache. 74async function cachePut(cacheName, request, response) { 75 if (response && response.status === 200 && response.type === 'basic') { 76 const cache = await caches.open(cacheName); 77 await cache.put(request, response); 78 } 79} 80 81async function networkFirst(event, request) { 82 try { 83 const response = await fetch(request); 84 event.waitUntil(cachePut(PAGE_CACHE, request, response.clone())); 85 return response; 86 } catch { 87 const cached = await caches.match(request); 88 if (cached) return cached; 89 // The offline page answers a NAVIGATION. This path also serves the A+ exam's 90 // scripts, and handing an HTML document back for a .js request does not 91 // degrade gracefully, it fails confusingly: under nosniff the browser 92 // refuses to execute a text/html script, and without it the page is parsed 93 // as JavaScript. A network error is the honest answer for a subresource. 94 if (request.mode === 'navigate') { 95 return (await caches.match(OFFLINE_URL)) || Response.error(); 96 } 97 return Response.error(); 98 } 99} 100 101async function cacheFirst(event, request) { 102 const cached = await caches.match(request); 103 if (cached) return cached; 104 const response = await fetch(request); 105 event.waitUntil(cachePut(STATIC_CACHE, request, response.clone())); 106 return response; 107} 108 109async function staleWhileRevalidate(event, request) { 110 const cached = await caches.match(request); 111 const network = fetch(request) 112 .then(async (response) => { 113 await cachePut(STATIC_CACHE, request, response.clone()); 114 return response; 115 }) 116 .catch(() => cached || Response.error()); 117 // waitUntil on the whole revalidation, and from here rather than from inside 118 // the .then above. A fetch event stays active only until the promise given to 119 // respondWith settles, and on a cache hit that is immediate, so the old call 120 // ran after the event was over and threw InvalidStateError straight into the 121 // catch below, where it was swallowed. The write had already started, but 122 // nothing was holding the worker alive for it, so a worker terminated 123 // mid-write left the stale copy in place and a redeployed icon or manifest 124 // kept serving the old one. 125 event.waitUntil(network); 126 return cached || network; 127} 128 129self.addEventListener('fetch', (event) => { 130 const { request } = event; 131 const url = new URL(request.url); 132 if (isBypassed(url, request)) return; 133 134 if (request.mode === 'navigate') { 135 event.respondWith(networkFirst(event, request)); 136 return; 137 } 138 // The A+ practice exam is a standalone page under public/, so its three 139 // scripts (the two question banks and the grader) are copied through with 140 // fixed names and no content hash. The default below would answer them from 141 // the cache and pair a freshly fetched page with the previous build's answer 142 // key, marking someone wrong on the exact question a deploy just corrected. 143 // Network-first keeps the page and its code from the same build, and still 144 // falls back to the cached copy when the network is gone. 145 if (url.pathname.startsWith('/aplus-practice-exam/')) { 146 event.respondWith(networkFirst(event, request)); 147 return; 148 } 149 // The A+ course builds keep the same kind of hashed chunks under their own base 150 // paths; they used to fall through to stale-while-revalidate and be fetched 151 // again in the background on every page load. 152 if (url.pathname.startsWith('/assets/') || /^\/aplus\/core-\d\/assets\//.test(url.pathname)) { 153 event.respondWith(cacheFirst(event, request)); 154 return; 155 } 156 event.respondWith(staleWhileRevalidate(event, request)); 157});
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.