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, PWA Oslo meetups organizer

  • ngVikings / Mobile Era conferences organizer

  • Google Dev Expert in Angular

Developer Engagement Lead at Microsoft

Web as an app platform

  • Historically depends on the "connection status"

  • Evergreen browsers

  • Performant JS engines

  • Excellent tooling

  • Huge community

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

Service worker 101

App

Service worker

Cache

fetch
self.addEventListener('fetch', event => {
    // Serve assets from cache or network
})

handmade-service-worker.js

Browser

self.addEventListener('install', event => {
    // Use Cache API to cache html/js/css
})

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

What could go wrong?

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!!!

  • Define assets

  • Put in the cache

  • Serve from the cache

  • Manage versions

}

Options for Angular

Angular

Service Worker

  • Precaching and routing

  • Runtime caching strategies

  • Replaying failed network requests

  • Recipes for quick start

  • Service worker communication helpers

+ full control over the service worker

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

Demo

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?

SW lifecycle hack

import { clientsClaim } from "workbox-core";

// Claiming control to start runtime caching asap
clientsClaim();

src/service-worker.js

Fetching data

Registering SW

Application's first load timeline

Workbox recipes

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

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

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

src/service-worker.js

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

Demo

  • Background Sync API

  • Background Fetch API

  • Native File System API

  • Badging API

  • Contact Picker API

  • Notification Triggers API

Other APIs for offline-ready?

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

Questions?

Maxim Salnikov

@webmaxru

Made with Slides.com