Ctrl K
ComunidadGuías…

sw.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// Cache version — bump this when deploying new static assets
// Prod uses stale-while-revalidate (instant nav + offline fallback); the
// worktree/test instance (port 3001) is forced network-first below so code
// changes always show on reload. Bumping this version evicts the old cache.
const CACHE = 'meistertracker-v58';

// Test/worktree instance detection. The worktree server runs on port 3001
// (prod is 3000 / 443). On the worktree we never serve static assets from
// cache — otherwise a code change requires SW-unregister gymnastics to show
// up. self.location is the SW script URL, so .port is reliable here.
const IS_WORKTREE = self.location.port === '3001';
const ASSETS = [
  '/',
  '/styles.css',
  '/app.js',
  '/manifest.json',
  '/icon-192.png',
  '/icon-512.png',
  // Vendor libs — pre-cached so the scanner and dashboard chart work on first
  // offline navigation (sw fetch handler is network-first, so without explicit
  // pre-caching these wouldn't be in the cache until they'd been fetched once).
  // P-02: app.js no longer eager-loads these via <script> tags; the in-app
  // loadVendorLibs() helper injects them on demand. They stay in the SW
  // pre-cache so the first offline use of the scanner / charts / labels
  // still finds them.
  '/lib/jsbarcode.min.js',
  '/lib/qrcode.min.js',
  '/lib/chart.min.js',
  '/lib/html5-qrcode.min.js',
  // P-03: per-locale language files. Pre-cache the default 'de' so first
  // load offline still has translations; en/pt are fetched on demand.
  '/lang/de.js'
];

// ── IndexedDB offline queues ────────────────────────────────
// Two stores under the same DB:
//   pending-scans         — POSTs to /api/scan-log (since v1)
//   pending-contam-reports — POSTs to /api/contamination-reports (added v2)
// Both queue the JSON body; replay POSTs it untouched. Schema change requires
// IDB_VERSION bump so onupgradeneeded fires for existing installs.
const IDB_NAME = 'meister-offline';
const IDB_VERSION = 2;
const STORE_SCANS = 'pending-scans';
const STORE_CONTAM = 'pending-contam-reports';

