Files
bitvid/sw.min.js
T
thePR0M3TH3ANandClaude Opus 4.8 84cec15483 [perf] Fix SW image cache slowing cold thumbnail bursts (grid load regression)
"Recently Added" grids loaded visibly slower after the image cache shipped. The
feed's data path was untouched — the regression was the thumbnails: the SW handler
had three compounding costs on a cold cache:

1. The network fetch waited behind caches.open + cache.match, delaying every
   thumbnail's download start.
2. caches.open ran per request instead of once.
3. trimImageCache ran a full keys() enumeration after EVERY put — a grid burst of
   dozens of thumbnails did dozens of O(n) enumerations, all contending with the
   cache.match calls of later images.

Fix: start the network fetch immediately (cold path is now as fast as no SW; a
warm cache still serves the cached copy for first paint), memoize the cache open,
and amortize trims (every 20 puts). SW_VERSION bumped so testers pick it up.

Tests: sw-image-cache (7 — adds a regression test that gates cache.match behind a
promise and asserts the download starts anyway; all prior behaviors re-verified
against the real sw.min.js). Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 14:59:21 -04:00

333 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
"use strict";
const SW_VERSION = "bitvid-sw-2026-07-02.1";
const SW_METADATA = Object.freeze({
version: SW_VERSION,
runtimePathPrefix: "/webtorrent/",
});
// Cross-origin image cache (thumbnails / avatars / banners). These are the
// images bitvid does NOT control headers for (external hosts, user CDNs), so
// without this they can re-download on every visit. Strategy:
// stale-while-revalidate — serve the cached copy instantly, refresh it in the
// background — so revisits are instant AND a changed image self-heals on the
// next view. Same-origin assets are left alone (they ride the host's HTTP
// cache + the dist cache-purge pipeline). Bump the version to discard.
const IMAGE_CACHE = "bitvid-images-v1";
const IMAGE_CACHE_MAX_ENTRIES = 300;
// Trim is O(entries) (a full keys() enumeration), so amortize it instead of
// running it after every put — a cold grid load puts dozens of thumbnails in a
// burst, and per-put trims made that burst contend with the cache.match calls
// of later images (the "grid loads slowly" regression).
const IMAGE_CACHE_TRIM_EVERY = 20;
let imageCachePromise = null;
let imagePutsSinceTrim = 0;
function openImageCache() {
if (!imageCachePromise) {
imageCachePromise = caches.open(IMAGE_CACHE);
imageCachePromise.catch(() => {
imageCachePromise = null;
});
}
return imageCachePromise;
}
function isCacheableImageRequest(request) {
if (!request || request.method !== "GET") {
return false;
}
if (request.destination !== "image") {
return false;
}
const url = request.url || "";
if (!/^https?:/.test(url)) {
return false;
}
// Same-origin images (app assets) keep the normal HTTP-cache path.
try {
return new URL(url).origin !== self.location.origin;
} catch (error) {
return false;
}
}
async function trimImageCache(cache) {
try {
const keys = await cache.keys();
if (keys.length <= IMAGE_CACHE_MAX_ENTRIES) {
return;
}
// Cache keys() preserves insertion order; drop the oldest overflow.
const excess = keys.length - IMAGE_CACHE_MAX_ENTRIES;
await Promise.all(keys.slice(0, excess).map((key) => cache.delete(key)));
} catch (error) {
// Trimming is best-effort.
}
}
async function putImageInCache(request, response) {
try {
const cache = await openImageCache();
await cache.put(request, response);
imagePutsSinceTrim += 1;
if (imagePutsSinceTrim >= IMAGE_CACHE_TRIM_EVERY) {
imagePutsSinceTrim = 0;
await trimImageCache(cache);
}
} catch (error) {
// Quota pressure (opaque responses are padded heavily for quota
// accounting) or storage failure: drop the whole image cache and keep
// going — the page still got its network response.
imageCachePromise = null;
try {
await caches.delete(IMAGE_CACHE);
} catch (cleanupError) {
// fail open
}
}
}
async function handleImageRequest(event) {
const request = event.request;
// Start the network fetch IMMEDIATELY — never delay a download behind cache
// I/O. On a cold cache this makes the SW path as fast as no SW at all; on a
// warm cache the cached copy below still wins the race for first paint.
const network = fetch(request).then((response) => {
// Cache good responses; opaque (cross-origin no-cors) counts as good —
// its status is unreadable, and these are exactly the images we target.
if (response && (response.ok || response.type === "opaque")) {
event.waitUntil(putImageInCache(request, response.clone()));
}
return response;
});
// Mark handled so a network failure can't surface as an unhandled rejection
// while we're still consulting the cache; the original promise stays
// observable for the no-cache path below.
network.catch(() => {});
let cached = null;
try {
const cache = await openImageCache();
cached = await cache.match(request, { ignoreVary: true });
} catch (error) {
// Cache unavailable — plain network behavior.
}
if (cached) {
// Serve the cached copy instantly; the refresh continues in the background.
event.waitUntil(network.catch(() => {}));
return cached;
}
return network;
}
let cancelled = false;
function broadcastLifecycle(eventType, details = {}) {
return clients
.matchAll({ type: "window", includeUncontrolled: true })
.then((windowClients) => {
windowClients.forEach((client) => {
client.postMessage({
type: "BITVID_SW_LIFECYCLE",
eventType,
metadata: SW_METADATA,
...details,
});
});
});
}
// Handle messages from clients
self.addEventListener("message", (event) => {
if (event.data && event.data.type === "SKIP_WAITING") {
self.skipWaiting();
}
if (event.data && event.data.type === "CLEAR_CACHES") {
caches
.keys()
.then((cacheNames) =>
Promise.all(cacheNames.map((cacheName) => caches.delete(cacheName)))
);
}
if (event.data && event.data.type === "ENSURE_CLIENTS_CLAIM") {
event.waitUntil(clients.claim());
}
});
// Immediately install and skip waiting
self.addEventListener("install", (event) => {
event.waitUntil(Promise.all([self.skipWaiting(), broadcastLifecycle("install")]));
});
// Claim clients on activation and clear caches. The CURRENT image cache is
// preserved (it is versioned; stale versions and everything else are wiped) so
// an SW update doesn't throw away every cached thumbnail/avatar.
self.addEventListener("activate", (event) => {
event.waitUntil(
Promise.all([
clients.claim(),
self.skipWaiting(),
broadcastLifecycle("activate", {
scope: self.registration?.scope || null,
}),
caches
.keys()
.then((cacheNames) =>
Promise.all(
cacheNames
.filter((cacheName) => cacheName !== IMAGE_CACHE)
.map((cacheName) => caches.delete(cacheName))
)
),
])
);
});
// Handle fetch events
self.addEventListener("fetch", (event) => {
const requestURL = event.request.url;
// Cross-origin images: serve from the image cache (stale-while-revalidate).
// Fail open — any handler error falls back to a plain network fetch so a
// caching bug can never break image loading.
if (isCacheableImageRequest(event.request)) {
event.respondWith(
handleImageRequest(event).catch(() => fetch(event.request))
);
return;
}
// Only handle WebTorrent streaming requests
if (!requestURL.includes("/webtorrent/")) {
return;
}
// Create a promise to handle the request
const responsePromise = (async () => {
// 1) Keepalive check
if (requestURL.includes("/webtorrent/keepalive/")) {
return new Response();
}
// 2) Cancel check
if (requestURL.includes("/webtorrent/cancel/")) {
return new Response(
new ReadableStream({
cancel() {
cancelled = true;
},
})
);
}
// 3) Streaming requests
// We define an async function and immediately invoke it with `event`
return (async function ({ request }) {
const { url, method, headers, destination } = request;
// 3a) Find open window clients
const windowClients = await clients.matchAll({
type: "window",
includeUncontrolled: true,
});
// 3b) We send a message to each client with a MessageChannel
const [clientResponse, port] = await new Promise((resolve) => {
for (const client of windowClients) {
const channel = new MessageChannel();
channel.port1.onmessage = ({ data }) => {
resolve([data, channel.port1]);
};
client.postMessage(
{
url,
method,
headers: Object.fromEntries(headers.entries()),
scope: self.registration.scope,
destination,
type: "webtorrent",
},
[channel.port2]
);
}
});
// 3c) Setup a small helper to close the port
let timeoutId = null;
const closeChannel = () => {
port.postMessage(false);
clearTimeout(timeoutId);
port.onmessage = null;
};
// 3d) Build a Headers object that prevents caching
const responseHeaders = new Headers(clientResponse.headers);
responseHeaders.set(
"Cache-Control",
"no-cache, no-store, must-revalidate, max-age=0"
);
responseHeaders.set("Pragma", "no-cache");
responseHeaders.set("Expires", "0");
responseHeaders.set("X-Bitvid-SW-Version", SW_VERSION);
// 3e) If the response is not a streaming request, return it directly
if (clientResponse.body !== "STREAM") {
closeChannel();
return new Response(clientResponse.body, {
status: clientResponse.status,
statusText: clientResponse.statusText,
headers: responseHeaders,
});
}
// 3f) Otherwise, create a streaming response
return new Response(
new ReadableStream({
pull(controller) {
return new Promise((resolvePull) => {
port.onmessage = ({ data }) => {
if (data) {
controller.enqueue(data);
} else {
closeChannel();
controller.close();
}
resolvePull();
};
// If not cancelled, auto‐close after 5s of no data
// *** Increase the timeout to avoid frequent CPU wake-ups ***
if (!cancelled && destination !== "document") {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
closeChannel();
resolvePull();
}, 30000); // 30 seconds
}
// Request next chunk from client
port.postMessage(true);
});
},
cancel() {
closeChannel();
},
}),
{
status: clientResponse.status,
statusText: clientResponse.statusText,
headers: responseHeaders,
}
);
})(event);
})();
// respondWith the promise if it exists
event.respondWith(responsePromise);
});
})();