200 lines
6.4 KiB
JavaScript
200 lines
6.4 KiB
JavaScript
// These placeholders are replaced in `vite.config.ts` at build time
|
|
const PRECACHE_MANIFEST = self.__PRECACHE_MANIFEST;
|
|
const DEFERRED_CACHE_MANIFEST = self.__DEFERRED_CACHE_MANIFEST;
|
|
const SERVICE_WORKER_CONTENT_HASH = self.__SERVICE_WORKER_CONTENT_HASH;
|
|
|
|
const STATIC_CACHE_NAME = `static-${SERVICE_WORKER_CONTENT_HASH}`;
|
|
const RUNTIME_ASSETS = "runtime-assets";
|
|
const RUNTIME_FONTS = "runtime-fonts";
|
|
|
|
const FONT_LIST_API = "https://api.graphite.art/font-list";
|
|
|
|
// Build a set of precache URLs for quick lookup during fetch
|
|
const PRECACHE_URLS = new Set(PRECACHE_MANIFEST.map((entry) => new URL(entry.url, self.location.origin).href));
|
|
|
|
// Track deferred manifest URLs and revisions for cache invalidation
|
|
const DEFERRED_ENTRIES = new Map(DEFERRED_CACHE_MANIFEST.map((entry) => [new URL(entry.url, self.location.origin).href, entry.revision]));
|
|
|
|
// ==================
|
|
// Caching strategies
|
|
// ==================
|
|
|
|
function isCacheable(response) {
|
|
// Cache normal successful responses and opaque responses (cross-origin no-cors, e.g. <link> stylesheets)
|
|
return response.ok || response.type === "opaque";
|
|
}
|
|
|
|
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 (isCacheable(response)) 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 (isCacheable(response)) cache.put(request, response.clone());
|
|
return response;
|
|
} catch {
|
|
const cached = await cache.match(request);
|
|
if (cached) return cached;
|
|
throw new Error(`Network request failed and no cache available for ${request.url}`);
|
|
}
|
|
}
|
|
|
|
// ================
|
|
// Lifecycle events
|
|
// ================
|
|
|
|
self.addEventListener("install", (event) => {
|
|
event.waitUntil(
|
|
(async () => {
|
|
// Precache app shell assets
|
|
const cache = await caches.open(STATIC_CACHE_NAME);
|
|
await Promise.all(
|
|
PRECACHE_MANIFEST.map(async (entry) => {
|
|
const response = await fetch(entry.url);
|
|
if (!response.ok) throw new Error(`Precache fetch failed for ${entry.url}: ${response.status}`);
|
|
// Strip the `redirected` flag which causes errors when served via respondWith
|
|
const cleaned = response.redirected
|
|
? new Response(response.body, {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
headers: response.headers,
|
|
})
|
|
: response;
|
|
await cache.put(entry.url, cleaned);
|
|
}),
|
|
);
|
|
|
|
// Proactively cache the font catalog API
|
|
try {
|
|
const fontResponse = await fetch(FONT_LIST_API);
|
|
if (fontResponse.ok) {
|
|
const fontCache = await caches.open(RUNTIME_FONTS);
|
|
await fontCache.put(FONT_LIST_API, fontResponse);
|
|
}
|
|
} catch {
|
|
// Font catalog prefetch is best-effort, don't block installation
|
|
}
|
|
|
|
await self.skipWaiting();
|
|
})(),
|
|
);
|
|
});
|
|
|
|
self.addEventListener("activate", (event) => {
|
|
event.waitUntil(
|
|
(async () => {
|
|
const cacheNames = await caches.keys();
|
|
|
|
// Delete old precache versions
|
|
const deletions = cacheNames.filter((name) => name.startsWith("static-") && name !== STATIC_CACHE_NAME).map((name) => caches.delete(name));
|
|
await Promise.all(deletions);
|
|
|
|
// Prune stale deferred (demo artwork) entries
|
|
const assetsCache = await caches.open(RUNTIME_ASSETS);
|
|
const assetsKeys = await assetsCache.keys();
|
|
await Promise.all(assetsKeys.filter((request) => !DEFERRED_ENTRIES.has(request.url)).map((request) => assetsCache.delete(request)));
|
|
|
|
await self.clients.claim();
|
|
})(),
|
|
);
|
|
});
|
|
|
|
// =============
|
|
// Fetch routing
|
|
// =============
|
|
|
|
self.addEventListener("fetch", (event) => {
|
|
const { request } = event;
|
|
const url = new URL(request.url);
|
|
|
|
// Pass through range requests (e.g. for large file streaming) and non-GET requests
|
|
if (request.headers.has("range") || request.method !== "GET") return;
|
|
|
|
// Pre-cached assets (JS and Wasm bundle files, favicons, index.html)
|
|
if (PRECACHE_URLS.has(url.href)) {
|
|
event.respondWith(cacheFirst(request, STATIC_CACHE_NAME));
|
|
return;
|
|
}
|
|
|
|
// Deferred-cached assets (demo artwork, third-party licenses, etc.)
|
|
if (DEFERRED_ENTRIES.has(url.href)) {
|
|
event.respondWith(cacheFirst(request, RUNTIME_ASSETS));
|
|
return;
|
|
}
|
|
|
|
// Font catalog API: network-first to keep it fresh
|
|
if (url.href.startsWith(FONT_LIST_API)) {
|
|
event.respondWith(networkFirst(request, RUNTIME_FONTS));
|
|
return;
|
|
}
|
|
|
|
// Google Fonts CSS (font preview stylesheets): cache-first since responses are stable for a given query
|
|
if (url.hostname === "fonts.googleapis.com") {
|
|
event.respondWith(cacheFirst(request, RUNTIME_FONTS));
|
|
return;
|
|
}
|
|
|
|
// Google Fonts static files: cache-first since they are immutable CDN URLs
|
|
if (url.hostname === "fonts.gstatic.com") {
|
|
event.respondWith(cacheFirst(request, RUNTIME_FONTS));
|
|
return;
|
|
}
|
|
|
|
// Navigation requests: serve cached index.html for all routes (SPA pattern)
|
|
if (request.mode === "navigate") {
|
|
event.respondWith(
|
|
(async () => {
|
|
const cache = await caches.open(STATIC_CACHE_NAME);
|
|
const cached = await cache.match("/index.html");
|
|
return cached || fetch(request);
|
|
})(),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Everything else: network-only (no respondWith, let the browser handle it)
|
|
});
|
|
|
|
// ============================
|
|
// Deferred caching via message
|
|
// ============================
|
|
|
|
self.addEventListener("message", (event) => {
|
|
if (event.data?.type !== "CACHE_DEFERRED") return;
|
|
|
|
event.waitUntil(
|
|
(async () => {
|
|
const cache = await caches.open(RUNTIME_ASSETS);
|
|
const fetchPromises = DEFERRED_CACHE_MANIFEST.map(async (entry) => {
|
|
const fullUrl = new URL(entry.url, self.location.origin).href;
|
|
|
|
// Skip if already cached with the same revision
|
|
const existing = await cache.match(fullUrl);
|
|
if (existing?.headers.get("x-sw-revision") === entry.revision) return;
|
|
|
|
try {
|
|
const response = await fetch(fullUrl);
|
|
if (response.ok) {
|
|
// Store the service worker revision in a custom header so we can check it on future installs
|
|
const headers = new Headers(response.headers);
|
|
headers.set("x-sw-revision", entry.revision);
|
|
const taggedResponse = new Response(response.body, { status: response.status, statusText: response.statusText, headers });
|
|
await cache.put(fullUrl, taggedResponse);
|
|
}
|
|
} catch {
|
|
// Best-effort: skip files that fail to fetch
|
|
}
|
|
});
|
|
await Promise.all(fetchPromises);
|
|
})(),
|
|
);
|
|
});
|