function openIDB() {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open(IDB_NAME, IDB_VERSION);
    req.onupgradeneeded = () => {
      const db = req.result;
      if (!db.objectStoreNames.contains(STORE_SCANS)) {
        db.createObjectStore(STORE_SCANS, { keyPath: 'id', autoIncrement: true });
      }
      if (!db.objectStoreNames.contains(STORE_CONTAM)) {
        db.createObjectStore(STORE_CONTAM, { keyPath: 'id', autoIncrement: true });
      }
    };
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

function _idbAdd(store, body) {
  return openIDB().then(
    (db) =>
      new Promise((resolve, reject) => {
        const tx = db.transaction(store, 'readwrite');
        tx.objectStore(store).add({ body, queuedAt: new Date().toISOString() });
        tx.oncomplete = () => {
          resolve();
          notifyClients();
        };
        tx.onerror = () => reject(tx.error);
      })
  );
}
// Overwrite an existing queued record in place (keyPath id preserved on the
// object). Used to remove one entry from a multi-entry queued POST without
// dropping the whole record — see dropQueuedScan.
function _idbPut(store, record) {
  return openIDB().then(
    (db) =>
      new Promise((resolve, reject) => {
        const tx = db.transaction(store, 'readwrite');
        tx.objectStore(store).put(record);
        tx.oncomplete = () => resolve();
        tx.onerror = () => reject(tx.error);
      })
  );
}
function _idbGetAll(store) {
  return openIDB().then(
    (db) =>
      new Promise((resolve, reject) => {
        const tx = db.transaction(store, 'readonly');
        const req = tx.objectStore(store).getAll();
        req.onsuccess = () => resolve(req.result);
        req.onerror = () => reject(req.error);
      })
  );
}
function _idbDelete(store, id) {
  return openIDB().then(
    (db) =>
      new Promise((resolve, reject) => {
        const tx = db.transaction(store, 'readwrite');
        tx.objectStore(store).delete(id);
        tx.oncomplete = () => resolve();
        tx.onerror = () => reject(tx.error);
      })
  );
}

// R-21: cap the scan queue. A 12-hour offline window with one rapid scanner
// can pile thousands of entries into IndexedDB; the IDB quota then rejects
// adds silently with no notification, and reconnect blasts the server with
// sequential POSTs (Phase 2 idempotency prevents duplicate writes but the
// server is unhappy). 500 is well above a normal day's scans. Drop oldest on
// overflow (FIFO) — the user is offline anyway and the oldest scans are the
// least likely to still be relevant.
const SCAN_QUEUE_MAX = 500;

async function queuePendingScan(body) {
  const all = await getPendingScans();
  if (all.length >= SCAN_QUEUE_MAX) {
    const dropCount = all.length - SCAN_QUEUE_MAX + 1;
    for (let i = 0; i < dropCount; i++) {
      try {
        await deletePendingScan(all[i].id);
      } catch {
        /* best-effort eviction */
      }
    }
    self.clients.matchAll().then((clients) => {
      for (const c of clients) {
        c.postMessage({ type: 'scan-queue-overflow', dropped: dropCount, max: SCAN_QUEUE_MAX });
      }
    });
  }
  return _idbAdd(STORE_SCANS, body);
}
const getPendingScans = () => _idbGetAll(STORE_SCANS);
const deletePendingScan = (id) => _idbDelete(STORE_SCANS, id);

const queuePendingContam = (body) => _idbAdd(STORE_CONTAM, body);
const getPendingContams = () => _idbGetAll(STORE_CONTAM);
const deletePendingContam = (id) => _idbDelete(STORE_CONTAM, id);

async function _replayQueue(store, getAll, del, url) {
  const pending = await getAll();
  // R-22: track whether we hit a server-side failure (5xx) so the caller
  // can apply exponential backoff. Network errors and 401s are NOT server
  // failures — they don't deserve a long cooldown.
  let serverFailure = false;
  for (const item of pending) {
    try {
      const resp = await fetch(url, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        credentials: 'same-origin', // Include session cookie for user attribution
        body: JSON.stringify(item.body)
      });
      if (resp.ok) {
        await del(item.id);
      } else if (resp.status === 401) {
        // Session expired — keep queued, notify client to re-auth
        notifyClients();
        return;
      } else if (resp.status === 409) {
        // Conflict (I-12): server rejected the entry because state
        // changed since it was queued (e.g. an admin moved the bag to a
        // different zone via the web UI before this offline scan replayed).
        // Retrying can't help — drop the entry and surface the rejection so
        // the user can reconcile manually.
        let detail = null;
        try {
          detail = await resp.json();
        } catch {
          /* ignore body parse errors */
        }
        await del(item.id);
        self.clients.matchAll().then((clients) => {
          for (const c of clients) {
            c.postMessage({ type: 'scan-replay-rejected', reason: 'zone_mismatch', detail });
          }
        });
      } else if (resp.status >= 500) {
        // R-22: 5xx — the server is unhealthy. Stop replay and signal the caller
        // so it can apply exponential backoff before retrying.
        serverFailure = true;
        break;
      } else {
        // Any other 4xx: the server has judged this specific entry unacceptable
        // (400 from validateScanEntries, 413 oversize, 507 photo store full).
        // Retrying cannot change that, so drop it and tell the client — exactly
        // as the 409 branch does.
        //
        // Previously this fell into the 5xx branch, which broke WITHOUT deleting
        // and without setting serverFailure. _replayQueue then returned normally,
        // so replayAll() resolved and reset _replayFailureCount/_replayCooldownUntil
        // — the failure actively cleared the backoff it should have triggered.
        // With scheduleReplay() firing on every asset fetch behind a 1 s debounce,
        // one 507'd 1.2 MB contamination report re-uploaded itself about once a
        // second for as long as the user browsed, and everything queued behind it
        // was stuck for good with no operator-visible signal.
        let detail = null;
        try {
          detail = await resp.json();
        } catch {
          /* ignore body parse errors */
        }
        await del(item.id);
        self.clients.matchAll().then((clients) => {
          for (const c of clients) {
            c.postMessage({ type: 'scan-replay-rejected', reason: 'rejected_' + resp.status, detail });
          }
        });
      }
    } catch {
      break; // Still offline — stop replay (no backoff; network errors retry naturally)
    }
  }
  notifyClients();
  if (serverFailure) {
    throw new Error('replay-server-failure');
  }
}

