PWAs laten offline laten werken met Service Workers

Nu dat we de structuur van de js13kPWA gezien hebben, en de basis "up & running" hebben kunnen we kijken naar de offline mogelijkheden van de "service worker" en hoe deze geïmplementeerd is.
In dit artikel kijken we naar het js13kPWA voorbeeld (zie broncode). En we onderzoeken hoe we offline functionaliteit kunnen toevoegen.

Service workers uitgelegd

Service Workers zijn een virtuele brug (proxy) tussen de browser en het netwerk. Ze lossen problemen op waar front-end ontwikkelaars al jaren mee kampen. Voornamelijk hoe ze op een goede manier zaken kunnen cachen deze toch aanspreken als het toestel niet niet verbonden is met het netwerk of internet.

Service Workers lopen op een andere thread dan de JavaScript code van een website. Ze hebben hierdoor ook geen toegang tot de DOM van een pagina. Hierdoor moeten we naar andere zaken kijken in vergelijking met traditionele web ontwikkeling. De API is "non-blocking" en verstuurt communicatie tussen de "contexts" (we hebben hiervoor vertaling nodig). Je kan een Service Worker iets geven om uit te voeren, en je ontvangt later het resultaat als het klaar is door middel van een Promise.

Niet enkel "offline" mogelijkheden, maar ook het afhandelen van "notifications", uitvoeren van zware berekeningen op een andere "thread" zijn voorbeelden die Service workers op zich kunnen nemen.  Ze zijn een bijzonder krachtige tool omdat ze controle kunnen overnemen van netwerk aanvragen, ze kunnen aanpassen om dan gewijzigde resultaten terug te sturen van een "cache", of een combinatie hiervan.

Beveiliging

Because they are so powerful, Service Workers can only be executed in secure contexts (meaning HTTPS). If you want to experiment first before pushing your code to production, you can always test on a localhost or setup GitHub Pages — both support HTTPS.

Offline First

The "offline first" — or "cache first" — pattern is the most popular strategy for serving content to the user. If a resource is cached and available offline, return it first before trying to download it from the server. If it isn’t in the cache already, download it and cache it for future usage.

"Progressive" in PWA

When implemented properly as a progressive enhancement, service workers can benefit users who have modern browsers that support the API by providing offline support, but won't break anything for those using legacy browsers.

Service workers in the js13kPWA app

Enough theory — let's see some source code!

Registering the Service Worker

We'll start by looking at the code that registers a new Service Worker, in the app.js file: 

NOTE : We're using the es6 arrow functions syntax in the Service Worker Implementation

if('serviceWorker' in navigator) {
    navigator.serviceWorker.register('./pwa-examples/js13kpwa/sw.js');
};

If the service worker API is supported in the browser, it is registered against the site using the ServiceWorkerContainer.register() method. Its contents reside in the sw.js file, and can be executed after the registration is successful. It's the only piece of Service Worker code that sits inside the app.js file; everything else that is Service Worker-specific is written in the sw.js file itself.

Lifecycle of a Service Worker

When registration is complete, the sw.js file is automatically downloaded, then installed, and finally activated.

Installation

The API allows us to add event listeners for key events we are interested in — the first one is the install event:

self.addEventListener('install', (e) => {
    console.log('[Service Worker] Install');
});

In the install listener, we can initialize the cache and add files to it for offline use. Our js13kPWA app does exactly that.

First, a variable for storing the cache name is created, the app shell files are listed in one array.

var cacheName = 'js13kPWA-v1';
var appShellFiles = [
  '/pwa-examples/js13kpwa/',
  '/pwa-examples/js13kpwa/index.html',
  '/pwa-examples/js13kpwa/app.js',
  '/pwa-examples/js13kpwa/style.css',
  '/pwa-examples/js13kpwa/fonts/graduate.eot',
  '/pwa-examples/js13kpwa/fonts/graduate.ttf',
  '/pwa-examples/js13kpwa/fonts/graduate.woff',
  '/pwa-examples/js13kpwa/favicon.ico',
  '/pwa-examples/js13kpwa/img/js13kgames.png',
  '/pwa-examples/js13kpwa/img/bg.png',
  '/pwa-examples/js13kpwa/icons/icon-32.png',
  '/pwa-examples/js13kpwa/icons/icon-64.png',
  '/pwa-examples/js13kpwa/icons/icon-96.png',
  '/pwa-examples/js13kpwa/icons/icon-128.png',
  '/pwa-examples/js13kpwa/icons/icon-168.png',
  '/pwa-examples/js13kpwa/icons/icon-192.png',
  '/pwa-examples/js13kpwa/icons/icon-256.png',
  '/pwa-examples/js13kpwa/icons/icon-512.png'
];

