How to PWA. An introduction on Progressive Web App and a tutorial to create one with full features : Push Notification, Service Worker, Offline mode

The Web failed on mobile. Now it's time to shine with PWA.

During the year 2007, the iPhone is unveil. And with that, we discover a complete new way of browsing the Internet. At that time the Web wasn't ready to this massive revolution. Almost every websites has some Flash element which were not supported by the iPhone. Steve will announce three years later in his famous open letter "Thoughts on Flash" that he has no intent to change that. Summer 2008, Apple unveil the iPhone 3G and the App Store. People can now find a large choice of apps to play, read the news, find some cooking recipes... As the ads repeat at the time, "there's an App for about anything". Concurrently, the Web still hasn't found any solution to give user a proper experience on mobile.
We will have to wait a couple of years to get some clean solutions with HTML5, CSS3 and some JavaScript Mobile Oriented Frameworks.
Eventually, these lasts years, some solutions like Apache Cordova and Adobe PhoneGap came to generate mobile apps that can be published on the mobile stores even though they are developed with web technologies. But, we had to wait for the phone to be powerful enough to launch a web server and load the JavaScript logic to give a natif-like experience.
Recently, as a proof of concept, we developed the app SpeedRun WR available on the App Store and Play Store with the frameworks Meteor, React.js, Ionic and we are really happy with the result.

Obviously the Web missed the beginning of the mobile era. We tried to much to do the Web we knew instead of rethink what does that really mean to access the Internet on mobile.

It's the thinking Alex Russell and his wife Frances did during a diner. They wrote on a peace of paper the key elements of a mobile experience and the prerequisites a Web App must implement to be said mobile-friendly. The orignal post can be seen here : https://infrequently.org/2015/06/progressive-apps-escaping-tabs-without-losing-our-soul/

This manifesto brought a new vision of the web on mobile, Alex and his wife called this approach the Progressive Web Apps.

Below the complete list:

  • Responsive: to fit any form factor
  • Connectivity independent: Progressively-enhanced with Service Workers to let them work offline
  • App-like-interactions: Adopt a Shell + Content application model to create appy navigations & interactions
  • Fresh: Transparently always up-to-date thanks to the Service Worker update process
  • Safe: Served via TLS (a Service Worker requirement) to prevent snooping
  • Discoverable: Are identifiable as “applications” thanks to W3C Manifests and Service Worker registration scope allowing search engines to find them
  • Re-engageable: Can access the re-engagement UIs of the OS; e.g. Push Notifications
  • Installable: to the home screen through browser-provided prompts, allowing users to “keep” apps they find most useful without the hassle of an app store
  • Linkable: meaning they’re zero-friction, zero-install, and easy to share. The social power of URLs matters.

During this post, we will go deeper in each of these points which uses really edgy web technology. We will build a super simple Progressive Web App from scratch. The idea of the app is to develop a collaborative counter. People would add hearts every time they click on a counter. The app must be useable offline and once the connexion is retrieved, the counter must be updated.

We created a Github repo with which you can launch quick small projects like this one. This Express.js boilerplate uses Gulp to watch the js, css and html files. If one of them has been modified your browser is reloaded automatically. This feature is commonly called hot reloading.

git clone https://github.com/MadeOnMars/Express-boilerplate.git myFirstPWA  
cd myFirstPWA  
npm install --global gulp-cli  
npm install  
gulp  
# You can see the project on this URL : http://localhost:3000

Responsive

Nowadays, the Responsive Web Design (RWD) is a must-do on any website or web app. His main goal is to adapt the website on any screen sizes, and thus giving a optimal reading and interacting experience. Ethan Marcotte was the first to introduce the term "Responsive Web Design" in May 2010 in A List Apart.

But how do you do Responsive Design? Well the idea is to add lines of CSS codes in your stylesheets. They are called Medias Queries. With CSS2, you would already be able to use the media attribute to specify which stylesheet should be use for what. But now with CSS3, you can directly distinguish the behaviour of each media inside your code through the Media Queries. Thanks the @media rule, you can define which media should use the properties (screen, print, tv, ...), but also other criteria as the height, the width, etc... Using criteria like min-width and min-height, it's possible to adapt progressively a layout from the biggest screen to the smallest one.

