|
| 1 | +// Service worker for caching Blazor WASM assets using stale-while-revalidate strategy. |
| 2 | +// Serves cached files immediately for fast startup, then revalidates in the background. |
| 3 | +// If files have changed, the cache is updated for the next load. |
| 4 | + |
| 5 | +const CACHE_NAME = 'blazor-wasm-cache-v1'; |
| 6 | + |
| 7 | +// Asset patterns to cache with stale-while-revalidate |
| 8 | +const CACHE_PATTERNS = [ |
| 9 | + /\/_framework\/.*\.wasm$/, |
| 10 | + /\/_framework\/.*\.dat$/, |
| 11 | + /\/_framework\/.*\.js$/, |
| 12 | +]; |
| 13 | + |
| 14 | +// Patterns that should always go to network first |
| 15 | +// NOTE: We intentionally do NOT put blazor.boot.json here. |
| 16 | +// If we serve a fresh boot.json but stale DLLs, Blazor's integrity checks will fail and crash the app. |
| 17 | +// By serving both from cache (stale-while-revalidate), we ensure the manifest matches the assemblies. |
| 18 | +// The app will update in the background and apply on the next reload. |
| 19 | +const NETWORK_FIRST_PATTERNS = [ |
| 20 | + // Add any specific API endpoints or non-versioned assets here if needed |
| 21 | +]; |
| 22 | + |
| 23 | +self.addEventListener('install', (event) => { |
| 24 | + console.log('[WasmCache] Installing...'); |
| 25 | + self.skipWaiting(); |
| 26 | +}); |
| 27 | + |
| 28 | +self.addEventListener('activate', (event) => { |
| 29 | + console.log('[WasmCache] Activating...'); |
| 30 | + event.waitUntil( |
| 31 | + Promise.all([ |
| 32 | + // Clean up old cache versions |
| 33 | + caches.keys().then((cacheNames) => { |
| 34 | + return Promise.all( |
| 35 | + cacheNames |
| 36 | + .filter((name) => name.startsWith('blazor-wasm-cache-') && name !== CACHE_NAME) |
| 37 | + .map((name) => { |
| 38 | + console.log('[WasmCache] Deleting old cache:', name); |
| 39 | + return caches.delete(name); |
| 40 | + }) |
| 41 | + ); |
| 42 | + }), |
| 43 | + self.clients.claim() |
| 44 | + ]) |
| 45 | + ); |
| 46 | +}); |
| 47 | + |
| 48 | +self.addEventListener('fetch', (event) => { |
| 49 | + const url = new URL(event.request.url); |
| 50 | + |
| 51 | + if (event.request.method !== 'GET') { |
| 52 | + return; |
| 53 | + } |
| 54 | + |
| 55 | + const shouldCache = CACHE_PATTERNS.some((pattern) => pattern.test(url.pathname)); |
| 56 | + if (!shouldCache) { |
| 57 | + return; |
| 58 | + } |
| 59 | + |
| 60 | + const isNetworkFirst = NETWORK_FIRST_PATTERNS.some((pattern) => pattern.test(url.pathname)); |
| 61 | + |
| 62 | + if (isNetworkFirst) { |
| 63 | + event.respondWith(networkFirstStrategy(event.request)); |
| 64 | + } else { |
| 65 | + event.respondWith(staleWhileRevalidateStrategy(event.request)); |
| 66 | + } |
| 67 | +}); |
| 68 | + |
| 69 | +/** |
| 70 | + * Stale-while-revalidate: Serve from cache immediately, revalidate in background. |
| 71 | + * If the resource has changed, update the cache for next time. |
| 72 | + */ |
| 73 | +async function staleWhileRevalidateStrategy(request) { |
| 74 | + const cache = await caches.open(CACHE_NAME); |
| 75 | + const cachedResponse = await cache.match(request); |
| 76 | + |
| 77 | + // Start the network fetch in the background |
| 78 | + const networkFetchPromise = fetch(request).then(async (networkResponse) => { |
| 79 | + if (networkResponse.ok) { |
| 80 | + // Check if the response has actually changed before updating cache |
| 81 | + const shouldUpdate = await hasResponseChanged(cachedResponse, networkResponse.clone()); |
| 82 | + if (shouldUpdate) { |
| 83 | + console.log('[WasmCache] Updating cache:', request.url); |
| 84 | + await cache.put(request, networkResponse.clone()); |
| 85 | + } |
| 86 | + } |
| 87 | + return networkResponse; |
| 88 | + }).catch((error) => { |
| 89 | + console.warn('[WasmCache] Network fetch failed:', request.url, error); |
| 90 | + return null; |
| 91 | + }); |
| 92 | + |
| 93 | + if (cachedResponse) { |
| 94 | + // Serve from cache immediately, don't wait for network |
| 95 | + // Fire and forget the network request for background revalidation |
| 96 | + networkFetchPromise.catch(() => { }); |
| 97 | + return cachedResponse; |
| 98 | + } |
| 99 | + |
| 100 | + // No cache available, wait for network |
| 101 | + console.log('[WasmCache] Cache miss, fetching:', request.url); |
| 102 | + const networkResponse = await networkFetchPromise; |
| 103 | + if (networkResponse) { |
| 104 | + return networkResponse; |
| 105 | + } |
| 106 | + |
| 107 | + // Both cache and network failed |
| 108 | + return new Response('Network error', { status: 503, statusText: 'Service Unavailable' }); |
| 109 | +} |
| 110 | + |
| 111 | +/** |
| 112 | + * Network-first strategy for critical files like boot.json. |
| 113 | + * Falls back to cache if network fails. |
| 114 | + */ |
| 115 | +async function networkFirstStrategy(request) { |
| 116 | + const cache = await caches.open(CACHE_NAME); |
| 117 | + |
| 118 | + try { |
| 119 | + const networkResponse = await fetch(request); |
| 120 | + if (networkResponse.ok) { |
| 121 | + await cache.put(request, networkResponse.clone()); |
| 122 | + } |
| 123 | + return networkResponse; |
| 124 | + } catch (error) { |
| 125 | + console.warn('[WasmCache] Network failed, trying cache:', request.url); |
| 126 | + const cachedResponse = await cache.match(request); |
| 127 | + if (cachedResponse) { |
| 128 | + return cachedResponse; |
| 129 | + } |
| 130 | + return new Response('Network error', { status: 503, statusText: 'Service Unavailable' }); |
| 131 | + } |
| 132 | +} |
| 133 | + |
| 134 | +/** |
| 135 | + * Compare responses to determine if cache should be updated. |
| 136 | + * Uses ETag or Last-Modified headers, or content length as fallback. |
| 137 | + */ |
| 138 | +async function hasResponseChanged(cachedResponse, networkResponse) { |
| 139 | + if (!cachedResponse) { |
| 140 | + return true; |
| 141 | + } |
| 142 | + |
| 143 | + // Compare ETags |
| 144 | + const cachedEtag = cachedResponse.headers.get('ETag'); |
| 145 | + const networkEtag = networkResponse.headers.get('ETag'); |
| 146 | + if (cachedEtag && networkEtag) { |
| 147 | + return cachedEtag !== networkEtag; |
| 148 | + } |
| 149 | + |
| 150 | + // Compare Last-Modified |
| 151 | + const cachedLastModified = cachedResponse.headers.get('Last-Modified'); |
| 152 | + const networkLastModified = networkResponse.headers.get('Last-Modified'); |
| 153 | + if (cachedLastModified && networkLastModified) { |
| 154 | + return cachedLastModified !== networkLastModified; |
| 155 | + } |
| 156 | + |
| 157 | + // Compare Content-Length as fallback |
| 158 | + const cachedLength = cachedResponse.headers.get('Content-Length'); |
| 159 | + const networkLength = networkResponse.headers.get('Content-Length'); |
| 160 | + if (cachedLength && networkLength) { |
| 161 | + return cachedLength !== networkLength; |
| 162 | + } |
| 163 | + |
| 164 | + // If we can't compare, assume it changed |
| 165 | + return true; |
| 166 | +} |
| 167 | + |
| 168 | +// Message handlers for interop with the main thread |
| 169 | +self.addEventListener('message', async (event) => { |
| 170 | + if (!event.data || !event.data.type) { |
| 171 | + return; |
| 172 | + } |
| 173 | + |
| 174 | + switch (event.data.type) { |
| 175 | + case 'CLEAR_WASM_CACHE': { |
| 176 | + const deleted = await caches.delete(CACHE_NAME); |
| 177 | + console.log('[WasmCache] Cache cleared:', deleted); |
| 178 | + if (event.ports && event.ports[0]) { |
| 179 | + event.ports[0].postMessage({ success: deleted }); |
| 180 | + } |
| 181 | + break; |
| 182 | + } |
| 183 | + |
| 184 | + case 'GET_CACHE_STATUS': { |
| 185 | + try { |
| 186 | + const cache = await caches.open(CACHE_NAME); |
| 187 | + const keys = await cache.keys(); |
| 188 | + if (event.ports && event.ports[0]) { |
| 189 | + event.ports[0].postMessage({ |
| 190 | + cacheName: CACHE_NAME, |
| 191 | + entryCount: keys.length |
| 192 | + }); |
| 193 | + } |
| 194 | + } catch { |
| 195 | + if (event.ports && event.ports[0]) { |
| 196 | + event.ports[0].postMessage({ cacheName: CACHE_NAME, entryCount: 0 }); |
| 197 | + } |
| 198 | + } |
| 199 | + break; |
| 200 | + } |
| 201 | + } |
| 202 | +}); |
0 commit comments