1/** 2 * Opportuning's service worker. 3 * 4 * It does two jobs and refuses a third. 5 * 6 * 1. Keeps the app shell, so an installed app opens offline after one 7 * successful online visit instead of showing the browser's dinosaur. 8 * 2. Keeps the last few content responses, so that opened app has something 9 * to show rather than an empty screen. 10 * 11 * The third job â caching anything to do with who you are or what you paid â 12 * it will not do. Auth and payment traffic is matched and passed straight 13 * through, never stored, because a token or an invoice sitting in a disk cache 14 * on a shared device is a worse outcome than any amount of offline polish. 15 * 16 * There is deliberately no build step here and no Workbox: this is the only 17 * service worker in the project, it is small enough to read in one sitting, 18 * and a second one fighting it would be worse than none. 19 */ 20 21// Replaced at deploy time with the commit being shipped. It must change on 22// every deploy: `activate` deletes only caches whose names do not match the 23// current ones, so a constant version means the shell cached on a reader's 24// first-ever visit is kept forever, and a publish can leave them on the old 25// app. Left as a literal here so a stamp that never runs still yields a 26// working â if unrotating â cache name. 27const VERSION = "4891a17"; 28const SHELL_CACHE = `opportuning-shell-${VERSION}`; 29const CONTENT_CACHE = `opportuning-content-${VERSION}`; 30 31/** Enough to boot the app with no network. Everything else arrives at runtime. */ 32const SHELL_ASSETS = ["/", "/index.html", "/favicon.png", "/manifest.webmanifest"]; 33 34/** The content cache is a small window on what was read recently, not an archive. */ 35const CONTENT_LIMIT = 60; 36/** Older than this and a cached answer is shown but treated as stale. */ 37const CONTENT_MAX_AGE_MS = 24 * 60 * 60 * 1000; 38 39/** 40 * Never stored, whatever else happens. 41 * 42 * Matched on the path rather than the method, so a token refresh, a checkout 43 * session and a subscription lookup are all excluded even when they arrive as 44 * an innocuous-looking GET. 45 */ 46const NEVER_CACHE = [ 47 "/auth/v1/", // Supabase auth: tokens, sessions, recovery 48 "/functions/v1/create-checkout", 49 "/functions/v1/create-portal", 50 "/functions/v1/buy-companion-credits", 51 "/functions/v1/check-subscription", 52 "/functions/v1/cancel-subscription", 53 "/functions/v1/stripe-webhook", 54 "/rest/v1/subscriptions", 55 "/rest/v1/payments", 56 "/rest/v1/invoices", 57 "/rest/v1/user_roles", 58 // Experiences: contribuições próprias, revisão e diretório. As chamadas de 59 // hoje são POST e já passam direto; isto cobre qualquer GET futuro. 60 "/rest/v1/rpc/experiences_", 61]; 62 63function isPrivate(url) { 64 return NEVER_CACHE.some((fragment) => url.pathname.includes(fragment) || url.href.includes(fragment)); 65} 66 67/** Content worth keeping for an offline read: the listings themselves. */ 68function isCacheableContent(url) { 69 if (isPrivate(url)) return false; 70 return ( 71 url.pathname.includes("/rest/v1/opportunities") || 72 url.pathname.includes("/rest/v1/saved_opportunities") 73 ); 74} 75 76self.addEventListener("install", (event) => { 77 event.waitUntil( 78 caches.open(SHELL_CACHE) 79 .then((cache) => cache.addAll(SHELL_ASSETS)) 80 // A missing asset must not wedge the install; the app still works online. 81 .catch(() => undefined) 82 .then(() => self.skipWaiting()), 83 ); 84}); 85 86self.addEventListener("activate", (event) => { 87 event.waitUntil( 88 caches.keys() 89 .then((keys) => Promise.all( 90 keys 91 .filter((k) => k.startsWith("opportuning-") && k !== SHELL_CACHE && k !== CONTENT_CACHE) 92 .map((k) => caches.delete(k)), 93 )) 94 .then(() => self.clients.claim()), 95 ); 96}); 97 98/** Keep the content cache to a window, oldest evicted first. */ 99async function trimContentCache() { 100 const cache = await caches.open(CONTENT_CACHE); 101 const keys = await cache.keys(); 102 if (keys.length <= CONTENT_LIMIT) return; 103 await Promise.all(keys.slice(0, keys.length - CONTENT_LIMIT).map((k) => cache.delete(k))); 104} 105 106/** 107 * Signing out, or signing in as somebody else. 108 * 109 * The page sends this; the worker drops every cached response. Content is 110 * per-account â a saved list, a matched feed â so leaving one reader's answers 111 * on the device for the next one is not acceptable, and the cheapest correct 112 * answer is to keep none of it. 113 */ 114self.addEventListener("message", (event) => { 115 if (event.data?.type === "CLEAR_USER_CACHE") { 116 event.waitUntil(caches.delete(CONTENT_CACHE)); 117 } 118}); 119 120self.addEventListener("fetch", (event) => { 121 const { request } = event; 122 if (request.method !== "GET") return; 123 124 const url = new URL(request.url); 125 126 // Anything private: straight to the network, never touched. 127 if (isPrivate(url)) return; 128 129 // Navigations: network first so the app is current, cache as the floor so an 130 // installed app opens with no signal at all. 131 if (request.mode === "navigate") { 132 event.respondWith( 133 fetch(request) 134 .then((response) => { 135 const copy = response.clone(); 136 caches.open(SHELL_CACHE).then((c) => c.put("/index.html", copy)).catch(() => undefined); 137 return response; 138 }) 139 .catch(async () => (await caches.match("/index.html")) ?? Response.error()), 140 ); 141 return; 142 } 143 144 // Build output is content-hashed, so a hit is always the right file. 145 if (url.origin === self.location.origin && url.pathname.startsWith("/assets/")) { 146 event.respondWith( 147 caches.match(request).then((hit) => 148 hit ?? fetch(request).then((response) => { 149 const copy = response.clone(); 150 caches.open(SHELL_CACHE).then((c) => c.put(request, copy)).catch(() => undefined); 151 return response; 152 }), 153 ), 154 ); 155 return; 156 } 157 158 // Listings: network first, and the cached copy is what makes an offline read 159 // possible. Stamped on the way in so the app can tell how old it is. 160 if (isCacheableContent(url)) { 161 event.respondWith( 162 fetch(request) 163 .then((response) => { 164 if (response.ok) { 165 const copy = response.clone(); 166 caches.open(CONTENT_CACHE).then(async (cache) => { 167 const stamped = new Response(await copy.blob(), { 168 status: copy.status, 169 statusText: copy.statusText, 170 headers: (() => { 171 const h = new Headers(copy.headers); 172 h.set("x-opportuning-cached-at", String(Date.now())); 173 return h; 174 })(), 175 }); 176 await cache.put(request, stamped); 177 await trimContentCache(); 178 }).catch(() => undefined); 179 } 180 return response; 181 }) 182 .catch(async () => { 183 const hit = await caches.match(request); 184 if (!hit) return Response.error(); 185 const at = Number(hit.headers.get("x-opportuning-cached-at") ?? 0); 186 // Served either way â something to read beats nothing â but the age 187 // is passed on so the app can say the content may be out of date. 188 const stale = Date.now() - at > CONTENT_MAX_AGE_MS; 189 const headers = new Headers(hit.headers); 190 headers.set("x-opportuning-from-cache", "1"); 191 headers.set("x-opportuning-stale", stale ? "1" : "0"); 192 return new Response(await hit.blob(), { status: 200, headers }); 193 }), 194 ); 195 } 196});
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.