Managing Service Worker cache - javascript

The service worker code I'm currently experimenting with looks like this in part
self.addEventListener('install', function(event) {
event.waitUntil(
caches.open('v1').then(function(cache) {
return cache.addAll([
'/react-redux/node_modules/react/dist/react-with-addons.js',
'/react-redux/node_modules/react-dom/dist/react-dom.js',
'/react-redux/a.js'
]);
})
);
});
With of course the standard fetch event listener that returns from cache, or runs a network request if the item is not there.
But what if, from the example above, a.js, and only a.js is updated—how do I get the service worker to update that file, but only that file; and how do I ensure that the next time the user browses to my page, they won't pull the now-stale version of the file from the service worker?
The best I can imagine would be to add a cache buster to those file urls, for example
'/react-redux/node_modules/react/dist/react-with-addons.js?hash=1MWRF3...'
then update whatever module loader I'm using to request these files with that same, current hash/cache buster, and then in the SW install event iterate over the current cache keys and delete anything that's stale, and add anything that's missing.
That would seem to solve both problems: when a file is updated, the network request that's sent won't match anything in the now-stale Service Worker, and so the same network fallback will happen; and the selective cache insertion in the Service Worker's install event wouldn't try to add things to the cache that are already there and current.
And of course the Service Worker code would change as these hash values change (automatically from a build process) and so getting the SW to re-install when files change would happen, as well.
But I can't help but think there's a simpler way. Is there?

Your understanding of what should ideally happen, and the difficulties in making sure that cached assets are updated efficiently and reliably, is spot-on.
While you could roll your own approach, there are existing tools that will automate the process of fingerprinting each file and then generating a service worker file that manages your cached assets. I've developed one of them, sw-precache. offline-plugin is another alternative that covers similar ground.

I ended up writing the code for exactly what you said, here is the code for anyone having difficulties writing it themselves:
Firstly, we need to write the code to add a timestamp/hash to URL of the bundle file everytime the bundle changes.
Most of us use webpack for bundling the application together, and every time the webpack config file is executed the bundle supposedly changes so we will do the hash/timestamp insertion in URL here. I have a file named index.template.html where I store the file served to the user so to modify the URL I did this:
// webpack.config.js
const webpack = require('webpack');
const fs = require('fs');
fs.readFile('./public/index.template.html', function (err, data) {
if (err) return console.log('Unable to read index.template file', err);
fs.writeFile('./public/index.template.html',
// finding and inserting current timestamp in front of the URL for cache busting
data.toString('utf8').replace(/bundle\.js.*"/g, "bundle\.js\?v=" + Math.floor(Date.now() / 1000) + "\""),
(err) => {
if (err) console.log("Unable to write to index.template.html", err);
});
});
module.exports = {
// configuration for webpack
};
Now here is the code for service worker which detects the change in URL and re-fetches and replaces the resource in the cache in case of a change, I've tried to explain the working in comments:
self.addEventListener("fetch", function (event) {
event.respondWith(
// intercepting response for bundle.js since bundle.js may change and we need to replace it in our cahce
event.request.url.indexOf('public/bundle.js') != -1 ?
checkBundle(event.request) : //if it is the bundle URL then use our custom function for handling the request
caches.match(event.request) //if its not then do the use service-worker code:
.then(function(response) {
// other requests code
})
);
});
// our custom function which does the magic:
function checkBundle(request) {
return new Promise(function(resolve, reject){ // respondWith method expects a Promise
caches.open(cacheName).then(function(cache) {
//first lets check whether its in cache already or not
// ignoreSearch parameter will ignore the query parameter while searching in cache, i.e., our cache busting timestmap
cache.keys(request, { ignoreSearch: true }).then(function(keys) {
if(keys.length == 0) {
// its not in cache so fetch it
return resolve(fetch(request).then(
function (response) {
if (!response || (response.status !== 200 && response.status !== 0)) {
return response;
}
cache.put(request, response.clone());
return response;
}
));
}
//it is in cache, so now we extract timestamp from current and cached URL and compare them
const lastVersion = /bundle.js\?v=(.*)$/.exec(keys[0].url)[1],
curVersion = /bundle.js\?v=(.*)$/.exec(request.url)[1];
if(lastVersion == curVersion) // if timestamp is change that means no change in the resource
return resolve(cache.match(request)); //return the cached resource
//bundle file has changed, lets delete it from cache first
cache.delete(keys[0]);
//now we fetch new bundle and serve it and store in cache
var fetchRequest = request.clone();
resolve(fetch(fetchRequest).then(
function (response) {
if (!response || (response.status !== 200 && response.status !== 0)) {
return response;
}
cache.put(request, response.clone());
return response;
}
));
});
});
});
}
As mentioned by Jeff Posnick in the comment of other answers generally these types of method require N+1 visits to see the updated resource but this one doesn't as the resource is re-fetched then served to the client and replaced in the cache at the same time.

