183 lines
5.5 KiB
JavaScript
183 lines
5.5 KiB
JavaScript
/* Service Worker: lightweight caching for a static Astro site.
|
|
- Navigations: network-first (avoid stale HTML indefinitely)
|
|
- Site shell: pre-cache on install
|
|
- Images: cache-first with bounded eviction
|
|
*/
|
|
|
|
const cacheVersionFromUrl = (() => {
|
|
try {
|
|
const v = new URL(self.location.href).searchParams.get("v");
|
|
if (!v) return null;
|
|
if (!/^[a-zA-Z0-9._-]{1,64}$/.test(v)) return null;
|
|
return v;
|
|
} catch {
|
|
return null;
|
|
}
|
|
})();
|
|
|
|
const CACHE_VERSION = cacheVersionFromUrl ? `v-${cacheVersionFromUrl}` : "v4";
|
|
|
|
const CACHE_SHELL = `shell-${CACHE_VERSION}`;
|
|
const CACHE_PAGES = `pages-${CACHE_VERSION}`;
|
|
const CACHE_MEDIA = `media-${CACHE_VERSION}`;
|
|
|
|
const assetSuffix = cacheVersionFromUrl ? `?v=${encodeURIComponent(cacheVersionFromUrl)}` : "";
|
|
|
|
const SHELL_ASSETS = ["/", `/styles/global.css${assetSuffix}`, "/favicon.png", "/robots.txt"];
|
|
|
|
// Cache.addAll() throws if there are any duplicate requests.
|
|
const SHELL_ASSETS_UNIQUE = Array.from(new Set(SHELL_ASSETS));
|
|
|
|
// Keep media cache bounded so we don't grow indefinitely.
|
|
const MAX_MEDIA_ENTRIES = 80;
|
|
|
|
const isGet = (request) => request && request.method === "GET";
|
|
|
|
const isNavigationRequest = (request) =>
|
|
request.mode === "navigate" || request.destination === "document";
|
|
|
|
const isImageRequest = (request, url) => {
|
|
if (request.destination === "image") return true;
|
|
const p = url.pathname.toLowerCase();
|
|
return (
|
|
p.endsWith(".png") ||
|
|
p.endsWith(".jpg") ||
|
|
p.endsWith(".jpeg") ||
|
|
p.endsWith(".webp") ||
|
|
p.endsWith(".gif") ||
|
|
p.endsWith(".avif") ||
|
|
p.endsWith(".svg")
|
|
);
|
|
};
|
|
|
|
async function trimCache(cacheName, maxEntries) {
|
|
const cache = await caches.open(cacheName);
|
|
const keys = await cache.keys();
|
|
const extra = keys.length - maxEntries;
|
|
if (extra <= 0) return;
|
|
// Cache keys are returned in insertion order in practice; delete the oldest.
|
|
for (let i = 0; i < extra; i += 1) {
|
|
await cache.delete(keys[i]);
|
|
}
|
|
}
|
|
|
|
async function cachePutSafe(cacheName, request, response) {
|
|
// Only cache successful or opaque responses. Avoid caching 404/500 HTML.
|
|
if (!response) return;
|
|
if (response.type !== "opaque" && !response.ok) return;
|
|
const cache = await caches.open(cacheName);
|
|
await cache.put(request, response);
|
|
}
|
|
|
|
self.addEventListener("install", (event) => {
|
|
event.waitUntil(
|
|
(async () => {
|
|
const cache = await caches.open(CACHE_SHELL);
|
|
await cache.addAll(SHELL_ASSETS_UNIQUE);
|
|
// Activate new worker ASAP to pick up new caching rules.
|
|
await self.skipWaiting();
|
|
})(),
|
|
);
|
|
});
|
|
|
|
self.addEventListener("activate", (event) => {
|
|
event.waitUntil(
|
|
(async () => {
|
|
const keep = new Set([CACHE_SHELL, CACHE_PAGES, CACHE_MEDIA]);
|
|
const keys = await caches.keys();
|
|
await Promise.all(keys.map((k) => (keep.has(k) ? Promise.resolve() : caches.delete(k))));
|
|
await self.clients.claim();
|
|
})(),
|
|
);
|
|
});
|
|
|
|
self.addEventListener("fetch", (event) => {
|
|
const { request } = event;
|
|
if (!isGet(request)) return;
|
|
|
|
const url = new URL(request.url);
|
|
|
|
// Only handle http(s).
|
|
if (url.protocol !== "http:" && url.protocol !== "https:") return;
|
|
|
|
// Network-first for navigations (HTML documents). Cache as fallback only.
|
|
if (isNavigationRequest(request)) {
|
|
event.respondWith(
|
|
(async () => {
|
|
try {
|
|
const fresh = await fetch(request);
|
|
// Cache a clone so we can serve it when offline.
|
|
await cachePutSafe(CACHE_PAGES, request, fresh.clone());
|
|
return fresh;
|
|
} catch {
|
|
const cached = await caches.match(request);
|
|
if (cached) return cached;
|
|
// Fallback: try cached homepage shell.
|
|
const home = await caches.match("/");
|
|
if (home) return home;
|
|
throw new Error("No cached navigation fallback.");
|
|
}
|
|
})(),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Cache-first for images/media with bounded cache size.
|
|
if (isImageRequest(request, url)) {
|
|
event.respondWith(
|
|
(async () => {
|
|
const cached = await caches.match(request);
|
|
if (cached) return cached;
|
|
|
|
const res = await fetch(request);
|
|
await cachePutSafe(CACHE_MEDIA, request, res.clone());
|
|
await trimCache(CACHE_MEDIA, MAX_MEDIA_ENTRIES);
|
|
return res;
|
|
})(),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Network-first for global CSS to avoid serving stale styling after deploy.
|
|
// (The SW already caches styles, but network-first prevents a "stuck" old CSS experience.)
|
|
if (url.origin === self.location.origin && url.pathname === "/styles/global.css") {
|
|
event.respondWith(
|
|
(async () => {
|
|
try {
|
|
const fresh = await fetch(request);
|
|
await cachePutSafe(CACHE_SHELL, request, fresh.clone());
|
|
return fresh;
|
|
} catch {
|
|
const cached = await caches.match(request);
|
|
if (cached) return cached;
|
|
return fetch(request);
|
|
}
|
|
})(),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Stale-while-revalidate for styles/scripts/fonts from same-origin.
|
|
if (
|
|
url.origin === self.location.origin &&
|
|
(request.destination === "style" ||
|
|
request.destination === "script" ||
|
|
request.destination === "font")
|
|
) {
|
|
event.respondWith(
|
|
(async () => {
|
|
const cached = await caches.match(request);
|
|
const networkPromise = fetch(request)
|
|
.then(async (res) => {
|
|
await cachePutSafe(CACHE_SHELL, request, res.clone());
|
|
return res;
|
|
})
|
|
.catch(() => null);
|
|
|
|
return cached || (await networkPromise) || fetch(request);
|
|
})(),
|
|
);
|
|
}
|
|
});
|
|
|