1// v30 elimina anche le copie v29 in cui l'iframe aveva sostituito la shell. 2// Documenti e asset hanno cache distinte; gli URL con intenti non si salvano. 3const CACHE_NAME = 'emiciclo-v30-cache'; 4const DOCUMENT_CACHE_NAME = 'emiciclo-v30-documents'; 5const DOCUMENT_MAX_AGE_MS = 60 * 60 * 1000; 6const NOTIF_ICON_CACHE = 'emiciclo-notif-icons-v1'; 7const NOTIF_ICONS = ['/icons/icon-192.png', '/icons/badge.png']; 8const APP_ROUTES = [ 9 '/hub', '/cosmo', '/login', '/auth', '/billing', '/discord', '/ufficio', 10 '/cyclesic', '/evedex-sign', '/maestro', '/tornei', '/tournaments', 11 '/assemblea', '/cyclepedia', '/quadro-ciclico', 12]; 13const pendingDocuments = new Map(); 14 15function onPath(pathname, base) { 16 return pathname === base || pathname.startsWith(base + '/'); 17} 18 19function documentPath(pathname) { 20 // L'HTML del terminale non può mai essere il fallback della piattaforma. 21 for (const base of ['/cyclesic-v2', '/embed/cyclesic']) { 22 if (onPath(pathname, base)) { 23 return !/\.[^/]+$/.test(pathname) || pathname === base + '/index.html' ? base + '/' : null; 24 } 25 } 26 if (pathname === '/' || pathname === '/index.html') return '/'; 27 // I callback .html e gli altri siti/documenti indipendenti restano di rete. 28 if (/\.[^/]+$/.test(pathname)) return null; 29 return APP_ROUTES.some((base) => onPath(pathname, base)) ? '/' : null; 30} 31 32function privateResponse(response) { 33 const control = response.headers.get('cache-control') || ''; 34 const vary = response.headers.get('vary') || ''; 35 return /(?:^|,)\s*private\b/i.test(control) 36 || /(?:^|,)\s*(?:cookie|authorization|\*)\s*(?:,|$)/i.test(vary); 37} 38 39function publicResponse(response) { 40 if (!response.ok || response.status !== 200 || privateResponse(response) 41 || /(?:^|,)\s*no-store\b/i.test(response.headers.get('cache-control') || '')) return false; 42 if (response.url && new URL(response.url).origin !== self.location.origin) return false; 43 return !response.redirected; 44} 45 46async function cachedResponse(cacheName, key) { 47 try { 48 return await (await caches.open(cacheName)).match(key); 49 } catch (_e) { return undefined; } 50} 51 52async function cacheResponse(cacheName, key, response) { 53 try { 54 await (await caches.open(cacheName)).put(key, response); 55 } catch (_e) { /* Spazio esaurito/cache disabilitata: la rete continua a funzionare. */ } 56} 57 58// Solo URL canonici pubblici, senza cookie/header utente o query. Non si salva 59// MAI la risposta della navigazione corrente. Prima copia all'installazione 60// (shell) o alla prima visita (V2/embed); poi al massimo un rinnovo ogni ora 61// durante una visita online, anche se il deploy non ha cambiato questo SW. 62function ensurePublicDocument(path) { 63 if (pendingDocuments.has(path)) return pendingDocuments.get(path); 64 const pending = (async () => { 65 const cached = await cachedResponse(DOCUMENT_CACHE_NAME, path); 66 const age = cached ? Date.now() - Number(cached.headers.get('x-emiciclo-cached-at')) : Infinity; 67 if (age >= 0 && age < DOCUMENT_MAX_AGE_MS) return; 68 const response = await fetch(new URL(path, self.location.origin).href, { 69 credentials: 'omit', cache: 'reload', redirect: 'error', 70 }); 71 if (!publicResponse(response) || !/\btext\/html\b/i.test(response.headers.get('content-type') || '')) { 72 throw new Error('Public document unavailable'); 73 } 74 // Qui l'errore deve propagarsi: senza shell non attiviamo una nuova versione. 75 const headers = new Headers(response.headers); 76 headers.set('X-Emiciclo-Cached-At', String(Date.now())); 77 await (await caches.open(DOCUMENT_CACHE_NAME)).put(path, new Response(response.body, { 78 status: response.status, statusText: response.statusText, headers, 79 })); 80 })().finally(() => pendingDocuments.delete(path)); 81 pendingDocuments.set(path, pending); 82 return pending; 83} 84 85function isPublicAsset(pathname) { 86 if (pathname === '/manifest.json') return true; 87 if (!/\.(?:m?js|css|png|jpe?g|webp|svg|gif|ico|woff2?|ttf|otf|mp3|ogg|wav)$/i.test(pathname)) return false; 88 // File statici della build e delle cartelle pubbliche, non un generico 89 // endpoint same-origin (JSON, dati di mercato o esportazioni personali). 90 return /^\/[^/]+$/.test(pathname) || [ 91 '/assets', '/cyclesic-v2', '/embed/cyclesic', '/brand', '/brands', 92 '/exchanges', '/fonts', '/sounds', '/direktor', '/og-concepts', 93 ].some((base) => onPath(pathname, base)); 94} 95
96function hasAssetContentType(response, pathname) { 97 const type = (response.headers.get('content-type') || '').split(';')[0].trim().toLowerCase(); 98 if (pathname === '/manifest.json') return type === 'application/json' || type === 'application/manifest+json'; 99 if (/\.m?js$/i.test(pathname)) return /^(?:text|application)\/(?:x-)?javascript$/.test(type); 100 if (/\.css$/i.test(pathname)) return type === 'text/css'; 101 if (/\.(?:woff2?|ttf|otf)$/i.test(pathname)) return /^(?:font\/|application\/(?:font-|x-font-|octet-stream))/.test(type); 102 if (/\.(?:mp3|ogg|wav)$/i.test(pathname)) return type.startsWith('audio/'); 103 return type.startsWith('image/'); 104} 105 106async function migrateV29BuildAssets() { 107 // skipWaiting prende in carico anche schede ancora sulla build precedente. 108 // Conserviamo solo i file compilati che il deploy marcava public+immutable: 109 // mai HTML, dati utente, URL con intenti o risposte ambigue della vecchia cache. 110 try { 111 const previous = await caches.open('emiciclo-v29-cache'); 112 for (const request of await previous.keys()) { 113 const url = new URL(request.url); 114 if (url.origin !== self.location.origin || url.search 115 || request.method !== 'GET' || request.headers.has('authorization')) continue; 116 if (!/^\/(?:assets|cyclesic-v2\/assets|embed\/cyclesic\/assets)\//.test(url.pathname) 117 || !/-[A-Za-z0-9_-]{8,}\.[A-Za-z0-9]+$/.test(url.pathname) 118 || !isPublicAsset(url.pathname)) continue; 119 const response = await previous.match(request); 120 if (!response || !publicResponse(response) || !hasAssetContentType(response, url.pathname)) continue; 121 const control = response.headers.get('cache-control') || ''; 122 if (!/(?:^|,)\s*public\s*(?:,|$)/i.test(control) 123 || !/(?:^|,)\s*immutable\s*(?:,|$)/i.test(control)) continue; 124 if (!(await cachedResponse(CACHE_NAME, request))) await cacheResponse(CACHE_NAME, request, response); 125 } 126 } catch (_e) { /* Cache vecchia illeggibile/quota: la pulizia resta necessaria. */ } 127} 128 129function offlineResponse() { 130 return new Response('Connessione assente. Riprova quando torni online.', { 131 status: 503, 132 headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' }, 133 }); 134} 135 136self.addEventListener('install', (event) => { 137 event.waitUntil((async () => { 138 await ensurePublicDocument('/'); 139 const manifest = await fetch('/manifest.json', { credentials: 'omit', cache: 'reload', redirect: 'error' }); 140 if (publicResponse(manifest) && hasAssetContentType(manifest, '/manifest.json')) { 141 await cacheResponse(CACHE_NAME, '/manifest.json', manifest); 142 } 143 // Prefetch icone notifica: al push Android non deve aspettare la rete 144 // (altrimenti la push scade muta prima di showNotification). 145 try { 146 const nc = await caches.open(NOTIF_ICON_CACHE); 147 await Promise.all(NOTIF_ICONS.map(async (path) => { 148 try { 149 const res = await fetch(path, { cache: 'reload' }); 150 if (res.ok) await nc.put(path, res.clone()); 151 } catch (_e) { /* install senza rete: ok, al push si riprova */ } 152 })); 153 } catch (_e) { /* best-effort */ } 154 })()); 155 self.skipWaiting(); 156}); 157 158self.addEventListener('activate', (event) => { 159 event.waitUntil((async () => { 160 const keep = new Set([CACHE_NAME, DOCUMENT_CACHE_NAME, NOTIF_ICON_CACHE]); 161 const keys = await caches.keys(); 162 if (keys.includes('emiciclo-v29-cache')) await migrateV29BuildAssets(); 163 await Promise.all(keys 164 .filter((key) => key.startsWith('emiciclo-') && !keep.has(key)) 165 .map((key) => caches.delete(key))); 166 await self.clients.claim(); 167 })()); 168}); 169 170function bytesLookLikeImage(buf, contentType) { 171 if (!buf || buf.byteLength < 8) return false; 172 const ct = (contentType || '').toLowerCase(); 173 if (ct && !ct.startsWith('image/') && ct !== 'application/octet-stream') return false; 174 const u8 = new Uint8Array(buf); 175 const isPng = u8[0] === 0x89 && u8[1] === 0x50 && u8[2] === 0x4e && u8[3] === 0x47; 176 const isJpeg = u8[0] === 0xff && u8[1] === 0xd8; 177 const isWebp = u8[0] === 0x52 && u8[1] === 0x49 && u8[2] === 0x46 && u8[3] === 0x46; 178 return isPng || isJpeg || isWebp; 179} 180 181/** 182 * Android resta muto se icon/badge puntano a HTML (SPA 200) o a un asset 183 * non-immagine: showNotification NON lancia. Controlliamo magic bytes. 184 * Timeout duro: al push non si può aspettare la rete (push scartata). 185 */ 186async function usableNotifImage(url, timeoutMs = 500) { 187 const check = (async () => { 188 try { 189 const path = new URL(url).pathname; 190 const nc = await caches.open(NOTIF_ICON_CACHE); 191 const cached = await nc.match(path) || await nc.match(url); 192 if (cached) { 193 const buf = await cached.arrayBuffer(); 194 if (bytesLookLikeImage(buf, cached.headers.get('content-type'))) return true; 195 } 196 } catch (_e) { /* cache miss */ } 197 try { 198 const res = await fetch(url, { cache: 'no-store' }); 199 if (!res.ok) return false;
200 const buf = await res.arrayBuffer(); 201 if (!bytesLookLikeImage(buf, res.headers.get('content-type'))) return false; 202 try { 203 const nc = await caches.open(NOTIF_ICON_CACHE); 204 const path = new URL(url).pathname; 205 await nc.put(path, new Response(buf.slice(0), { 206 headers: { 'Content-Type': res.headers.get('content-type') || 'image/png' }, 207 })); 208 } catch (_e) { /* ignore */ } 209 return true; 210 } catch (_e) { 211 return false; 212 } 213 })(); 214 return Promise.race([ 215 check, 216 new Promise((resolve) => setTimeout(() => resolve(false), timeoutMs)), 217 ]); 218} 219 220// ââ WEB PUSH (allarmi CycleSic ad app chiusa â PWA desktop + mobile) âââââ 221// Il backend invia { title, body, tag, url }. Mostriamo la notifica nativa; 222// al click riapriamo l'APP (start_url /), non solo l'iframe V2. 223// icon: logo colorato (icon-192). badge: silhouette bianca su trasparente. 224self.addEventListener('push', (event) => { 225 let data = {}; 226 try { data = event.data ? event.data.json() : {}; } 227 catch (_e) { data = { body: (event.data && event.data.text && event.data.text()) || '' }; } 228 const origin = self.location.origin; 229 const title = data.title || 'CycleSic'; 230 const body = data.body || 'Allarme scattato'; 231 const tag = data.tag || undefined; 232 const notifData = { url: data.url || '/' }; 233 const iconUrl = origin + '/icons/icon-192.png'; 234 const badgeUrl = origin + '/icons/badge.png'; 235 236 event.waitUntil((async () => { 237 // Max ~500ms per le icone: oltre Android scarta la push come "silent". 238 const [iconOk, badgeOk] = await Promise.all([ 239 usableNotifImage(iconUrl, 500), 240 usableNotifImage(badgeUrl, 500), 241 ]); 242 const options = { 243 body, 244 tag, 245 renotify: !!tag, 246 data: notifData, 247 }; 248 if (iconOk) options.icon = iconUrl; 249 if (badgeOk) options.badge = badgeUrl; 250 try { 251 await self.registration.showNotification(title, options); 252 } catch (_e) { 253 try { 254 await self.registration.showNotification(title, { body, data: notifData }); 255 } catch (_e2) { /* niente altro da fare */ } 256 } 257 })()); 258}); 259 260// Rotazione/scadenza dell'iscrizione: il browser la sostituisce. Ri-iscriviamo 261// con la STESSA applicationServerKey (presa dalla vecchia sub) così il push 262// resta valido; il client, alla prossima apertura, la ri-registra sul backend 263// (fetchPushStatus fa un upsert idempotente). Senza questo il push morirebbe zitto. 264self.addEventListener('pushsubscriptionchange', (event) => { 265 const oldSub = event.oldSubscription; 266 const appKey = oldSub && oldSub.options && oldSub.options.applicationServerKey; 267 if (!appKey) return; 268 event.waitUntil( 269 self.registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: appKey }) 270 .catch(() => { /* la ri-registrazione sul server avviene all'apertura del client */ }) 271 ); 272}); 273 274function inAppScope(url, scope) { 275 return url.origin === self.location.origin 276 && (url.protocol === 'https:' || url.protocol === 'http:') 277 && !url.username && !url.password 278 && url.pathname.startsWith(scope.pathname); 279} 280 281const EVEDEX_RETURN_FOCUS = 'emiciclo:evedex-return-focus'; 282const EVEDEX_RETURN_PROBE = 'emiciclo:evedex-return-probe'; 283const EVEDEX_RETURN_ACTIVATE = 'emiciclo:evedex-return-activate'; 284const EVEDEX_RETURN_TOKEN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; 285const EVEDEX_RETURN_MAX_CLIENTS = 24; 286const EVEDEX_RETURN_PROBE_MS = 400; 287 288function evedexReturnClientUrl(client, signing = false) { 289 // window.open creates an auxiliary top-level context, never a nested pane. 290 if (!client || client.type !== 'window' || !['top-level', 'auxiliary'].includes(client.frameType) 291 || typeof client.id !== 'string' || !client.id) return null; 292 try { 293 const url = new URL(client.url); 294 if (url.origin !== self.location.origin || !['https:', 'http:'].includes(url.protocol) 295 || url.username || url.password) return null; 296 if (signing) return url.pathname === '/evedex-sign' ? url : null; 297 if (documentPath(url.pathname) !== '/' || ['/evedex-sign', '/auth', '/login'].some(path => onPath(url.pathname, path))) return null; 298 return url; 299 } catch (_e) { return null; } 300} 301 302function probeEvedexReturnClient(client, token) { 303 return new Promise(resolve => { 304 const channel = new MessageChannel(); 305 let settled = false;
306 const finish = ready => { 307 if (settled) return; 308 settled = true; 309 clearTimeout(timer); 310 channel.port1.onmessage = null; 311 channel.port1.onmessageerror = null; 312 channel.port1.close(); 313 channel.port2.close(); 314 resolve(ready); 315 }; 316 const timer = setTimeout(() => finish(false), EVEDEX_RETURN_PROBE_MS); 317 channel.port1.onmessage = event => finish(event.data?.type === EVEDEX_RETURN_PROBE 318 && event.data.token === token && event.data.ready === true); 319 channel.port1.onmessageerror = () => finish(false); 320 try { client.postMessage({ type: EVEDEX_RETURN_PROBE, token }, [channel.port2]); } 321 catch (_e) { finish(false); } 322 }); 323} 324 325// A return click can focus only the live app window that still holds this 326// handoff token. Tokens are not stored in the worker, cache or notifications. 327self.addEventListener('message', event => { 328 if (event.data?.type !== EVEDEX_RETURN_FOCUS) return; 329 const port = event.ports?.[0]; 330 if (!port || typeof port.postMessage !== 'function') return; 331 const token = event.data.token; 332 event.waitUntil((async () => { 333 let focused = false; 334 try { 335 if (typeof token !== 'string' || !EVEDEX_RETURN_TOKEN.test(token) || !evedexReturnClientUrl(event.source, true)) return; 336 const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); 337 const candidates = all.filter(client => client.id !== event.source.id && evedexReturnClientUrl(client)); 338 // Truncation could hide a second owner of the token. Ambiguity stays closed. 339 if (!candidates.length || candidates.length > EVEDEX_RETURN_MAX_CLIENTS) return; 340 const ready = await Promise.all(candidates.map(client => probeEvedexReturnClient(client, token))); 341 const matches = candidates.filter((_client, index) => ready[index]); 342 if (matches.length !== 1) return; 343 const selected = matches[0]; 344 const client = typeof self.clients.get === 'function' ? await self.clients.get(selected.id) : selected; 345 if (!client || client.id !== selected.id || client.id === event.source.id 346 || !evedexReturnClientUrl(client) || typeof client.focus !== 'function') return; 347 const returned = await client.focus(); 348 if (!returned || returned.id !== client.id || returned.focused !== true || !evedexReturnClientUrl(returned)) return; 349 returned.postMessage({ type: EVEDEX_RETURN_ACTIVATE, token }); 350 focused = true; 351 } catch (_e) { /* Missing client, expired activation or denied focus: stay on the signing page. */ } 352 finally { 353 try { port.postMessage({ type: EVEDEX_RETURN_FOCUS, token, focused }); } 354 catch (_e) { /* The signing page may already be closed. */ } 355 try { port.close(); } catch (_e) { /* Port already detached. */ } 356 } 357 })()); 358}); 359 360self.addEventListener('notificationclick', (event) => { 361 event.notification.close(); 362 const scope = new URL(self.registration.scope || '/', self.location.origin); 363 let destination = scope; 364 try { 365 const requested = new URL((event.notification.data && event.notification.data.url) || scope.href, scope); 366 if (inAppScope(requested, scope)) destination = requested; 367 } catch (_e) { /* Payload non valido: apre l'app, mai un sito esterno. */ } 368 event.waitUntil((async () => { 369 let all = []; 370 try { 371 all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); 372 } catch (_e) { /* Una finestra nuova resta possibile. */ } 373 const candidates = []; 374 for (const client of all) { 375 if (!client.url || typeof client.focus !== 'function' || client.frameType === 'nested') continue; 376 try { 377 const url = new URL(client.url); 378 if (inAppScope(url, scope)) candidates.push({ client, url }); 379 } catch (_e) { /* Finestra chiusa o URL non utilizzabile. */ } 380 } 381 // Prima la destinazione esatta, poi lo stesso percorso con altro intento, 382 // infine un'altra finestra dell'app. Non si naviga mai l'iframe del grafico. 383 const rank = (url) => url.href === destination.href ? 0 : url.pathname === destination.pathname ? 1 : 2; 384 candidates.sort((a, b) => rank(a.url) - rank(b.url)); 385 for (const { client, url } of candidates) {
386 try { 387 if (url.href === destination.href) return await client.focus(); 388 if (typeof client.navigate !== 'function') continue; 389 const navigated = await client.navigate(destination.href); 390 if (navigated && typeof navigated.focus === 'function') return await navigated.focus(); 391 } catch (_e) { /* Chiusura/rifiuto navigazione: prova un'altra finestra. */ } 392 } 393 if (self.clients.openWindow) { 394 try { return await self.clients.openWindow(destination.href); } 395 catch (_e) { /* Browser o sistema operativo hanno rifiutato l'apertura. */ } 396 } 397 return null; 398 })()); 399}); 400 401self.addEventListener('fetch', (event) => { 402 const { request } = event; 403 const url = new URL(request.url); 404 405 if (request.method !== 'GET') return; 406 407 /* â MAI TOCCARE IL TRAFFICO DI UN'ALTRA ORIGINE (audit 2026-08). 408 Qui sotto si filtrava solo per PERCORSO, e il percorso non dice niente su 409 CHI stai interrogando: le richieste verso Supabase (`/auth/v1/user`, i 410 dati via PostgREST) e verso il backend su Render hanno percorsi che non 411 combaciano con nessuno di questi filtri, quindi finivano nella cache 412 comune del sito. Sono risposte AUTENTICATE: restavano sul disco del 413 browser dopo l'uscita, e il ramo offline le riserviva a chi prendeva quel 414 computer dopo â senza sessione. Una fuga silenziosa, su un dispositivo 415 condiviso. */ 416 if (url.origin !== self.location.origin) return; 417 418 /* â E IL PONTE VERSO LO STESSO BACKEND. Il service worker vede l'indirizzo 419 PRIMA della riscrittura di Netlify (`/cyclesic-api/*` â 420 `/.netlify/functions/cyclesic-v2-passthrough/*`, netlify.toml): per lui è 421 same-origin, quindi supera la guardia qui sopra, e il filtro 422 `/.netlify/functions/` della riga sotto non lo vede perché quel percorso 423 non esiste ancora. Senza questa riga, tutte le risposte del terminale â 424 candele, indicatori, analisi â finivano in cache. */ 425 if (url.pathname === '/cyclesic-api' || url.pathname.startsWith('/cyclesic-api/')) return; 426 427 // I dati di mercato Bybit devono arrivare sempre dalla rete: niente Cache API 428 // e niente fallback offline con open interest, funding o trade ormai vecchi. 429 if (url.pathname === '/bybit-api' || url.pathname.startsWith('/bybit-api/')) return; 430 431 if (onPath(url.pathname, '/.netlify/functions')) return; 432 if (onPath(url.pathname, '/api')) return; 433 // Icone notifica: mai passare dal cache-all (un 200 HTML SPA avvelenerebbe 434 // il badge e Android non mostrerebbe più le push). 435 if (url.pathname.startsWith('/icons/')) return; 436 437 const isNavigate = request.mode === 'navigate'; 438 const document = isNavigate ? documentPath(url.pathname) : null; 439 if (isNavigate && !document) return; 440 if (request.headers.has('authorization') || request.cache === 'no-store') return; 441 if (!isNavigate && (url.search || !isPublicAsset(url.pathname))) return; 442 443 let cacheWork = Promise.resolve(); 444 const responsePromise = fetch(request).then(async (response) => { 445 if (isNavigate) { 446 // Redirect manuali (anche opaque status 0), 404 e 5xx restano intatti. 447 // La pagina richiesta non viene mai salvata: prepariamo separatamente 448 // il suo documento pubblico, senza query o credenziali, se manca. 449 if (publicResponse(response) && /\btext\/html\b/i.test(response.headers.get('content-type') || '')) { 450 cacheWork = ensurePublicDocument(document).catch(() => {}); 451 } 452 return response; 453 } 454 // no-store vieta di SALVARE questa risposta, non di recuperare un asset 455 // pubblico già salvato quando un vecchio chunk ora risponde 404/HTML. 456 // Una risposta privata o negata, invece, non si sostituisce mai. 457 if (response.redirected || privateResponse(response) || response.status === 401 || response.status === 403) return response; 458 const isHtml = /\btext\/html\b/i.test(response.headers.get('content-type') || ''); 459 if (publicResponse(response) && hasAssetContentType(response, url.pathname)) { 460 cacheWork = cacheResponse(CACHE_NAME, request, response.clone()); 461 return response; 462 } 463 // Un chunk già visitato continua a funzionare dopo un rilascio anche se 464 // Netlify risponde 404 o HTML 200. Mai rimpiazzare un redirect con cache. 465 if (response.ok && isHtml) { 466 return (await cachedResponse(CACHE_NAME, request)) || response; 467 } 468 if (!isNavigate && response.status >= 400) { 469 return (await cachedResponse(CACHE_NAME, request)) || response; 470 } 471 return response; 472 }).catch(async () => { 473 const cached = await cachedResponse(isNavigate ? DOCUMENT_CACHE_NAME : CACHE_NAME, document || request); 474 return cached || offlineResponse(); 475 }); 476 477 event.respondWith(responsePromise); 478 // waitUntil è registrato durante l'evento: il worker resta vivo finché 479 // termina la scrittura, senza ritardare la risposta al browser. 480 event.waitUntil(responsePromise.then(() => cacheWork).catch(() => { 481 /* Offline/quota: preserva comunque la risposta di rete. */ 482 })); 483});
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.