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:
co-authored by
Claude Opus 4.5
parent
8b230c6920
commit
d34df7c314
@@ -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
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
Reference in New Issue
Block a user