const replayPendingScans = () => _replayQueue(STORE_SCANS, getPendingScans, deletePendingScan, '/api/scan-log');
const replayPendingContams = () =>
  _replayQueue(STORE_CONTAM, getPendingContams, deletePendingContam, '/api/contamination-reports');

// R-22: every successful asset fetch in the SW's stale-while-revalidate
// branch was triggering replayAll(), so a single page load with 50 assets
// fired 50 replays in quick succession. Phase 2 idempotency prevents
// duplicate writes but the server gets flogged. Solution:
//   1. 1-second debounce — collapse bursts.
//   2. On 5xx response, set a cooldown 30s in the future; back off
//      exponentially up to 5 minutes after consecutive failures.
//   3. Reset failure count on first successful replay.
let _replayDebounceTimer = null;
let _replayCooldownUntil = 0;
let _replayFailureCount = 0;

function scheduleReplay() {
  if (_replayDebounceTimer) return;
  if (Date.now() < _replayCooldownUntil) return;
  _replayDebounceTimer = setTimeout(() => {
    _replayDebounceTimer = null;
    replayAll().catch(() => {});
  }, 1000);
}

async function replayAll() {
  try {
    await replayPendingScans();
    await replayPendingContams();
    // First successful pass after a streak of failures clears the backoff.
    _replayFailureCount = 0;
    _replayCooldownUntil = 0;
  } catch (e) {
    _replayFailureCount++;
    const cooldownSec = Math.min(30 * Math.pow(2, _replayFailureCount - 1), 300);
    _replayCooldownUntil = Date.now() + cooldownSec * 1000;
    throw e;
  }
}

function notifyClients() {
  self.clients.matchAll().then((clients) => {
    Promise.all([getPendingScans().catch(() => []), getPendingContams().catch(() => [])])
      .then(([scans, contams]) => {
        const msg = {
          type: 'offline-queue-update',
          // Existing clients read pendingCount as the total — keep that contract.
          pendingCount: scans.length + contams.length,
          pendingScans: scans.length,
          pendingContams: contams.length
        };
        clients.forEach((c) => c.postMessage(msg));
      })
      .catch(() => {});
  });
}

// ── Service Worker lifecycle ────────────────────────────────
self.addEventListener('install', (e) => {
  e.waitUntil(
    caches.open(CACHE).then((cache) =>
      // Precache each asset independently rather than via the atomic
      // cache.addAll(): one missing asset (e.g. a 404 icon) shouldn't drop the
      // whole set. Critically, skip any response that came via a redirect.
      // The app shell (/, /app.js, /styles.css, /lang/de.js) sits behind the
      // session auth gate; if install runs while the session is invalid those
      // requests 302 to /login.html, which fetch() transparently follows to a
      // 200. Caching that login HTML under an asset key poisons the cache —
      // the browser then parses the login page as CSS (unstyled UI) and as a
      // script (i18n dictionary never loads → raw keys like dash.zoneTent1).
      // res.redirected is true for exactly that followed-302 case, so it's the
      // precise guard. A poisoned entry only clears on a CACHE version bump.
      Promise.all(
        ASSETS.map((url) =>
          fetch(url, { credentials: 'same-origin' })
            .then((res) => {
              if (res.ok && !res.redirected) return cache.put(url, res);
            })
            .catch(() => {})
        )
      )
    )
  );
  self.skipWaiting();
});

