How to load different files from cache? - javascript

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.

Related

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.

Cannot construct a Request with a Request whose mode is 'navigate' and a non-empty RequestInit

Consider this sample index.html file.
<!DOCTYPE html>
<html><head><title>test page</title>
<script>navigator.serviceWorker.register('sw.js');</script>
</head>
<body>
<p>test page</p>
</body>
</html>
Using this Service Worker, designed to load from the cache, then fallback to the network if necessary.
cacheFirst = (request) => {
var mycache;
return caches.open('mycache')
.then(cache => {
mycache = cache;
cache.match(request);
})
.then(match => match || fetch(request, {credentials: 'include'}))
.then(response => {
mycache.put(request, response.clone());
return response;
})
}
addEventListener('fetch', event => event.respondWith(cacheFirst(event.request)));
This fails badly on Chrome 62. Refreshing the HTML fails to load in the browser at all, with a "This site can't be reached" error; I have to shift refresh to get out of this broken state. In the console, it says:
Uncaught (in promise) TypeError: Failed to execute 'fetch' on 'ServiceWorkerGlobalScope': Cannot construct a Request with a Request whose mode is 'navigate' and a non-empty RequestInit.
"construct a Request"?! I'm not constructing a request. I'm using the event's request, unmodified. What am I doing wrong here?
Based on further research, it turns out that I am constructing a Request when I fetch(request, {credentials: 'include'})!
Whenever you pass an options object to fetch, that object is the RequestInit, and it creates a new Request object when you do that. And, uh, apparently you can't ask fetch() to create a new Request in navigate mode and a non-empty RequestInit for some reason.
In my case, the event's navigation Request already allowed credentials, so the fix is to convert fetch(request, {credentials: 'include'}) into fetch(request).
I was fooled into thinking I needed {credentials: 'include'} due to this Google documentation article.
When you use fetch, by default, requests won't contain credentials such as cookies. If you want credentials, instead call:
fetch(url, {
credentials: 'include'
})
That's only true if you pass fetch a URL, as they do in the code sample. If you have a Request object on hand, as we normally do in a Service Worker, the Request knows whether it wants to use credentials or not, so fetch(request) will use credentials normally.
https://developers.google.com/web/ilt/pwa/caching-files-with-service-worker
var networkDataReceived = false;
// fetch fresh data
var networkUpdate = fetch('/data.json').then(function(response) {
return response.json();
}).then(function(data) {
networkDataReceived = true;
updatePage(data);
});
// fetch cached data
caches.match('mycache').then(function(response) {
if (!response) throw Error("No data");
return response.json();
}).then(function(data) {
// don't overwrite newer network data
if (!networkDataReceived) {
updatePage(data);
}
}).catch(function() {
// we didn't get cached data, the network is our last hope:
return networkUpdate;
}).catch(showErrorMessage).then(console.log('error');
Best example of what you are trying to do, though you have to update your code accordingly. The web example is taken from under Cache then network.
for the service worker:
self.addEventListener('fetch', function(event) {
event.respondWith(
caches.open('mycache').then(function(cache) {
return fetch(event.request).then(function(response) {
cache.put(event.request, response.clone());
return response;
});
})
);
});
Problem
I came across this problem when trying to override fetch for all kinds of different assets. navigate mode was set for the initial Request that gets the index.html (or other html) file; and I wanted the same caching rules applied to it as I wanted to several other static assets.
Here are the two things I wanted to be able to accomplish:
When fetching static assets, I want to sometimes be able to override the url, meaning I want something like: fetch(new Request(newUrl))
At the same time, I want them to be fetched just as the sender intended; meaning I want to set second argument of fetch (i.e. the RequestInit object mentioned in the error message) to the originalRequest itself, like so: fetch(new Request(newUrl), originalRequest)
However the second part is not possible for requests in navigate mode (i.e. the initial html file); at the same time it is not needed, as explained by others, since it will already keep it's cookies, credentials etc.
Solution
Here is my work-around: a versatile fetch that...
can override the URL
can override RequestInit config object
works with both, navigate as well as any other requests
function fetchOverride(originalRequest, newUrl) {
const fetchArgs = [new Request(newUrl)];
if (request.mode !== 'navigate') {
// customize the request only if NOT in navigate mode
// (since in "navigate" that is not allowed)
fetchArgs.push(request);
}
return fetch(...fetchArgs);
}
In my case I was contructing a request from a serialized form in a service worker (to handle failed POSTs). In the original request it had the mode attribute set, which is readonly, so before one reconstructs the request, delete the mode attribute:
delete serializedRequest["mode"];
request = new Request(serializedRequest.url, serializedRequest);

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.

Service worker offline support with pushstate and client side routing

I'm using a service worker to introduce offline functionality for my single page web app. It's pretty straightforward - use the network when available, or try and fetch from the cache if not:
service-worker.js:
self.addEventListener("fetch", event => {
if(event.request.method !== "GET") {
return;
}
event.respondWith(
fetch(event.request)
.then(networkResponse => {
var responseClone = networkResponse.clone();
if (networkResponse.status == 200) {
caches.open("mycache").then(cache => cache.put(event.request, responseClone));
}
return networkResponse;
})
.catch(_ => {
return caches.match(event.request);
})
)
})
So it intercepts all GET requests and caches them for future use, including the initial page load.
Switching to "offline" in DevTools and refreshing at the root of the application works as expected.
However, my app uses HTML5 pushstate and a client side router. The user could navigate to a new route, then go offline, then hit refresh, and will get a "no internet" message, because the service worker was never told about this new URL.
I can't think of a way around it. As with most SPAs, my server is configured to serve the index.html for a number of catch-all URLs. I need some sort of similar behaviour for the service worker.
Inside your fetch handler, you need to check whether event.request.mode is set to 'navigate'. If so, it's a navigation, and instead of responding with a cached response that matches the specific URL, you can respond with a cached response for your index.html. (Or app-shell.html, or whatever URL you use for the generic HTML for your SPA.)
Your updated fetch handler would look roughly like:
self.addEventListener('fetch', event => {
if (event.request.method !== 'GET') {
return;
}
if (event.request.mode === 'navigate') {
event.respondWith(caches.match('index.html'));
return;
}
// The rest of your fetch handler logic goes here.
});
This is a common use case for service workers, and if you'd prefer to use a pre-packaged solution, the NavigationRoute class in the workbox-routing module can automate it for you.

Managing Service Worker cache

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.

Categories