Workbox

Maxim Salnikov

Angular GDE

Building an Angular PWA:

NGSW

?

?

?

- or -

How to create an Angular Progressive Web App?

Using the appropriate method

Maxim Salnikov

  • Google Developer Expert in Angular

  • Angular Oslo / PWA Oslo meetups organizer

  • ngVikings /  ngCommunity organizer

Azure Developer Technical Lead at Microsoft

What is PWA at all?

Progressive web apps use modern web APIs along with traditional progressive enhancement strategy to create cross-platform web applications.

These apps work everywhere and provide several features that give them the same user experience advantages as native apps.

Cross-platform?

Browser

Desktop

Mobile

OS

#YearOfPWA

works everywhere*

* but not everything**

natively

** use progressive enhancement strategy

UX advantages?

Smart networking + Offline

Proper app experience

Staying notified

Other cool things

}

Service Worker API

Web App Manifest

Create Angular PWA

  • Code service worker manually

  • Use Angular Service Worker (NGSW)

  • Use some PWA libraries

sw-precache

Minimum viable PWA

=

+

Application shell

Web App Manifest

Fast, responsive, mobile-first

Served via HTTPS

Let's build an App shell

My App

  • Pick only the files we need

  • Create the list of files and their hashes

  • First load: put these files into the Cache Storage

  • Next loads: serve them from Cache Storage

  • If some files were updated (hashes comparison) put their new versions into the Cache Storage and remove old ones *

  • On the n+1 load - serve the updated  files

The app was updated.

Refresh?

1

2

3

Service Worker 101

Logically

Physically

-file(s)

Website

Service-worker

Browser/OS

Event-driven worker

Similar to SharedWorker

  • Works in its own global context

  • Works in a separate thread

  • Isn’t tied to a particular page

  • Has no DOM access

Different from SharedWorker

  • Can run without any page at all

  • Works only with HTTPS (localhost is an exception)

  • Can be terminated by the browser anytime

  • Has specified lifecycle model

Lifecycle

'install'

Parsed

Installing

Activating

Redundant

'activate'

Waiting

Active

Managing cache

self.addEventListener('install', (event) => {
  
    // Put app's html/js/css to cache

})
self.addEventListener('activate', (event) => {
  
    // Wipe previous version of app files from cache

})

In the real world

  • Can't add opaque responses directly

  • Redirected requests should be managed

  • Always creating a new version of cache and deleting the old one is not optimal

  • Control over cache size is required

  • Cache invalidation for runtime caching is complex

  • ...

Intercepting requests

self.addEventListener('fetch', (event) => {

  if (event.request.url.indexOf('/api') != -1) {
    event.respondWith(
      // Network-First Strategy
    )
  } else {
    event.respondWith(
      // Cache-First Strategy
    )
  }
})

In the real world

  • All kinds of fallbacks needed for the strategies

  • There are more complex strategies like Stale-While-Revalidate

  • Good to have routing

  • Good to have the possibility to provide some extra settings for different resource groups

  • ...

Pros

  • Great flexibility!

 

Cons

  • Great responsibility!

 

  • Implementing complex algorithms

  • Adopting best practices

  • Focusing on YOUR task

  • Following specifications updates

  • Handling edge cases

Tools help with

NGSW

Angular Service Worker

NGSW

Automation

Scaffolding

Building

Serving

Schematics

Angular CLI

NGSW

$ ng add @angular/pwa

Scaffold

  • Add service worker registration code to the root module

  • Generate default service worker configuration file

  • Generate and link default Web App Manifest

  • Generate default icons set

  • Enable build support in Angular CLI config

$ ng build --prod

Build

ngsw.json

ngsw-worker.js

dist/project-name

1

2

  • Builds service worker manifest based on configuration file

  • Copies Angular Service Worker and safety workers

NGSW manifest

{
    "hashTable": {
        "/favicon.ico": "84161b857f5c547e3699ddffc6d8d",
        "/index.html": "64397c08d1f0da35f8e38e05c5512",
        ...
    },
    ...
}

ngsw-config.json / assetGroups

{
    "name": "app",
    "installMode": "prefetch",
    "resources":







}
                {    
        "files": [
            "/favicon.ico",
            "/index.html",
            "/*.css",
            "/*.js"
        ]
    }

Configuration file

