1/** 2 * Service Worker for AI Crypto News 3 * Offline support + tuned caching strategies 4 * NOTE: this file lives in /public and must be self-contained (no imports). 5 */ 6 7// ===== Versioning ===== 8const VERSION = "v3.2.219"; // â bump on SW changes (added push notifications) 9 10// ===== Brand config ===== 11const BRAND = { 12 name: "CryptoBeast", 13 bg: "#0a0a0f", 14 fg: "#E85A32" 15}; 16 17// Cache keys (isolate per version) 18const HOST = self.location.host; 19const CACHE_PREFIX = `aicrypto-${HOST}`; 20const RUNTIME_CACHE = `${CACHE_PREFIX}-runtime-${VERSION}`; 21const STATIC_CACHE = `${CACHE_PREFIX}-static-${VERSION}`; 22const HTML_CACHE = `${CACHE_PREFIX}-html-${VERSION}`; 23 24// Assets to precache (root-relative; middleware prepisuje na /skins/{skin}/â¦) 25const PRECACHE_ASSETS = [ 26 "/", 27 "/favicon.ico", 28 "/favicon-48x48.png", 29 "/favicon-32x32.png", // + (opciono, ali korisno) 30 "/apple-touch-icon.png", // + (opciono, iOS add-to-home) 31 "/logo.png", 32 "/android-chrome-192x192.png", 33 "/android-chrome-512x512.png", 34 "/site.webmanifest", 35]; 36 37// File type helpers 38const STATIC_EXT_RE = /\.(?:png|jpg|jpeg|webp|svg|gif|ico|css|js|mjs|woff2?|ttf|otf|eot|json)$/i; 39 40// Strategije 41const STRATEGY = { 42 NETWORK_FIRST: "network-first", 43 CACHE_FIRST: "cache-first", 44 NETWORK_ONLY: "network-only", 45 CACHE_ONLY: "cache-only", 46}; 47 48// Enable navigation preload if available 49self.addEventListener("activate", (event) => { 50 event.waitUntil( 51 (async () => { 52 if (self.registration.navigationPreload) { 53 try { 54 await self.registration.navigationPreload.enable(); 55 } catch {} 56 } 57 })() 58 ); 59}); 60 61// Install â precache osnovnih asseta 62self.addEventListener("install", (event) => { 63 event.waitUntil( 64 caches 65 .open(STATIC_CACHE) 66 .then((cache) => cache.addAll(PRECACHE_ASSETS)) 67 .catch(() => {}) 68 ); 69 self.skipWaiting(); 70}); 71 72// Activate â cleanup starih keÅ¡eva (po prefiksu) 73self.addEventListener("activate", (event) => { 74 event.waitUntil( 75 caches 76 .keys() 77 .then((keys) => 78 Promise.all( 79 keys.map((k) => 80 k.startsWith(`${CACHE_PREFIX}-`) && 81 ![RUNTIME_CACHE, STATIC_CACHE, HTML_CACHE].includes(k) 82 ? caches.delete(k) 83 : Promise.resolve() 84 ) 85 ) 86 ) 87 ); 88 self.clients.claim(); 89}); 90 91// Fetch 92self.addEventListener("fetch", (event) => { 93 const req = event.request; 94 const url = new URL(req.url); 95 96 // GET only 97 if (req.method !== "GET") return; 98 99 // non-http(s), chrome-extension, data:, etc. 100 if (!url.protocol.startsWith("http")) return; 101 102 // Different origin â let it go (ili primeni network-first ako želiÅ¡) 103 // Ovde ostavljamo default: ne cache-ujemo cross-origin (manje bugova) 104 if (url.origin !== self.location.origin) return; 105 106 // Skip Next.js internals koji nisu static buildovi 107 if (url.pathname.startsWith("/_next/image")) return; // image optimizer ima sopstvenu cache logiku 108 // _next/static & /skins/ â static cache-first 109 const isNextStatic = url.pathname.startsWith("/_next/static/"); 110 const isSkinsPath = url.pathname.startsWith("/skins/"); 111 const isOg = url.pathname.startsWith("/og/"); // middleware rewrite â /skins/{skin}/og/* 112 113 // Odredi strategiju 114 let strategy; 115 if (url.pathname.startsWith("/api/")) { 116 strategy = STRATEGY.NETWORK_FIRST; // API uvek svež 117 } else if (isNextStatic || isSkinsPath || STATIC_EXT_RE.test(url.pathname)) { 118 strategy = STRATEGY.CACHE_FIRST; // static i assets 119 } else if (isOg) { 120 strategy = STRATEGY.CACHE_FIRST; // OG slike â brzo uÄitavanje 121 } else if (acceptsHtml(req)) { 122 strategy = STRATEGY.NETWORK_FIRST; // HTML (stranice) 123 } else { 124 strategy = STRATEGY.NETWORK_FIRST; // default 125 } 126 127 event.respondWith(handle(req, strategy)); 128}); 129 130// ===== Helpers ===== 131 132function acceptsHtml(request) { 133 const accept = request.headers.get("accept") || ""; 134 return accept.includes("text/html"); 135} 136 137async function handle(request, strategy) { 138 switch (strategy) { 139 case STRATEGY.CACHE_FIRST: 140 return cacheFirst(request); 141 case STRATEGY.NETWORK_FIRST: 142 return networkFirst(request); 143 case STRATEGY.NETWORK_ONLY: 144 return fetch(request); 145 case STRATEGY.CACHE_ONLY: 146 return caches.match(request); 147 default: 148 return networkFirst(request); 149 } 150} 151 152async function cacheFirst(request) { 153 const cache = await caches.open(STATIC_CACHE); 154 const cached = await cache.match(request); 155 if (cached) { 156 // quiet background refresh 157 fetchAndUpdate(cache, request); 158 return cached; 159 } 160 try { 161 const res = await fetch(request);
162 if (isCacheable(res)) cache.put(request, res.clone()); 163 return res; 164 } catch { 165 return offlineFallback(request); 166 } 167} 168 169async function networkFirst(request) { 170 const cacheStore = acceptsHtml(request) ? HTML_CACHE : RUNTIME_CACHE; 171 const cache = await caches.open(cacheStore); 172 173 // Try navigationPreload first for HTML 174 if (acceptsHtml(request) && self.registration.navigationPreload) { 175 try { 176 const preloaded = await self.registration.navigationPreload.getState(); 177 // if enabled, browser may provide a preloaded response 178 } catch {} 179 } 180 181 try { 182 const res = await fetch(request); 183 if (isCacheable(res)) cache.put(request, res.clone()); 184 return res; 185 } catch { 186 const cached = await cache.match(request); 187 if (cached) return cached; 188 return offlineFallback(request); 189 } 190} 191 192function isCacheable(response) { 193 return ( 194 response && 195 response.status === 200 && 196 (response.type === "basic" || response.type === "default") 197 ); 198} 199 200function fetchAndUpdate(cache, request) { 201 fetch(request) 202 .then((res) => {
203 if (isCacheable(res)) cache.put(request, res.clone()); 204 }) 205 .catch(() => {}); 206} 207 208// Branded offline fallback 209function offlineFallback(request) { 210 if (acceptsHtml(request)) { 211 const html = ` 212 <!DOCTYPE html> 213 <html lang="en"> 214 <head> 215 <meta charset="UTF-8" /> 216 <meta name="viewport" content="width=device-width, initial-scale=1" /> 217 <title>Offline - ${escapeHtml(BRAND.name)}</title> 218 <style> 219 :root { --bg:${BRAND.bg}; --fg:${BRAND.fg}; } 220 html,body{height:100%} 221 body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Ubuntu,"Helvetica Neue",Arial,sans-serif;background:var(--bg);color:var(--fg);display:flex;align-items:center;justify-content:center;padding:24px;text-align:center} 222 .wrap{max-width:520px} 223 h1{font-size:24px;margin:0 0 12px} 224 p{opacity:.85;line-height:1.6;margin:0 0 20px} 225 button{padding:12px 20px;border:0;border-radius:10px;background:var(--fg);color:var(--bg);font-weight:700;cursor:pointer} 226 button:active{transform:translateY(1px)} 227 </style> 228 </head> 229 <body> 230 <div class="wrap"> 231 <h1>ð¡ You're Offline</h1> 232 <p>Unable to connect to ${escapeHtml(BRAND.name)}. Check your internet connection and try again.</p> 233 <button onclick="location.reload()">Retry</button> 234 </div> 235 </body> 236 </html>`; 237 return new Response(html, { status: 503, headers: { "Content-Type": "text/html" } }); 238 } 239 return new Response("Offline", { status: 503 }); 240} 241 242function escapeHtml(s) { 243 return String(s).replace( 244 /[&<>"']/g, 245 (m) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[m] 246 ); 247} 248 249// Messages (optional commands) 250self.addEventListener("message", (event) => { 251 if (!event.data) return; 252 if (event.data.type === "SKIP_WAITING") self.skipWaiting(); 253 if (event.data.type === "CLEAR_CACHE") { 254 event.waitUntil( 255 (async () => { 256 const keys = await caches.keys(); 257 await Promise.all( 258 keys.filter((k) => k.startsWith(CACHE_PREFIX)).map((k) => caches.delete(k)) 259 ); 260 // eslint-disable-next-line no-console 261 console.log("[SW] Cleared caches for", CACHE_PREFIX); 262 })() 263 ); 264 } 265}); 266 267// ===== Push Notifications ===== 268self.addEventListener("push", (event) => { 269 if (!event.data) return; 270 271 let payload; 272 try { 273 payload = event.data.json(); 274 } catch { 275 payload = { title: BRAND.name, body: event.data.text() }; 276 } 277 278 const { title, body, icon, badge, url, tag, data, actions } = payload; 279 280 event.waitUntil( 281 self.registration.showNotification(title || BRAND.name, { 282 body: body || "", 283 icon: icon || "/android-chrome-192x192.png", 284 badge: badge || "/favicon-48x48.png", 285 tag: tag || "cb-default", 286 data: { url: url || "/", ...data }, 287 vibrate: [100, 50, 100], 288 actions: actions || [], 289 }) 290 ); 291}); 292 293self.addEventListener("notificationclick", (event) => { 294 event.notification.close(); 295 const targetUrl = event.notification.data?.url || "/"; 296 297 event.waitUntil( 298 clients.matchAll({ type: "window", includeUncontrolled: true }).then((windowClients) => { 299 for (const client of windowClients) { 300 if (client.url.includes(self.location.origin) && "focus" in client) { 301 client.navigate(targetUrl); 302 return client.focus(); 303 } 304 } 305 return clients.openWindow(targetUrl); 306 }) 307 ); 308}); 309 310// Log 311// eslint-disable-next-line no-console 312console.log("[SW] Loaded", { VERSION, HOST, RUNTIME_CACHE, STATIC_CACHE, HTML_CACHE });
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.