1/* 2 * SocialShot AI service worker. 3 * 4 * Scope is deliberately narrow: this exists to make the site installable and 5 * to make repeat loads fast on slow mobile networks (India is the target 6 * market). It is NOT a general offline-first cache, and it is written to be 7 * boring on purpose â a service worker is the one script on the domain that 8 * can keep serving stale bytes after a bad deploy, so every caching decision 9 * below is opt-in rather than opt-out. 10 * 11 * ââ What is NEVER touched (the request falls straight through to network) ââ 12 * 13 * 1. Anything cross-origin. That covers api.socialshot.ai (live credit 14 * balances, generation state), assets.socialshot.ai + *.r2.dev (signed 15 * media URLs that EXPIRE â a cached one renders a permanently broken 16 * image with no obvious cause), Clerk, Razorpay and PostHog. The origin 17 * check at the top of the fetch handler is what enforces this, so no 18 * third party can be cached by accident when one is added later. 19 * 20 * 2. Same-origin /api/* and the /ingest/* PostHog proxy. 21 * 22 * 3. RSC payload requests (`?_rsc=`, `RSC: 1`, `Accept: text/x-component`). 23 * These are App Router navigation payloads keyed to a specific build and 24 * to the signed-in user. Serving a stale one produces hydration errors 25 * that are near-impossible to reproduce locally. 26 * 27 * 4. /_next/image. The optimizer proxies user-generated content, including 28 * media behind signed R2 URLs, so its responses are both private and 29 * short-lived. Same reasoning as (1). 30 * 31 * 5. Any URL carrying a credential-shaped query param (token / signature / 32 * X-Amz-*). Belt and braces on top of (1) and (4). 33 * 34 * 6. HTML documents. Pages here are auth-dependent and are served 35 * `Cache-Control: private, no-store`. Caching them risks handing a 36 * signed-in shell to the next person on a shared device, which is a real 37 * scenario for the reseller/kirana users this app targets. Navigations 38 * are network-only with an offline fallback, nothing more. 39 * 40 * ââ What IS cached ââ 41 * 42 * ⢠/_next/static/* â cache-first. Content-hashed and immutable by 43 * contract, so this is safe and is where essentially all of the repeat- 44 * visit speedup comes from. 45 * ⢠Static files we ship in /public (icons, fonts, marketing imagery) â 46 * stale-while-revalidate. 47 * ⢠/offline.html â precached, shown only when a navigation fails. 48 */ 49 50const VERSION = "v1"; 51const STATIC_CACHE = `socialshot-static-${VERSION}`; 52const PRECACHE = `socialshot-precache-${VERSION}`; 53const EXPECTED_CACHES = new Set([STATIC_CACHE, PRECACHE]); 54 55const OFFLINE_URL = "/offline.html"; 56const PRECACHE_URLS = [OFFLINE_URL, "/android-chrome-192x192.png"]; 57 58// Cache-first entries are hashed and never invalidated within a VERSION, so 59// without a cap the bucket grows by roughly one build's worth of chunks per 60// deploy on a returning user's device. Trim on write rather than scheduling 61// anything: Safari in particular evicts whole origins under storage pressure, 62// and losing the precache to a quota eviction would take the offline page 63// with it. 64const STATIC_CACHE_MAX_ENTRIES = 160; 65 66const CACHEABLE_ASSET_RE = 67 /\.(?:png|jpe?g|gif|webp|avif|svg|ico|woff2?|ttf|otf|mp4|webm)$/i; 68 69const CREDENTIAL_PARAM_RE = /^(?:token|signature|sig|key|x-amz-)/i; 70 71function isNeverCacheable(url, request) { 72 // Same-origin API + analytics proxy. 73 if (url.pathname.startsWith("/api/")) return true; 74 if (url.pathname.startsWith("/ingest/")) return true; 75 76 // App Router RSC payloads. 77 if (url.searchParams.has("_rsc")) return true; 78 if (request.headers.get("RSC") === "1") return true; 79 const accept = request.headers.get("Accept") || ""; 80 if (accept.includes("text/x-component")) return true; 81 82 // Next's image optimizer proxies private / signed user media. 83 if (url.pathname.startsWith("/_next/image")) return true; 84 85 // Anything that looks like it carries a credential. 86 for (const name of url.searchParams.keys()) { 87 if (CREDENTIAL_PARAM_RE.test(name)) return true; 88 } 89 90 return false;
91} 92 93async function trimCache(cacheName, maxEntries) { 94 const cache = await caches.open(cacheName); 95 const keys = await cache.keys(); 96 if (keys.length <= maxEntries) return; 97 // Cache Storage preserves insertion order, so the head of the list is the 98 // oldest. Evict just enough to get back under the cap. 99 await Promise.all( 100 keys.slice(0, keys.length - maxEntries).map((key) => cache.delete(key)), 101 ); 102} 103 104async function cacheFirst(request) { 105 const cache = await caches.open(STATIC_CACHE); 106 const hit = await cache.match(request); 107 if (hit) return hit; 108 109 const response = await fetch(request); 110 // Only store real, complete responses. `response.ok` excludes 4xx/5xx, and 111 // the type check excludes opaque cross-origin responses (which should be 112 // unreachable here given the origin guard, but a cached opaque response is 113 // both unreadable and counted against quota at its full padded size). 114 if (response.ok && response.type === "basic") { 115 await cache.put(request, response.clone()); 116 trimCache(STATIC_CACHE, STATIC_CACHE_MAX_ENTRIES); 117 } 118 return response; 119} 120 121async function staleWhileRevalidate(request) { 122 const cache = await caches.open(STATIC_CACHE); 123 const hit = await cache.match(request); 124 125 const network = fetch(request) 126 .then((response) => { 127 if (response.ok && response.type === "basic") { 128 cache.put(request, response.clone()); 129 trimCache(STATIC_CACHE, STATIC_CACHE_MAX_ENTRIES); 130 } 131 return response; 132 }) 133 .catch(() => undefined); 134 135 if (hit) return hit; 136 137 const response = await network; 138 if (response) return response; 139 return Response.error(); 140} 141 142async function handleNavigation(request) { 143 try { 144 return await fetch(request); 145 } catch { 146 // Offline (or the origin is unreachable). Never a cached page â see the 147 // header comment on why HTML is not cached at all. 148 const cache = await caches.open(PRECACHE); 149 const offline = await cache.match(OFFLINE_URL); 150 if (offline) return offline; 151 return new Response("You are offline.", { 152 status: 503, 153 headers: { "Content-Type": "text/plain; charset=utf-8" }, 154 }); 155 } 156} 157 158self.addEventListener("install", (event) => { 159 event.waitUntil( 160 caches.open(PRECACHE).then((cache) => cache.addAll(PRECACHE_URLS)), 161 ); 162 // Deliberately NOT calling skipWaiting(). 163 // 164 // A new worker activating under an already-open tab would start serving the 165 // new build's hashed chunks to a document rendered against the old build, 166 // which surfaces as ChunkLoadError. Letting the update wait for the last tab 167 // to close is the boring, correct default. The SKIP_WAITING message below is 168 // the escape hatch if an explicit "refresh to update" prompt is ever added. 169}); 170 171self.addEventListener("activate", (event) => { 172 event.waitUntil( 173 (async () => { 174 const names = await caches.keys(); 175 await Promise.all( 176 names 177 .filter((name) => name.startsWith("socialshot-") && !EXPECTED_CACHES.has(name)) 178 .map((name) => caches.delete(name)), 179 ); 180 await self.clients.claim(); 181 })(), 182 ); 183}); 184 185self.addEventListener("message", (event) => { 186 if (event.data === "SKIP_WAITING") self.skipWaiting(); 187}); 188 189self.addEventListener("fetch", (event) => { 190 const request = event.request; 191 192 if (request.method !== "GET") return; 193 194 let url; 195 try { 196 url = new URL(request.url); 197 } catch { 198 return; 199 } 200 201 // The single most important line in this file: everything cross-origin is 202 // the network's problem, not ours. 203 if (url.origin !== self.location.origin) return; 204 205 if (isNeverCacheable(url, request)) return; 206 207 if (request.mode === "navigate") { 208 event.respondWith(handleNavigation(request)); 209 return; 210 } 211 212 if (url.pathname.startsWith("/_next/static/")) { 213 event.respondWith(cacheFirst(request)); 214 return; 215 } 216 217 if (CACHEABLE_ASSET_RE.test(url.pathname)) { 218 event.respondWith(staleWhileRevalidate(request)); 219 return; 220 } 221 222 // Anything else (route handlers, /llms.txt, /openapi.json, â¦) is left alone. 223});
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.