PageSourceSearch

https://beamosearch.com/sw.js

js beamosearch.com collected 2026-10-02 01:24:03 UTC 15,730 bytes, 513 lines download raw bytes

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.