Related

How to load different files from cache?

I am using service worker to provide a fallback page that shows the user is offline. The service worker during interception of request, fetches the same request and on error on fetching, provides response for 'offline.html' request from the cache. A small snippet of doing this is.
self.addEventListener("fetch", (event) => {
event.respondWith(
caches.match(event.request).then(() => {
return fetch(event.request).catch((err) => {
return caches.match("offline.html");
});
})
);
});
now if the offline html has other request, probably to its css files, or images, how do I load them from cache. I've tried doing the following:
self.addEventListener("fetch", (event) => {
event.respondWith(
caches.match(event.request).then(() => {
return fetch(event.request).catch((err) => {
let url = event.request.url;
if(url.endsWith('.css')) return caches.match('offline.css');
if(url.endsWith('.jpg') || url.endsWith('.png')) return caches.match('images/banner.jpg');
return caches.match("offline.html");
});
})
);
});
But is there a better way of doing this? Is there a standard way of doing this?
First off, I would recommend checking to see whether event.request.destination === 'document' before you decide whether or not to use offline.html as the fallback content. That ensure that you're not accidentally returning an HTML document to satisfy, say, a random API request that happens to fail.
Additionally, your current code includes caches.match(event.request) but then doesn't actually used the cached response, which is likely not what you intend.
That said, let's walk through what I think is your desired logic:
Your service worker attempts to make a request against the network.
If that request returns a valid response, use it, and you'd done.
If that request fails, then:
If it was a navigation request, regardless of the destination URL, use the cached offline.html for the response.
Otherwise, for non-navigation requests (like CSS or JS requests), use the cached entry matching the desired URL for the response.
Here's a service worker that implements that. You'll need to ensure that the CSS, JS, and offline.html assets are cached during service worker installation; this just includes the fetch handler logic.
self.addEventListener('install', (event) => {
event.waitUntil(
/* Cache your offline.html and the CSS and JS it uses here. */
);
});
async function fetchLogic(request) {
try {
// If the network request succeeds, just use
// that as the response.
return await fetch(request);
} catch(error) {
// Otherwise, implement fallback logic.
if (request.mode === 'navigate') {
// Use the cached fallback.html for failed navigations.
return await caches.match('offline.html');
}
// Otherwise, return a cached copy of the actual
// subresource that was requested.
// If there's a cache miss for that given URL, you'll
// end up with a NetworkError, just like you would if
// there were no service worker involvement.
return await caches.match(request.url);
}
}
self.addEventListener('fetch', (event) => {
event.respondWith(fetchLogic(event.request));
});
There's also some formal guidance in this article.

How to update the cached files in my service worker every 30 minutes?

