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
-
Create a source service worker
-
Inject app assets versioned list
-
Bundle and compress
-
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,713