Refresh page after load on cache-first Service Worker - javascript

I'm currently considering adding service workers to a Web app I'm building.
This app is, essentially, a collection manager. You can CRUD items of various types and they are usually tightly linked together (e.g. A hasMany B hasMany C).
sw-toolbox offers a toolbox.fastest handler which goes to the cache and then to the network (in 99% of the cases, cache will be faster), updating the cache in the background. What I'm wondering is how you can be notified that there's a new version of the page available. My intent is to show the cached version and, then, if the network fetch got a newer version, to suggest to the user to refresh the page in order to see the latest edits. I saw something in a YouTube video a while ago but the presenter gives no clue of how to deal with this.
Is that possible? Is there some event handler or promise that I could bind to the request so that I know when the newer version is retrieved? I would then post a message to the page to show a notification.
If not, I know I can use toolbox.networkFirst along with a reasonable timeout to make the pages available even on Lie-Fi, but it's not as good.

I just stumbled accross the Mozilla Service Worker Cookbooj, which includes more or less what I wanted: https://serviceworke.rs/strategy-cache-update-and-refresh.html
Here are the relevant parts (not my code: copied here for convenience).
Fetch methods for the worker
// On fetch, use cache but update the entry with the latest contents from the server.
self.addEventListener('fetch', function(evt) {
console.log('The service worker is serving the asset.');
// You can use respondWith() to answer ASAP…
evt.respondWith(fromCache(evt.request));
// ...and waitUntil() to prevent the worker to be killed until the cache is updated.
evt.waitUntil(
update(evt.request)
// Finally, send a message to the client to inform it about the resource is up to date.
.then(refresh)
);
});
// Open the cache where the assets were stored and search for the requested resource. Notice that in case of no matching, the promise still resolves but it does with undefined as value.
function fromCache(request) {
return caches.open(CACHE).then(function (cache) {
return cache.match(request);
});
}
// Update consists in opening the cache, performing a network request and storing the new response data.
function update(request) {
return caches.open(CACHE).then(function (cache) {
return fetch(request).then(function (response) {
return cache.put(request, response.clone()).then(function () {
return response;
});
});
});
}
// Sends a message to the clients.
function refresh(response) {
return self.clients.matchAll().then(function (clients) {
clients.forEach(function (client) {
// Encode which resource has been updated. By including the ETag the client can check if the content has changed.
var message = {
type: 'refresh',
url: response.url,
// Notice not all servers return the ETag header. If this is not provided you should use other cache headers or rely on your own means to check if the content has changed.
eTag: response.headers.get('ETag')
};
// Tell the client about the update.
client.postMessage(JSON.stringify(message));
});
});
}
Handling of the "resource was updated" message
navigator.serviceWorker.onmessage = function (evt) {
var message = JSON.parse(evt.data);
var isRefresh = message.type === 'refresh';
var isAsset = message.url.includes('asset');
var lastETag = localStorage.currentETag;
// ETag header usually contains the hash of the resource so it is a very effective way of check for fresh content.
var isNew = lastETag !== message.eTag;
if (isRefresh && isAsset && isNew) {
// Escape the first time (when there is no ETag yet)
if (lastETag) {
// Inform the user about the update.
notice.hidden = false;
}
//For teaching purposes, although this information is in the offline cache and it could be retrieved from the service worker, keeping track of the header in the localStorage keeps the implementation simple.
localStorage.currentETag = message.eTag;
}
};

Related

Blazor - Service worker not installing due to integrity check failure

