Wednesday 30 January 2019

Creating an Offline-First Progressive Web App in Blazor


In this post, I will cover the basics of turning your Blazor application into an Offline-First Progressive Web App that can be installed on a user's desktop / home screen.

Offline First

This describes a behaviour where your application will display basic information even when the client computer is not connected to the internet, or has poor connectivity.
So, none of this...

Browser offline page notification

The Service Worker

To achieve offline-first behaviour, we need to cache the minimum set of assets that allow the application to function offline, and we will write a Service Worker to handle this.

Our service worker is simply a javascript file that implements a basic set of methods, the first of which is install, where we create a cache of the apps assets.

For a Blazor application, this is a lengthy list as we need to cache the application and all it's dependencies.

Create a new file under the wwwroot folder in your Blazor application and call it give it a meaningful name - mine is blazorServiceWorker.js
const filesToCache = [
    // Blazor standard requirements
    '/_framework/_bin/Microsoft.AspNetCore.Blazor.Browser.dll',
    '/_framework/_bin/Microsoft.AspNetCore.Blazor.dll',
    '/_framework/_bin/Microsoft.AspNetCore.Blazor.TagHelperWorkaround.dll',
    '/_framework/_bin/Microsoft.Extensions.DependencyInjection.Abstractions.dll',
    '/_framework/_bin/Microsoft.Extensions.DependencyInjection.dll',
    '/_framework/_bin/Microsoft.JSInterop.dll',
    '/_framework/_bin/Mono.WebAssembly.Interop.dll',
    '/_framework/_bin/mscorlib.dll',
    '/_framework/_bin/System.Core.dll',
    '/_framework/_bin/System.dll',
    '/_framework/_bin/System.Net.Http.dll',
    '/_framework/wasm/mono.js',
    '/_framework/wasm/mono.wasm',
    '/_framework/blazor.boot.json',
    '/_framework/blazor.webassembly.js',
 
    // App specific requirements
    '/_framework/_bin/BlazorAppUpdates.dll',
    '/_framework/_bin/BlazorAppUpdates.pdb',
    '/css/bootstrap/bootstrap.min.css',
    '/css/open-iconic/font/css/open-iconic-bootstrap.min.css',
    '/css/site.css',
    '/favicon.ico',
    '/index.html',
 
    // Service Worker
    '/blazorSWRegister.js',
 
    // Application Manifest (PWA)
    '/manifest.json'
];
 
const staticCacheName = 'blazor-cache-v1';
self.addEventListener('install', event => {
    self.skipWaiting();
    event.waitUntil(
        caches.open(staticCacheName)
            .then(cache => {
                return cache.addAll(filesToCache);
            })
    );
});

Here, we are adding an event listener for the install event, which will create a browser cache and add all of the files listed in filesToCache.

filesToCache is simply an array listing all the assets our application requires to function offline.

staticCacheName is where we define the name of the cache - it is good practice to include a version number here, so we can manage cache recycling for each new version of our application.

Now we have code to configure our application assets cache but it is not being called from anywhere, so let's take a small diversion to set that up.

Create another JavaScript file under wwwroot - mine is called blazorSWRegister.js.
if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register('/blazorServiceWorker.js')
        .then(function (registration) {
            console.log('Registration successful, scope is:', registration.scope);
        })
        .catch(function (error) {
            console.log('Service worker registration failed, error:', error);
        });
}

This code is checking that Service Worker is supported in the browser and passing our service worker JS file to the register method. There are a couple of console logs just for the developer.

Add the register JS file to your index.html
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width">
    <title>BlazorAppUpdates</title>
    <base href="/" />
    <link href="css/bootstrap/bootstrap.min.css" rel="stylesheet" />
    <link href="css/site.css" rel="stylesheet" />
 
    <!-- Register our service worker -->
    <script src="blazorSWRegister.js"></script>
</head>

So, now we have code that will run on startup to register the service worker, which will then cache all the files needed to work offline.

