mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
"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]>
333 lines
11 KiB
JavaScript
333 lines
11 KiB
JavaScript
(() => {
|
||
"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);
|
||
});
|
||
})();
|