101 lines
3.0 KiB
JavaScript
101 lines
3.0 KiB
JavaScript
/* eslint-disable no-restricted-globals */
|
|
/**
|
|
* Minimal service worker for Toolctrl Boilerplate.
|
|
*
|
|
* Strategy:
|
|
* - Static, hashed asset requests under /assets/ → cache-first (long-term
|
|
* immutable cache; the React-Router build emits hashed filenames so we
|
|
* can keep stale entries until the next deploy bumps the version).
|
|
* - /api/ and /app/ and /admin/ requests → network-first; fall through to
|
|
* cache only when offline so SSR routes stay fresh.
|
|
* - Everything else → network-first with cache fallback.
|
|
*
|
|
* Spec: docs/improvement-plan/10-compliance-polish/lighthouse.md
|
|
*
|
|
* SECURITY: This file is loaded on /sw.js scope = `/`. It must NEVER call
|
|
* dynamic import() with untrusted input nor cache POST/PUT/PATCH/DELETE
|
|
* responses. Bumping CACHE_VERSION invalidates all old caches on the next
|
|
* activate event, which is the rollback path documented in the spec.
|
|
*/
|
|
|
|
const CACHE_VERSION = "v1-2026-05-17";
|
|
const ASSET_CACHE = `toolctrl-assets-${CACHE_VERSION}`;
|
|
const RUNTIME_CACHE = `toolctrl-runtime-${CACHE_VERSION}`;
|
|
|
|
self.addEventListener("install", (event) => {
|
|
// Activate immediately so cached responses from old versions are evicted.
|
|
event.waitUntil(self.skipWaiting());
|
|
});
|
|
|
|
self.addEventListener("activate", (event) => {
|
|
event.waitUntil(
|
|
(async () => {
|
|
const keys = await caches.keys();
|
|
await Promise.all(
|
|
keys
|
|
.filter((key) => key.startsWith("toolctrl-") && key !== ASSET_CACHE && key !== RUNTIME_CACHE)
|
|
.map((key) => caches.delete(key))
|
|
);
|
|
await self.clients.claim();
|
|
})()
|
|
);
|
|
});
|
|
|
|
self.addEventListener("fetch", (event) => {
|
|
const request = event.request;
|
|
|
|
// Never intercept non-GET — auth/mutation traffic must hit the network.
|
|
if (request.method !== "GET") {
|
|
return;
|
|
}
|
|
|
|
const url = new URL(request.url);
|
|
// Same-origin only. Third-party assets (Sentry, Crisp, recaptcha) stay on the network.
|
|
if (url.origin !== self.location.origin) {
|
|
return;
|
|
}
|
|
|
|
if (url.pathname.startsWith("/assets/")) {
|
|
event.respondWith(cacheFirst(request, ASSET_CACHE));
|
|
return;
|
|
}
|
|
|
|
if (
|
|
url.pathname.startsWith("/api/") ||
|
|
url.pathname.startsWith("/app/") ||
|
|
url.pathname.startsWith("/admin/")
|
|
) {
|
|
event.respondWith(networkFirst(request, RUNTIME_CACHE));
|
|
return;
|
|
}
|
|
|
|
event.respondWith(networkFirst(request, RUNTIME_CACHE));
|
|
});
|
|
|
|
async function cacheFirst(request, cacheName) {
|
|
const cache = await caches.open(cacheName);
|
|
const cached = await cache.match(request);
|
|
if (cached) return cached;
|
|
|
|
const response = await fetch(request);
|
|
if (response.ok) {
|
|
cache.put(request, response.clone());
|
|
}
|
|
return response;
|
|
}
|
|
|
|
async function networkFirst(request, cacheName) {
|
|
const cache = await caches.open(cacheName);
|
|
try {
|
|
const response = await fetch(request);
|
|
if (response.ok) {
|
|
cache.put(request, response.clone());
|
|
}
|
|
return response;
|
|
} catch (err) {
|
|
const cached = await cache.match(request);
|
|
if (cached) return cached;
|
|
throw err;
|
|
}
|
|
}
|