Angular 2 PWA Workshop
Angular 2 PWA Workshop
Workshop
Maxim Salnikov
Angular GDE
@webmaxru
[email protected]
Contents
Introduction to PWA
Useful resources
Standards and APIs
Cookbooks and guides
Collections
Personal experiences
Credits
Introduction to PWA
Slides
Setting up the environment
We need (latest versions):
Git
Node
NPM
Yarn
Chrome 52 or above
Firefox Developer Edition (latest)
Open http://localhost:8080/
https://code.facebook.com/posts/1840075619545360
https://angular.io/docs/ts/latest/cookbook/aot-compiler.html
Result:
If something went wrong
Installing dependencies
Docs:
https://github.com/angular/material2/blob/master/GETTING_STARTED.md
Adding styles to index.html
<style>
body {
margin: 0;
}
</style>
<link href="/indigo-pink.css" rel="stylesheet">
imports: [
...
import {MaterialModule} from '@angular/material';
],
Styling toolbar
File src/root.html
<md-toolbar color="primary">
<span>{{title}}</span>
</md-toolbar>
<place-list></place-list>
Docs:
https://github.com/angular/material2/blob/master/src/lib/toolbar/README.md
Designing a list of places
File src/places/place-list.html
<md-list>
<md-list-item *ngFor="let place of places">
<img md-list-avatar src="/assets/img/{{place.imgName}}"
alt="{{place.title}}">
<h3 md-line>
{{place.title}}
</h3>
<p md-line class="line-secondary">
{{place.description}}
</p>
<ul md-line class="list-tags">
<li *ngFor="let tag of place.tags">
{{tag}}
</li>
</ul>
</md-list-item>
</md-list>
Docs:
https://github.com/angular/material2/blob/master/src/lib/list/README.md
Result:
If something went wrong
<script defer>
if (navigator.serviceWorker) {
navigator.serviceWorker.register('/sw.js').then(() => {
console.log('Service worker installed')
}, err => {
console.error('Service worker error:', err);
});
}
</script>
You can call register() every time a page loads without concern; the browser will figure out if the
service worker is already registered or not and handle it accordingly.
Docs:
https://developers.google.com/web/fundamentals/primers/service-worker/register?hl=en
Verify that this has worked by visiting Chrome Dev Tools (right-click > Inspect Element
or F12)
In latest Chrome: Application -> Service Workers
In earlier versions: go to Resources panel and look for Service Workers in the
left-hand panel
In versions of Chrome <48, visit chrome://serviceworker-internals to understand
the current state of the system
In both cases, visit the http://localhost:8080/ and observe that you now have a
Service Worker in the Active state
To verify this in Firefox Dev Tools, use Firefox Developer Edition
Open a new tab and visit about:debugging#workers or about:#serviceworkers
and ensure that a worker appears for your URL
If it isnt there, it may have been killed so try refreshing the source document
Result:
Adding events (Install, Activate, Fetch)
To build your application, youll need to ensure that youre handling the important events for
your application. Let's start by logging out the various parts of the lifecycle.
File src/sw.js:
In Chrome you can use the Application -> Service Workers panel in DevTools to remove previous
versions of the Service Worker, or use Shift-Ctrl-R to refresh your application. In Firefox
developer edition you can use the controls in about:serviceworkers to accomplish the same
thing.
Once the new Service Worker become active, you should see logs in the DevTools console
confirm that. Refreshing the app again should show logs for each request from the application.
Tip: during development, to ensure the SW gets updated with each page refresh, go to Application
-> Service Workers, and check Update on reload.
Result:
Forcing SW to skip waiting the update
Normally when you modify your service worker code, the currently open tabs continue to be
controlled by the old service worker. The browser will only swap to using the new service worker
at the point where no tabs (which we call clients in this context) are still open.
To avoid this, you can use a set of features: skipWaiting and Clients.claim().
This code will prevent the service worker from waiting until all of the currently open tabs on your
site are closed before it becomes active:
File src/sw.js:
And this code will cause the new service worker to take over responsibility for the still open
pages.
Going offline
Run
git checkout step-app-shell
The goal of a Progressive Web App is to ensure that our app starts fast and stays fast.
To accomplish this we want all resources for our Application to boot to be cached by the Service
Worker and ensure theyre sent to the page without hitting the network on subsequent visits.
Service Workers are very manual. They dont provide any automation for accomplishing this
goal, but they do provide a way for us to accomplish it ourselves.
Let's replace our dummy Service Worker with something more useful. Edit src/sw.js:
e.waitUntil(
caches.open(cacheName).then((cache) => {
log('Service Worker: Caching App Shell');
return cache.addAll(appShellFilesToCache);
})
);
});
Result:
This code is relatively self explanatory, and adds the files listed above to the local cache. If
something is unclear about it, ask and we can talk through it.
Next, lets make sure that each time a new service worker is activated that we clear the old
cache. You can see here that we clear any caches which have a name that doesnt match the
current version set at the top of our service worker file. Edit src/sw.js:
e.waitUntil(
caches.keys().then((keyList) => {
return Promise.all(keyList.map((key) => {
}));
})
);
});
Ok, we have our apps UI in cache. How could we use this to allow offline access to our app?
Were going to ensure that we hand back resources we know are in the cache when we get an
onfetch event inside the Service Worker. Add the following to your Service Worker:
e.respondWith(
caches.match(e.request).then((response) => {
if (response) {
log('Service Worker: returning ' + e.request.url + ' from cache');
return response;
} else {
log('Service Worker: returning ' + e.request.url + ' from net');
return fetch(e.request);
}
})
);
});
Result:
First, we respondWith() a Promise, in this case the result of an operation that searches
all the caches for a matching resource for the request we were sent. To search a specific
cache, caches.match() takes an optional parameter.
Next, when we dont get a response from the cache, it still uses the success chain in the
Promise. This is because the underlying storage didnt have an error, it just didnt have
the result we were looking for.
To handle this case, we check to see if the response is a truthy object
Lastly, if we dont get a response from the cache, we use the fetch() method to check for
one from the network
Weve just witnessed something new: were always responding from the local cache for
resources we know we have. This is the basis for Application Shells!
https://www.npmjs.com/package/@angular/app-shell
Installation
npm install @angular/app-shell --save
App Shell will provide handy utilites for specifying parts of your app to be included into an App
Shell. Contains directives:
shellRender
shellNoRender
Edit src/root.html:
<div *shellNoRender>
<place-list></place-list>
</div>
Updating a SW
In addition to the shell, we also will want to cache data. To do this we can add a new cache
name at the top of sw.js, and then whenever a fetch for data is made, we add the response into
the cache.
Now, in the fetch handler, well handle data separately (you can replace your old fetch handler
with this). File src/sw.js:
e.respondWith(
caches.match(e.request.clone()).then((response) => {
return response || fetch(e.request.clone()).then((r2) => {
return caches.open(dataCacheName).then((cache) => {
console.log('Service Worker: Fetched & Cached URL ',
e.request.url);
cache.put(e.request.url, r2.clone());
return r2.clone();
});
});
})
);
} else {
}
});
Or we could use a network-first variant:
e.respondWith(
fetch(e.request)
.then((response) => {
return caches.open(dataCacheName).then((cache) => {
cache.put(e.request.url, response.clone());
log('Service Worker: Fetched & Cached URL ', e.request.url);
return response.clone();
});
})
);
} else {
}
});
Because we can use promises to handle these scenarios, we could imagine an alternative
version of the same logic that tried to use the network first for App Shell:
fetch(e.request).catch((err) => {
return caches.match(e.request);
})
As in the previous example, we could add read-through caching to this as well. The skys the
limit!
Well see many of these patterns return later as we investigate SW Toolbox and the strategies it
implements.
If something went wrong
Installation
npm install @angular/service-worker --save
2. The second thing is the webpack plugin for generating manifests. That's in the module
@angular/service-worker/webpack. You can require and use that in your webpack config
to automatically generate a file called ngsw-manifest.json, which provides data for the
worker script, including which files the worker should cache.
3. The third piece is the "companion" library, which you can use in your app by installing
ServiceWorkerModule from @angular/service-worker. That module allows your app to
talk to the worker itself, and do things like register to receive push notifications.
4. And the fourth piece is @angular/service-worker/worker, which you would only import
from if you were writing a plugin for the worker itself.
bootstrapServiceWorker({
manifestUrl: '/ngsw-manifest.json',
plugins: [
StaticContentCache(),
RouteRedirection(),
Push(),
],
});
https://github.com/angular/mobile-toolkit/blob/master/service-
worker/worker/src/worker/builds/basic.ts
More tools for easier development of offline experiences
https://github.com/GoogleChrome/sw-toolbox
https://github.com/TalAter/UpUp
If something went wrong
Lets use http://realfavicongenerator.net/ service for the generation of graphic assets and
manifest.json file. Just use a logo.png, located in /assets folder and #DE3541 as theme color.
Place all the generated files to /assets/favicons folder. Your manifest.json will look like:
{
"name": "ngPoland Guide",
"icons": [
{
"src": "\/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image\/png"
}
],
"theme_color": "#DE3541",
"display": "standalone"
}
"short_name": "ngPoland",
"start_url": "index.html"
Next, inside your index.html, we'll need to link to the manifest. This is done by adding a
reference to the manifest inside your <head>:
chrome://flags/#bypass-app-banner-engagement-checks
chrome://flags/#enable-add-to-shelf
These will ensure that the A2HS banner appears immediately instead of waiting
for 5 minutes, and enables the feature on desktop for testing.
Checking you've configured Chrome correctly:
Visit airhorner.com and click install, checking that the A2HS prompt
appears
Result:
/*****************************************************************************
*
* Listen for the add to home screen events
*
****************************************************************************/
window.addEventListener('beforeinstallprompt', function(e) {
console.log('[App] Showing install prompt');
e.userChoice.then(function(choiceResult) {
console.log(choiceResult.outcome);
});
});
This event is fired just before the browser shows the add to home screen banner. You can call
e.preventDefault() to tell the browser youre not ready yet, for example if you want to wait for the
user to click a particular button. If you call e.preventDefault(), you can call e.prompt() at any
point later to show the banner.
As you can see, the e.userChoice promise gives you the ability to observe the users response
to the banner, for analytics or to update the UI appropriately.
Adding a Splash Screen to your app
Splash screens are automatically generated from a combination of the short_name property, the
icons you specify, and colors you may include in your manifest. The manifest you've added so
far will already include short_name and icons. To fully control the colors, add something like this
to your manifest file:
"background_color": "#DE3541"
Sadly the only way to verify this today is on-device, so if youve set-up on-device, use port
forwarding to check the results of your work on localhost!
Notes:
to debug this on-device youll likely also need to set chrome://flags/#bypass-app-banner-
engagement-checks on your Chrome for Android
this does not work in Chrome for iOS or any other iOS browser today
If something went wrong
Note: there are good instructions for setting up push notifications on your site at:
bit.ly/webpushguide.
"gcm_sender_id": "70689946818"
https://developers.google.com/web/updates/2016/07/web-push-interop-wins
https://web-push-codelab.appspot.com/
Setting up the backend
We have to create a backend part of our app. Let it be a simple express-powered server:
app.use(express.static(__dirname));
app.use(bodyParser.json());
console.log(req);
function sendNotification(endpoint) {
console.log('endpoint', endpoint)
webPush.sendNotification(endpoint)
.then(function(response) {
if (response) {
console.log("PUSH RESPONSE: ", response);
} else {
console.log("PUSH SENT");
}
})
.catch(function(err) {
console.error("PUSH ERR: " + err);
});
}
Run it:
node push-server
Test it:
http://localhost:8090/pushdata
Result:
{
msg: "We have 6 new reviews"
}
Setting up the client
We first want to setup push when the user clicks on some UI offering push notifications. Lets
add a simple button:
<button id="btnNotification">Push</button>
Now we have to register an event listener. The flow here is to request permission to send
notifications, then get a reference to the serviceWorker, which we can use to trigger the client to
subscribe with the push server.
Add to index.html:
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) %
4);
const base64 = (base64String + padding).replace(/\-/g,
'+').replace(/_/g, '/');
Finally, once we have the subscription we simply pass it up to the server via a network request.
On your site you may also send up a user ID etc, but for now were just going to post it to the
/push endpoint on our server.
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) %
4);
const base64 = (base64String + padding).replace(/\-/g,
'+').replace(/_/g, '/');
Result:
The node server has been configured to send a push message immediately when a new client
subscribes and again 5 seconds later.
At this point you should check in the network inspection panel that what looks like a valid
subscription is being sent to the server.
Receiving the push message in your service worker
Whenever server decides to push an event to your device, a push event will be fired in your
service worker, so let's start by adding an event listener in push-sw.js.
self.addEventListener('push', function(e) {
});
When that event fired, your service worker needs to decide what notification to show. To do this,
we will send a fetch request to our server, which will provide the JSON for the notification we will
show. Before we start, we must call e.waitUntil so the browser knows to keep our service worker
alive until were done fetching data and showing a notification.
e.waitUntil(
fetch('http://localhost:8090/pushdata').then(function(response) {
return response.json();
}).then(function(data) {
// Here we will show the notification returned in `data`
}, function(err) {
err(err);
})
);
Now we have the notification to show, we can actually show it. To do so, replace the comment
above with:
Now remember to increment your service worker version at the top of sw.js, then go back to
your web browser, hard refresh the page (or clear the old SW to ensure youre on the latest
version) and then press the notification bell. If everything is working, after waiting 5 seconds you
should see a notification appear.
Result:
If something went wrong
Run:
lighthouse http://localhost:8080/
Result:
https://surge.sh
Install:
gulp build
Publish:
cd dist
surge
Useful resources
Collections
https://github.com/hemanth/awesome-pwa
https://pwa.rocks/
https://jakearchibald.github.io/isserviceworkerready/
Personal experiences
Stuff I wish I'd known sooner about service workers
https://gist.github.com/Rich-Harris/fd6c3c73e6e707e312d7c5d7d0f3b2f9
Credits
Some parts of the workshop:
Alex Rickabaugh (Angular Team)
Alex Russell (Google)
Owen Campbell-Moore (Google)
Aditya Punjani (Flipkart)