Marko Knöbl
Code available at: https://github.com/marko-knoebl/courses-code
npm package http-server
npm install -g http-server
http-server
https://developers.google.com/web/ilt/pwa/why-build-pwa
Service workers:
https://caniuse.com/#search=service%20workers
Web app manifest:
https://caniuse.com/#search=manifest
indexedDB:
developer tools - audits
Service workers are at the core of PWAs. They are a client-side proxy between the web browser and the server.
Main use case: offline / faster usage of web apps (replaces the deprecated AppCache functionality)
app ⟺ web server
app ⟺ service worker ⟺ web server
service woroker = script (web worker) that runs in the background
functionality:
We call .register()
and provide the location of the service worker script
// main.js
navigator.serviceWorker.register('./serviceWorker.js');
// serviceWorker.js
console.log('this is the service worker');
Chrome: developer tools (F12) âž¡ Application âž¡ Service Workers
Firefox: go to about:debugging âž¡ this Firefox âž¡ Service Workers
Workbox = Library which simplifies writing service workers
Including a service worker which will cache responses and use them as a fallback if they are not reachable:
// service-worker.js
importScripts(
'https://storage.googleapis.com/' +
'workbox-cdn/releases/4.1.1/workbox-sw.js'
);
workbox.routing.registerRoute(
new RegExp('.*'),
new workbox.strategies.NetworkFirst()
);
We can inspect the effects of using this service worker in the Chrome developer tools under Application/Service Workers and Application/Cache Storage
The web app manifest is a json file that provides information on a web app.
Providing a manifest file can enable installation of a PWA.
include it via:
<link rel="manifest" href="manifest.json" />
{
"name": "Todo",
"short_name": "Todo",
"start_url": ".",
"display": "standalone",
"icons": [
{
"src": "images/icon-32.png",
"sizes": "32x32",
"type": "image/png"
},
...
]
}
crucial entries for Chrome:
name
short_name
start_url
icons
- used in the menu, in the splash screen; for Chrome we should provide square icons of sizes: 144
, 192
, 512
display
: fullscreen
/ standalone
/ minimal-ui
/ browser
background_color
- should be the same as the app's CSS background colordescription
orientation
:
any
natural
landscape
(landscape-primary
, landscape-secondary
)portrait
(portrait-primary
,
portrait-secondary
)theme_color
:These meta tags are helpful in the browser:
<meta name="theme-color" content="..." />
- this should be the same as theme_color
in the manifest<meta name="apple-mobile-web-app-capable" content="yes">
- this hides the browser UIBrowsers may offer the ability to add entries to the device's start menu / to the homescreen
In iOS users can add any website to the phone's menu. The mechanism for PWAs is the same.
On Chrome PWAs may prompt the user to be installed. Installed PWAs will behave differently from websites - e.g. they will be displayed in a standalone window.
App install prompt on Chrome:
https://developers.google.com/web/fundamentals/app-install-banners/
requirements to show the prompt:
once all the requirements are met, a beforeinstallprompt
event will fire; we can listen for this event and store it for later use
let installPromptEvent;
window.addEventListener(
'beforeinstallprompt',
(ipEvent) => {
// the browser is ready to show the install prompt
ipEvent.preventDefault();
installPromptEvent = ipEvent;
showInstallBtn();
}
);
Once the user wants to install the app, we can use the stored event:
installBtn.addEventListener('click', () => {
// Show the prompt
installPromptEvent.prompt();
hideInstallBtn();
});
hosting options for testing a deployment:
Service workers are client-side proxies between the web browser and the server.
Service workers can cache resources and retrieve them from either the network or the internal cache.
Service workers are special web workers:
postMessage
support for service workers ⇒ support for ES2015
When deciding on a strategy there are different goals to consider:
for each resource associated with our web app we should ask ourselves:
key questions:
asset retrieval:
caching strategies:
(we can combine these strategies)
See the offline cookbook
Workbox has built-in support for several service worker strategies
asset retrieval:
NetworkOnly
CacheOnly
NetowrkFirst
(cache as fallback)CacheFirst
(network as fallback)StaleWhileRevalidate
(load from cache, which is updated in the background)caching:
precacheAndRoute
fetch
and the belowNetworkFirst
, CacheFirst
, StaleWhileRevalidate
workbox.routing.registerRoute(
new RegExp('/static/.*'),
new workbox.strategies.CacheFirst()
);
workbox.routing.registerRoute(
'/articles.json',
new workbox.strategies.NetworkFirst()
);
workbox.precaching.precacheAndRoute([
'/',
'/index.html',
'/logo.svg',
]);
Workbox CLI: Tool for simplifying precaching in particular
workbox wizard --injectManifest
~45 min
https://codelabs.developers.google.com/codelabs/workbox-lab/
(update version of "workbox-cli" in package.json - older versions will fail on Windows)
Turn one of these apps into a PWA and use various caching strategies:
https://developers.google.com/web/tools/workbox/guides/codelabs/npm-script
see presentation Javascript: async and network requests
creating a worker
const worker = new Worker('worker.js');
listening for messages from the worker
worker.onmessage = function(message) {
console.log(message.data);
};
passing some task to a worker
worker.postMessage(42);
inside the worker:
onmessage = function(message) {
const result = longComputation(message);
postMessage(result);
};
When passing data to and from web workers: Data are copied and passed as "plain" JavaScript Objects
Exercise: Fibonacci
function fib(n) {
if (n <= 2) {
return 1;
}
return fib(n - 1) + fib(n - 2);
}
= "a request to response map"
We can cache resources from both the current domain and other domains;
We can distinguish three types:
Example: see the stock app examples in the Chrome devtools
Any time the page is loaded we call navigator.serviceWorker.register
and pass in the URL for the service worker.
If the service worker file is new or has changed it will be installed.
window.addEventListener('load', () => {
// registration can be deferred until
// completion of page load
if (navigator.serviceWorker) {
navigator.serviceWorker
.register('/serviceworker.js')
.then(registration => {
// is executed if there is a *new* sw file
console.log(
`SW registered for ${registration.scope}`
);
})
.catch(/* reg failed */);
}
});
By default a service worker will control all requests that lie within its "directory" on the server.
navigator.serviceWorker.register('/css/serviceworker.js');
The SW will control requests to /css/default.css, but not to /index.html.
We can narrow down a service worker to only work on a subpath:
navigator.serviceWorker.register('/css/serviceworker.js', {
scope: '/css/xyz/
})
The service worker's install
event occurs when there is a new service worker file:
Good opportunity for downloading and caching resources for later use
self.addEventListener('install', event => {
console.log(event);
});
If there was no previous version of the service worker, it activates immediately
If there was a previous version, it activates on "restart" (when all corresponding tabs have been closed)
Good opportunity for cleaning up unneeded cached files
self.addEventListener('activate', event => {
console.log(event);
});
We can force immediate activation of a new service worker from the install event:
self.addEventListener('install', event => {
self.skipWaiting();
});
Uninstalling all service workers for this domain:
navigator.serviceWorker
.getRegistrations()
.then(function(registrations) {
for (let registration of registrations) {
registration.unregister();
}
});
// this code can be executed in the
// browser console for any website
const url = '/';
fetch(url)
.then(response => response.text())
.then(console.log);
self.addEventListener('fetch', event => {
event.respondWith(
new Response('All pages look like this')
);
});
Exercise: We can build a small local website with pages like /home, /about, ...
self.addEventListener('fetch', event => {
if (new RegExp('/about/$ ').test(event.request.url)) {
event.respondWith(new Response('About'));
} else if (new RegExp('/a$ ').test(event.request.url)) {
event.respondWith(new Response('Home'));
} else {
event.respondWith(new Response('404'));
}
});
Exercise: logging all network requests and passing the work on to fetch
self.addEventListener('fetch', event => {
console.log(event);
return fetch(event.request);
});
core associated technologies:
= "a request to response map"
Via the global variable caches.open
or via self.caches.open
in the service worker
Promise:
let myCache;
caches.open('test', mc => {
myCache = mc;
});
cache methods:
myCache.add(request)
myCache.addAll(requests)
myCache.put(request, response)
myCache.delete(request)
myCache.match(request)
myCache.matchAll(requests)
The request
can be either a string or a Request
object.
We provide a URL; the resource will be automatically requested and stored
cache.add('/main.js');
cache.addAll(['/', '/index.html', '/main.js']);
Can be used if we already have the response
fetch('myurl').then(response => {
console.log(response.clone());
cache.put('myurl', response.clone());
cache.put('otherurl', response);
});
cache.delete('myurl');
Retrieve an entry from the cache that matches a request
// returns a response or undefined
const content = cache.match('myurl');
An application that will precache resources and always provide them to the user
self.addEventListener('install', () => {
cache.addAll(['/', '/index.html', '/about'])
})
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request);
)
})
self.addEventListener('install', installEvent => {
// wait for the cache to be populated;
// abort install on error
installEvent.waitUntil(
caches.open('app-shell-cache-v3').then(cache => {
return cache.addAll(['/', '/index.html', '/about']);
})
);
// optional - don't abort install on error
caches.open('app-shell-cache-v3').then(cache => {
cache.addAll['/icon1.png'];
});
});
A call to waitUntil
can be used to signify when the install was successfull - the service worker will only activate if it was
deleting old entries:
self.addEventListener('activate', activateEvent => {
activateEvent.waitUntil(
Promise.all([
caches.delete('app-shell-cache-v2'),
caches.delete('app-shell-cache-v1'),
])
);
});
self.addEventListener('fetch', event => {
event.respondWith(
caches
.match(event.request)
.then(response => response || fetch(event.request))
);
});
self.addEventListener('fetch', event => {
event.respondWith(
fetch(event.request).then(response => {
cache.put(event.request, response.clone());
return response;
})
);
});
https://developers.google.com/web/ilt/pwa/lab-scripting-the-service-worker
https://developers.google.com/web/fundamentals/instant-and-offline/offline-cookbook/
localStorage is a simple key-value-store in the browser; both keys and values are strings
The browser stores data separately for each domain
important methods:
localStorage.setItem('name', 'John')
localStorage.getItem('name')
localStorage.removeItem('name')
storing and retrieving some data
const todoString = JSON.stringify(todos);
localStorage.setItem('todos', todoString);
const todoString = localStorage.getItem('todos');
todos = JSON.parse(todoString);
indexedDB is a "real" database
advantages over localStorage:
disadvantage: more complicated interface
library that enables using indexedDB with Promises
https://github.com/jakearchibald/idb
CDN link: https://cdn.jsdelivr.net/npm/idb@2.1.2/lib/idb.min.js
creates / opens a database; returns a promise
idb.open(name, version, upgradeCallback);
const upgradeCallback = upgradeDb => {
if (!upgradeDb.objectStoreNames.contains('todos')) {
upgradeDb.createObjectStore('todos', {
autoIncrement: true,
keyPath: 'key',
});
}
};
const dbPromise = idb.open('todo-db', 1, upgradeCallback);
The last argument (upgradeCallback
) can be used to migrate to a new database schema; it can be used to create, delete or change stores
The callback is called any time the version number increases
Each element in the object store has a unique key (~id)
The key can be an entry in the element or a separate value
numeric id:
upgradeDb.createObjectStore('todos', {
autoIncrement: true,
});
numeric id stored in the object
upgradeDb.createObjectStore('todos', {
autoIncrement: true,
keyPath: 'key
})
use an entry in the objects as key
upgradeDb.createObjectStore('users', {
keyPath: 'email',
});
transaction = group of operations on the database (reading, adding, writing, ...)
steps:
idb.open
)readonly
(default) or readwrite
)getting the database object:
let db;
idb.open('todo-db', 1).then(openedDb => {
db = openedDb;
});
adding data
const transaction = db.transaction(['todos'], 'readwrite');
const todoStore = transaction.objectStore('todos');
todoStore.add({ text: 'groceries', done: false });
overwriting data (put)
const transaction = db.transaction(['todos'], 'readwrite');
const todoStore = transaction.objectStore('todos');
// ersetze den Eintrag mit index 1
todoStore.put({ text: 'groceris', done: true, key: 1 });
deleting data
const transaction = db.transaction(['todos'], 'readwrite');
const todoStore = transaction.objectStore('todos');
todoStore.delete(1);
reading data (getAll
)
const transaction = db.transaction(['artists'], 'readonly');
const artistsStore = transaction.objectStore('artists');
artistsStore.getAll().then(console.log);
reading data by its key
const transaction = db.transaction(['artists'], 'readonly');
const artistsStore = transaction.objectStore('artists');
artistsStore.get(1).then(console.log);
reading data via indexes
Entries in a database are basically stored sorted by their key.
This means it's fast to search for a specific key in the database
Example: In a phone book looking for a last name is fast, but looking for a first name or for a phone number is slow
In order to quickly look up by something other than the primary key: additional index
const store = upgradeDb.createObjectStore('contacts');
store.createIndex('email', 'email', { unique: true });
store.createIndex('firstName', 'firstName');
store.createIndex('name', ['lastName', 'firstName']);
const nameIndex = objectStore.index('name');
nameIndex.get(['Andy', 'Jones']).then(...)
Notification enable displaying messages outside of the app / browser (OS notifications)
let notificationsAllowed;
Notification.requestPermission().then(result => {
if (result === 'granted') {
notificationsAllowed = true;
}
});
This can be tried in the browser console when any web page is open
if (Notification.permission === 'granted') {
new Notification('Hello world');
}
new Notification('cloudy', {
body: 'The weather in Vienna is cloudy',
icon: 'static/images/cloudy.png',
vibrate: [100, 50, 100],
});
The notifications we've seen so far originated from one particular browser window. Notifications can also be displayed from the service worker. These notifications will be more capable than the ones we've encountered so far. In particular:
let serviceWorkerRegistration = null;
navigator.serviceWorker
.getRegistration()
.then(registration => {
serviceWorkerRegistration = registration;
});
serviceWorkerRegistration.showNotification('cloudy', {
body: 'The weather in Vienna is cloudy',
icon: 'static/images/cloudy.png',
vibrate: [100, 50, 100],
// new option available:
actions: [
{ action: 'close', title: 'Close' },
{ action: 'details', title: 'Details' },
],
});
two events in the service worker:
notificationclick
notificationclose
https://developers.google.com/web/ilt/pwa/lab-integrating-web-push
1-3
Removing the service worker in Firefox: about:debugging -> worker
Push notifications can be sent to a user via the browser vendor (Google, Mozilla, ...). It works via endpoint URLs like these:
https://android.googleapis.com/gcm/send/IDENTIFIER
https://updates.push.services.mozilla.com/wpush/v1/IDENTIFIER
https://android.googleapis.com/gcm/send/IDENTIFIER
https://updates.push.services.mozilla.com/wpush/v1/IDENTIFIER
serviceWorkerRegistration.pushManager
.subscribe({
userVisibleOnly: true,
})
.then((subscription) => {
console.log(subscription.endpoint);
// could be: https://android.googleapis.com/gcm/send/..
});
serviceWorkerRegistration.pushManager
.getSubscription()
.then((subsription) => {
if (subscription !== undefined) {
console.log(JSON.stringify(subscription.toJSON()));
// send the subscription object to our server
}
});
Once we obtain this subscription object on the server we can send push messages to the client
{
"endpoint": "https://android.googleapis.com/gcm/send/f2L...",
"keys": {
"auth": "5I2BuN...",
"p256dh": "BLc45n..."
}
}
in node.js:
const webPush = require('web-push');
const subscripton = {
endpoint: '...',
keys: { auth: '...', p256dh: '...' },
};
webPush.sendNotification(subscription, 'Hello world!');
Push notifications for Chrome are sent via Firebase Cloud Messaging (formerly: Google Cloud Messaging); in order to develop an application that receives push notifications on Chrome we need a firebase account and API key
webPush.sendNotification(subscription, 'Hello world!', {
gcmAPIKey: '....',
});
When a push notification arrives via the network the app can react in various ways
Displaying notifications is common but not required by the spec
Chrome currently requires displaying a notification; Firefox has a limit on how many messages can be received without showing notifications
https://developers.google.com/web/ilt/pwa/lab-integrating-web-push
1-3
Publishing PWAs in App Stores
As of February 2019:
TWA = Trusted Web Activity = method of publishing a PWA on the Play Store
https://developers.google.com/web/updates/2019/02/using-twa
PWAs (or HTML apps in general) can be packaged for various stores even if those stores don't natively support PWAs: