Goeleven.com

Offline Shell

O

Last time I introduced the application shell pattern, which is a way to build Progressive Web Apps similar to how you would build a native application.

This time I'd like to dig a little bit deeper into the 'installation' phase of this pattern and show how you can leverage google workbox to make your app available offline.

This article provides further detail to the first two steps of the application lifecycle.

Offline Shell

These steps were described as:

  1. For each registered Micro Frontend, the Service Worker will call an installer. This class will install all the files that are contained in the capability. The Service Worker will start serving those files from local storage instead of downloading them from the network.
  2. The installer will put the file content in local storage and register the metadata in Indexeddb. The metadata contains versioning information that will be used to update the file content whenever new versions of that file become available. All of this happens in the Service Worker, so it's outside the scope of a specific page.

Let's break this down!

Service Worker

I already introduced the Service Worker as being a prerequisite for Progressive Web Apps. In essence it is a script, usually name sw.js, that runs in the background of a website and it keeps running even when the website is closed.

You can register this script the following way:

 if ('serviceWorker' in navigator) {
    window.addEventListener('load', function() {
        navigator.serviceWorker
            .register('/sw.js')
            .then(function(registration) {
                    console.log('ServiceWorker registration successful with scope: ', registration.scope);
            }, function(err) {
                console.log('ServiceWorker registration failed: ', err);
            });
    });
}	

In this script you can use a global variable called self to subscribe on certain lifecycle events of the service worker. For a full list of lifecycle events refer to the google documentation on the topic.

The most important events for installing and serving files are respectively install and fetch.

The basics to make your app function offline are to add all of the application files into local cache when the service worker installs, and the return those files from cache whenever a fetch request matches the cached filenames.

self.addEventListener('install', (event) => {

  // add files to cache on install
  event.waitUntil(
    caches.open('clubmgmt-appname-cache-1.0.0.0')
          .then(cache => cache.add('/index.html'))
  );
});

self.addEventListener('fetch', (event) => {

  // return files from cache instead of fetching it from the network
  const url = new URL(event.request.url);

  if (url.origin == location.origin && url.pathname == '/index.html') {
    event.respondWith(caches.match('/index.html'));
  }

});

Serving files from local cache is fairly easy, but you've probably heard the saying before:

There are only two hard things in Computer Science: cache invalidation and naming thing.

Cache invalidation is notoriously hard! Files change in an indeterministic way and figuring out when a cached file has been updated at just the right time, without sacrificing the benefits of the cache, is a big challenge.

Luckily some smart engineers at google have figured out ways to do it and bundled up their knowledge into a library called Workbox.

Workbox

The workbox library can be imported directly from the google cdn.

importScripts('https://storage.googleapis.com/workbox-cdn/releases/6.0.2/workbox-sw.js');

To configure workbox you need to provide it a json array containing the url to cache as well as a revision value indicating the file version.

self.__precacheManifest = [
  { url:'/css/bootstrap.min.css', revision: '5a3d8c05785485d36ee5c94d4681e5b1d9e4b94c5be8b5bd7b0f3168fff1bd9a' }, 
  { url:'/css/layout.css', revision: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' }, 
  { url:'/css/theme.css', revision: 'c48b611dc53366e3cd4bbc41b41b0b549c0e1d28b2e7c4e056a9601f7873adb9' }, 
  { url:'/index.js', revision: '2e585d9682811b39ca591522ba7831ed098e96413dd731c3e7ecc588f673963c' },
  { url:'/index.html', revision: 'a988e7c0b6d78ff0e340e880361bb4a1aad1d4367889fad0a00a69b50544f24e' },
  { url:'/favicon.ico', revision: '7178dfc35f7dd45eab8b85821dcd6e0d49f814f041381ec8160b99f51fc1d77a' }
];

File versioning

The revision value can be any value representing the file version. "1.0.0" and "1.0.1" would work. It's however not advised to manually maintain this value.

Instead it's better to use a hashing algorithm, such as MD5 or SHA256, on the file content to compute a hash value. I have built this capability into my static site generator, but you could also use the workbox-cli for this purpose.

Cache

Next to defining the file information, you can also control the name of the underlying indexeddb and cache storage entries by setting the cache name details. Changing either the prefix or the suffix part of the name will result in a completely new cache of all the files, so don't change these over time unless you really want to reset everything, e.g. on a major version.

if (workbox) {
   var cacheVersion = '1.0.0.0'; // use for majors only

  workbox.core.setCacheNameDetails({
    prefix: 'clubmgmt-appname-cache',
    suffix: cacheVersion
  });
}

Routing

When workbox has been configured you can instruct it to start returning cached file content, for traffic routed via the service worker, by calling the precacheAndRoute function. This function will also start downloading all the files asynchronously, even when the user should navigate away from the page.

if (workbox) {

  workbox.precaching.precacheAndRoute(self.__precacheManifest || []);

}

Workbox can also be configured to cache any future request to files that haven't been precached. Very convenient to make image files available offline, when they have been accessed before.

if (workbox) {
  workbox.routing.registerRoute(/\.(?:png|gif|jpg|svg)$/,
    new workbox.strategies.CacheFirst({
      cacheName: 'clubmgmt-appname-images-cache-' + cacheVersion
    })
  );
}

Attaching the service worker to active pages

Installing a new service worker happens outside of the scope of the active web page, it's asynchronous. But that also means that requests from any already active page will not be served by the service worker, only future pages will.

If you want the service worker to start serving requests from pages that are already open, you need to instruct it to do so!

By calling .skipWaiting the freshly installed service worker will immediately replace the previous instance instead of waiting till it shuts down.

self.addEventListener('install', function (event) {
  self.skipWaiting();
});

By calling clients.claim(), the service worker will also start to accept fetch requests from all open pages.

self.addEventListener('activate', event => {
  event.waitUntil(clients.claim());
});

Composition

There is only one active service worker for the entire web app. As I compose my web app from multiple capabilities I had to figure out how to compose the service worker as well, only the capabilities know which files to cache.

The solution I came up with is a convention where each capability exposes a file with an .app.installer.js extension. In this file, there must reside a class that has an install(workbox) function and a name field. This file should also push an instance of this class into an self.installers array scoped to the service worker. In the install function the installer class will configure workbox according to its needs.

Note: seeing how volatile workbox versioning is, a new major every few months, I will probably reconsider this design as it's api's are likely to break quite often. I really do not want to update every single capability each time a vendor decides to make a change. Software development these days often feels like building on quicksand.

if (!self.installers) {
    self.installers = [];
}

class OrganizationAppInstaller{
    
    constructor(){
        this.name = "Organization"
    }

    install(workbox){    
        workbox.precaching.precacheAndRoute( [ 
            { url:'/js/clubmanagement.organization.app.js', revision: '61306ae74fa9d0069a3d8e65818662ac1cf8f399c7924c0d59d7c77adabd09aa' },
            { url:'/js/clubmanagement.organization.feature.js', revision: 'f0e177a03e7352bf9547662dd715dcb9ee82aca64c021fe53168d982e44a51df' },
            { url:'/js/organization/config.js', revision: '825736f0aec0f7daf63069e0b16eb4594ba3b823b1e44fbeda02b2fe8ee080af' },
            { url:'/js/organization/referencedata.js', revision: 'c26cb72d34e88463b6aabc4ba56200e4927f900de50a77177dbfc1c11faf5596' },
            { url:'/js/organization/components/organization-pane.js', revision: '949d9f1119371a047a82e0725dc3c63277fcb862732d984d2c23a01da32f8342' },
            { url:'/js/organization/components/organization-name.js', revision: 'a3cb8d54963dd910f62526880bd8515beef01721988ac940f44c4f78f035c39e' },
            { url:'/js/organization/components/group-name.js', revision: '165c9c5c71ffe83187aa0df717302165f9b10a52ea6d7b817cfe30c0e862f5b3' },
            { url:'/js/organization/components/group-selector.js', revision: '08eff09948bf5a266eca5c50be54d89b1aa47a10dafb4964c85c2924c16ec9ed' },
            { url:'/js/organization/components/role-selector.js', revision: '6600fd37b567f8d702f181a9adf2439936ac11bbaeda6ce3b8c021c5c7d58eaf' }
        ]);
    }
}

self.installers.push(new OrganizationAppInstaller());

In the service worker script, the installer files are injected at the top by my static site generator. This ensures that the self.installers array is populated and the install function can be called on each later in the script.

importScripts('/js/clubmanagement.organization.app.installer.js');

var buildRevision = '637488974300060735';

if (workbox) {
    if (self.installers) {    
        for(var installer of self.installers){
            if (typeof installer.install === 'function')
            {
                console.log(`Calling install on installer ` + installer.name); 
        
                installer.install(workbox);
            }
        }  
    }
}

Service worker update behavior

A new service worker instance is only registered when the sw.js file content has changed. So any change in an installer file will NOT trigger a new service worker instance.

Adding the installer files into the workbox configuration will not help with updating either. Workbox would download the new version, but it would never call install on it.

So the solution I came up with is to ensure that the service worker file content itself changes every time a new version is built. This by changing the value of an otherwise unused variable called buildRevision.

This way the service worker script will run every time a new deployment occurs, which in turn will call the install functions again, adding any new files or file versions to the workbox configuration.

Wrapup

In this article I've tried to explain how I use google workbox to store my app's files in cache and serve them even when the app is offline.

I had to jump a few hoops to make it play ball with my approach to UI composition, but in the end it works out quite well.

This approach outlined here only provides a solution for the app's files, but it won't make your business logic magically offline capable. I will discuss approaches to achieve that in future posts, so stay tuned!

About the author

YVES GOELEVEN

I love to build web and cloud apps and have been doing so for the past 20 years.

My main areas of expertise are progressive web applications, event driven architecture, domain driven design, event sourcing, messaging, and Microsoft Azure.

Through this blog I hope to share some of this experience with you.

Get in touch

Sign up to my newsletter to get notified about new content

You can unsubscribe at any time by clicking the link in the footer of your emails. I use Mailchimp as my marketing platform. By clicking subscribe, you acknowledge that your information will be transferred to Mailchimp for processing. Learn more about Mailchimp's privacy practices here.