PageSourceSearch

https://aianyone.com/sw.js

js aianyone.com collected 2026-10-02 01:24:51 UTC 10,740 bytes, 312 lines download raw bytes

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.