Maxim Salnikov

Angular GDE

Sending the Angular app into deep, deep offline with Workbox

How to build an offline-ready Angular app

Using a framework-agnostic library

Maxim Salnikov

  • Angular Oslo meetup organizer

  • ngVikings conference founder

  • ngCommunity initiative starter

  • Google Dev Expert in Angular

Developer Engagement Lead at Microsoft

Build with Angular[JS] since v1.1.4

Proper offline-ready web app

  • App itself

  • Online runtime data

  • Offline runtime data

  • Connection failures

  • Updates

  • Platform features

  • Always available

  • Thoughtfully collected

  • Safely preserved

  • Do not break the flow

  • Both explicit and implicit

  • For the win!

While keeping its web nature!

Precaching the app [shell] itself

  • Define assets

  • Put in the cache

  • Serve from the cache

  • Manage versions

}

Service worker

Hereafter: "cache" = Cache Storage

  • Define assets

  • Put in the cache

  • Serve from the cache

  • Manage versions

}

Options for Angular

Angular

Service Worker

  • Balanced abstraction level

  • Declarativeness where appropriate

  • Modularity and extensibility

  • Rich functionality out of the box

  • Powerful tooling 

~30% of the service workers — are...

Open source, active maintenance and support 

Setting up

  1. Create a source service worker

  2. Inject app assets versioned list

  3. Bundle and compress

  4. Register SW in the app

 

# Installing the Workbox Node module
$ npm install workbox-build --save-dev

}

On every app build

Source service worker

import {
  precacheAndRoute,
  createHandlerBoundToURL
} from "workbox-precaching";
import { NavigationRoute, registerRoute } from "workbox-routing";

// Precaches and routes resources from __WB_MANIFEST array
precacheAndRoute(self.__WB_MANIFEST);

// Setting up navigation for SPA
const navHandler = createHandlerBoundToURL("/index.html");
const navigationRoute = new NavigationRoute(navHandler);
registerRoute(navigationRoute);

src/service-worker.js

Injecting app asset list

const { injectManifest } = require("workbox-build");

let workboxConfig = {
  swSrc: "src/service-worker.js",
  swDest: "dist/prog-web-news/sw.js",
  // + more on the next slide
};

injectManifest(workboxConfig).then(() => {
  console.log(`Generated ${workboxConfig.swDest}`);
});

workbox-inject.js

Config for Angular

globDirectory: "dist/prog-web-news",
globPatterns: ["index.html", "*.css", "*.js", "assets/**/*"],
globIgnores: [
    "**/*-es5.*.js", // Skip ES5 bundles for Angular
],

// Angular takes care of cache busting for JS and CSS (in prod mode)
dontCacheBustURLsMatching: new RegExp(".+.[a-f0-9]{20}.(?:js|css)"),

// By default, Workbox will not cache files larger than 2Mb
// (might be an issue for dev builds)
maximumFileSizeToCacheInBytes: 4 * 1024 * 1024, // 4Mb

workbox-inject.js / workboxConfig object

[Almost] ready service worker

import { precacheAndRoute } from "workbox-precaching";
import { NavigationRoute, registerRoute } from "workbox-routing";
...

precacheAndRoute([
  { revision: "866bcc582589b8920dbc5bccb73933b1", url: "index.html" },
  { revision: null, url: "styles.c2761edff7776e1e48a3.css" },
  { revision: null, url: "main.3469613435532733abd9.js" },
  { revision: null, url: "polyfills.25b2e0ae5a439ecc1193.js" },
  { revision: null, url: "runtime.359d5ee4682f20e936e9.js" },
  {
    revision: "33c3a22c05e810d2bb622d7edb27908a",
    url: "assets/img/pwa-logo.png",
  },
]);

dist/prog-web-news/sw.js

Bundling and compressing

import resolve from 'rollup-plugin-node-resolve'
import replace from 'rollup-plugin-replace'
import { terser } from 'rollup-plugin-terser'

export default {
  input: 'dist/prog-web-news/sw.js',
  output: {
    file: 'dist/prog-web-news/sw.js',
    format: 'iife'
  },
  plugins: [ /* Next slide */ ]
}

rollup.config.js

Rollup plugins configuration

plugins: [
  resolve(),
  replace({
    'process.env.NODE_ENV': JSON.stringify('production')
  }),
  terser()
]

rollup.config.js / plugins

Gathering all together

"build-pwa":
  "ng build --prod &&
   node workbox-inject.js &&
   npx rollup -c"

package.json / scripts

Resulting dist/prog-web-news/sw.js on every build

Service worker registration

import { Workbox, messageSW } from 'workbox-window';
...
ngOnInit(): void {
  if ('serviceWorker' in navigator) {
      const wb = new Workbox('/sw.js');      
      wb.register();
    
      // Reload-to-Update flow Using messageSW
      // See demo repo aka.ms/angular-workbox

  }
}

app-shell.component.ts

Runtime caching

import { registerRoute } from "workbox-routing";
import {
  CacheFirst,
  NetworkFirst,
  StaleWhileRevalidate,
} from "workbox-strategies";

src/service-worker.js

// Gravatars can live in cache
registerRoute(
  new RegExp("https://www.gravatar.com/avatar/.*"),
  new CacheFirst()
);

Runtime caching for API

// Keeping lists always fresh
registerRoute(
  new RegExp("https://progwebnews-app.azurewebsites.net.*posts.*"),
  new NetworkFirst()
);

// Load details immediately and check and inform about update right after
import { BroadcastUpdatePlugin } from 'workbox-broadcast-update';