I'm trying to setup PWA for my blazor application. I followed the instructions on: https://learn.microsoft.com/en-us/aspnet/core/blazor/progressive-web-app?view=aspnetcore-6.0&tabs=visual-studio
But when I open the deployed website the following error occurs:
Failed to find a valid digest in the 'integrity' attribute for resource 'domain/manifest.json' with computed SHA-256 integrity 'uDWnAIEnaz9hFx7aEpJJVS1a+QB/W7fMELDfHWSOFkQ='. The resource has been blocked.
Unknown error occurred while trying to verify integrity.
service-worker.js:22
Uncaught (in promise) TypeError: Failed to fetch
at service-worker.js:22:54
at async onInstall (service-worker.js:22:5)
In the source file this happens here:
async function onInstall(event) {
console.info('Service worker: Install');
// Fetch and cache all matching items from the assets manifest
const assetsRequests = self.assetsManifest.assets
.filter(asset => offlineAssetsInclude.some(pattern => pattern.test(asset.url)))
.filter(asset => !offlineAssetsExclude.some(pattern => pattern.test(asset.url)))
.map(asset => new Request(asset.url, { integrity: asset.hash, cache: 'no-cache' }));
await caches.open(cacheName).then(cache => cache.addAll(assetsRequests));
}
I think the error is happening since the entry in assetsRequests has a wrong hash and the resource is blocked. If I remove the file from the service-worker-assets.js, the service worker installs and the PWA can be used. But I think this is not a reliable solution.
This also happens sometimes for the appsettings.json. In the service-worker-assets.js I can find the following entry:
{
"hash": "sha256-+Py0\/ezc+0k1sm\/aruGPrVhS1jOCTfPKMhOSS+bunek=",
"url": "manifest.json"
},
So the hash does not seem to match. Where does the browser take the wrong hash from? How can I fix this so it does match?
Also it seems that the app is caching older files sometimes. Even when I do a "Reset Cache & Hard Reload" in Chrome the service-worker.js file is still an older version. Any idea how to fix this as well, since it might be related?
Edit: I was also checking this solution: https://stackoverflow.com/a/69935118/11385442. But in the mentioned blazor.boot.json I cannot find any reference to the manifest.json or the appsettings.json. Only Dlls are listed. So the problem only seems to relate to files not listed in blazor.boot.json.
Edit2: What I can see on the webserver is that the following files are published:
appsettings.json
appsettings.json.br
appsettings.json.gzip
So it seems like compressed version are added. Also the appsettings.json has a different size than the one in the solution. My guess is that somewhere in the build or release pipeline (Azure) the files are modified. But even when I copy the appsettings.json manually to the webserver the error still occurs. I was following Information provided here: https://learn.microsoft.com/en-us/aspnet/core/blazor/host-and-deploy/webassembly?view=aspnetcore-5.0
(Diagnosing integrity problems)
My guess was right. The appsettings.json was modified probably due to the xml transformation in the azure pipeline. My current solution is to exclude integrity validation for such resources as described in the following answer: Error loading appsettings.Production.json due to digest integrity issue
Also I changed the "sw-registrator.js" mentioned in the original posts comments to work correctly, because it didn't load the new files into the cache:
function invokeServiceWorkerUpdateFlow(registration) {
if (confirm("New version available, reload?") == true) {
if (registration.waiting) {
console.info(`Service worker registrator: Post skip_waiting...`);
// let waiting Service Worker know it should became active
registration.waiting.postMessage('SKIP_WAITING')
}
}
}
function checkServiceWorkerUpdate(registration) {
setInterval(() => {
console.info(`Service worker registrator: Checking for update... (scope: ${registration.scope})`);
registration.update();
}, 60 * 1000); // 60000ms -> check each minute
}
// check if the browser supports serviceWorker at all
if ('serviceWorker' in navigator) {
// wait for the page to load
window.addEventListener('load', async () => {
// register the service worker from the file specified
const registration = await navigator.serviceWorker.register('/service-worker.js');
// ensure the case when the updatefound event was missed is also handled
// by re-invoking the prompt when there's a waiting Service Worker
if (registration.waiting) {
invokeServiceWorkerUpdateFlow(registration);
}
// detect Service Worker update available and wait for it to become installed
registration.addEventListener('updatefound', () => {
if (registration.installing) {
// wait until the new Service worker is actually installed (ready to take over)
registration.installing.addEventListener('statechange', () => {
if (registration.waiting) {
// if there's an existing controller (previous Service Worker), show the prompt
if (navigator.serviceWorker.controller) {
invokeServiceWorkerUpdateFlow(registration);
} else {
// otherwise it's the first install, nothing to do
console.log('Service worker registrator: Initialized for the first time.')
}
}
});
}
});
checkServiceWorkerUpdate(registration);
let refreshing = false;
// detect controller change and refresh the page
navigator.serviceWorker.addEventListener('controllerchange', () => {
console.info(`Service worker registrator: Refreshing app... (refreshing: ${refreshing})`);
if (!refreshing) {
window.location.reload();
refreshing = true
}
});
});
}
else
{
console.error(`Service worker registrator: This browser doesn't support service workers.`);
}
Also I had to add this in service-worker.js:
self.addEventListener('message', (event) => {
console.info('Service worker: Message received');
if (event.data === 'SKIP_WAITING') {
// Cause the service worker to update
self.skipWaiting();
}
});
This code was mostly taken from https://whatwebcando.today/articles/handling-service-worker-updates/

Can I catch 404s in a service worker?

