1/** 2 * destination.com â minimal service worker for in-trip offline use. 3 * 4 * Scope (deliberately narrow) 5 * --------------------------- 6 * - Caches PUBLIC share URLs: /trips/<id>/share/<token> 7 * These are explicitly designed for any-user access â no Clerk session 8 * required â so caching them in a shared device-level SW doesn't leak 9 * user data. The HMAC token is itself the credential. 10 * 11 * - Does NOT cache /trips/<id> (the owner view). That route is auth-gated 12 * and shows user-specific content; caching it in a device-level SW 13 * could leak across browser profiles. A future PR can scope per-user 14 * caches with the userId in the cache key. 15 * 16 * - Pre-caches a minimal app shell on install (/, /plan, /trips) so the 17 * nav surfaces don't blank out on flaky in-trip connections. 18 * 19 * Strategy 20 * -------- 21 * - Share URLs: stale-while-revalidate. Show cached immediately, fetch 22 * the fresh version in background and update the cache. 23 * - Everything else: pass through (no SW involvement) so the existing 24 * network/proxy/etc. behavior stays unchanged. 25 * 26 * Versioning 27 * ---------- 28 * Bump CACHE_NAME (v1 â v2 â ...) to invalidate all caches at once on a 29 * release that needs it. Old caches are pruned on activate. 30 */ 31 32const CACHE_NAME = 'destination-v1'; 33const SHELL_URLS = ['/', '/plan', '/trips']; 34 35self.addEventListener('install', (event) => { 36 // Pre-cache the shell. Failures (e.g. 404 on /trips before sign-in) 37 // shouldn't block install â best-effort. 38 event.waitUntil( 39 caches 40 .open(CACHE_NAME) 41 .then((cache) => Promise.allSettled(SHELL_URLS.map((u) => cache.add(u)))) 42 .then(() => self.skipWaiting()), 43 ); 44}); 45 46self.addEventListener('activate', (event) => { 47 // Prune old caches so a bumped CACHE_NAME actually frees disk. 48 event.waitUntil( 49 caches 50 .keys() 51 .then((keys) => 52 Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))), 53 ) 54 .then(() => self.clients.claim()), 55 ); 56}); 57 58/** 59 * Match anything under /trips/<id>/share/<token>. We're permissive on the 60 * id/token format (just non-empty segments) because the server-side 61 * verifyShareToken() does the real check; SW just decides whether to 62 * cache the response. 63 */ 64function isShareUrl(url) { 65 return /^\/trips\/[^/]+\/share\/[^/]+\/?$/.test(url.pathname); 66} 67 68self.addEventListener('fetch', (event) => { 69 // Only navigations + same-origin GET. POST / API calls / cross-origin 70 // (Mapbox tiles, Clerk, etc.) flow through untouched. 71 const req = event.request; 72 if (req.method !== 'GET') return; 73 const url = new URL(req.url); 74 if (url.origin !== self.location.origin) return; 75 76 if (isShareUrl(url)) { 77 // Stale-while-revalidate for public share URLs. 78 event.respondWith( 79 caches.open(CACHE_NAME).then((cache) => 80 cache.match(req).then((cached) => { 81 const network = fetch(req) 82 .then((resp) => { 83 // Only cache successful, non-opaque responses. 503 / 4xx 84 // stay in the network branch and never overwrite a good cache. 85 if (resp && resp.status === 200) { 86 cache.put(req, resp.clone()).catch(() => {}); 87 } 88 return resp; 89 }) 90 .catch(() => null); 91 // Prefer cached if present (instant load); otherwise wait for net. 92 return cached || network.then((r) => r || new Response('Offline', { status: 503 })); 93 }), 94 ), 95 ); 96 } 97}); 98 99/* ââ Web Push âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 100 * Round 8: handle push events fired by /api/push/send via our /api/cron 101 * jobs. Payload shape ({ title, body, url, icon, tag, data }) matches 102 * the PushPayload interface in src/lib/web-push.ts. 103 * 104 * Click handler opens the URL in the existing tab if it's already open, 105 * otherwise opens a fresh window. 106 */ 107self.addEventListener('push', (event) => { 108 if (!event.data) return; 109 let payload; 110 try { 111 payload = event.data.json(); 112 } catch (err) { 113 payload = { title: 'destination.com', body: String(event.data.text() || '') }; 114 } 115 const title = payload.title || 'destination.com';
116 const opts = { 117 body: payload.body || '', 118 icon: payload.icon || '/icon.svg', 119 badge: '/icon.svg', 120 tag: payload.tag, 121 data: { url: payload.url || '/', ...(payload.data || {}) }, 122 }; 123 event.waitUntil(self.registration.showNotification(title, opts)); 124}); 125 126self.addEventListener('notificationclick', (event) => { 127 event.notification.close(); 128 const target = (event.notification.data && event.notification.data.url) || '/'; 129 event.waitUntil( 130 self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((wins) => { 131 for (const w of wins) { 132 if ('focus' in w && new URL(w.url).origin === self.location.origin) { 133 w.focus(); 134 if ('navigate' in w) w.navigate(target); 135 return; 136 } 137 } 138 if (self.clients.openWindow) return self.clients.openWindow(target); 139 }), 140 ); 141});
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.