registerRoute(
  new RegExp("https://progwebnews-app.azurewebsites.net.*posts/slug.*"),
  new StaleWhileRevalidate({
    plugins: [
      new BroadcastUpdatePlugin(),
    ],
  })
);

src/service-worker.js

Strategies

  • CacheFirst

  • CacheOnly

  • NetworkFirst

  • NetworkOnly

  • StaleWhileRevalidate

  • ...your custom strategy?

Plugins

  • Expiration

  • CacheableResponse

  • BroadcastUpdate

  • BackgroundSync

  • ...your own plugin?

Workbox recipes

import {
  googleFontsCache,
  imageCache
} from "workbox-recipes";

// GOOGLE FONTS
googleFontsCache({ cachePrefix: "wb6-gfonts" });

// CONTENT
imageCache({ maxEntries: 10 });

src/service-worker.js

How to extend your SW?

// Adding you own event handlers
self.addEventListener("periodicsync", function (event) {
  // Your code
});
// Using existing Workbox plugins
import { BackgroundSyncPlugin } from 'workbox-background-sync';

const bgSyncPlugin = new BackgroundSyncPlugin('myQueue', {
  maxRetentionTime: 24 * 60 // Retry for max of 24 Hours
});
// Writing your own Workbox plugins
import { MyBackgroundFetchPlugin } from 'my-background-fetch';
  • Framework-agnostic

  • Rich functionality

  • Maximum flexible configuration

  • Full power of our own service worker

  • Ready for transpilation, tree-shaking, bundling

Set up -> Configure -> Code

Get what you want

  • Demo application

  • Source code

  • Hosted on Azure Static Web Apps

Thank you!

Maxim Salnikov

@webmaxru

Web as an app platform

  • Historically depends on the "connection status"

  • Evergreen browsers

  • Performant JS engines

  • Excellent tooling

  • Huge community

Demo

Demo

A joke from 2019

In theory

self.addEventListener('install', event => {
    // Putting resources into the Cache Storage
})

self.addEventListener('activate', event => {
    // Managing versions
})

self.addEventListener('fetch', event => {
    // Exctracting from the cache and serving
})

handmade-service-worker.js

In the guide

const PRECACHE = 'precache-v1';
const RUNTIME = 'runtime';

const PRECACHE_URLS = [
  'index.html',
  './',
  'styles.css',
  '../../styles/main.css',
  'demo.js'
];

self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(PRECACHE)
      .then(cache => cache.addAll(PRECACHE_URLS))
      .then(self.skipWaiting())
  );
});

self.addEventListener('activate', event => {
  const currentCaches = [PRECACHE, RUNTIME];
  event.waitUntil(
    caches.keys().then(cacheNames => {
      return cacheNames.filter(cacheName => !currentCaches.includes(cacheName));
    }).then(cachesToDelete => {
      return Promise.all(cachesToDelete.map(cacheToDelete => {
        return caches.delete(cacheToDelete);
      }));
    }).then(() => self.clients.claim())
  );
});

self.addEventListener('fetch', event => {
  if (event.request.url.startsWith(self.location.origin)) {
    if (event.request.url.indexOf('api/') != -1) {
      event.respondWith(
        caches.match(event.request.clone()).then((response) => {
          return response || fetch(event.request.clone()).then((r2) => {
            return caches.open(RUNTIME).then((cache) => {
              cache.put(event.request.url, r2.clone());
              return  r2.clone();
            });
          });
        })
      );
    } else {
      event.respondWith(
        caches.match(event.request).then(cachedResponse => {
          if (cachedResponse) {
            return cachedResponse;
          }
          return caches.open(RUNTIME).then(cache => {
            return fetch(event.request).then(response => {
              return cache.put(event.request, response.clone()).then(() => {
                return response;
              });
            });
          });
        })
      );
    }
  }
});

handmade-service-worker.js

− Build automation

− Configurability

− Extensibility

− Smart caching

− All possible fallbacks

− Communication with the app

− Debug information 

− ...

In production

Redirects?

Fallbacks?

Opaque response?

Versioning?

Cache invalidation?

Spec updates?

Cache storage space?

Variable asset names?

Feature detection?

Minimal required cache update?

Caching strategies?

Routing?

Fine-grained settings?

Kill switch?

I see the old version!!!

Simplest offline fallback

import { offlineFallback } from 'workbox-recipes'
import { precacheAndRoute } from 'workbox-precaching'

// Include offline.html, offline.png in the WB manifest
precacheAndRoute(self.__WB_MANIFEST)

// Serves a precached web page, or image
// if there's neither connection nor cache hit
offlineFallback({
  pageFallback: "offline.html",
  imageFallback: "offline.png"
});

src/service-worker.js

  • Background Sync API

  • Background Fetch API

  • Native File System API

  • Badging API

  • Contact Picker API

  • Notification Triggers API

Other APIs for offline-ready?

Questions?

Maxim Salnikov

@webmaxru

Sending the Angular app into deep, deep offline with Workbox

By Maxim Salnikov

Sending the Angular app into deep, deep offline with Workbox

There is no need to advocate for progressive web apps anymore. The idea of connection-independent applications has proven its viability and we see more and more large and small projects following that path, making the offline-ready behavior a best practice, good manner of the web. In my session, based on the deep exploration of Service Worker API possibilities and gathered UX gotchas, we go through the history of the offline web, the importance of treating the network as an enhancement, current challenges (and their solutions) and proper tooling. We architect our offline-ready Angular app applying the best tech and UX practices adding the features one-by-one: app shell, caching resources and data, sync when online. All in name of our users who demand the new level of the resilient web experience.

  • 8,714