self.addEventListener('activate', (e) => {
  // Evict all caches that don't match the current version
  e.waitUntil(caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))));
  self.clients.claim();
});

self.addEventListener('message', (e) => {
  if (e.data && e.data.type === 'replay-pending') {
    scheduleReplay();
  }
  if (e.data && e.data.type === 'get-pending-count') {
    notifyClients();
  }
  // Undo of a scan that is still sitting in the offline queue. The page cannot
  // DELETE it server-side — the server has never seen it — so the only way to
  // honour the undo is to drop it here before replay. Without this the entry
  // replayed after reconnect and the "undone" scan came back on the next poll,
  // silently removing the bag again.
  if (e.data && e.data.type === 'drop-queued-scan' && e.data.clientUuid) {
    e.waitUntil(dropQueuedScan(e.data.clientUuid));
  }
});

// Remove any queued scan-log entry carrying this client_uuid. Entries are stored
// as the whole POST body ({entries: [...]}), so an entry may be one of several in
// a batch — drop just that one, and the record only when it empties.
async function dropQueuedScan(clientUuid) {
  try {
    const pending = await getPendingScans();
    for (const item of pending) {
      const list = (item.body && item.body.entries) || [];
      const keep = list.filter((x) => x && x.client_uuid !== clientUuid);
      if (keep.length === list.length) continue;
      if (keep.length === 0) await deletePendingScan(item.id);
      else await _idbPut(STORE_SCANS, Object.assign({}, item, { body: { entries: keep } }));
    }
    notifyClients();
  } catch (err) {
    /* best-effort: a failure here leaves the entry queued, which is the old behaviour */
  }
}

// A request that failed while the browser believed it was online, said once.
//
// Once, because a dead API means every poll, every SSE retry and every save
// takes this path — a line per request buries the one line that explains it.
// And said at all, because the alternative is what happened: the only trace of
// a TLS failure was a 503 the worker invented, indistinguishable in the network
// panel from a server that was genuinely down.
// What a failed API request should say it was. Pure, so it can be tested
// without a network, a certificate or a browser.
//
// `online` is navigator.onLine, which is only trustworthy in the negative:
// false means there is genuinely no connection; true means the browser has an
// interface up and nothing more. So false is the only thing that earns the word
// "offline", and everything else is "unreachable" — carrying the real reason,
// because that is the sentence that was missing.
function _apiFailureBody(err, online, url) {
  const reason = (err && (err.message || err.name)) || 'fetch failed';
  return {
    error: online ? 'unreachable' : 'offline',
    reason: online ? reason : 'no network connection',
    url: url
  };
}

const _unreachableSeen = new Set();
function _warnUnreachable(url, reason) {
  let origin;
  try {
    origin = new URL(url).origin;
  } catch {
    origin = url;
  }
  if (_unreachableSeen.has(origin)) return;
  _unreachableSeen.add(origin);
  console.error(
    '[meistertracker sw] cannot reach ' +
      origin +
      ' — ' +
      reason +
      '. The browser reports it is online, so this is not an offline device: a certificate that does not ' +
      'cover this hostname, a stopped server, or a blocked port. A page can carry a click-through ' +
      'certificate exception; this worker cannot, so the app will look disconnected until it is fixed.'
  );
}

