1// Service Worker for AI Anyone 2// Offline queue for chat + lightweight runtime cache for hashed assets/images. 3// IMPORTANT: never cache HTML, that's the main source of "stale page" INP regressions. 4 5// v3: purges caches poisoned by the pre-2026-06-12 bug where a post-deploy 6// chunk miss returned 200 index.html and got cached under the .js URL. 7// v4 (2026-09-28): purges no-store answers v3 kept before isCacheableFor 8// honoured no-store, /api/pricing/regional among them (docs/PRICING-TEST.md). 9const CACHE_NAME = 'aia-runtime-v4'; 10 11// Only cache a response when its content-type matches what the request 12// expects. The server's SPA fallback can answer asset requests with HTML, 13// and caching that under a .js/.css/image URL bricks the app until the 14// cache is purged. 15function isCacheableFor(destination, response) { 16 if (!response.ok) return false; 17 // no-store means no store, the offline cache included: a price answer 18 // (/api/pricing/regional) replayed offline could show a price checkout no 19 // longer charges. 20 if (/no-store/i.test(response.headers.get('cache-control') || '')) return false; 21 const type = (response.headers.get('content-type') || '').toLowerCase(); 22 if (destination === 'script') return type.includes('javascript'); 23 if (destination === 'style') return type.includes('css'); 24 if (destination === 'image') return type.startsWith('image/'); 25 return !type.includes('text/html'); 26} 27 28// ââ IndexedDB helpers (idb-keyval pattern, no dependencies) ââ 29 30const DB_NAME = 'ai-anyone-offline'; 31const DB_VERSION = 1; 32const STORE_PENDING_MESSAGES = 'pending-messages'; 33const STORE_PENDING_FEEDBACK = 'pending-feedback'; 34 35function openDB() { 36 return new Promise((resolve, reject) => { 37 const request = indexedDB.open(DB_NAME, DB_VERSION); 38 request.onupgradeneeded = (event) => { 39 const db = event.target.result; 40 if (!db.objectStoreNames.contains(STORE_PENDING_MESSAGES)) { 41 db.createObjectStore(STORE_PENDING_MESSAGES, { keyPath: 'id', autoIncrement: true }); 42 } 43 if (!db.objectStoreNames.contains(STORE_PENDING_FEEDBACK)) { 44 db.createObjectStore(STORE_PENDING_FEEDBACK, { keyPath: 'id', autoIncrement: true }); 45 } 46 }; 47 request.onsuccess = () => resolve(request.result); 48 request.onerror = () => reject(request.error); 49 }); 50} 51 52function idbPut(storeName, value) { 53 return openDB().then(db => { 54 return new Promise((resolve, reject) => { 55 const tx = db.transaction(storeName, 'readwrite'); 56 const store = tx.objectStore(storeName); 57 const req = store.add(value); 58 req.onsuccess = () => resolve(req.result); 59 req.onerror = () => reject(req.error); 60 tx.oncomplete = () => db.close(); 61 }); 62 }); 63} 64 65function idbGetAll(storeName) { 66 return openDB().then(db => { 67 return new Promise((resolve, reject) => { 68 const tx = db.transaction(storeName, 'readonly'); 69 const store = tx.objectStore(storeName); 70 const req = store.getAll(); 71 req.onsuccess = () => resolve(req.result); 72 req.onerror = () => reject(req.error); 73 tx.oncomplete = () => db.close(); 74 }); 75 }); 76} 77 78function idbDelete(storeName, key) { 79 return openDB().then(db => { 80 return new Promise((resolve, reject) => { 81 const tx = db.transaction(storeName, 'readwrite'); 82 const store = tx.objectStore(storeName); 83 const req = store.delete(key); 84 req.onsuccess = () => resolve(); 85 req.onerror = () => reject(req.error); 86 tx.oncomplete = () => db.close(); 87 }); 88 }); 89} 90 91// Patterns for offline queue routing 92const MESSAGE_URL_PATTERN = /\/api\/conversations\/[^/]+\/messages/; 93const FEEDBACK_URL_PATTERNS = [ 94 /\/api\/conversations\/[^/]+\/feedback/, 95 /\/api\/conversations\/[^/]+\/exit-intent/, 96 /\/api\/nps/, 97]; 98 99// Install: take over immediately, don't precache anything. 100// Precaching dev paths (/src/main.tsx) was breaking prod and serving stale code. 101self.addEventListener('install', (event) => { 102 event.waitUntil(self.skipWaiting()); 103}); 104 105// Activate: delete every cache that isn't the current version + claim all clients. 106// This kills any v1 cache (and its stale HTML) on first load of the new SW. 107self.addEventListener('activate', (event) => { 108 event.waitUntil( 109 caches.keys() 110 .then((cacheNames) => 111 Promise.all( 112 cacheNames 113 .filter((name) => name !== CACHE_NAME) 114 .map((name) => caches.delete(name)) 115 ) 116 ) 117 .then(() => self.clients.claim()) 118 ); 119}); 120 121self.addEventListener('fetch', (event) => { 122 const { request } = event; 123 const url = new URL(request.url); 124 125 // Don't intercept cross-origin requests (Stripe, external scripts, etc.) 126 if (url.origin !== self.location.origin) { 127 return; 128 } 129 130 // NEVER cache navigation/HTML, stale HTML serves outdated asset hashes 131 // and re-introduces INP regressions on returning users. Just pass through. 132 if (request.mode === 'navigate' || request.destination === 'document') { 133 return; 134 } 135 136 // API requests 137 if (url.pathname.startsWith('/api/')) { 138 // Offline queue for chat + feedback POSTs 139 if (request.method === 'POST') { 140 const isMessagePost = MESSAGE_URL_PATTERN.test(url.pathname); 141 const isFeedbackPost = FEEDBACK_URL_PATTERNS.some(p => p.test(url.pathname)); 142 143 if (isMessagePost || isFeedbackPost) { 144 event.respondWith( 145 request.clone().text().then(bodyText => { 146 return fetch(request).catch(async (err) => { 147 const storeName = isMessagePost ? STORE_PENDING_MESSAGES : STORE_PENDING_FEEDBACK; 148 const syncTag = isMessagePost ? 'sync-messages' : 'sync-feedback'; 149 await idbPut(storeName, { 150 url: request.url, 151 body: bodyText, 152 headers: Object.fromEntries([...request.headers.entries()]), 153 timestamp: Date.now(), 154 }); 155 try { 156 await self.registration.sync.register(syncTag); 157 } catch (e) {
158 console.log('Background sync registration failed:', e); 159 } 160 return new Response(JSON.stringify({ queued: true, offline: true }), { 161 status: 202, 162 headers: { 'Content-Type': 'application/json' }, 163 }); 164 }); 165 }) 166 ); 167 return; 168 } 169 // Other POSTs: pass through, no SW caching 170 return; 171 } 172 173 // GET /api/*: network-first, cache as offline fallback 174 event.respondWith( 175 fetch(request) 176 .then((response) => { 177 if (isCacheableFor('api', response)) { 178 const responseClone = response.clone(); 179 caches.open(CACHE_NAME).then((cache) => { 180 cache.put(request, responseClone); 181 }); 182 } 183 return response; 184 }) 185 .catch(() => caches.match(request)) 186 ); 187 return; 188 } 189 190 // Images: cache-first (avatars rarely change once hashed) 191 if (request.destination === 'image') { 192 event.respondWith( 193 caches.match(request).then((cached) => { 194 if (cached) return cached; 195 return fetch(request).then((response) => { 196 if (isCacheableFor('image', response)) { 197 const responseClone = response.clone(); 198 caches.open(CACHE_NAME).then((cache) => { 199 cache.put(request, responseClone); 200 }); 201 } 202 return response; 203 }); 204 }) 205 ); 206 return; 207 } 208 209 // Hashed JS/CSS bundles: cache-first (file content is immutable per hash) 210 if (request.destination === 'script' || request.destination === 'style') { 211 event.respondWith( 212 caches.match(request).then((cached) => { 213 if (cached) return cached; 214 return fetch(request).then((response) => { 215 if (isCacheableFor(request.destination, response)) { 216 const responseClone = response.clone(); 217 caches.open(CACHE_NAME).then((cache) => { 218 cache.put(request, responseClone); 219 }); 220 } 221 return response; 222 }); 223 }) 224 ); 225 return; 226 } 227 228 // Everything else: pass through (no SW interference) 229}); 230 231// Push notification handling 232self.addEventListener('push', (event) => { 233 const data = event.data?.json() || { title: 'AI Anyone', body: 'You have a new notification' }; 234 event.waitUntil( 235 self.registration.showNotification(data.title, { 236 body: data.body, 237 icon: '/icons/icon-192x192.png', 238 badge: '/icons/icon-96x96.png', 239 data: { url: data.url || '/' }, 240 vibrate: [100, 50, 100], 241 }) 242 ); 243}); 244 245self.addEventListener('notificationclick', (event) => { 246 event.notification.close(); 247 const url = event.notification.data?.url || '/'; 248 event.waitUntil(clients.openWindow(url)); 249}); 250 251// Background sync for analytics + offline queues 252self.addEventListener('sync', (event) => { 253 if (event.tag === 'analytics-sync') { 254 event.waitUntil(syncAnalytics()); 255 } 256 if (event.tag === 'sync-messages') { 257 event.waitUntil(replayQueue(STORE_PENDING_MESSAGES)); 258 } 259 if (event.tag === 'sync-feedback') { 260 event.waitUntil(replayQueue(STORE_PENDING_FEEDBACK)); 261 } 262}); 263 264async function replayQueue(storeName) { 265 const items = await idbGetAll(storeName); 266 items.sort((a, b) => a.timestamp - b.timestamp); 267 268 for (const item of items) { 269 try { 270 const response = await fetch(item.url, { 271 method: 'POST', 272 headers: item.headers, 273 body: item.body, 274 credentials: 'include', 275 }); 276 if (response.ok || (response.status >= 400 && response.status < 500)) { 277 await idbDelete(storeName, item.id); 278 } 279 } catch (err) { 280 console.log('Replay failed, will retry on next sync:', err); 281 break; 282 } 283 } 284} 285 286async function syncAnalytics() { 287 try { 288 const analyticsData = await getStoredAnalytics(); 289 if (analyticsData.length > 0) { 290 await fetch('/api/analytics/sync', { 291 method: 'POST', 292 headers: { 293 'Content-Type': 'application/json', 294 }, 295 body: JSON.stringify(analyticsData), 296 }); 297 await clearStoredAnalytics(); 298 } 299 } catch (error) { 300 console.log('Analytics sync failed:', error); 301 } 302} 303 304async function getStoredAnalytics() { 305 return JSON.parse(localStorage.getItem('ai-anyone-pending-analytics') || '[]'); 306} 307 308async function clearStoredAnalytics() { 309 localStorage.removeItem('ai-anyone-pending-analytics'); 310} 311!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;
311n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="bdb19f0f-24a3-5a6f-acb3-b799619cf9f2")}catch(e){}}(); 312//# debugId=bdb19f0f-24a3-5a6f-acb3-b799619cf9f2
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.