1// Offline support for app shell + runtime-cached book assets. 2// Kept intentionally dependency-free. 3 4const CACHE_NAME = 'book-reader-v7'; 5const APP_SHELL_URL = '/index.html'; 6const CORE_ASSETS = [ 7 '/', 8 '/index.html', 9 '/manifest.json', 10 '/offline-precache.json', 11 '/service-worker.js', 12 '/gc-icon.png', 13]; 14const PRECACHE_MANIFEST_PATH = '/offline-precache.json'; 15const OFFLINE_STATUS_URL = '/__offline-status'; 16 17let warmupPromise = null; 18let offlineReady = false; 19 20async function getManifestAssets() { 21 try { 22 const res = await fetch(PRECACHE_MANIFEST_PATH, { cache: 'no-store' }); 23 if (!res.ok) return []; 24 const json = await res.json(); 25 if (!json || !Array.isArray(json.assets)) return []; 26 return json.assets.filter((x) => typeof x === 'string' && x.startsWith('/')); 27 } catch { 28 return []; 29 } 30} 31 32async function addMany(cache, urls) { 33 for (const url of urls) { 34 try { 35 await cache.add(url); 36 } catch { 37 // Continue even if a single asset fails. 38 } 39 } 40} 41 42async function cacheUrlWithRetry(cache, url, attempts = 3) { 43 for (let i = 0; i < attempts; i++) { 44 try { 45 const req = new Request(url, { cache: 'no-store' }); 46 const res = await fetch(req); 47 if (res && res.ok) { 48 await cache.put(url, res.clone()); 49 return true; 50 } 51 } catch { 52 // retry 53 } 54 } 55 return false; 56} 57 58async function setOfflineStatus(cache, payload) { 59 try { 60 await cache.put( 61 OFFLINE_STATUS_URL, 62 new Response(JSON.stringify(payload), { 63 headers: { 'Content-Type': 'application/json' }, 64 }) 65 ); 66 } catch { 67 // ignore status write issues 68 } 69} 70 71async function warmOfflineCache() { 72 if (warmupPromise) return warmupPromise; 73 74 warmupPromise = (async () => { 75 const cache = await caches.open(CACHE_NAME); 76 const manifestAssets = await getManifestAssets(); 77 const dedupedAssets = Array.from(new Set(manifestAssets)); 78 79 let cached = 0; 80 let failed = 0; 81 82 for (const asset of dedupedAssets) { 83 const ok = await cacheUrlWithRetry(cache, asset, 3); 84 if (ok) cached += 1; 85 else failed += 1; 86 } 87 88 offlineReady = dedupedAssets.length > 0 && failed === 0; 89 90 await setOfflineStatus(cache, { 91 ready: offlineReady, 92 total: dedupedAssets.length, 93 cached, 94 failed, 95 updatedAt: new Date().toISOString(), 96 }); 97 98 const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); 99 clients.forEach((client) => { 100 client.postMessage({ 101 type: 'OFFLINE_CACHE_STATUS', 102 ready: offlineReady, 103 total: dedupedAssets.length, 104 cached, 105 failed, 106 }); 107 }); 108 })() 109 .catch(async () => { 110 offlineReady = false; 111 try { 112 const cache = await caches.open(CACHE_NAME); 113 await setOfflineStatus(cache, { 114 ready: false, 115 total: 0, 116 cached: 0, 117 failed: 1, 118 updatedAt: new Date().toISOString(), 119 }); 120 } catch { 121 // no-op 122 } 123 }) 124 .finally(() => { 125 warmupPromise = null; 126 }); 127 128 return warmupPromise; 129} 130 131self.addEventListener('message', (event) => { 132 const data = event.data; 133 if (!data || typeof data !== 'object') return; 134 135 if (data.type === 'WARM_OFFLINE_CACHE') { 136 event.waitUntil(warmOfflineCache()); 137 return; 138 } 139 140 if (data.type === 'GET_OFFLINE_CACHE_STATUS') { 141 const source = event.source; 142 if (source && typeof source.postMessage === 'function') { 143 source.postMessage({ type: 'OFFLINE_CACHE_STATUS', ready: offlineReady }); 144 } 145 } 146}); 147 148async function putInCache(req, res) { 149 if (!res || !res.ok) return; 150 const cache = await caches.open(CACHE_NAME); 151 await cache.put(req, res.clone()); 152} 153 154self.addEventListener('install', (event) => { 155 event.waitUntil( 156 (async () => { 157 const cache = await caches.open(CACHE_NAME); 158 // Keep install fast and reliable (especially on iOS). 159 // Large content pre-caching is done right after activation and can be retried. 160 await addMany(cache, CORE_ASSETS); 161 })() 162 );
163 self.skipWaiting(); 164}); 165 166self.addEventListener('activate', (event) => { 167 event.waitUntil( 168 (async () => { 169 const keys = await caches.keys(); 170 await Promise.all(keys.map((k) => (k !== CACHE_NAME ? caches.delete(k) : Promise.resolve()))); 171 await self.clients.claim(); 172 await warmOfflineCache(); 173 })() 174 ); 175}); 176 177self.addEventListener('fetch', (event) => { 178 const req = event.request; 179 180 // Only handle same-origin GET requests. 181 if (req.method !== 'GET') return; 182 if (req.headers.has('range')) return; 183 const url = new URL(req.url); 184 if (url.origin !== self.location.origin) return; 185 186 const isBookContent = url.pathname.startsWith('/book-content/'); 187 const isDataFile = url.pathname.startsWith('/data/'); 188 189 if (url.pathname === OFFLINE_STATUS_URL) { 190 event.respondWith( 191 (async () => { 192 const cached = await caches.match(OFFLINE_STATUS_URL); 193 if (cached) return cached; 194 return new Response( 195 JSON.stringify({ ready: offlineReady, total: 0, cached: 0, failed: 0 }), 196 { headers: { 'Content-Type': 'application/json' } } 197 ); 198 })() 199 ); 200 return; 201 } 202 203 // Navigations: network-first, fall back to cached app shell. 204 // This avoids serving stale HTML after deploys (which can reference removed bundles). 205 if (req.mode === 'navigate') { 206 event.respondWith( 207 (async () => { 208 try { 209 const res = await fetch(req, { cache: 'no-store' }); 210 event.waitUntil(putInCache(APP_SHELL_URL, res.clone())); 211 return res; 212 } catch { 213 return (await caches.match(APP_SHELL_URL)) || (await caches.match('/')) || Response.error(); 214 } 215 })() 216 ); 217 return; 218 } 219 220 // Static assets: cache-first, update cache in background. 221 const dest = req.destination; 222 const isStatic = dest === 'script' || dest === 'style' || dest === 'image' || dest === 'font'; 223 224 // Book/text/data files are fetched by JS with destination="". 225 // Stale-while-revalidate keeps offline reopen reliable and updates when online. 226 if (isBookContent || isDataFile) { 227 event.respondWith( 228 (async () => { 229 const cached = await caches.match(req); 230 const networkFetch = fetch(req) 231 .then((res) => { 232 event.waitUntil(putInCache(req, res.clone())); 233 return res; 234 }) 235 .catch(() => null); 236 237 if (cached) { 238 event.waitUntil(networkFetch); 239 return cached; 240 } 241 242 const net = await networkFetch; 243 return net || Response.error(); 244 })() 245 ); 246 return; 247 } 248 249 if (isStatic) { 250 event.respondWith( 251 (async () => { 252 const cached = await caches.match(req); 253 if (cached) { 254 event.waitUntil( 255 fetch(req) 256 .then((res) => putInCache(req, res)) 257 .catch(() => null) 258 ); 259 return cached; 260 } 261 262 const res = await fetch(req); 263 event.waitUntil(putInCache(req, res.clone())); 264 return res; 265 })() 266 ); 267 } 268});
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.