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

Made with Slides.com