workbox
workbox copied to clipboard
Recipe/feature enhancement to expire old hashed URLs
A typical modern serving setup uses hashes in URLs to uniquely identify a given version of a named resource. (This might be a chunk of JS, or a CSS file, etc.)
Throughout the lifetime of your web app, these URLs might go from /app.hash1.js to /app.hash2.js to /app.hash3.js, where hashN is some hexadecimal string—unfortunately there isn't really a standard around the number of hex characters or separator between the asset name and hash.
If you're precaching those URLs, then when /app.hash1.js is removed from the precache manifest and replaced with /app.hash2.js following a rebuild, workbox-precaching will take care of "cleaning up" /app.hash1.js once the updated service worker activates.
If you're not precaching those URLs (perhaps because they are best lazy-loaded on demand when a particular subsection of your web app is visited), then you're probably doing something like:
registerRoute(
({request}) => request.destination === 'script',
new CacheFirst({cacheName: 'scripts'}),
);
to tell Workbox to serve any script resources from the cache if there's a match, otherwise from the network. (You might use a RegExp as the first parameter if you prefer URL matching to request.destination.)
The problem with that runtime caching configuration is that old URLs, like /app.hash1.js, will never get cleaned up when /app.hash2.js is added.
You can work around this inelegantly by adding in CacheExpirationPlugin to the CacheFirst strategy, but that will only let you specify maxAgeSecond or maxEntries. Neither of those options capture the idea of "please delete resources that have the same name but a different hash than this current URL."
I'd like to address this by starting with a recipe for a custom plugin that we could add to the docs, and then potentially expanding the behavior of CacheExpirationPlugin to include that logic. The recipe would look something like:
const hashRegExp = /(.+)\.[a-f0-9]{8}\./;
const hashExpirationPlugin = {
isCurrentlyRunning: false,
cacheDidUpdate: ({cacheName, request, event}) => {
if (hashExpirationPlugin.isCurrentlyRunning) {
// See https://github.com/GoogleChrome/workbox/issues/2555
return;
}
hashExpirationPlugin.isCurrentlyRunning = true;
const matches = request.url.match(hashRegExp);
if (!matches) {
return;
}
const resourceName = matches[1];
const expireOldHashes = async () => {
const cache = await caches.open(cacheName);
const cachedRequests = await cache.keys();
for (const cachedRequest of cachedRequests) {
const cachedRequestMatches = cachedRequest.url.match(hashRegExp);
if (cachedRequestMatches &&
cachedRequestMatches[1] === resourceName &&
cachedRequest.url !== request.url) {
await cache.delete(cachedRequest);
}
}
hashExpirationPlugin.isCurrentlyRunning = false;
};
event.waitUntil(expireOldHashes());
},
};
registerRoute(
({request}) => request.destination === 'script',
new CacheFirst({
cacheName: 'scripts',
plugins: [hashExpirationPlugin],
}),
);
The main problem I see if coming up with a good developer expiration around providinga RegExp that will match all versioned URLs while providing a capture group around the resource name portion, especially given that developers might be using -, ., or other characters to separate the hash portion of their URL.
CC: @philipwalton and @snugug
@jeffposnick Thanks for this function..
I think instead of await caches.delete(cachedRequest); it should be await cache.delete(cachedRequest);
Right! Changed.
@jeffposnick Just for other folks to follow the next issue this causes https://github.com/GoogleChrome/workbox/issues/2555
C.f. https://jeffy.info/2021/10/10/smart-caching-hashes.html