Let's get our hands dirty

So we are going to build the structure of our app in the file public/index.html

<!DOCTYPE html>  
<html>  
<head>  
  <meta charset="utf-8">
  <title>Love, love, love - a Progressive Web App to send some love</title>
  <meta name="viewport" content="width=device-width, height=device-height, initial-scale=1.0, maximum-scale=1.0">
  <!-- See all the metas to include here on our repos -->
</head>  
<body>  
  <header>
    <nav id="menu">
      <ul>
        <li> ... </li>
        <li> ... </li>
        <li> ... </li>
        <li id="hamburger"> ... </li>
      </ul>
    </nav>
    <ul id="param"> <button id="subscribeBtn" disabled>Subscribe to PN</button> </ul>
  </header>

  <div id="mobileNavigation">
    <div class="inner">
      <div id="close"> Close </div>
      <div id="illu"> ... </div>
      <ul id="mobileNav"> ... </ul>
      <ul id="mobileParam"> ... </ul>
      <div id="mobileFooter"> ... </div>
    </div>
  </div>

  <div id="counter">
    <span id="count">0</span>
  </div>

  <section id="mainContainer">
    <div id="content">
      <div id="legend"> ... </div>
      <div id="btn"> <button>+1</button> </div>
    </div>
  </section>

  <footer> ... </footer>
</body>  
<link rel="stylesheet" href="/css/style.css">  
<script src="/js/app.js"></script>  
</html>  

To improve readability of the article, the code above has been simplified but you can see the full code on Github : https://github.com/MadeOnMars/lovelovelove

Thus, we are going to define the css styles of the layout for the biggest screen.

In the file public/css/style.css:

html, body {  
  margin:0;
  padding:0;
}

body {  
  font-family:'Lato', sans-serif;
  font-weight:100;
  background:#3b395e;
  color:#ffffff;
}

header {  
  background:#2e2e49;
  padding:20px;
  position:relative;
  z-index:30;
}

header #menu {  
  float:left;
}

header #menu ul li#hamburger {  
  margin:0;
  display:none; /* We hide the burger btn on desktop */
}

header #param {  
  text-align:right;
}

header #param li span {  
  display:inline-block;
  vertical-align:middle;
  margin-left:10px;
}

#mobileNavigation {
  height:100%;
  width:90%;
  display:table; /* vertical alignment */
  table-layout:fixed;
  position:fixed;
  z-index:100;
  top:0;
  left:0;
  background:#2e2e49;
  transform: translate3d(-100%, 0, 0); /* The menu will be out of the viewport */
  transition: all 0.3s ease-in;
}

#mobileNavigation .inner {
  position: relative;
  display: table-cell; /* vertical alignment */
  vertical-align: middle;
}

body.navigation #mobileNavigation {  
  transform: translate3d(0, 0, 0); /* init menu position */
}

footer {  
  padding:20px;
  text-align:center;
  position:fixed;
  right:0;
  bottom:0;
  font-weight:600;
  font-size:14px;
  color:#25253a;
  z-index:15;
}

footer a {  
  text-decoration:none;
  color:#25253a;
}

Then, we define the container style.

#counter {
  background:#2e2e49;
  padding:80px 20px 20px 20px;
  text-align:right;
  position:relative;
  z-index:20;
}

#counter span {
  font-size:100px;
  display:block;
}

#mainContainer {
  padding:0;
  height:calc(100% - 288px);
  width:100%;
  overflow:hidden;
  position:absolute;
  display:table;
  table-layout:fixed;
  top:288px;
  left:0;
  z-index:10;
}

#mainContainer #content {
  display:table-cell;
  vertical-align:middle;
  text-align:center;
  height:100%;
  width:100%;
  padding:40px 0;
}

#mainContainer #content #legend h1 {
  font-weight:100;
  font-size:50px;
  text-align:center;
  margin:0 0 40px 0;
}

#mainContainer #content #btn {
  margin-top:90px;
}

#mainContainer #content #btn svg {
  width:140px;
  padding:10px;
  cursor:pointer;
}