// Welchen Stand der App die Seite gerade bekommen hat. Der Server hängt ihn als
// X-App-Build an app.js; hier wird er der Seite gemeldet, die das app.js
// angefordert hat — auch und gerade, wenn es aus dem Zwischenspeicher kam, weil
// das Netz zu langsam war. Die Seite vergleicht ihn mit dem Stand, den der
// Server nennt, und weiß so, ob sie alten Code ausführt; ohne diese Meldung
// hielte sie den Stand des Servers für ihren eigenen. Gibt die Antwort
// unverändert weiter, auch wenn sie leer ist.
function meldeAppStand(e, res) {
  try {
    const build = res && res.headers && res.headers.get('X-App-Build');
    if (build && e.clientId && new URL(e.request.url).pathname === '/app.js') {
      self.clients
        .get(e.clientId)
        .then((c) => {
          if (c) c.postMessage({ type: 'app-build', build: build });
        })
        .catch(() => {});
    }
  } catch {
    /* a report that cannot be made must not break the response */
  }
  return res;
}

// ── Fetch handler ───────────────────────────────────────────
self.addEventListener('fetch', (e) => {
  // API calls
  if (e.request.url.includes('/api/')) {
    // Special: queue scan-log POSTs when offline
    if (e.request.method === 'POST' && e.request.url.includes('/api/scan-log')) {
      e.respondWith(
        fetch(e.request.clone()).catch(async () => {
          const body = await e.request.json();
          await queuePendingScan(body);
          return new Response(JSON.stringify({ ok: true, queued: true }), {
            headers: { 'Content-Type': 'application/json' }
          });
        })
      );
      return;
    }
    // Special: queue contamination-report POSTs when offline. The body carries
    // the report JSON + base64-encoded photos; cap the queue at 20 items so
    // a sustained outage doesn't blow IndexedDB quota (each report is up to
    // ~1.2 MB in the worst case of 4 photos, so 20 ≈ 24 MB).
    if (e.request.method === 'POST' && /\/api\/contamination-reports(\?|$)/.test(e.request.url)) {
      e.respondWith(
        fetch(e.request.clone()).catch(async () => {
          try {
            const body = await e.request.json();
            const existing = await getPendingContams();
            if (existing.length >= 20) {
              return new Response(JSON.stringify({ error: 'queue_full' }), {
                status: 507,
                headers: { 'Content-Type': 'application/json' }
              });
            }
            await queuePendingContam(body);
            return new Response(JSON.stringify({ queued: true, photoIds: [] }), {
              headers: { 'Content-Type': 'application/json' }
            });
          } catch {
            return new Response(JSON.stringify({ error: 'offline_queue_failed' }), {
              status: 503,
              headers: { 'Content-Type': 'application/json' }
            });
          }
        })
      );
      return;
    }
    // All other API calls: network only. A failure here is reported as what it
    // actually was, which used to be a single word: "offline".
    //
    // fetch() rejects for reasons that have nothing to do with the network. The
    // one that cost a day: a Let's Encrypt certificate covers the public
    // hostname and cannot cover localhost, so the moment the server restarted
    // onto a renewed cert, every request to https://localhost:PORT failed the
    // hostname check. A page can carry a click-through exception; a service
    // worker cannot. So the worker's fetch died on TLS, this line called it
    // "offline", and an app whose server was healthy the whole time reported
    // itself disconnected — with the real reason visible nowhere.
    //
    // navigator.onLine is the only thing that actually knows about the network,
    // and it is only trustworthy in the negative: false means there is no
    // connection, true means little. That asymmetry is the whole distinction —
    // say "offline" when the browser says so, and "unreachable" plus the
    // underlying message when it does not.
    e.respondWith(
      fetch(e.request).catch((err) => {
        const body = _apiFailureBody(err, self.navigator.onLine, e.request.url);
        if (body.error === 'unreachable') _warnUnreachable(e.request.url, body.reason);
        return new Response(JSON.stringify(body), {
          status: 503,
          headers: { 'Content-Type': 'application/json' }
        });
      })
    );
    return;
  }
  // Worktree/test instance: network-first for every non-API request so a code
  // change always shows up on a plain reload. Falls back to cache only when
  // truly offline (localhost rarely is). Prod keeps stale-while-revalidate
  // below for fast navigation + offline PWA use.
  if (IS_WORKTREE) {
    e.respondWith(
      fetch(e.request)
        .catch(() => caches.match(e.request))
        .then((res) => meldeAppStand(e, res))
    );
    return;
  }
  // Compute the pathname once — used by the login guard and the app-shell
  // network-first branch below. Comparing pathname (not the full URL) means a
  // query string such as /login.html?redirect=… still matches the guard.
  const path = new URL(e.request.url).pathname;
  // Login page: network only — never serve stale login form from cache
  if (path === '/login.html' || e.request.url.endsWith('/login.js')) {
    e.respondWith(fetch(e.request));
    return;
  }
  // App shell — navigations (/, /index.html), /app.js AND /lang/*.js:
  // network-first with a short timeout so a fresh deploy replaces stale app code
  // on the next reload instead of serving the previous build from cache. On
  // success, cache the clone and return it; on timeout/offline, fall back to the
  // cached copy. Mirrors the IS_WORKTREE network-first branch above, but keeps
  // the cache write so offline navigation still works.
  //
  // The lang files MUST travel with app.js: t() falls back to printing the raw
  // key, so a fresh app.js paired with a stale cached locale renders new strings
  // as "zoneCheck.btn" instead of the translation.
  //
  // styles.css travels with them for the same reason, one layer over: its URL
  // carries no version either, it changes on every deploy that touches a rule,
  // and app.js writes markup that expects the rules of its own build. Left on
  // stale-while-revalidate it arrived one reload late — new markup laid out by
  // the previous stylesheet, which reads as a broken page rather than an old
  // one. Vendor libs and icons keep stale-while-revalidate below: /lib/ is
  // pinned and genuinely immutable, icons do not pair with anything.
  if (
    path === '/' ||
    path === '/index.html' ||
    path === '/app.js' ||
    path === '/styles.css' ||
    path.startsWith('/lang/')
  ) {
    e.respondWith(
      fetch(e.request, { signal: AbortSignal.timeout(3000) })
        .then((res) => {
          // !res.redirected: never cache a followed auth redirect (a lapsed
          // session 302s to /login.html); returning it is fine, but caching
          // it under the shell key would poison the cache until the next bump.
          if (res && res.ok && !res.redirected) {
            const clone = res.clone();
            caches.open(CACHE).then((c) => c.put(e.request, clone));
          }
          scheduleReplay();
          return res;
        })
        .catch(() => caches.match(e.request))
        .then((res) => meldeAppStand(e, res))
    );
    return;
  }
  // Everything else — stale-while-revalidate. Serve cache instantly (so
  // navigation never waits for the network on flaky lab WiFi), then refresh
  // the cache in the background. The CACHE version bump on activate purges
  // stale caches across deploys, so users still see new builds within one
  // page reload after deploy.
  e.respondWith(
    caches.match(e.request).then((cached) => {
      const networked = fetch(e.request)
        .then((res) => {
          // !res.redirected: never overwrite a real asset with a followed
          // auth redirect. When the session lapses, an asset fetch 302s to
          // /login.html and fetch() follows it to a 200 — caching that under
          // the asset key would serve login HTML as CSS/JS (unstyled UI +
          // untranslated i18n keys) until the next CACHE bump.
          if (res && res.ok && !res.redirected) {
            const clone = res.clone();
            caches.open(CACHE).then((c) => c.put(e.request, clone));
          }
          // Opportunistically replay queued scans + contam reports when network
          // comes back. Cheap when the queues are empty. R-22: scheduleReplay
          // debounces bursts (asset fetches during a page load can fire 50+
          // times in quick succession) and applies exponential backoff on
          // server errors so we don't flog a struggling server.
          scheduleReplay();
          return res;
        })
        .catch(() => null);
      // If we have a cached copy, serve it immediately and let the network
      // refresh happen in the background. Otherwise wait for the network.
      return cached || networked;
    })
  );
});