|
| 1 | +// These placeholders are replaced in `vite.config.ts` at build time |
| 2 | +const PRECACHE_MANIFEST = self.__PRECACHE_MANIFEST; |
| 3 | +const DEFERRED_CACHE_MANIFEST = self.__DEFERRED_CACHE_MANIFEST; |
| 4 | +const SERVICE_WORKER_CONTENT_HASH = self.__SERVICE_WORKER_CONTENT_HASH; |
| 5 | + |
| 6 | +const STATIC_CACHE_NAME = `static-${SERVICE_WORKER_CONTENT_HASH}`; |
| 7 | +const RUNTIME_ASSETS = "runtime-assets"; |
| 8 | +const RUNTIME_FONTS = "runtime-fonts"; |
| 9 | + |
| 10 | +const FONT_LIST_API = "https://api.graphite.art/font-list"; |
| 11 | + |
| 12 | +// Build a set of precache URLs for quick lookup during fetch |
| 13 | +const PRECACHE_URLS = new Set(PRECACHE_MANIFEST.map((entry) => new URL(entry.url, self.location.origin).href)); |
| 14 | + |
| 15 | +// Track deferred manifest URLs and revisions for cache invalidation |
| 16 | +const DEFERRED_ENTRIES = new Map(DEFERRED_CACHE_MANIFEST.map((entry) => [new URL(entry.url, self.location.origin).href, entry.revision])); |
| 17 | + |
| 18 | +// ================== |
| 19 | +// Caching strategies |
| 20 | +// ================== |
| 21 | + |
| 22 | +function isCacheable(response) { |
| 23 | + // Cache normal successful responses and opaque responses (cross-origin no-cors, e.g. <link> stylesheets) |
| 24 | + return response.ok || response.type === "opaque"; |
| 25 | +} |
| 26 | + |
| 27 | +async function cacheFirst(request, cacheName) { |
| 28 | + const cache = await caches.open(cacheName); |
| 29 | + const cached = await cache.match(request); |
| 30 | + if (cached) return cached; |
| 31 | + |
| 32 | + const response = await fetch(request); |
| 33 | + if (isCacheable(response)) cache.put(request, response.clone()); |
| 34 | + return response; |
| 35 | +} |
| 36 | + |
| 37 | +async function networkFirst(request, cacheName) { |
| 38 | + const cache = await caches.open(cacheName); |
| 39 | + try { |
| 40 | + const response = await fetch(request); |
| 41 | + if (isCacheable(response)) cache.put(request, response.clone()); |
| 42 | + return response; |
| 43 | + } catch { |
| 44 | + const cached = await cache.match(request); |
| 45 | + if (cached) return cached; |
| 46 | + throw new Error(`Network request failed and no cache available for ${request.url}`); |
| 47 | + } |
| 48 | +} |
| 49 | + |
| 50 | +// ================ |
| 51 | +// Lifecycle events |
| 52 | +// ================ |
| 53 | + |
| 54 | +self.addEventListener("install", (event) => { |
| 55 | + event.waitUntil( |
| 56 | + (async () => { |
| 57 | + // Precache app shell assets |
| 58 | + const cache = await caches.open(STATIC_CACHE_NAME); |
| 59 | + await Promise.all( |
| 60 | + PRECACHE_MANIFEST.map(async (entry) => { |
| 61 | + const response = await fetch(entry.url); |
| 62 | + if (!response.ok) throw new Error(`Precache fetch failed for ${entry.url}: ${response.status}`); |
| 63 | + // Strip the `redirected` flag which causes errors when served via respondWith |
| 64 | + const cleaned = response.redirected |
| 65 | + ? new Response(response.body, { |
| 66 | + status: response.status, |
| 67 | + statusText: response.statusText, |
| 68 | + headers: response.headers, |
| 69 | + }) |
| 70 | + : response; |
| 71 | + await cache.put(entry.url, cleaned); |
| 72 | + }), |
| 73 | + ); |
| 74 | + |
| 75 | + // Proactively cache the font catalog API |
| 76 | + try { |
| 77 | + const fontResponse = await fetch(FONT_LIST_API); |
| 78 | + if (fontResponse.ok) { |
| 79 | + const fontCache = await caches.open(RUNTIME_FONTS); |
| 80 | + await fontCache.put(FONT_LIST_API, fontResponse); |
| 81 | + } |
| 82 | + } catch { |
| 83 | + // Font catalog prefetch is best-effort, don't block installation |
| 84 | + } |
| 85 | + |
| 86 | + await self.skipWaiting(); |
| 87 | + })(), |
| 88 | + ); |
| 89 | +}); |
| 90 | + |
| 91 | +self.addEventListener("activate", (event) => { |
| 92 | + event.waitUntil( |
| 93 | + (async () => { |
| 94 | + const cacheNames = await caches.keys(); |
| 95 | + |
| 96 | + // Delete old precache versions |
| 97 | + const deletions = cacheNames.filter((name) => name.startsWith("static-") && name !== STATIC_CACHE_NAME).map((name) => caches.delete(name)); |
| 98 | + await Promise.all(deletions); |
| 99 | + |
| 100 | + // Prune stale deferred (demo artwork) entries |
| 101 | + const assetsCache = await caches.open(RUNTIME_ASSETS); |
| 102 | + const assetsKeys = await assetsCache.keys(); |
| 103 | + await Promise.all(assetsKeys.filter((request) => !DEFERRED_ENTRIES.has(request.url)).map((request) => assetsCache.delete(request))); |
| 104 | + |
| 105 | + await self.clients.claim(); |
| 106 | + })(), |
| 107 | + ); |
| 108 | +}); |
| 109 | + |
| 110 | +// ============= |
| 111 | +// Fetch routing |
| 112 | +// ============= |
| 113 | + |
| 114 | +self.addEventListener("fetch", (event) => { |
| 115 | + const { request } = event; |
| 116 | + const url = new URL(request.url); |
| 117 | + |
| 118 | + // Pass through range requests (e.g. for large file streaming) and non-GET requests |
| 119 | + if (request.headers.has("range") || request.method !== "GET") return; |
| 120 | + |
| 121 | + // Pre-cached assets (JS and Wasm bundle files, favicons, index.html) |
| 122 | + if (PRECACHE_URLS.has(url.href)) { |
| 123 | + event.respondWith(cacheFirst(request, STATIC_CACHE_NAME)); |
| 124 | + return; |
| 125 | + } |
| 126 | + |
| 127 | + // Deferred-cached assets (demo artwork, third-party licenses, etc.) |
| 128 | + if (DEFERRED_ENTRIES.has(url.href)) { |
| 129 | + event.respondWith(cacheFirst(request, RUNTIME_ASSETS)); |
| 130 | + return; |
| 131 | + } |
| 132 | + |
| 133 | + // Font catalog API: network-first to keep it fresh |
| 134 | + if (url.href.startsWith(FONT_LIST_API)) { |
| 135 | + event.respondWith(networkFirst(request, RUNTIME_FONTS)); |
| 136 | + return; |
| 137 | + } |
| 138 | + |
| 139 | + // Google Fonts CSS (font preview stylesheets): cache-first since responses are stable for a given query |
| 140 | + if (url.hostname === "fonts.googleapis.com") { |
| 141 | + event.respondWith(cacheFirst(request, RUNTIME_FONTS)); |
| 142 | + return; |
| 143 | + } |
| 144 | + |
| 145 | + // Google Fonts static files: cache-first since they are immutable CDN URLs |
| 146 | + if (url.hostname === "fonts.gstatic.com") { |
| 147 | + event.respondWith(cacheFirst(request, RUNTIME_FONTS)); |
| 148 | + return; |
| 149 | + } |
| 150 | + |
| 151 | + // Navigation requests: serve cached index.html for all routes (SPA pattern) |
| 152 | + if (request.mode === "navigate") { |
| 153 | + event.respondWith( |
| 154 | + (async () => { |
| 155 | + const cache = await caches.open(STATIC_CACHE_NAME); |
| 156 | + const cached = await cache.match("/index.html"); |
| 157 | + return cached || fetch(request); |
| 158 | + })(), |
| 159 | + ); |
| 160 | + return; |
| 161 | + } |
| 162 | + |
| 163 | + // Everything else: network-only (no respondWith, let the browser handle it) |
| 164 | +}); |
| 165 | + |
| 166 | +// ============================ |
| 167 | +// Deferred caching via message |
| 168 | +// ============================ |
| 169 | + |
| 170 | +self.addEventListener("message", (event) => { |
| 171 | + if (event.data?.type !== "CACHE_DEFERRED") return; |
| 172 | + |
| 173 | + event.waitUntil( |
| 174 | + (async () => { |
| 175 | + const cache = await caches.open(RUNTIME_ASSETS); |
| 176 | + const fetchPromises = DEFERRED_CACHE_MANIFEST.map(async (entry) => { |
| 177 | + const fullUrl = new URL(entry.url, self.location.origin).href; |
| 178 | + |
| 179 | + // Skip if already cached with the same revision |
| 180 | + const existing = await cache.match(fullUrl); |
| 181 | + if (existing?.headers.get("x-sw-revision") === entry.revision) return; |
| 182 | + |
| 183 | + try { |
| 184 | + const response = await fetch(fullUrl); |
| 185 | + if (response.ok) { |
| 186 | + // Store the service worker revision in a custom header so we can check it on future installs |
| 187 | + const headers = new Headers(response.headers); |
| 188 | + headers.set("x-sw-revision", entry.revision); |
| 189 | + const taggedResponse = new Response(response.body, { status: response.status, statusText: response.statusText, headers }); |
| 190 | + await cache.put(fullUrl, taggedResponse); |
| 191 | + } |
| 192 | + } catch { |
| 193 | + // Best-effort: skip files that fail to fetch |
| 194 | + } |
| 195 | + }); |
| 196 | + await Promise.all(fetchPromises); |
| 197 | + })(), |
| 198 | + ); |
| 199 | +}); |
0 commit comments