We get this result on a 1280px screen.

(If you are following the tutorial from the article your version will be simplified)

Screen demo 1280

Below 768px, the screen becomes too small to display the complete menu. We should tell the css to change the layout to be more mobile-friendly.

@media screen and (max-width: 768px) {
  /* On cache le menu et on affiche le bouton Burger */
  header #menu ul li,
  header #param li span,
  footer {
    display:none;
  }
  header #menu ul li#hamburger {
    display:block
  }
  /* On adapte les tailles de texte et les marges */
  header {
    padding:20px 20px 0 20px;
  }
  #mainContainer #content #legend h1 {
    font-size:24px;
    font-weight:300;
  }
  #counter span {
    font-size:60px;
  }
  #counter {
    padding:20px;
  }
  #mainContainer {
    height:calc(100% - 160px);
    top:160px;
  }
}

Thus, we adapt the display on a tablet like this :

Screen démo 768

Eventually, we adapt more styles to manage screens under 480px:

@media screen and (max-width: 480px) {
  #mainContainer #content #btn {
    margin-top:30px;
  }
  #mainContainer #content #legend h1 {
    margin:0 0 20px 0;
  }
}

Screen demo 480

The user experience first

Responsive Design has been around for a while now, it has become a must-do on any web project and even more so since Google launched this Pigeon algorithm in July 2014 which promotes mobile-friendly websites in his search results.

Screen Google Site Mobile

Most of the time when Google pushes a new standard, it goes in the way to improve web experiences. As for his Pigeon algorithm, it makes no doubt that at some point the PWAs will have a special treatment. Indeed, Google has a lot to gain from having people turning their backs to the App Store and to come back to the Web again.

App-like-interactions

The main goal of a Progressive Web App is to give native-like experience to the user. Along with the smartphones came some new standard for navigation and structuration. We had to rethink the design for smaller screens but also find way to exploit the touchscreen.

Mobile design

The most explicit example might be the "Burger Menu" (or "Hamburger Menu" or "Split View"). Few years back, this icon made of three horizontal small straight lines wouldn't have evoke you a thing. But now it is the global symbol of the navigation menu. Its first goal is to hide the navigation menu on mobile, thus avoiding to add too much height to a page. Most of the time placed in the corner top left of the screen, the user can deploy the menu with a click or touch. For the story, this icon has been created in 1981 by Norm Cox for Xerox Star. He said that this icon had to be really simple and rememberable and looks like a form list.

Interactions

App-like-interactions aren't just about design. With the iPhone also came the multipoint screen and the famous "slide to unlock". A lot of gesture became natural when you use a phone, but the Web doesn't have this kind of thing.

With a PWA, we will need to use some JavaScript to detect a specific gesture. Let's say, I want to detect a slide from left to right to deploy the burger menu. We are going to use a JS library which will do the heavy work for us - analyse the position of the touch and translate it to a "swipe" gesture. The library Hammerjs does that really well.

Let's get on with it

Let's take our example back. First we add some interactions to make the counter do something.
We add the code below in the file public/js/app.js

var hamburgerBtn = document.getElementById('hamburger');  
var closeBtn = document.getElementById('close');  
var loveBtn = document.getElementById('btn');  
var countElement = document.getElementById('count');

// By default, counter value at 0
var count = 0;

// If user click on the burger we add a class 'navigation'
// to the body to deploy the menu
hamburgerBtn.addEventListener('click', function() {  
  document.body.classList.add('navigation');
}, false);

// If user click on the close button we remove the class 'navigation'
// to the body to close the menu
closeBtn.addEventListener('click', function() {  
  document.body.classList.remove('navigation');
}, false);

// If user click on the counter we add one to the count variable
// then we update the span
loveBtn.addEventListener('click', function() {  
  count++;
  countElement.innerText = count;
}, false);

This code is really simple, we add a click listener on the main button. If there is a click, we modify the text of the span count with the value of the counter variable.

Adding a class navigation on the body will deploy the menu navigation. We add the class if there is a click on the hamburger button. Now we add the hammerjs lib to handle swipe gesture too.

