1/* Beamo Search service worker â offline shell, static assets, and last-search replay. */ 2/* SHELL_CACHE auto-versions from sw-precache.generated.js (run npm run build:pwa); no manual bump. */ 3importScripts('/sw-precache.generated.js'); 4 5// Derive the shell cache name from the precache version so every deploy that changes any 6// precached asset produces a new cache name; the activate handler then deletes the prior 7// shell cache, guaranteeing precached bundles are never served stale across deploys. 8// (RESULTS_CACHE â the user's offline search results â is preserved by the activate filter.) 9const SHELL_CACHE = 'beamo-pwa-shell-' + (self.__BEAMO_SHELL_PRECACHE_VERSION || 'v4'); 10const RESULTS_CACHE = 'beamo-pwa-results-v2'; 11const RESULTS_TTL_MS = 30 * 60 * 1000; 12const RESULTS_QUEUE_MAX = 5; 13// Query-specific results HTML can contain the search term and inline result data, 14// so it follows the same privacy boundary as the JSON replay cache: at most five 15// entries for 30 minutes. Precache entries are never counted/trimmed. 16const NAVIGATION_QUEUE_MAX = RESULTS_QUEUE_MAX; 17const NAVIGATION_TTL_MS = RESULTS_TTL_MS; 18const NAVIGATION_CACHED_AT_HEADER = 'X-Beamo-Navigation-Cached-At'; 19const OFFLINE_CACHE_HEADER = 'X-Beamo-Offline-Cache'; 20const OFFLINE_CACHE_QUERY_HEADER = 'X-Beamo-Cached-Query'; 21const OFFLINE_CACHE_TYPE_HEADER = 'X-Beamo-Cached-Type'; 22const OFFLINE_DEGRADED_HEADER = 'X-Beamo-Offline-Degraded'; 23const CACHEABLE_NAVIGATION_PATHS = new Set([ 24 '/', 25 '/index.php', 26 '/index.html', 27 '/results', 28 '/html/results.php', 29 '/html/results.html', 30]); 31 32const SHELL_PRECACHE = Array.isArray(self.__BEAMO_SHELL_PRECACHE) ? self.__BEAMO_SHELL_PRECACHE : [ 33 '/offline.html', 34 '/manifest.webmanifest', 35 '/images/favicon.ico', 36 '/images/logo.webp', 37 '/images/logo-dark.webp', 38]; 39 40const PRECACHE_PATHNAMES = new Set( 41 SHELL_PRECACHE.map((entry) => { 42 try { 43 return new URL(entry, self.location.origin).pathname; 44 } catch { 45 return String(entry).split('?')[0]; 46 } 47 }), 48); 49 50function isSameOrigin(request) { 51 try { 52 return new URL(request.url).origin === self.location.origin; 53 } catch { 54 return false; 55 } 56} 57 58function pathnameOf(request) { 59 try { 60 return new URL(request.url).pathname; 61 } catch { 62 return ''; 63 } 64} 65 66function isSearchApiRequest(url) { 67 return url.pathname === '/PHP/api_request.php'; 68} 69 70function isStaticAssetPath(pathname) { 71 return pathname.startsWith('/css/bundles/') 72 || pathname.startsWith('/JS/dist/') 73 || pathname.startsWith('/fonts/') 74 || pathname.startsWith('/locales/'); 75} 76 77function isPrecachePath(pathname) { 78 return PRECACHE_PATHNAMES.has(pathname); 79} 80 81function isCacheableNavigation(request) { 82 try { 83 const url = new URL(request.url); 84 return url.origin === self.location.origin && CACHEABLE_NAVIGATION_PATHS.has(url.pathname); 85 } catch { 86 return false; 87 } 88} 89 90function canonicalNavigationCacheUrl(request) { 91 try { 92 const url = new URL(request.url); 93 if (url.origin !== self.location.origin) { 94 return request.url; 95 } 96 97 if (url.pathname === '/results' || url.pathname === '/html/results.php' || url.pathname === '/html/results.html') { 98 if ((url.searchParams.get('type') || 'web') === 'web') { 99 url.searchParams.delete('type'); 100 } 101 if ((url.searchParams.get('offset') || '0') === '0') { 102 url.searchParams.delete('offset'); 103 } 104 } 105 106 return url.toString(); 107 } catch { 108 return request.url; 109 } 110} 111 112async function trimResultsCache() { 113 const cache = await caches.open(RESULTS_CACHE); 114 const keys = await cache.keys(); 115 const now = Date.now(); 116 117 await Promise.all(keys.map(async (request) => { 118 const response = await cache.match(request); 119 if (!response) { 120 await cache.delete(request); 121 return; 122 } 123 124 const cachedAt = Number(response.headers.get('X-Beamo-Cached-At') || 0); 125 if (!cachedAt || now - cachedAt > RESULTS_TTL_MS) { 126 await cache.delete(request); 127 } 128 })); 129} 130 131// Expire and bound query-bearing navigation entries in SHELL_CACHE. Precache
132// pathnames (the fixed shell assets) are excluded so they are never dropped. 133async function trimNavigationCache(cache) { 134 let keys; 135 try { 136 keys = await cache.keys(); 137 } catch { 138 return; 139 } 140 const navKeys = keys.filter((request) => { 141 try { 142 const url = new URL(request.url); 143 return url.origin === self.location.origin 144 && CACHEABLE_NAVIGATION_PATHS.has(url.pathname) 145 && !isPrecachePath(url.pathname); 146 } catch { 147 return false; 148 } 149 }); 150 const now = Date.now(); 151 const live = []; 152 for (const request of navKeys) { 153 const response = await cache.match(request); 154 const cachedAt = Number(response?.headers.get(NAVIGATION_CACHED_AT_HEADER) || 0); 155 if (!response || !cachedAt || now - cachedAt > NAVIGATION_TTL_MS) { 156 await cache.delete(request); 157 continue; 158 } 159 live.push({ request, cachedAt }); 160 } 161 162 live.sort((left, right) => right.cachedAt - left.cachedAt); 163 await Promise.all( 164 live.slice(NAVIGATION_QUEUE_MAX).map((entry) => cache.delete(entry.request)), 165 ); 166} 167 168function navigationCacheResponse(response, cachedAt = Date.now()) { 169 const headers = new Headers(response.headers); 170 headers.set(NAVIGATION_CACHED_AT_HEADER, String(cachedAt)); 171 return new Response(response.body, { 172 status: response.status, 173 statusText: response.statusText, 174 headers, 175 }); 176} 177 178function searchQueryFromApiUrl(urlString) { 179 try { 180 const url = new URL(urlString, self.location.origin); 181 return url.searchParams.get('searchTerm') 182 || url.searchParams.get('q') 183 || ''; 184 } catch { 185 return ''; 186 } 187} 188 189function searchTypeFromApiUrl(urlString) { 190 try { 191 const url = new URL(urlString, self.location.origin); 192 const type = String(url.searchParams.get('apiType') || url.searchParams.get('type') || 'web').toLowerCase(); 193 return ['images', 'news', 'videos', 'maps', 'chat'].includes(type) ? type : 'web'; 194 } catch { 195 return 'web'; 196 } 197} 198 199function buildCachedSearchHeaders(data, cachedAt, queryLabel, typeLabel) { 200 return new Headers({ 201 'Content-Type': 'application/json; charset=utf-8', 202 'X-Beamo-Cached-At': String(cachedAt), 203 [OFFLINE_CACHE_HEADER]: '1', 204 [OFFLINE_CACHE_QUERY_HEADER]: String(queryLabel || '').slice(0, 200), 205 [OFFLINE_CACHE_TYPE_HEADER]: String(typeLabel || 'web').slice(0, 40), 206 }); 207} 208 209async function listCachedSearchEntries() { 210 await trimResultsCache(); 211 const cache = await caches.open(RESULTS_CACHE); 212 const keys = await cache.keys(); 213 const entries = []; 214 215 for (const request of keys) { 216 const response = await cache.match(request); 217 if (!response) { 218 await cache.delete(request); 219 continue; 220 } 221 222 const cachedAt = Number(response.headers.get('X-Beamo-Cached-At') || 0); 223 if (!cachedAt || Date.now() - cachedAt > RESULTS_TTL_MS) { 224 await cache.delete(request); 225 continue; 226 } 227 228 entries.push({ 229 request, 230 response, 231 cachedAt, 232 query: response.headers.get(OFFLINE_CACHE_QUERY_HEADER) || searchQueryFromApiUrl(request.url), 233 type: response.headers.get(OFFLINE_CACHE_TYPE_HEADER) || searchTypeFromApiUrl(request.url), 234 }); 235 } 236 237 entries.sort((a, b) => b.cachedAt - a.cachedAt); 238 return entries; 239} 240 241async function cacheSearchResult(url, data, cachedAt = Date.now()) { 242 const cache = await caches.open(RESULTS_CACHE); 243 const normalizedUrl = new URL(url, self.location.origin).toString(); 244 const queryLabel = searchQueryFromApiUrl(normalizedUrl); 245 const typeLabel = searchTypeFromApiUrl(normalizedUrl); 246 247 await cache.put( 248 normalizedUrl, 249 new Response(JSON.stringify(data), { 250 status: 200, 251 headers: buildCachedSearchHeaders(data, cachedAt, queryLabel, typeLabel), 252 }), 253 ); 254 255 const entries = await listCachedSearchEntries(); 256 const overflow = entries.slice(RESULTS_QUEUE_MAX); 257 await Promise.all(overflow.map((entry) => cache.delete(entry.request))); 258} 259 260async function matchCachedSearchResult(request, { degraded = false } = {}) { 261 const entries = await listCachedSearchEntries(); 262 const exact = entries.find((entry) => entry.request.url === request.url); 263 const requestedType = searchTypeFromApiUrl(request.url); 264 const sameType = entries.find((entry) => entry.type === requestedType); 265 const chosen = exact || sameType; 266 if (!chosen) { 267 return null; 268 } 269 270 const headers = new Headers(chosen.response.headers); 271 headers.set(OFFLINE_CACHE_HEADER, '1'); 272 if (!exact) { 273 headers.set('X-Beamo-Offline-Fallback', '1'); 274 } 275 if (degraded) { 276 headers.set(OFFLINE_DEGRADED_HEADER, '1'); 277 } 278 return new Response(chosen.response.body, { 279 status: chosen.response.status, 280 statusText: chosen.response.statusText, 281 headers, 282 }); 283} 284 285function isUpstreamUnavailableStatus(status) { 286 return status === 502 || status === 503 || status === 504; 287} 288 289async function matchCachedSearchOnUpstreamFailure(request) { 290 return matchCachedSearchResult(request, { degraded: true }); 291} 292 293async function cacheVersionedAsset(cache, request, response) { 294 await cache.put(request, response); 295 296 // Drop older ?v= versions of the same path so the cache does not grow unbounded. 297 const requestPath = pathnameOf(request); 298 const keys = await cache.keys(); 299 await Promise.all(keys.map(async (key) => { 300 if (key.url !== request.url && pathnameOf(key) === requestPath) { 301 await cache.delete(key); 302 } 303 })); 304} 305 306async function staleWhileRevalidate(request) { 307 const cache = await caches.open(SHELL_CACHE); 308 // Exact (query-aware) match so a request for ?v=<newHash> does NOT match a cached 309 // ?v=<oldHash> entry â ignoreSearch here would defeat the build's cache-busting and 310 // serve stale JS/CSS bundles indefinitely. 311 const cached = await cache.match(request); 312 313 const networkPromise = fetch(request) 314 .then((response) => { 315 if (response.ok) { 316 void cacheVersionedAsset(cache, request, response.clone()); 317 } 318 return response; 319 }) 320 .catch(() => null); 321 322 if (cached) { 323 void networkPromise; 324 return cached; 325 } 326 327 const network = await networkPromise; 328 if (network) { 329 return network; 330 } 331 332 // Offline and this exact version is not cached â fall back to any cached version of 333 // the same path so the app still loads offline. 334 const fallback = await caches.match(request, { ignoreSearch: true }); 335 if (fallback) { 336 return fallback; 337 } 338 339 return new Response('Offline', { status: 503, statusText: 'Offline' }); 340} 341 342async function navigateWithOfflineFallback(request) { 343 const cache = await caches.open(SHELL_CACHE); 344 const cacheableNavigation = isCacheableNavigation(request); 345 const canonicalUrl = cacheableNavigation ? canonicalNavigationCache
345Url(request) : request.url; 346 347 try { 348 const response = await fetch(request); 349 if (response.ok && cacheableNavigation) { 350 // Store one canonical entry per navigation. The previous exact + canonical 351 // pair counted a default web search twice and could retain far more query 352 // HTML than the user-facing five-search privacy contract promised. 353 await cache.put(canonicalUrl, navigationCacheResponse(response.clone())); 354 await trimNavigationCache(cache); 355 } 356 return response; 357 } catch { 358 if (cacheableNavigation) { 359 // Never replay expired query/result HTML merely because the device is 360 // offline and no successful navigation has run the normal trim path. 361 await trimNavigationCache(cache); 362 const cachedPage = await cache.match(request); 363 if (cachedPage) { 364 return cachedPage; 365 } 366 if (canonicalUrl !== request.url) { 367 const cachedCanonicalPage = await cache.match(canonicalUrl); 368 if (cachedCanonicalPage) { 369 return cachedCanonicalPage; 370 } 371 } 372 } 373 374 const offline = await caches.match('/offline.html', { ignoreSearch: true }); 375 if (offline) { 376 return offline; 377 } 378 379 return new Response('Offline', { 380 status: 503, 381 statusText: 'Offline', 382 headers: { 'Content-Type': 'text/plain; charset=utf-8' }, 383 }); 384 } 385} 386 387self.addEventListener('install', (event) => { 388 event.waitUntil( 389 caches 390 .open(SHELL_CACHE) 391 .then((cache) => cache.addAll(SHELL_PRECACHE)) 392 .then(() => self.skipWaiting()), 393 ); 394}); 395 396self.addEventListener('activate', (event) => { 397 event.waitUntil( 398 caches 399 .keys() 400 .then((keys) => 401 Promise.all( 402 keys 403 .filter((key) => key !== SHELL_CACHE && key !== RESULTS_CACHE) 404 .map((key) => caches.delete(key)), 405 ), 406 ) 407 .then(() => self.clients.claim()), 408 ); 409}); 410 411self.addEventListener('message', (event) => { 412 const payload = event.data; 413 if (!payload || typeof payload.type !== 'string') { 414 return; 415 } 416 417 if (payload.type === 'BEAMO_CACHE_SEARCH_RESULT') { 418 event.waitUntil( 419 cacheSearchResult(payload.url, payload.data, payload.cachedAt || Date.now()), 420 ); 421 return; 422 } 423 424 if (payload.type === 'BEAMO_CLEAR_SEARCH_CACHE') { 425 event.waitUntil( 426 caches.delete(RESULTS_CACHE).then(async () => { 427 const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); 428 await Promise.all(clients.map((client) => { 429 client.postMessage({ type: 'BEAMO_OFFLINE_CACHE_CLEARED' }); 430 })); 431 }), 432 ); 433 return; 434 } 435 436 if (payload.type === 'BEAMO_LIST_SEARCH_CACHE') { 437 event.waitUntil( 438 listCachedSearchEntries().then(async (entries) => { 439 const payloadEntries = entries.map((entry) => ({ 440 url: entry.request.url, 441 query: entry.query, 442 type: entry.type, 443 cachedAt: entry.cachedAt, 444 })); 445 if (event.source && 'postMessage' in event.source) { 446 event.source.postMessage({ 447 type: 'BEAMO_SEARCH_CACHE_LIST', 448 entries: payloadEntries, 449 maxEntries: RESULTS_QUEUE_MAX, 450 ttlMs: RESULTS_TTL_MS, 451 }); 452 } 453 }), 454 ); 455 } 456}); 457 458self.addEventListener('fetch', (event) => { 459 const { request } = event; 460 if (request.method !== 'GET' || !isSameOrigin(request)) { 461 return; 462 } 463 464 const url = new URL(request.url); 465 const pathname = url.pathname; 466 467 if (pathname.startsWith('/PHP/')) { 468 if (isSearchApiRequest(url)) { 469 event.respondWith((async () => { 470 try { 471 const response = await fetch(request); 472 if (isUpstreamUnavailableStatus(response.status)) { 473 const cached = await matchCachedSearchOnUpstreamFailure(request); 474 if (cached) { 475 return cached; 476 } 477 } 478 return response; 479 } catch { 480 const cached = await matchCachedSearchResult(request); 481 if (cached) { 482 return cached; 483 } 484 return new Response(JSON.stringify({ 485 error: 'offline',
486 message: 'You are offline and no cached results are available for this search. Up to five recent searches may be cached on this device for 30 minutes.', 487 }), { 488 status: 503, 489 headers: { 'Content-Type': 'application/json; charset=utf-8' }, 490 }); 491 } 492 })()); 493 } 494 return; 495 } 496 497 if (request.mode === 'navigate') { 498 event.respondWith(navigateWithOfflineFallback(request)); 499 return; 500 } 501 502 if (isPrecachePath(pathname)) { 503 // Revalidate precached assets too. Install still guarantees an offline 504 // response, while the background fetch self-heals any CDN or interrupted- 505 // deploy mismatch that was cached under the current versioned URL. 506 event.respondWith(staleWhileRevalidate(request)); 507 return; 508 } 509 510 if (isStaticAssetPath(pathname)) { 511 event.respondWith(staleWhileRevalidate(request)); 512 } 513});
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.