Serve (dev)

$ ng serve

Static dev webserver

  • serve

  • superstatic

  • lite-server

$ ng serve --prod
  • Application shell

  • Runtime caching

  • Replaying failed network requests

  • Offline Google Analytics

  • Broadcasting updates

Have our own service worker!

Working modes

  • Workbox CLI

  • Webpack plugin

  • Node module

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

Build script

// We will use injectManifest mode
const {injectManifest} = require('workbox-build')

// Sample configuration with the basic options
var workboxConfig = {...}

// Calling the method and output the result
injectManifest(workboxConfig).then(({count, size}) => {
    console.log(`Generated ${workboxConfig.swDest},
    which will precache ${count} files, ${size} bytes.`)
})

workbox-build-inject.js

Build script configuration

// Sample configuration with the basic options
var workboxConfig = {
  globDirectory: 'dist/angular-pwa/',
  globPatterns: [
    '**/*.{txt,png,ico,html,js,json,css}'
  ],
  swSrc: 'src/service-worker.js',
  swDest: 'dist/angular-pwa/service-worker.js'
}

workbox-build-inject.js

Source service worker

// Importing Workbox itself from Google CDN
importScripts('https://googleapis.com/.../workbox-sw.js');

// Precaching and setting up the routing
workbox.precaching.precacheAndRoute([])

src/service-worker.js

1

2

Workbox manifest

[
  {
    "url": "index.html",
    "revision": "34c45cdf166d266929f6b532a8e3869e"
  },
  {
    "url": "favicon.ico",
    "revision": "b9aa7c338693424aae99599bec875b5f"
  },
  ...
]

Build flow integration

{
  "scripts": {
    "build-prod": "ng build --prod &&
                   node workbox-build-inject.js"
  }
}

package.json

NGSW

  • Convenient build module

  • Having our own service worker and extending it by Workbox modules

  • One-liner to start

  • Seamless integration

  • Smart defaults

Better app update UX

App version updates

v1

v2

v1

v1

v2

Deployed

Displayed

v2

A new version of the app is available. Click to refresh.

SwUpdate service

import { SwUpdate } from '@angular/service-worker';
constructor(updates: SwUpdate) {}
this.updates.available.subscribe(event => {




})

updates.component.ts

    if (confirm(`New Version is available! OK to refresh`)) {
            window.location.reload();
    }

3

Hint: Provide a version description

{
  "appData": {
    "changelog": "New version: Dinosaur pic was added!"
  }
}
let changelog = event.available.appData['changelog']
let message = `${changelog} Click to refresh.`

ngsw-config.json

updates.component.ts

New version: Dinosaur pic was added! Click to refresh.

const updateChannel = new BroadcastChannel('app-shell');

updateChannel.addEventListener('message', event => {
    // Inform about the new version & prompt to reload
});

Option #1: BroadcastChannel

updates.component.ts

workbox.precaching.addPlugins([
    new workbox.broadcastUpdate.Plugin('app-shell')
]);

src/service-worker.js

3

// Feature detection
if ('serviceWorker' in navigator) {

  // Postponing the registration for better performance
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('/service-worker.js');
  });

}

Option #2: Service worker lifecycle

app.js

Workbox v4: workbox-window

import { register } from 'register-service-worker'

platformBrowserDynamic().bootstrapModule(AppModule)
  .then( () => {
    if ('serviceWorker' in navigator) {
      const wb = new Workbox('service-worker.js');
      // Event listeners...
      wb.register();
    }
  })

main.ts

$ npm install workbox-window

Was service worker file updated?

wb.addEventListener('installed', event => {
  if (event.isUpdate) {
  // Show "Newer version is available. Refresh?" prompt
  } else {
  // Optionally: show "The app is offline-ready" toast
  }
});

main.ts

3

workbox.core.skipWaiting()
workbox.core.clientsClaim()

service-worker.js

Must have!

NGSW

  • Possibility to use broadcastUpdate plugin also for receiving runtime caching updates 

  • Angular-style coding: services, DI, observables

  • Passing version info to display in the notification

Runtime caching

Configuring strategies

ngsw-config.json / dataGroups

{
    "name": "api-freshness",
    "urls": [
      "/api/breakingnews/**"
    ],






}
    "cacheConfig": {
      "strategy": "freshness",
      "maxSize": 10,
      "maxAge": "12h",
      "timeout": "10s"
    }