We add the library in the js folder and we call it in the index.html

...
</body>  
<link rel="stylesheet" href="/css/style.css">  
<script src="/js/hammer.min.js"></script>  
<script src="/js/app.js"></script>  
...

In the file public/js/app.js, we create an instance of the library and we make it listen to the body.

var hammertime = new Hammer(document.body);  
// Left swipe closes the menu
hammertime.on('swipeleft', function() {  
    document.body.classList.remove('navigation');
});
// Right swipe opens it
hammertime.on('swiperight', function() {  
  document.body.classList.add('navigation');
});

Connectivity independent

The main strength of native app versus the Web at the time was the possibility to use them without connectivity. Today with more global 3G/4G cover, we could think that an offline is less necessary. But the offline mode is not just to use the Web without Internet. It also permits to let some assets in the user cache so the Web app will load faster the next time because it will have to download only new content.

Our application will have during the first visit of the user to put the static assets in cache. It will save locally some data - the number of hearts, and will verify as soon as the connectivity is retrieve to fetch the up-to-dated data (we will see that in the section Fresh).

The technical solution to do that is called the Service Worker.

The Service Worker is a JavaScript script which will be executed in background when the user is visiting our PWA. Its particularities are:

  • it can not access the DOM but can communicate with it through the postMessage method.
  • it can intercept network requests and thus modify entirely their behaviour.
  • it only works on HTTPS or localhost websites (see Safe chapter).

Be aware that at the time of writing Service Workers are only supported by Chrome, Firefox and Opera. Edge is about to integrate them, but Safari has just consider it for now.

Some practice now

Let's take back our counter example. We are going to use a service worker to cache some files.

First, we add a JS file (which is our SW). We can call it sw.js, place it in public/sw.js and add the code below inside it.

console.log('I am the Service Worker');  

Then, in the file public/js/app.js, we add the code below to register the SW.
At the top of our file we add a global reg variable to save our SW registration.

var reg;  

At the bottom of the public/js/app.js

// If service worker is supported by the browser
if ('serviceWorker' in navigator) {  
  // We register our sw.js script
  navigator.serviceWorker.register('sw.js').then(function() {
    return navigator.serviceWorker.ready;
  }).then(function(serviceWorkerRegistration) {
    reg = serviceWorkerRegistration;
    console.log('SW registration success.');
  }).catch(function(error) {
    console.log('Error during SW registration', error);
  });
}

This code begins by verifying if the browser supports Service Workers. If so, we try to register our sw.js script.

We can verify the result in our console.

Service Worker enregistrement

FYI, Application tab in Google Chrome developer tool gives you access to lots of information our the SW.

Service Worker Application Onglet

For now, the SW isn't really useful. Let's add some code to the sw.js file to tell him to cache some assets.

var CACHE_NAME = 'my-site-cache-v1';  
var urlsToCache = [  
  '/',
  '/css/first.css',
  '/css/style.css',
  '/js/hammer.min.js',
  '/js/app.js'
];

self.addEventListener('install', function(event) {  
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(function(cache) {
        console.log('Cache ready');
        return cache.addAll(urlsToCache);
      })
  );
});

We add a listener to the install event. Thus, as soon as it's triggered, we'll cache all the assets of the urlsToCache array in the cache file named my-site-cache-v1.

Launch your project, you will see that the cache has been used.
However, if you stop the network connexion though, the app won't be display. For now, we only asked the service worker to cache some files. Now we need it to retrieve it.

We add a listener on the event fetch. The Service Worker will behave like a proxy, it is going to listen to each request and we will be able to modify the response. Do you get it now? How powerful service workers are? That's why it's important to secure the app and so use https. If one of the requests is equal to one of our cached files, we can return it. Otherwise we let the request look for a response on the network.
Thus, even though we don't have Internet, the app can be displayed and if we have connectivity the app will be speed up because we won't have to fetch everything on the network.

self.addEventListener('fetch', function(event) {  
  event.respondWith(
    // We look if the request fits an element in the cache
    caches.match(event.request)
      .then(function(response) {
        if (response) {
          // We return the element in the cache
          return response;
        }
        // Otherwise we let the request look into the network
        return fetch(event.request);
      })
    );
});

