This commit is contained in:
BoulderBadgeDad 2026-06-11 09:22:52 -07:00
commit 8bf03a7d5e

View file

@ -108,28 +108,82 @@ self.addEventListener('fetch', (event) => {
}); });
// ── image fetch throttle ───────────────────────────────────────────────
// A discography page fires 70+ cover-art requests at once. Routed through the
// SW one-for-one, that burst overruns the browser's per-host connection pool
// (~6); the overflow fetches reject, and a cache-first strategy that maps a
// rejection to Response.error() turns each into a hard NS_ERROR_INTERCEPTION_
// FAILED — a permanently broken, *uncached* image for that load. (It only
// "fixes itself" on reload because the images that did win the race are then
// served from cache, shrinking the burst.) We cap how many image fetches the
// SW runs at once so the rest queue instead of failing.
const MAX_CONCURRENT_IMAGE_FETCHES = 6;
let _activeImageFetches = 0;
const _imageFetchQueue = [];
function _acquireImageSlot() {
if (_activeImageFetches < MAX_CONCURRENT_IMAGE_FETCHES) {
_activeImageFetches++;
return Promise.resolve();
}
return new Promise((resolve) => _imageFetchQueue.push(resolve));
}
function _releaseImageSlot() {
const next = _imageFetchQueue.shift();
// Hand the slot straight to the next waiter (count stays put); only
// decrement when nobody is queued.
if (next) next();
else _activeImageFetches--;
}
async function _fetchWithRetry(request, retries = 1, backoffMs = 200) {
try {
return await fetch(request);
} catch (err) {
if (retries <= 0) throw err;
// Most failures here are transient connection-cap rejections that
// clear as the burst drains — one short retry recovers the bulk.
await new Promise((resolve) => setTimeout(resolve, backoffMs));
return _fetchWithRetry(request, retries - 1, backoffMs);
}
}
// ── strategies ─────────────────────────────────────────────────────── // ── strategies ───────────────────────────────────────────────────────
async function _cacheFirst(request, cacheName) { async function _cacheFirst(request, cacheName) {
// Cache lookup first — a hit must never consume a fetch slot. If the cache
// layer itself is unavailable, fall through to a throttled network fetch.
let cache = null;
try { try {
const cache = await caches.open(cacheName); cache = await caches.open(cacheName);
const hit = await cache.match(request); const hit = await cache.match(request);
if (hit) return hit; if (hit) return hit;
} catch (err) {
cache = null;
}
const response = await fetch(request); await _acquireImageSlot();
try {
const response = await _fetchWithRetry(request);
// Only cache successful, opaque-OK responses. Don't cache 404s // Only cache successful, opaque-OK responses. Don't cache 404s
// / 500s — would pin a bad placeholder for the lifetime of the // / 500s — would pin a bad placeholder for the lifetime of the
// cache version. // cache version.
if (response && (response.ok || response.type === 'opaque')) { if (cache && response && (response.ok || response.type === 'opaque')) {
// Clone before .put — body is consumed otherwise. // Clone before .put — body is consumed otherwise.
cache.put(request, response.clone()).catch(() => { /* quota / disk full */ }); cache.put(request, response.clone()).catch(() => { /* quota / disk full */ });
} }
return response; return response;
} catch (err) { } catch (err) {
// Network failure with no cache hit — let the browser surface // Both attempts failed (offline / connection cap / CDN error). Return a
// its standard offline / error UI (returning Response.error() // benign 504 rather than Response.error(): a network-error response
// is equivalent to letting the fetch reject naturally). // from a SW surfaces as NS_ERROR_INTERCEPTION_FAILED in Firefox, which
return Response.error(); // hard-fails the <img>. A plain 504 just yields a broken image that
// recovers on the next navigation once the cache is warm.
return new Response('', { status: 504, statusText: 'Image fetch failed' });
} finally {
_releaseImageSlot();
} }
} }