📜 ⬆️ ⬇️

Service Workers: transparent cache update

Service Workes as a technology for creating offline applications is very well suited for caching various resources. A variety of tactics work in the service worker with a local cache are described in detail on the Internet.

Not described one - how to update the files in the cache. The only thing that Google and MDN offers is to make several caches for different types of resources, and, when necessary, change the version of this cache in the worker's sw.js service script, after which it will be completely removed.

Cache removal
var CURRENT_CACHES = { font: 'font-cache-v1', css:'css-cache-v1', js:'js-cache-v1' }; self.addEventListener('activate', function(event) { var expectedCacheNames = Object.keys(CURRENT_CACHES).map(function(key) { return CURRENT_CACHES[key]; }); // Delete out of date cahes event.waitUntil( caches.keys().then(function(cacheNames) { return Promise.all( cacheNames.map(function(cacheName) { if (expectedCacheNames.indexOf(cacheName) == -1) { console.log('Deleting out of date cache:', cacheName); return caches.delete(cacheName); } }) ); }) ); }); 


In other words, if you have, for example, ten js files, and you have changed one of them, all users will have to reload all js files. Enough clumsy work.
')
Of the third-party products (albeit from Google developers), the worker's service closest to solving the problem of updating cache files is the sw-precache library . It adds hashes to sw.js for all files whose change tracking has been set by the developer. When at least one of them changes on the server, the next time the service is activated, the worker’s service again updates the entire client cache, but now without the programmer’s special gestures. Ax replaced with a hammer.

Formulation of the problem


We need a transparent and reliable update of the worker service cache files. This means that the developer uploads the modified files to the server, and only the user updates them automatically at the next login / request. Let's try to solve this problem.

Take the common Google example of a worker service, which operates on the principle: “first from the cache, if not there, from the network”.

For a start it is clear that you need to have a list of monitored files Also, you need to somehow compare them with the versions of files in the cache. This can be done either on the server or on the client.

Option 1


We use cookies. We will write to the user's cookie the time of his last visit. On the next run, we compare it with the modification time of the monitored files on the server, and transmit a list of the files that have been modified since that moment in the html code of the page, immediately before registering the worker's service. To do this, we include there the output of this php file:

updated_resources.php
 <?php $files = [ "/css/fonts.css", "/css/custom.css", "/js/m-js.js", "/js/header.css"]; $la = $_COOKIE["vg-last-access"]; if(!isset($la)) $la = 0; forEach($files as $file) { if (filemtime(__DIR__ . "/../.." . $file) > $la) { $updated[] = $file; echo "<script src='/update-resource$file'></script>\n"; } } setcookie("vg-last-access", time(), time() + 31536000, "/"); ?> 


$ files - an array of monitored resources. For each modified file a script tag with the / update-resource keyword will be generated, which will entail a request to the service worker.

There we filter these requests by keyword and reload resources.

sw.js fetch
 self.addEventListener('fetch', function(event) { var url = event.request.url; if (url.indexOf("/update-resource") > 0) { var r = new Request(url.replace("\/update-resource", "")); return fetchAndCache(r); } //    ,   -  fetchAndCache() ... }); 


That's all, resources are updated as they change. However, there are weaknesses: cookies may disappear, and then the user will have to download all the files again. It is also likely that after the user has installed the cookie, for some reason he will not be able to download all the updated files. In this case, he will have a "broken" application. Let's try to think of something more reliable.

Option 2


We will, like the guys from Google, transfer the tracked files to sw.js, and check the changes on the client side. As a functional measure without inventing a hash bike, we take the E-Tag or Last-Modified respawn headers — they are perfectly stored in the worker's cache. It is more correct to take an E-Tag, but to get it on the server side, you will need to perform a local request to the web server, which is a little expensive, and Last-Modified is perfectly calculated using filemtime ().

So, instead of sw.js, we now register sw.php with the following code:

sw.php
 <?php header("Content-Type: application/javascript"); header("Cache-Control: no-store, no-cache, must-revalidate"); $files = [ "/css/fonts.css", "/css/custom.css", "/js/m-js.js", "/js/header.js"]; echo "var updated = {};\n"; forEach($files as $file) { echo "updated['$file'] = '" . gmdate("D, d MYH:i:s \G\M\T", filemtime(__DIR__ . $file)) . "';\n"; } echo "\n\n"; readfile('sw.js'); ?> 


It generates at the beginning of sw.js a declaration of an associative array initialized by the {url, Last-Modified} pairs of our monitored resources.

 var updated = {}; updated['/css/fonts.css'] = 'Mon, 07 May 2018 02:47:54 GMT'; updated['/css/custom.css'] = 'Sat, 05 May 2018 13:10:07 GMT'; updated['/js/m-js.js'] = 'Mon, 07 May 2018 11:33:56 GMT'; updated['/js/header.js'] = 'Mon, 07 May 2018 15:34:08 GMT'; 

Further, with each request by the client for a resource, if the url hits the updated array, we check with what we have in the cache.

sw.js fetch
 self.addEventListener('fetch', function(event) { console.log('Fetching:', event.request); event.respondWith(async function() { const cachedResponse = await caches.match(event.request); if (cachedResponse) { console.log("Cached version found: " + event.request.url); var l = new URL(event.request.url); if (updated[l.pathname] === undefined || updated[l.pathname] == cachedResponse.headers.get("Last-Modified")) { console.log("Returning from cache"); return cachedResponse; } console.log("Updating to recent version"); } return await fetchAndCache(event.request); }()); }); 


Fifteen lines of code, and you can safely upload files to the server, and they will be updated in the client cache.

The only remaining time - after the resource is loaded, it will be necessary to update the updated [url.pathname] new response.headers.get (“Last-Modified”) - there is a possibility that this file has been updated once again after the last receipt of sw.php last modified time, and this file will be constantly updated upon request.

findings


We need to remember about the cycle of life sw.js / sw.php. This file is subject to the rules of the standard browser caching with one exception - it lives on the client for no more than 24 hours, then the service worker will be forced to restart at the next registration. With sw.php, we are almost guaranteed to always have the latest version.

If you do not want to get into the generation of sw.js, you can download a list of monitored resources from Last-Modified from the server in the activate block - this is probably the more correct way, but at the cost of one extra request to the server. And as in option 1, it is possible to crash into the html page code, create an ajax request with json data into the service worker, where it can be processed by initializing the updated array - this is probably the most optimal and dynamic option, it will, if you wish, update the cache resources without reinstalling the service worker.

As a further development of this scheme, each monitored resource will not have problems adding a declarative ability to load postponed - first return to the client from the cache, then download from the network for subsequent hits.

Another sample application is images with different sizes (srcset or software installation). When downloading such a resource, you can search for a higher resolution image in the cache first, thereby saving the request to the server. Or use a smaller image at the time of loading the main one.

Premature loading is also interesting from common caching techniques: for example, it is known that additional resources will appear in the next release of the application — a new font or a heavy picture, for example. You can load it into the cache in advance for the load event — when the user opens the page completely and starts reading it. It will be unnoticed and effective.

Finally, an example of a working sw.js (works in conjunction with the aforementioned sw.php) with several caches (including caching php-generated images) and implemented a transparent update of the cache according to the second variant.

sw.js
 // Caches var CURRENT_CACHES = { font: 'font-cache-v1', css:'css-cache-v1', js:'js-cache-v1', icons: 'icons-cache-v1', icons_ext: 'icons_ext-cache-v1', image: 'image-cache-v1' }; self.addEventListener('install', (event) => { self.skipWaiting(); console.log('Service Worker has been installed'); }); self.addEventListener('activate', (event) => { var expectedCacheNames = Object.keys(CURRENT_CACHES).map(function(key) { return CURRENT_CACHES[key]; }); // Delete out of date cahes event.waitUntil( caches.keys().then(function(cacheNames) { return Promise.all( cacheNames.map(function(cacheName) { if (expectedCacheNames.indexOf(cacheName) == -1) { console.log('Deleting out of date cache:', cacheName); return caches.delete(cacheName); } }) ); }) ); console.log('Service Worker has been activated'); }); self.addEventListener('fetch', function(event) { console.log('Fetching:', event.request.url); event.respondWith(async function() { const cachedResponse = await caches.match(event.request); if (cachedResponse) { // console.log("Cached version found: " + event.request.url); var l = new URL(event.request.url); if (updated[l.pathname] === undefined || updated[l.pathname] == cachedResponse.headers.get("Last-Modified")) { // console.log("Returning from cache"); return cachedResponse; } console.log("Updating to recent version"); } return await fetchAndCache(event.request); }()); }); function fetchAndCache(url) { return fetch(url) .then(function(response) { // Check if we received a valid response if (!response.ok) { return response; // throw Error(response.statusText); } // console.log(' Response for %s from network is: %O', url.url, response); if (response.status < 400 && response.type === 'basic' && response.headers.has('content-type')) { // debugger; var cur_cache; if (response.headers.get('content-type').indexOf("application/javascript") >= 0) { cur_cache = CURRENT_CACHES.js; } else if (response.headers.get('content-type').indexOf("text/css") >= 0) { cur_cache = CURRENT_CACHES.css; } else if (response.headers.get('content-type').indexOf("font") >= 0) { cur_cache = CURRENT_CACHES.font; } else if (url.url.indexOf('/css/icons/') >= 0) { cur_cache = CURRENT_CACHES.icons; } else if (url.url.indexOf('/misc/image.php?') >= 0) { cur_cache = CURRENT_CACHES.image; } if (cur_cache) { console.log(' Caching the response to', url); return caches.open(cur_cache).then(function(cache) { cache.put(url, response.clone()); updated[(new URL(url.url)).pathname] = response.headers.get("Last-Modified"); return response; }); } } return response; }) .catch(function(error) { console.log('Request failed:', error); throw error; // You could return a custom offline 404 page here }); } 

Source: https://habr.com/ru/post/358060/


All Articles