PageSourceSearch

https://ufficio-direktor.netlify.app/sw.js

js ufficio-direktor.netlify.app collected 2026-10-03 11:34:23 UTC 22,913 bytes, 483 lines download raw bytes

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.