feat: Add service worker for asset caching

- Add Workbox-based service worker to cache JS, CSS, and fonts
- JS/CSS cached for 30 days, fonts for 1 year
- Support for CDN assets via ASSET_CDN_HOST
- Version-based cache invalidation on deployments
- Automatic cache cleanup for old versions
- Build service worker during asset precompilation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Pranav
2026-01-10 14:48:23 -08:00
co-authored by Claude Opus 4.5
parent 8b230c6920
commit d34df7c314
9 changed files with 386 additions and 2 deletions
+22 -1
View File
@@ -16,7 +16,28 @@ export const verifyServiceWorkerExistence = (callback = () => {}) => {
navigator.serviceWorker
.register('/sw.js')
.then(registration => callback(registration))
.then(registration => {
// Check for updates on load
registration.update();
// Listen for updates
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
newWorker.addEventListener('statechange', () => {
if (
newWorker.state === 'installed' &&
navigator.serviceWorker.controller
) {
// New service worker available, will activate on next page load
console.log(
'New service worker available, will activate on next page load'
);
}
});
});
callback(registration);
})
.catch(registrationError => {
// eslint-disable-next-line
console.log('SW registration failed: ', registrationError);
@@ -0,0 +1,39 @@
/* Development-only service worker - Push notifications only, no caching */
/* eslint-disable no-restricted-globals, no-console */
/* globals clients */
self.addEventListener('push', event => {
let notification = event.data && event.data.json();
event.waitUntil(
self.registration.showNotification(notification.title, {
tag: notification.tag,
data: {
url: notification.url,
},
})
);
});
self.addEventListener('notificationclick', event => {
let notification = event.notification;
event.waitUntil(
clients.matchAll({ type: 'window' }).then(windowClients => {
let matchingWindowClients = windowClients.filter(
client => client.url === notification.data.url
);
if (matchingWindowClients.length) {
let firstWindow = matchingWindowClients[0];
if (firstWindow && 'focus' in firstWindow) {
firstWindow.focus();
return;
}
}
if (clients.openWindow) {
clients.openWindow(notification.data.url);
}
})
);
});
@@ -0,0 +1,64 @@
/* eslint-disable no-restricted-globals, no-console */
/* globals clients */
// Push notification handler
self.addEventListener('push', event => {
let notification = event.data && event.data.json();
event.waitUntil(
self.registration.showNotification(notification.title, {
tag: notification.tag,
data: {
url: notification.url,
},
})
);
});
// Notification click handler
self.addEventListener('notificationclick', event => {
let notification = event.notification;
event.waitUntil(
clients.matchAll({ type: 'window' }).then(windowClients => {
let matchingWindowClients = windowClients.filter(
client => client.url === notification.data.url
);
if (matchingWindowClients.length) {
let firstWindow = matchingWindowClients[0];
if (firstWindow && 'focus' in firstWindow) {
firstWindow.focus();
return;
}
}
if (clients.openWindow) {
clients.openWindow(notification.data.url);
}
})
);
});
// Cache cleanup on activation
self.addEventListener('activate', event => {
const currentCacheVersion = '__CACHE_VERSION__';
const cacheWhitelist = [
`js-cache-${currentCacheVersion}`,
`css-cache-${currentCacheVersion}`,
`font-cache-${currentCacheVersion}`,
];
event.waitUntil(
caches
.keys()
.then(cacheNames => {
cacheNames.forEach(cacheName => {
if (!cacheWhitelist.includes(cacheName)) {
console.log('Deleting old cache:', cacheName);
caches.delete(cacheName);
}
});
})
.then(() => self.clients.claim())
);
});
@@ -0,0 +1,84 @@
/* eslint-disable no-restricted-globals */
import { registerRoute } from 'workbox-routing';
import { CacheFirst } from 'workbox-strategies';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
import { ExpirationPlugin } from 'workbox-expiration';
// Cache version will be injected at build time
const CACHE_VERSION = '__CACHE_VERSION__';
// Asset origin will be injected at build time (CDN host or empty string)
const ASSET_ORIGIN = '__ASSET_ORIGIN__';
// Define cache names with versioning
const JS_CACHE = `js-cache-${CACHE_VERSION}`;
const CSS_CACHE = `css-cache-${CACHE_VERSION}`;
const FONT_CACHE = `font-cache-${CACHE_VERSION}`;
// Cache JavaScript bundles from /packs/ or CDN
registerRoute(
({ request, url }) => {
const isScript = request.destination === 'script';
const isFromPacks = url.pathname.startsWith('/packs/');
const isFromCDN = ASSET_ORIGIN && url.origin === ASSET_ORIGIN;
return isScript && (isFromPacks || isFromCDN);
},
new CacheFirst({
cacheName: JS_CACHE,
plugins: [
new CacheableResponsePlugin({
statuses: [0, 200], // 0 for opaque responses from CDN, 200 for same-origin
}),
new ExpirationPlugin({
maxEntries: 100,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
}),
],
})
);
// Cache CSS files from /packs/ or CDN
registerRoute(
({ request, url }) => {
const isStyle = request.destination === 'style';
const isFromPacks = url.pathname.startsWith('/packs/');
const isFromCDN = ASSET_ORIGIN && url.origin === ASSET_ORIGIN;
return isStyle && (isFromPacks || isFromCDN);
},
new CacheFirst({
cacheName: CSS_CACHE,
plugins: [
new CacheableResponsePlugin({
statuses: [0, 200],
}),
new ExpirationPlugin({
maxEntries: 30,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
}),
],
})
);
// Cache fonts (from anywhere)
registerRoute(
({ request, url }) => {
const isFont = request.destination === 'font';
const hasFontExtension = /\.(woff|woff2|ttf|otf|eot)$/i.test(url.pathname);
return isFont || hasFontExtension;
},
new CacheFirst({
cacheName: FONT_CACHE,
plugins: [
new CacheableResponsePlugin({
statuses: [0, 200],
}),
new ExpirationPlugin({
maxEntries: 30,
maxAgeSeconds: 365 * 24 * 60 * 60, // 1 year
}),
],
})
);