I have this service worker:
//IMPORT POLYFILL
importScripts('cache-polyfill.js');
//INSTALL
self.addEventListener('install', function(e) {
e.waitUntil(
caches.open('stock_item_balance_v1').then(function(cache) {
return cache.addAll([
'/app/offline_content/purchase/stock_items/stock_items_balance.php',
'/app/offline_content/purchase/stock_items/js/stock_items_balance.js'
]);
})
);
});
//FETCH (FETCH IS WHEN YOU CHECK FOR INTERNET)
self.addEventListener('fetch', function(event) {
//console.log(event.request.url);
event.respondWith(
caches.match(event.request).then(function(response) {
return response || fetch(event.request);
})
);
});
In "stock_items_balance.php" i fetch data from my DB. So in every 30 minutes i would like to update my cached pages and reload the window.
So first i have a script that checks for internet connection.
If true, i want to clean/update the cache and reload the page.
How can i do that?
//INTERVAL
setInterval(function(){
//CLEAN/UPDATE CACHED FILES
serviceworker.update(); // ???
//RELOAD PAGE
window.location.reload();
}, 180000);
(I think you have a larger question as to whether the approach you describe is actually going to give a good, predictable, offline-capable experience for your users, but I'm just going to focus on the actual technical question you asked.)
Messaging the service worker
First off, you should keep in mind that it's possible to have multiple tabs open for the same URL, and if you, you're going to end up with your update code potentially running multiple times. The code in this answer handles the "reload" step for you from inside of the service worker, after the asynchronous cache update has completed, by getting a list of all the active clients of the service worker and telling each to navigate to the current URL (which is effectively a reload).
// Additions to your service worker code:
self.addEventListener('message', (event) => {
// Optional: if you need to potentially send different
// messages, use a different identifier for each.
if (event.data === 'update') {
event.waitUntil((async () => {
// TODO: Move these URLs and cache names into constants.
const cache = await caches.open('stock_item_balance_v1');
await cache.addAll([
'/app/offline_content/purchase/stock_items/stock_items_balance.php',
'/app/offline_content/purchase/stock_items/js/stock_items_balance.js'
]);
const windowClients = await clients.matchAll();
for (const windowClient of windowClients) {
// Optional: check windowClient.url first and
// only call navigate() if it's the URL for one
// specific page.
windowClient.navigate(windowClient.url);
}
})());
}
});
// Additions to your window/page code:
setInterval(() => {
if (navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage('update');
}
}, 180000);
What won't work
The Cache Storage API is available from both inside a service worker and inside of your page's window scope. Normally what I'd recommend that folks do is to open up the same cache from the window context, and call cache.add() to update the cached entry with the latest from the network. However, calling cache.add() from the window context will cause the network request to be intercepted by your fetch handler, and at that point, your response won't actually come from the network. By calling cache.add() from inside your service worker, you can guarantee that the resulting network request won't trigger your fetch handler.

Service worker doesn't return file from cache

I'm trying to cache a single page webapp with a service worker. It should get all it's files from the cache and update that cache only when a new service worker-version is published.
With a precache function i'm writing some files to the cache as follows:
function precache() {
return caches.open(CACHE).then(function(cache) {
return cache.addAll([
'index.html',
'js/script.js',
'img/bg.png',
'img/logo.svg',
...
]);
});
}
(I've tried to cache with and without "/" before the paths, and even with absolute paths. Makes no difference)
In Chrome's Cache Storage, the content of all those files is exactly as it should be. But when I try to serve the files from cache on reload of the page, none of the requests match with the cache, they all get rejected, even when I'm still online.
self.addEventListener('fetch', function(evt) {
evt.respondWith(
caches.match(evt.request).then(function(response) {
if(response){
return response;
} else {
reject('no result');
}
}).catch(function(){
if(evt.request.url == 'https://myurl.com'){
return caches.match('/index.html');
}
});
)
});
The index.html from the catch-function gets served correctly, and in turn requests the other files, like /js/script.js. Those request show up like this in the console:
Request { method: 'GET', url: 'https://myurl.com/js/script.js', ... referrer: 'https://myurl.com' }
But they do not return a response, only a notice like this shows:
The FetchEvent for "https://myurl.com/js/script.js" resulted in a network error response: an object that was not a Response was passed to respondWith().
Am I missing something here?
Thanks to the link from Rajit https://developer.mozilla.org/en-US/docs/Web/API/Cache/match I've found that the caches.match() function accepts an options-object.
I've updated that line in my service worker to
caches.match(evt.request,{cacheName:CACHE,ignoreVary:true}).then(function(response) {
so it includes the cache-name and an ignores VARY header matching, and now it returns the correct files.
I had the same problem and it seems to have been solved by using the ignoreVary:true parameter. The documentation explicitly states that the cacheName parameter is ignored by Cache.match()
Important note is to add all possible url versions, with, without trailing slash, because even when autocompleted, it seems to bee seen as two different things. So, for example, if you had a pwa in domain/folder/,
calling domain/folder/ online and caching wont make domain/folder work offline (in some cases) unless you previously accessed the later online as well.
Solution:
when adding via caches.addAll or similar, add both
'/folder/'
AND
'/folder'.
What never did a thing for me on the other hand, was ignoreVary .

Best practice for instant claim of a page by Service Worker

I currently have a service worker set up to claim the page immediately, due to the nature of the application and user experience.
As this application is converting over from AppCache, which had a dynamic Manifest file created for each user, I found that the best method was to Parse this Manifest file to a JSON array and send it to the Service Worker in order to cache it. The problem being I need to wait until the Service Worker is active before it can receive this array.
I currently have set a timeout on the function to 10000 (see below), but the success rate is not 100%.
Edit:
I often find that the Service Worker is not activated before the end of this 10000 timeout resulting in an error: "TypeError: navigator.serviceWorker.controller is null".
//Get Request - Service Worker Preperation
setTimeout(function getRequest() {
console.log("APP: Enetered getRequest() Method");
$.ajax({
type : "GET",
url : "https://ExampleURL/App/" +
localStorage.getItem("user") + ".manifest",
contentType: "text/plain",
async : false,
success : function(response) {
var myArray = listToArray(response, '\n');
send_message_to_sw(myArray);
},
error : function(msg) {
console.log("ERROR: " + msg);
}
});
}, 10000);
My question is what is the best practice for checking if the Service worker is active, or should I just increase the amount of time in the timeout?
I am attaching the relevant Service Worker code below incase there is a problem with the way I have set up the immediate claim.
// INSTALL
self.addEventListener('install', function(event) {
console.log('[ServiceWorker] Installed version', version);
event.waitUntil(
caches.open(version).then(function(cache) {
console.log('[ServiceWorker] Cached cachedFiles for', version);
return cache.addAll(cacheFiles);
}).then(function() {
console.log('[ServiceWorker] Skip waiting on install');
return self.skipWaiting();
})
);
});
//ACTIVATE
self.addEventListener('activate', function(event) {
self.clients.matchAll({
includeUncontrolled: true
}).then(function(clientList) {
var urls = clientList.map(function(client) {
return client.url;
});
console.log('[ServiceWorker] Matching clients:', urls.join(', '));
});
event.waitUntil(
caches.keys().then(function(cacheNames) {
return Promise.all(
cacheNames.map(function(cacheName) {
if (cacheName !== version) {
console.log('[ServiceWorker] Deleting old cache:', cacheName);
return caches.delete(cacheName);
}
})
);
}).then(function() {
console.log('[ServiceWorker] Claiming clients for version', version);
return self.clients.claim();
})
);
});
//RECIEVE DATA FROM JAVASCRIPT FILE
self.addEventListener('message', function(event){
console.log("SW Received Message: " + event.data);
var fullArray = [];
var che = event.data;
fullArray = che.splice(',');
console.log("SWChe2: " + fullArray);
var result = fullArray.slice(1,-1);
caches.open(version + 'Manifest')
.then(function(cache) {
return cache.addAll(result);
});
});
navigator.serviceWorker.ready is a promise that client pages can wait on, and will resolve when there's an active service worker whose scope encompasses the current page.
navigator.serviceWorker.ready.then(registration => {
// Do something, confident that registration corresponds to
// an active SW's registration.
});
But... two things.
You can add items using the Cache Storage API from the context of your page, without having to send a message to a service worker. window.caches.open('my-cache-name') will give you access to the same cache that a service worker can use.
If you've got a set of resources that always need to be cached, you're probably better off caching them inside of your install handler. That ensures that the service worker won't finish installation unless the required files are cached. You can use fetch() inside of your install handler to retrieve a JSON manifest of resources that need to be precached, and IndexedDB (instead of localStorage, which isn't exposed to service workers) to store the name of the current user. Just make sure that if you go this route, you make use of event.waitUntil() to delay the install handler's completion until the full set of operations is successful.
I ended up wrapping the contents of my getRequest() function in the promise suggested by Jeff.
navigator.serviceWorker.ready
This allowed my application to complete with the shortest delay!

Service-workers blocks backbonejs?

I have built a web app using Backbone.js and it has lots of calls to a RESTful service and it works like a charm.
I tried adding a ServiceWorker to cache all the previous calls so they'll be available offline.
What I actually get is that the calls I do for the first time, dies with this error:
Failed to load resource: net::ERR_FAILED
However on page reload, I get it's cached data
My service worker fetch:
self.addEventListener('fetch', function(e) {
// e.respondWidth Responds to the fetch event
e.respondWith(
// Check in cache for the request being made
caches.match(e.request)
.then(function(response) {
// If the request is in the cache
if ( response ) {
console.log("[ServiceWorker] Found in Cache", e.request.url, response);
// Return the cached version
return response;
}
// If the request is NOT in the cache, fetch and cache
var requestClone = e.request.clone();
fetch(requestClone)
.then(function(response) {
if ( !response ) {
console.log("[ServiceWorker] No response from fetch ")
return response;
}
var responseClone = response.clone();
// Open the cache
caches.open(cacheName).then(function(cache) {
// Put the fetched response in the cache
cache.put(e.request, responseClone);
console.log('[ServiceWorker] New Data Cached', e.request.url);
// Return the response
return response;
}); // end caches.open
console.log("Response is.. ?", response)
return response;
})
.catch(function(err) {
console.log('[ServiceWorker] Error Fetching & Caching New Data', err);
});
}) // end caches.match(e.request)
); // end e.respondWith
});
edit:
I don't think there is a need for any Backbone.js web app code.
I use the fetch method from Backbone.js models and collections.
calls like
https://jsonplaceholder.typicode.com/posts/1
and
https://jsonplaceholder.typicode.com/posts/2
will replay show this error on first time. after refreshing the page, i do have this info without requesting. all from cache.
and all other request that i still didn't do, will stay error
i solved it after searching more.
Backbone.js my views in the Web app used to do:
this.listenTo(this.collection,"reset",this.render);
this.listenTo(this.collection,"add",this.addCollectionItem);
this.listenTo(this.collection,"error", this.errorRender);
while my Service worker is returning Promises.
I had to change my some code my Web app views to something like this:
this.collection.fetch({},{reset:true})
.then(_.bind(this.render, this))
.fail(_.bind(this.errorRender,this))
more or less...
The only problem I see is that when the request is not in the cache, then you do a fetch, but you do not return the result of that fetch to the enclosing then handler. You need to add a return so that you have:
return fetch(requestClone)
.then(function(response) {
None of the data provided by the return statements inside your then handler for the fetch will get transferred up the chain otherwise.
I also see that you do not return the promise provided by caches.open(cacheName).then.... This may be fine if you want to decouple saving a response in the cache from returning a result up the chain, but at the very least I'd put a comment saying that that's what I'm doing here rather than leave it to the reader to figure out whether a return statement is missing by accident, or it was done on purpose.

Categories