But, we don't have anything to serve the cached files when they are needed, so let's add some more code to our service worker that will intercept web requests and serve them from the cache. We can do this in the fetch event handler.
self.addEventListener('fetch', event => {
    const requestUrl = new URL(event.request.url);
 
    // First, handle requests for the root path - server up index.html
    if (requestUrl.origin === location.origin) {
        if (requestUrl.pathname === '/') {
            event.respondWith(caches.match('/index.html'));
            return;
        }
    }
    // Anything else
    event.respondWith(
        // Check the cache
        caches.match(event.request)
            .then(response => {
                // anything found in the cache can be returned from there
                // without passing it on to the network
                if (response) {
                    console.log('Found ', event.request.url, ' in cache');
                    return response;
                }
                // otherwise make a network request
                return fetch(event.request)
                    .then(response => {
                        // if we got a valid response 
                        if (response.ok) {
                            // and the request was for something rfom our own app url
                            // we should add it to the cache
                            if (requestUrl.origin === location.origin) {
 
                                const pathname = requestUrl.pathname;
                                console.log("CACHE: Adding " + pathname);
                                return caches.open(staticCacheName).then(cache => {
                                    // you can only "read" a response once, 
                                    // but you can clone it and use that for the cache
                                    cache.put(event.request.url, response.clone());
                                });
                            }
                        }
                        return response;
                    });
            }).catch(error => {
                // handle this error - for now just log it
                console.log(error);
            })
    );
});

I have included comments in the code, but the logic is just this

  • Request for the root "/" path => get index.html from cache and return that.
  • If the request was for anything else, check the cache and return from the cache if found.
  • If it wasn't found in the cache, let the request out to the network
  • If the network responds ok, and the request was for something from our own URL, add it to the cache and return it
  • Return the network response.
Now you should have an offline first app that will cache itself and fall back to the network for any external requests.

Note: This is an MVP implementation - in the real world you will also need code to clean up the cache and to allow for updates when you publish a new version. Look out for a follow up post about those.


Progressive Web App

This describes a behaviour where, for the purpose of this post, your application will be installable on the user's desktop/home screen and will launch in it's own window / context.

Thankfully, this is also simple - it's just a manifest.json file in wwwroot

{
  "short_name": "Blazor",
  "name": "Blazor PWA",
  "icons": [
    {
      "src": "/images/android-icon-192x192.png",
      "type": "image/png",
      "sizes": "192x192"
    },
    {
      "src": "/images/android-icon-512x512.png",
      "type": "image/png",
      "sizes": "512x512"
    }
  ],
  "start_url": "/",
  "background_color": "#3367D6",
  "display": "standalone",
  "theme_color": "#3367D6"
}

I suggest reading about manifest.json elsewhere, just know that "display":"standalone" is what makes it open like a native app on the users' machine - after they choose to install it, and you should include at least two icons at 192x192 and 512x512.

You add this as a link in the head of your index.html
<head>
...
    <!-- Manifest for enabling PWA -->
    <link href="manifest.json" rel="manifest" />
...
</head>

Now, when you visit your application URL, (I think the convention is you have to visit at least two times several minutes apart?) the browser should prompt you to install the PWA.

If it doesn't, in Chrome you can open the browser menu (three vertical dots in the top right corner) and it should have an option to "Install your_app_name...".

Summary

You now have the tools to make your Blazor app into an installable offline-first PWA, but there is more to do!

You may have Apis that you call - what behaviour do you want from your application when they are not accessible?

Add code to your application to gracefully handle this - remember you have only cached the minimum required for your application so far.

Perhaps you read a feed of some kind and display posts in your application - you can add code to the service worker to store those posts (in IndexedDB for example) and serve them from there when they are offline.

This is just the beginning - have fun!

Want to try out Blazor? Head to Blazor.net

edit: added the app's pdb to the cache list - seems to be required at this stage at least during development - presumably not when published.
edit: remove extra reference to blazorCache.js - should not have been there - thanks kiyote!