Basically, I have an online app that uses a htaccess file to silently redirect all requests in a given /folder/ to the same html. Then, to decide what to show the user, the page calls
var page_name = location.href.split('/').pop();
This works well online, but could I use a ServiceWorker to support this folder/file model while the page is offline? Or will I always get the page cannot be found error unless I explicitly cache the URLs?
What you describe can be accomplished using the App Shell model.
Your service worker's exact code might look a little different, and tools like Workbox can automate some of this for you, but a very basic, "vanilla" example of a service worker that accomplishes this is:
self.addEvenListener('install', (event) => {
const cacheShell = async () => {
const cache = await caches.open('my-cache');
await cache.add('/shell.html');
};
event.waitUntil(cacheShell());
});
self.addEventListener('fetch', (event) => {
// If this is a navigation request...
if (event.request.mode === 'navigate') {
// ...respond with the cached shell HTML.
event.respondWith(caches.match('/shell.html'));
return;
}
// Any other caching/response logic can go here.
});
Regardless of what the location.href value is, when this service worker is in control, the App Shell HTML will be used to fulfill all navigation requests.

How to simulate real "visit" with fetch request inside service worker

I'm building an PWA with limited offline capability, I'm using this code to save page content to dynamic cache every time user visits a new url:
self.addEventListener('fetch', function(event) {
event.respondWith(
fetch(event.request)
.then(function(res) {
return caches.open('cache')
.then(function(cache) {
cache.put(event.request.url, res.clone());
return res;
})
})
.catch(function(err) {
console.log( err );
return caches.match(event.request);
})
);
});
This works great, after a page is loaded all of it assets are cached and can be seen in offline mode.
But, I would also like to add the option to automatically cache some of the more important urls when the user comes back online.
I do that by putting the list of urls in the array, loop through it and send a fetch request to each url, so those pages can be cached without user visiting/revisiting the page.
Problem is that when I do that some of the assets on some pages are not cached, for example google map on one page, is there a way to simulate real visit to a page, that gets all of the assets from an url with fetch request?
Fetch code:
function fillDynamicCache(user_id = false) {
let urls = [
'/homepage',
'/someotherpage',
'/thirdpage',
'/...',
];
urls.map((url, id) => (
fetch(url)
.then(
function(response) {
if (response.status !== 200) {
console.log('Looks like there was a problem. Status Code: ' +
response.status);
return;
}
console.log( 'in fetch: ' + url );
}
)
.catch(function(err) {
console.log('Fetch Error :-S', err);
})
));
}
self.addEventListener('message', (event) => {
// refresh cache when user comes back online
if (event.data == 'is_online') {
fillDynamicCache();
} else if (event.data == 'is_updated') {
self.skipWaiting();
Typically if you have important assets you want to provide the users, even when they are offline, you should consider an offline first strategy, meaning you prefetch those resources while the service worker is installing.
This way the matching requests will be served from the cache, improving the performance because you skip the relative network calls entirely.
In case the target resources tend to update/change frequently on the server, then you can opt for a stale while revalidate strategy (after the data is provided from the cache, the SW will update its value with a newer one from the network, if available) or even network first, fallback to cache, the latter if you want to provide always the latest values and provide cache data only if the network connection times out or is unavailable.
I wrote an article about service worker and caching strategies, in case you want to go deeper into the topic.

How to make persistent PWA cache?

I've been trying to make offline only PWAs for Android, but the site's cache keeps clearing every so often. Is there any way to make the cache stay permanently?
You can define caching strategies for static assets and data requests for your service worker.
In the following article about service workers and caching strategies I list the different strategies and describe when it makes more sense to implement a specific one.
You can cache static assets and provide them offline when the SW is installing. Those files should be only the "minimum" version of your app (usually called app shell). Because of this, the cache.addAll method rejects the promise if it is not possible to get any of the resources. This means the service worker will install successfully only if all the targeted resources are cached.
self.addEventListener('install', function(event) {
event.waitUntil(
caches.open('staticAssetsCache').then(function(cache) {
return cache.addAll(
[
'/css/bootstrap.css',
'/css/styles.css',
'/js/jquery.min.js',
'/offline.html'
]
);
})
);
});
You can also cache HTTP GET Requests, for example below the stale while revalidate strategy that returns the data from the cache, if available, and in the background attempts to fetch and cache a newer version from the network:
self.addEventListener('fetch', function(event) {
event.respondWith(
caches.open('www.my-web-app.com')
.then(function(cache) {
return cache.match(event.request)
.then(function(response) {
var fetchPromise = fetch(event.request).then(function(networkResponse) {
cache.put(event.request, networkResponse.clone());
return networkResponse;
})
// response contains cached data, if available
return response || fetchPromise;
})
})
);
});
If you are using Angular or Workbox library, https://dev.to/paco_ita/create-progressive-web-apps-with-angular-workbox-pwa-builder-step-4-27d for more details.
I believe I read somewhere iOS Safari and Chrome would invalidate the cache frequently to get new updates. No logic behind it, just re-fetching the files.
Solution:
(In a recent Chrome devlog, it mentions a reduction in frequency from 3 days to 1)
to prevent the clearing of the cache / IndexDB I found this.
if (navigator.storage && navigator.storage.persist)
// '.persist()' will silently pass or trigger a dialog
navigator.storage.persist().then(function(persistent) {
alert(persistent ? 'persistent' : 'denied');
})
else
alert('not available - iOS / ancient Android?');

Service Worker: How to cache the first (dynamic) page

I have this one-page app with a dynamic URL built with a token, like example.com/XV252GTH and various assets, like css, favicon and such.
Here is how I register the Service Worker:
navigator.serviceWorker.register('sw.js');
And in said sw.js, I pre-cache the assets while installing:
var cacheName = 'v1';
var cacheAssets = [
'index.html',
'app.js',
'style.css',
'favicon.ico'
];
function precache() {
return caches.open(cacheName).then(function (cache) {
return cache.addAll(cacheAssets);
});
}
self.addEventListener('install', function(event) {
event.waitUntil(precache());
});
Note that the index.html (that registers the Service Worker) page is just a template, that gets populated on the server before being sent to the client ; so in this pre-caching phase, I'm only caching the template, not the page.
Now, in the fetch event, any requested resource that is not in the cache gets copied to it:
addEventListener('fetch', event => {
event.respondWith(async function() {
const cachedResponse = await caches.match(event.request);
if (cachedResponse) return cachedResponse;
return fetch(event.request).then(updateCache(event.request));
}());
});
Using this update function
function updateCache(request) {
return caches.open(cacheName).then(cache => {
return fetch(request).then(response => {
const resClone = response.clone();
if (response.status < 400)
return cache.put(request, resClone);
return response;
});
});
}
At this stage, all the assets are in the cache, but not the dynamically generated page. Only after a reload, can I see another entry in the cache: /XV252GTH. Now, the app is offline-ready ; But this reloading of the page kind of defeats the whole Service Worker purpose.
Question: How can I send the request (/XV252GTH) from the client (the page that registers the worker) to the SW? I guess I can set up a listener in sw.js
self.addEventListener('message', function(event){
updateCache(event.request)
});
But how can I be sure that it will be honored in time, ie: sent by the client after the SW has finished installing? What is a good practice in this case?
OK, I got the answer from this page: To cache the very page that registers the worker at activation time, just list all the SW's clients, and get their URL (href attribute).
self.clients.matchAll({includeUncontrolled: true}).then(clients => {
for (const client of clients) {
updateCache(new URL(client.url).href);
}
});
Correct me if I understood you wrong!
You precache your files right here:
var cacheAssets = [
'index.html',
'app.js',
'style.css',
'favicon.ico'
];
function precache() {
return caches.open(cacheName).then(function (cache) {
return cache.addAll(cacheAssets);
});
}
It should be clear that you cache the template since you cache it before the site gets build and this approach is not wrong, at least not for all types of files.
Your favicon.ico for example is a file that you would probably consider as static. Also, it does not change very often or not at all and it isn't dynamic like your index.html.
Source
It should also be clear why you have the correct version after reloading the page since you have an update function.
The solution to this problem is the answer to your question:
How can I send the request (/XV252GTH) from the client (the page that registers the worker) to the SW?
Instead of caching it before the service-worker is installed you want to cache it if the back end built your web page. So here is how it works:
You have an empty cache or at least a cache without your index.html.
Normally a request would be sent to the server to get the index.html. Instead, we do a request to the cache and check if the index.html is in the cache, at least if you load the page for the first time.
Since there is no match in the cache, do a request to the server to fetch it. This is the same request the page would do if it would load the page normally. So the server builds your index.html and sends it back to the page.
After receiving the index.html load it to the page and store it in the cache.
An example method would be Stale-while-revalidate:
If there's a cached version available, use it, but fetch an update for next time.
self.addEventListener('fetch', function(event) {
event.respondWith(
caches.open('mysite-dynamic').then(function(cache) {
return cache.match(event.request).then(function(response) {
var fetchPromise = fetch(event.request).then(function(networkResponse) {
cache.put(event.request, networkResponse.clone());
return networkResponse;
})
return response || fetchPromise;
})
})
);
});
Source
Those are the basics for your problem. Now you got a wide variety of options you can choose from that use the same method but have some additional features. Which one you choose is up to you and without knowing your project in detail no one can tell you which one to choose. You are also not limited to one option. In some cases you might combine two or more options together.
Google wrote a great guide about all the options you have and provided code examples for everything. They also explained your current version. Not every option will be interesting and relevant for you but I recommend you to read them all and read them thoroughly.
This is the way to go.

Categories

Resources