Configuring strategies

{
    "name": "api-performance",
    "urls": [
      "/api/archive/**"
    ],






}
    "cacheConfig": {
      "strategy": "performance",
      "maxSize": 100,
      "maxAge": "365d"
    }

ngsw-config.json / dataGroups

Hint: Support API versioning

{
    "version": 1,
    "name": "api-performance",
    "urls": [
      "/api/**"
    ],
    ...
}
{
    "version": 2,
    "name": "api-performance",
    "urls": [
      "/api/**"
    ],
    ...
}

ngsw-config.json / dataGroups

Strategies and plugins

workbox.routing.registerRoute(
  new RegExp('/app/v2/'),
  workbox.strategies.networkFirst()
);

src/service-worker.js

workbox.routing.registerRoute(
  new RegExp('/images/'),
  workbox.strategies.cacheFirst({
    plugins: [...]
  })
);

Strategies

  • CacheFirst

  • CacheOnly

  • NetworkFirst

  • NetworkOnly

  • StaleWhileRevalidate

Plugins

  • Expiration

  • CacheableResponse

  • BroadcastUpdate

  • BackgroundSync

  • ...your own plugin?

NGSW

  • Variety of strategies

  • Maximum flexible configuration including adding own logic via the plugins

  • Code-free configuration of two strategies

  • Runtime cache versioning

Push notifications

Subscription

import { SwPush } from '@angular/service-worker';
constructor(push: SwPush) {}
subscribeToPush() {
  this.push.requestSubscription({
    serverPublicKey: this.VAPID_PUBLIC_KEY
  })
    .then(pushSubscription => {
      // Pass subscription object to the backend
    })
}

push.component.ts

Sending: following convention

{
  "notification": {










  }
}

backend.js / sendNotification payload

    "title": "Very important notification",
    "body": "Angular Service Worker is cool!",
    "icon": "https://angular.io/assets/logo.png",
    "actions": [
      {
        "action": "gocheck",
        "title": "Go and check"
      }
    ],
    ...

Notifications handling

self.addEventListener('push', (event) => {
    self.registration.showNotification(...)
})

src/service-worker.js

self.addEventListener('notificationclick', (event) => {
    // React on notification actions
})
self.addEventListener('notificationclose', (event) => {
    // React on notification closing
})

NGSW

  • Full power and flexibility of Web Push specification because of having our own service worker

  • Convenient shortcut for the subscription

  • Convention-based automatic notifications displaying

  • [Soon] Notification clicks handling

Background syncronization

const postTweetPlugin =
    new workbox.backgroundSync.Plugin('tweetsQueue', {
        maxRetentionTime: 24 * 60 // Max retry period
    })

src/service-worker.js

workbox.routing.registerRoute(
  /(http[s]?:\/\/)?([^\/\s]+\/)post-tweet/,
  new workbox.strategies.NetworkOnly({
    plugins: [postTweetPlugin]
  }),
  'POST'
)

Summary

NGSW

  • Easy to start

  • Seamless integration with Angular

  • Coding-free basic features

  • Angular-friendly approach

Add -> Configure

Get what's included

  • Framework-agnostic

  • Rich functionality

  • Maximum flexible configuration

  • Full power of our own service worker

Setup -> Configure -> Code

Get what you want

  • 1800+ developers

  • Major browsers/frameworks/libs reps

Thank you!

Maxim Salnikov

@webmaxru

Questions?

Maxim Salnikov

@webmaxru

Bundling

Deploying only what we use

import { precacheAndRoute }
  from 'workbox-precaching/precacheAndRoute.mjs'
import { skipWaiting }
  from 'workbox-core/skipWaiting.mjs'
import { clientsClaim }
  from 'workbox-core/clientsClaim.mjs'

skipWaiting()
clientsClaim()

precacheAndRoute([])

src/service-worker-bundle.js

Using bundler

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

export default {
  input: 'dist/angular-pwa/service-worker.js',
  output: {
    file: 'dist/angular-pwa/service-worker.js',
    format: 'iife'
  },
  plugins: [...]
}

rollup.config.js

Needed plugins

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

rollup.config.js / plugins

Build script

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

package.json / scripts

Resulting service-worker.js: