PageSourceSearch

https://kaboodu.org/sw.js

js kaboodu.org collected 2026-10-02 02:04:35 UTC 4,738 bytes, 125 lines download raw bytes

1/**
2 * Kaboodu service worker.
3 *
4 *  1. Makes the site installable (a fetch handler is required).
5 *  2. Caches what makes the closet open fast on a train: the app shell,
6 *     the hashed build assets, web fonts, and the item cutouts, so a
7 *     second visit on a weak connection paints from cache while the
8 *     network catches up.
9 *  3. Shows push notifications ("Tomorrow: Sunday, slow") and opens the
10 *     right day when tapped.
11 *
12 * API calls are never cached: the app's own store decides what is fresh,
13 * and live sync keeps it that way.
14 */
15
16const VERSION = 'kaboodu-sw-v2';
17const SHELL = `${VERSION}-shell`;
18const ASSETS = `${VERSION}-assets`;
19const IMAGES = `${VERSION}-images`;
20const FONTS = `${VERSION}-fonts`;
21const MAX_IMAGES = 400;
22
23self.addEventListener('install', (event) => {
24  event.waitUntil(
25    caches.open(SHELL).then((c) => c.addAll(['/', '/manifest.webmanifest']).catch(() => {}))
26  );
27  self.skipWaiting();
28});
29
30self.addEventListener('activate', (event) => {
31  event.waitUntil((async () => {
32    const keys = await caches.keys();
33    await Promise.all(keys.filter((k) => !k.startsWith(VERSION)).map((k) => caches.delete(k)));
34    await self.clients.claim();
35  })());
36});
37
38function isApi(url) { return url.pathname.startsWith('/api/') || /\/api\//.test(url.href); }
39function isBuildAsset(url) { return url.origin === self.location.origin && (url.pathname.startsWith('/assets/') || url.pathname.startsWith('/fonts/')); }
40function isFont(url) { return /fonts\.(googleapis|gstatic)\.com$/.test(url.hostname); }
41function isItemImage(url) { return /\/storage\/v1\/object\/public\//.test(url.pathname) || /supabase\.co$/.test(url.hostname); }
42
43async function cacheFirst(cacheName, request) {
44  const cache = await caches.open(cacheName);
45  const hit = await cache.match(request);
46  if (hit) return hit;
47  const res = await fetch(request);
48  if (res.ok) cache.put(request, res.clone());
49  return res;
50}
51
52async function staleWhileRevalidate(cacheName, request, max) {
53  const cache = await caches.open(cacheName);
54  const hit = await cache.match(request);
55  const refresh = fetch(request).then(async (res) => {
56    if (res.ok || res.type === 'opaque') {
57      await cache.put(request, res.clone());
58      if (max) trim(cache, max);
59    }
60    return res;
61  }).catch(() => null);
62  return hit || (await refresh) || Response.error();
63}
64
65async function trim(cache, max) {
66  const keys = await cache.keys();
67  if (keys.length <= max) return;
68  await Promise.all(keys.slice(0, keys.length - max).map((k) => cache.delete(k)));
69}
70
71self.addEventListener('fetch', (event) => {
72  const { request } = event;
73  if (request.method !== 'GET') return;
74  const url = new URL(request.url);
75  if (isApi(url)) return;
76
77  if (request.mode === 'navigate') {
78    // Network first, so deploys land; the cached shell when offline.
79    event.respondWith((async () => {
80      try {
81        const res = await fetch(request);
82        if (res.ok) (await caches.open(SHELL)).put('/', res.clone());
83        return res;
84      } catch {
85        return (await caches.match('/')) || Response.error();
86      }
87    })());
88    return;
89  }
90  if (isBuildAsset(url)) { event.respondWith(cacheFirst(ASSETS, request)); return; }
91  if (isFont(url)) { event.respondWith(staleWhileRevalidate(FONTS, request)); return; }
92  if (isItemImage(url) && !url.pathname.includes('/auth/')) { event.respondWith(staleWhileRevalidate(IMAGES, request, MAX_IMAGES)); }
93});
94
95// ── Push ──────────────────────────────────────────────────────────────
96
97self.addEventListener('push', (event) => {
98  let data = {};
99  try { data = event.data ? event.data.json() : {}; } catch { data = { title: 'Kaboodu', body: event.data?.text() || '' }; }
100  const title = data.title || 'Kaboodu';
101  const options = {
102    body: data.body || '',
103    icon: '/icon-192.png',
104    badge: '/icon-192.png',
105    tag: data.tag || undefined,
106    data: { url: data.url || '/calendar' }
107  };
108  event.waitUntil(self.registration.showNotification(title, options));
109});
110
111self.addEventListener('notificationclick', (event) => {
112  event.notification.close();
113  const target = new URL(event.notification.data?.url || '/calendar', self.location.origin).href;
114  event.waitUntil((async () => {
115    const wins = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
116    for (const w of wins) {
117      if (new URL(w.url).origin === self.location.origin && 'focus' in w) {
118        await w.focus();
119        if ('navigate' in w) { try { await w.navigate(target); } catch { /* noop */ } }
120        return;
121      }
122    }
123    await self.clients.openWindow(target);
124  })());
125});

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.