Next, the links to images to be loaded along with the content from the data/games.js file are generated in the second array. After that, both arrays are merged using the Array.prototype.concat() function.

var gamesImages = [];
for(var i=0; i<games.length; i++) {
  gamesImages.push('data/img/'+games[i].slug+'.jpg');
}
var contentToCache = appShellFiles.concat(gamesImages);

Then we can manage the install event itself:

self.addEventListener('install', (e) => {
  console.log('[Service Worker] Install');
  e.waitUntil(
    caches.open(cacheName).then((cache) => {
          console.log('[Service Worker] Caching all: app shell and content');
      return cache.addAll(contentToCache);
    })
  );
});

There are two things that need an explanation here: what ExtendableEvent.waitUntil does, and what the caches object is.

The service worker does not install until the code inside waitUntil is executed. It returns a promise — this approach is needed because installing may take some time, so we have to wait for it to finish.

caches is a special CacheStorage object available in the scope of the given Service Worker to enable saving data — saving to web storage won't work, because web storage is synchronous. With Service Workers, we use the Cache API instead.

Here, we open a cache with a given name, then add all the files our app uses to the cache, so they are available next time it loads (identified by request URL).

Activation

There is also an activate event, which is used in the same way as install. This event is usually used to delete any files that are no longer necessary and clean up after the app in general. We don't need to do that in our app, so we'll skip it.

Responding to fetches

We also have a fetch event at our disposal, which fires every time an HTTP request is fired off from our app. This is very useful, as it allows us to intercept requests and respond to them with custom responses. Here is a simple usage example:

self.addEventListener('fetch', (e) => {
    console.log('[Service Worker] Fetched resource '+e.request.url);
});

The response can be anything we want: the requested file, its cached copy, or a piece of JavaScript code that will do something specific — the possibilities are endless.

In our example app, we serve content from the cache instead of the network as long as the resource is actually in the cache. We do this whether the app is online or offline. If the file is not in the cache, the app adds it there first before then serving it:

self.addEventListener('fetch', (e) => {
  e.respondWith(
    caches.match(e.request).then((r) => {
          console.log('[Service Worker] Fetching resource: '+e.request.url);
      return r || fetch(e.request).then((response) => {
                return caches.open(cacheName).then((cache) => {
          console.log('[Service Worker] Caching new resource: '+e.request.url);
          cache.put(e.request, response.clone());
          return response;
        });
      });
    })
  );
});

Here, we respond to the fetch event with a function that tries to find the resource in the cache and return the response if it's there. If not, we use another fetch request to fetch it from the network, then store the response in the cache so it will be available there next time it is requested.

The FetchEvent.respondWith method takes over control — this is the part that functions as a proxy server between the app and the network. This allows us to respond to every single request with any response we want: prepared by the Service Worker, taken from cache, modified if needed.

That's it! Our app is caching its resources on install and serving them with fetch from the cache, so it works even if the user is offline. It also caches new content whenever it is added.

Updates

There is still one point to cover: how do you upgrade a Service Worker when a new version of the app containing new assets is available? The version number in the cache name is key to this:

var cacheName = 'js13kPWA-v1';

When this updates to v2, we can then add all of our files (including our new files) to a new cache:

contentToCache.push('/pwa-examples/js13kpwa/icons/icon-32.png');

// ...

self.addEventListener('install', (e) => {
  e.waitUntil(
    caches.open('js13kPWA-v2').then((cache) => {
      return cache.addAll(contentToCache);
    })
  );
});

A new service worker is installed in the background, and the previous one (v1) works correctly up until there are no pages using it — the new Service Worker is then activated and takes over management of the page from the old one.

Clearing the cache

Remember the activate event we skipped? It can be used to clear out the old cache we don't need anymore:

self.addEventListener('activate', (e) => {
  e.waitUntil(
    caches.keys().then((keyList) => {
          return Promise.all(keyList.map((key) => {
        if(cacheName.indexOf(key) === -1) {
          return caches.delete(key);
        }
      }));
    })
  );
});

This ensures we have only the files we need in the cache, so we don't leave any garbage behind; the available cache space in the browser is limited, so it is a good idea to clean up after ourselves.

Other use cases

Serving files from cache is not the only feature Service Worker offers. If you have heavy calculations to do, you can offload them from the main thread and do them in the worker, and receive results as soon as they are available. Performance-wise, you can prefetch resources that are not needed right now, but might be in the near future, so the app will be faster when you actually need those resources.

Summary

In this article we took a simple look at how you can make your PWA work offline with service workers. Be sure to check out our further documentation if you want to learn more about the concepts behind the Service Worker API and how to use it in more detail.

Service Workers are also used when dealing with push notifications — this will be explained in a subsequent article.