You can now reload the page and see that your SW has taken into account your new sw.js file.

To simulate a no Internet connexion, stop your web server (Ctrl+c if you are using Gulp). Reload the page, you should be able to see it.

You can see in the Network analysis that the files have been served by the SW.

Service Worker réseau

Important note if you want to update your assets. You need to find a way to modify the cache. Multiple approachs can be used, but here is the simplest of them. The idea is to change the name of the cache var CACHENAME = 'my-site-cache-v1'; by this for example var CACHENAME = 'my-site-cache-v2';

At each new connexion, if the SW notice a modification of the sw.js, it will automatically try to install itself again and so create a new cache. However, the new SW will take control only once the "old one" has been stopped i.e. once the page web has been closed.

The cache has a limited size, not defined precisely yet, but it's a good practice to delete the one you are not using anymore. Thus, every time a new cache is activated, the event "activate" is propagated. We just have to listen to it and delete all the caches minus the one we are using.

self.addEventListener('activate', function(event) {

  var cacheWhitelist = [CACHE_NAME];

  event.waitUntil(
    caches.keys().then(function(cacheNames) {
      return Promise.all(
        cacheNames.map(function(cacheName) {
          // We remove all the cache except the ones in cacheWhitelist array
          if (cacheWhitelist.indexOf(cacheName) === -1) {
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

Voilà, your app can be used offline. Maybe you noticed that the counter always stays at 0 after a reload. That is what we are going to implement in the next section.

Fresh

We will have to store the data in a database on server side. But we will also need to store this info on the client side. Thus, if the user doesn't have any connection, this local base will be able to keep track of the count incrementation.

First, we are going to implement the counter if we are online. We are going to modify the app.js file at the root (NOT the one in /public/js/app.js) to let him store the counter value in a count.json with the content {count:0}. The fs Node.js library will do the trick. This solution shouldn't be used in production.

Through the API we can fetch the value of the counter and increment it.

To simplify, we are going to implement only one method. This method will take one integer which will be the value of the increment and will return the result of the counter. For example /add/1 will add one to the counter and so /add/0 will return the current value of the counter.

Add a count.json file at the root of the project with this content:

{
    "count": 0
}

In the file app.js (at the root not the one in /public/js/app.js) we add a require to fs and a route.

var fs = require('fs');

// This route increment the counter
app.get('/add/:id', function(req, res){  
  // We store the id parameter
  var increment = parseInt(req.params.id) || 0;
  // If it's not a number or it is less than 0 we return an error
  if(Number.isNaN(increment) || increment < 0){
    res.status(500).json({status: 'err'});
    return;
  }
  // Everything looks good we can fetch the count file
  fs.readFile('./count.json', 'utf8', function(err, data){
    if (err) {
      res.status(500).json({status: 'err'});
      return;
    }

    // We convert the file to JSON
    var counter = JSON.parse(data);

    // To save some writing, if the increment is 0 we return the response
    if(increment === 0){
      res.json({status: 'ok', counter});
      return;
    }

    // We increment
    counter.count += increment;
    // We write the new counter value in count.json
    fs.writeFile('./count.json', JSON.stringify(counter, null, 4), function(err, data){
      if (err) {
        res.status(500).json({status: 'err'});
        return;
      }
      res.json({status: 'ok', counter});
    });

  });
});

Now we modify the file public/js/app.js to use the API.

var xhr = new XMLHttpRequest();

// This function calls the different API endpoints. You can pass a callback to
// modify your UIs once you get the result.
function api(action, value, cb){  
  var path = '/';
  switch (action) {
    case 'add':
      path += 'add/'+value;
      break;
    default:
      path += 'add/0';
  }
  xhr.onreadystatechange = function () {
    if (xhr.readyState === 4 && xhr.status === 200) {
      var response = JSON.parse(xhr.responseText);
      if(response.status === 'ok'){
        cb(null, response);
      } else {
        cb('Something is broken.', null);
      }
    }
  };
  xhr.open('GET', path);
  xhr.send();
}

// We fetch the initial value
api('add', 0, function(err, res){  
  if(err){return;}
  counter = countElement.innerText = res.counter.count;
});

We also modify the click eventListener on the increment button like so:

loveBtn.addEventListener('click', function() {  
  api('add', 1, function(err, res){
    if(err){return;}
    counter = countElement.innerText = res.counter.count;
  });
}, false);

With this code our app is completely functionnal if we are online and with all browsers. The progressive enhancement concept is respected since we built our app to be functional on every browsers. Now, we are going to add more features with newer technology to give people with more recent browsers a better experience. The progressive comes actually from this term - progressive enhancement.

Now, let's deal with offline mode. If you try the app without connection you will see that it tries to call the API and return an error.

The solution is to store the value of the counter in a user local DB. But we will also need to be able to update the online DB once the Internet connection is retrieve. The idea is to create a variable defer which will count the number of clicks done offline.

To detect if the user is online or not we will use the propriety navigator.isOnline. We use the localStorage as local DB.

Let's add this in the public/js/app.js

// By default, counter and defer value at the localStorage or 0
var count = localStorage.count || 0;  
var defer = localStorage.defer || 0;  

Then, we declare a function updateCounter that we will be able to call either in online or offline mode. If we are offline, we increment defer, the value of the counter is then the sum of the old counter plus the defer.
Once the connection is retrieve we pass the defer value to the API to update the server database.

// This function will automatically manage the counter increment depending on
// the network status (online, offline)
function updateCounter(){  
  if(navigator.onLine){
    api('add', defer, function(err, res){
      if(err){return;}
      count = localStorage.count = countElement.innerText = res.counter.count;
      localStorage.defer = defer = 0;
    });
  } else {
    localStorage.defer = defer;
    countElement.innerText = parseInt(count) + parseInt(defer);
  }
}

/*
api('add', 0, function(err, res){  
  if(err){return;}
  counter = countElement.innerText = res.counter.count;
});
*/

// We replace the call to the API on the load by calling this function instead
updateCounter();  

Finally, a click on the main button will increment the defer value and call the updateCounter().

loveBtn.addEventListener('click', function() {  
  defer++;
  updateCounter();
}, false);

You can now test your app offline. Stop your webserver but also you will need to tell your browser to be offline.
Don't forget to change you CACHE_NAME in the public/sw.js file otherwise your /public/js/app.js won't be updated.

Safe

To use service workers, we must serve PWA through HTTPS to secure the user data. It always has been a real pain to configure a HTTPS domain in the past and pretty expensive too.

Let's Encrypt

But as I said that was the past. About a year ago, a project called Let's Encrypt, promoted by the biggest technology companies around, was born. The idea is to automate the process of creation, signature, and validation of the certificate for free.

Let's Encrypt created a tool called Certbot
Once installed, just one command is needed to generate the certificate.

letsencrypt certonly --webroot -w /var/www/example -d example.com -d www.example.com -w /var/www/thing -d thing.is -d m.thing.is  

The certificate are available during 90 days. But don't worry, you can renew them as long as you want with only one command.

letsencrypt renew  

Set a cron job and that's it.

Installable

We add a manifest.json in our public/ folder with the configuration lines below. The web app install banner will be triggered automatically by Google Chrome if it respects the guidelines below :

  • The manifest must have a short_name, a name for display in the banner,
  • A start URL (e.g. / or index.html) which must be loadable,
  • At least an 144x144 PNG icon
  • Your icon declarations should include a mime type of image/png
  • You have a service worker registered on your site.
  • Your site is served over HTTPS
  • The user has visited your site at least twice, with at least five minutes between visits.

Here is the public/manifest.json our app

{
  "name": "Love, love, love",
  "short_name": "Love",
  "icons": [
    {
      "src": "images/icons/icon-android-144x144.png",
      "sizes": "144x144",
      "type": "image/png"
    }],
  "start_url": "/"
}

Don't forget to call it in your public/index.html too

<head>  
...
  <link rel="manifest" href="manifest.json">
...
</head>

web app install banners

Linkable

As for the metas, the Open Graph tags must be placed in the head section of your app:

...
  <head>
...

    /* Meta Tags for Facebook, Google+, LinkedIn... */
    <meta property="og:url" content="https://lovelovelove.xyz/">
    <meta property="og:site_name" content="Love, love, love | A simple Progressive Web App to send some love">
    <meta property="og:title" content="Love, love, love">
    <meta property="og:description" content="A simple Progressive Web App to send some love">
    <meta property="og:image" content="https://lovelovelove.xyz/img/love-love-love-share.jpg">
    <meta property="og:image:type" content="image/jpg">
    <meta property="og:image:width" content="1200">
    <meta property="og:image:height" content="630">

    /* Meta Tags for Twitter */
    <meta property="twitter:site" content="https://lovelovelove.xyz/">
    <meta property="twitter:creator" content="Made On Mars">
    <meta property="twitter:card" content="summary_large_image">
    <meta property="twitter:title" content="Love, love, love">
    <meta property="twitter:description" content="A simple Progressive Web App to send some love">
    <meta property="twitter:image:src" content="https://lovelovelove.xyz/img/love-love-love-share.jpg">
    <meta property="twitter:image:width" content="1200">
    <meta property="twitter:image:height" content="630">
...    
  </head>
...

You can find all the technical specifications on the official Open Graph website : http://ogp.me/

Discoverable

Instead of copying/pasting, I'd rather invite you to read this post from John Mueller. He says it all: https://plus.google.com/u/0/+JohnMueller/posts/LT4fU7kFB8W

Re-engageable

Certainly one of features that gave a lot of power to the native apps Push Notifications.
This is becoming available to the web through the Push API and Notification API.

The Push lets the server communicate with the app and more precisiley the service worker. The notification API lets the SW display the notification.

In our app we are going to integrate Push Notifications to inform the users when the counter reached some milestones - let's say every hundreds.

First, we have to create a project in Firebase Developer Console to get some keys to use the Google Push Notification API.

  • Click on "create a project" and choose a name. Firebase_Console
  • Go to project settings at the top left. Firebase_Console
  • Go to "Cloud Messaging" tab. Firebase_Console
  • Keep your server key and sender Id somewhere safe.

We need to add a new db file to store the device ids to send them PNs. So we create a new file clients.json at the root of the project with an empty array.

[]

Now we add an API endpoint to add device id in our clients.json file.

app.get('/client/:id', function(req, res){  
  var clientId = req.params.id || undefined;
  if(!clientId){
    res.status(500).json({status: 'err'});
    return;
  }
  fs.readFile('./clients.json', 'utf8', function(err, data){
    if (err) {
      res.status(500).json({status: 'err'});
      return;
    }
    var clients = JSON.parse(data);
    if(clients.indexOf(clientId) == -1){
      clients.push(clientId);
      fs.writeFile('./clients.json', JSON.stringify(clients, null, 4), function(err, data){
        if (err) {
          console.log(err);
        }
      });
    }
    res.json({status: 'ok'});
  });
});

We add the request npm module to help use communicate with Firebase :

npm install request --save  

Then in our app.js file we include the module and the server key.

var request = require('request');  
var gcmAPIendpoint = 'https://fcm.googleapis.com/fcm/send';  
var gcmAPIKey = 'YOUR_SERVER_KEY_HERE';  

Below before writing in the clients.json file, if the value of counter is divisible by 100, we send a PN.

// If the total counter is divisible by 100 we send a PN
if(counter.count % 100 == 0){  
  fs.readFile('./clients.json', 'utf8', function(err, data){
    if (err) {
      res.status(500).json({status: 'err'});
      return;
    }

    var clients = JSON.parse(data);
    request(
      { method: 'POST',
        headers: {
          'Authorization': 'key='+gcmAPIKey,
          'Content-Type' : 'application/json'
        },
        json:{
          registration_ids: clients
        },
        url: gcmAPIendpoint
      }, function (err, response, body) {
        if(err){
          console.log(err);
        }
      });
  });
}

On the client side, there is still a lot to do.

First, in the file manifest.json, we need to indicate the gcmsenderid that we get from the Firebase console.

{
  "name": "Love love love",
  "gcm_sender_id": "INSERT_YOUR_gcm_sender_id"
}

In the file public/js/app.js we add an interaction to ask for the authorization for sending Push Notification.
We add globales variables to follow the user acceptation state.

var subscribeButton = document.getElementById('subscribeBtn');  
var sub;  
var isSubscribed = false;

We also add a listener on the subscribe button. The button has two states possibles, subscribed or unsubscribed.

subscribeButton.addEventListener('click', function() {  
  if (isSubscribed) {
    unsubscribe();
  } else {
    subscribe();
  }
});

function subscribe() {  
  reg.pushManager.subscribe({userVisibleOnly: true}).
  then(function(pushSubscription) {
    sub = pushSubscription;
    var clientId = pushSubscription.endpoint.split('/').pop();
    xhr.onreadystatechange = function () {
      if (xhr.readyState === 4 && xhr.status === 200) {
        var response = JSON.parse(xhr.responseText);
        if(response.status === 'ok'){
          subscribeButton.textContent = 'Unsubscribe';
          isSubscribed = true;
        }
      }
    };
    xhr.open('GET', '/client/'+clientId);
    xhr.send();
  });
}

function unsubscribe() {  
  sub.unsubscribe().then(function(event) {
    subscribeButton.textContent = 'Subscribe';
    isSubscribed = false;
  }).catch(function(error) {
    subscribeButton.textContent = 'Subscribe';
  });
}

The last things to add in the public/js/app.js is to prompt the user to subscribe to the PNs if the registration of the SW has been a success.

// If service worker is supported by the browser
if ('serviceWorker' in navigator) {  
  // We register our sw.js script
  navigator.serviceWorker.register('sw.js').then(function() {
    return navigator.serviceWorker.ready;
  }).then(function(serviceWorkerRegistration) {
    reg = serviceWorkerRegistration;
    subscribeButton.disabled = false;
    subscribe();
    console.log('Enregistrement du SW avec succès.');
  }).catch(function(error) {
    console.log('Une erreur est survenue.', error);
  });
}

Finally, in the service worker script we add two listeners. The first one which is triggered when the server send the Push Notification.

self.addEventListener('push', function(event) {  
  var title = "We reached a milestone.";
  var body = "Come quick! The counter is going crazy!";
  var icon = 'images/icons/icon-android-152x152.png';
  event.waitUntil(
    self.registration.showNotification(title, {
      'body': body,
      'icon': icon
    }));
});

We can remark that we indicate the Push Notification text directly in the code. It is possible to pass data between the server and the service worker which would give us the possibility to indicate the counter value. However it would add some complexity to the tutorial so we choose to let it like that. But if you want to go deeper you can check this link https://developers.google.com/web/updates/2016/03/web-push-encryption

And we add a listener to the event of a click on the notification so we can open the page if it's not open yet.

self.addEventListener('notificationclick', function(event) {  
  event.notification.close();
  event.waitUntil(clients.matchAll({
    type: 'window'
  }).then(function(clientList) {
    for (var i = 0; i < clientList.length; i++) {
      var client = clientList[i];
      if (client.url === '/' && 'focus' in client) {
        return client.focus();
      }
    }
    if (clients.openWindow) {
      return clients.openWindow('/');
    }
  }));
});

Now every time a user reaches a hundreds, a Push Notification will be sent to all the authorised devices.

Conclusion

Progressive Web Apps are a Google initiative, so they are endorsed by the Moutain View company. They recently said that they are going to focus on the mobile search from now on instead of the classic search. So the PWAs seems to have a really nice future ahead.

Now our PWA is finished, it would be good to try it out and see it's score on a Google tool called "Lighthouse".

This tool is available on Github : https://github.com/GoogleChrome/lighthouse or through a Google Chrome extension.

Our PWA can be seen here : https://lovelovelove.xyz and has a 79/100 score. Which is not that bad for a tiny PWA like this. A lot of optimisation can be done and we invite you to send PR on this repo : https://github.com/MadeOnMars/lovelovelove

Bravo for the ones who reached this far into the article, and thanks in advance for sharing our article on your favorite social networks.

Thomas Foricher

Read more posts by this author.