Chapter 10. Reach Out with Push Notifications

There are few (if any) features that were as central to the gulf between native apps and web apps as the ability to send notifications to your users.

Push notifications allow users to opt in to updates from apps they care about, and to get timely updates to the content and data they need. Can you even imagine using an instant messaging app that doesn’t offer notifications?

As developers, push notifications allow us to improve our user’s experience with our apps and thus increase usage. They are perhaps more essential to our apps’ adoption by users, and their success, than perhaps any other factor.

For businesses, being able to re-engage users and bring them back to their apps over and over again has been key to increasing the value derived from each app install. This has allowed businesses to pour more and more money into user acquisition while still maintaining a positive return on investment.

It would not be an understatement to say that push notifications have been one of the biggest driving factors for the success of the native app.

But now that the web has full access to the power of push notifications, we can finally say the reverse of the statement that opened this chapter: There are few (if any) features that can be added to your web app which can have as big an impact as the ability to send notifications to your users.

Life of a Pushed Notification

We have been talking push notifications up since Chapter 1, but it is finally the time to say this:

Push notifications are not actually a “thing.”

A push notification is actually comprised of two separate “things,” a message sent using the Push API, and a notification shown using the Notification API.

The Notification API

The Notification API allows a web page or a service worker to create and control the display of system notifications.

These notifications are shown outside the browser (on the device’s UI) and thus exist outside of the context of any single browser window or tab. As they are not dependent on any browser windows or tabs, they can be created even after a user has left your site.

Before you can show notifications to a user, you will first need to ask for the user’s permission.

The whole process is relatively simple and straightforward, as can be seen in this fully functional code sample:

Notification.requestPermission().then(function(permission){
  if (permission === "granted") {
    new Notification("Shiny");
  }
});

This sample code is all it takes to request permission to show notifications, and then if that permission is granted, create a notification with the title "Shiny". It’s as simple as that.

Later in the chapter, we will look at adding buttons, icons, and even making the notification vibrate the user’s phone to the Star Wars theme.

The Push API

The Push API allows your users to subscribe to push messages from your app and lets your server send messages to their browser at any time. These messages are handled by the service worker, which listens for them and can act on them even after your users have left your app. The most common way to “act on them” is to display a notification to the user.

This exposes an extraordinary amount of power to your app. Once you can send messages to your user’s device at any time, you could potentially harass him with endless messages. You could even silently track his behavior by sending messages to the service worker every few seconds and then sending a response back to your server with some compromising data.

To make sure the Push API isn’t misused like this, all push messages pass through a central messaging server. This central server is maintained by the browser vendor and keeps track of all of your users’ subscriptions for you. It ensures that push messages aren’t exploited and users aren’t spammed. It also takes care of the complexities of making sure messages are delivered even if the user was unreachable when you sent it.

This middleman between you and your user—along with all the encryption needed to make sure only your server can send messages to your users—makes the learning curve a bit steeper. We will unpack this process into four steps which we will tackle one at a time.

The first two steps are subscribing a user to push messages and saving the details of that subscription on your server—these only need to happen once per user.

The final two steps—sending a message from your server and acting on it in the browser—happen every time you want to send a message to the user. This can be immediately after creating the subscription, or even a week later.

Lets look at the first two steps (Figure 10-1).

Creating and storing a push subscription
Figure 10-1. Creating and storing a push subscription

First, your web page uses the Push API to call subscribe(). This calls the central messaging server which stores the details of this new subscription and returns those details to the page. Next, your page can send the subscription details to your server where they can be stored for future use. You will often want to save these subscription details in your database—perhaps in the same table or object store where you keep the rest of the users’ details.

Next are the two final steps you take every time you send a message (Figure 10-2).

Sending a push message from the server
Figure 10-2. Sending a push message from the server

When you decide to send a message, your server takes the subscription details it previously stored (in step 2), and uses them to send the message to the messaging server. The messaging server then forwards this message to the user’s browser. Finally, the service worker registered in the user’s browser receives the message, reads its contents, and decides what to do with it.

One final note: creating a new push subscription (step 1) requires a permission from the user. Luckily, this uses the same permission required for showing a notification, so you only need to ask once for a single permission to both show notifications and send push messages.

Push + Notification

Let’s put the pieces together and see the entire process for sending a push notification to the user:

  1. Your page requests permission from the user to show notifications, and the user grants it.

  2. Your page contacts the central messaging server, asking it to create a new subscription for this user.

  3. The messaging server returns a new subscription details object in response.

  4. Your page sends the subscription details to your server.

  5. Your server stores the subscription details for future use.

  6. Time passes. Seasons change. The need to send a notification arises.

  7. Your server uses the subscription details to send a message to the user through the messaging server.

  8. The messaging server forwards that message to the user’s browser.

  9. Your service worker’s push event listener receives the message.

  10. Your service worker shows a notification with the contents of the message.

Browser Support for Push Notifications

Creating simple notifications from an active window, as shown earlier in this chapter, is possible in most modern desktop browsers.

Receiving push messages and showing a notification based on it requires service worker, Notification API, and Push API support.

At the time of writing, this is supported on Firefox, Chrome, Chrome for Android, Samsung Internet, and Opera, and is under development for Edge.

Long before the API described in this chapter was finalized, Apple created its own API for sending notifications to Safari users. You can read more about it on Apple’s developer site.

Creating Notifications

Now that we have a theoretical understanding of push notifications, let’s get to coding our first notification.

As always, start by making sure that your code is in the state we left it in at the end of the last chapter by running the following commands in the command line:

git reset --hard
git checkout ch10-start

Requesting Permission for Notifications

As we have seen in “Life of a Pushed Notification”, before we can show notifications to the user, we need to make sure we have the user’s permission first.

You can check if the current site has the permission to create notifications by checking the value of Notification.permission. The permission attribute will equal "granted" if the current page has permission to show notifications, "default" if the user hasn’t decided yet, or "denied" if the user declined the permission request:

if (Notification.permission === "granted") {
  console.log("Notification permission was granted");
}

If you do not have permission yet, you can ask the user for it by calling the requestPermission() method of the Notification API:

Notification.requestPermission();

This will show the browser’s UI for asking for this permission (Figure 10-3).

The notification permission dialog
Figure 10-3. The notification permission dialog

requestPermission() returns a promise that resolves when the user (or the browser) makes a choice about the permission. It is important to remember that this promise will always resolve, even if the user denied the permission, or the browser automatically blocked the permission request. That is why it’s important to always check the current state of the permission after asking for it and before attempting to create a notification:

Notification.requestPermission().then(function(permission) {
  if (permission === "granted") {
    console.log("Notification permission granted");
  }
});

The permission argument in the promise returned by requestPermission() can have any of the following values:

granted

The current page has permission to show notifications. This can mean one of two things:

  1. requestPermission() was called, a permission dialog was shown, and the user agreed to it.

  2. requestPermission() was called, but because the permission was already granted in the past, no permission dialog had to be shown.

denied

The current page does not have permission to show notifications. This can mean one of two things:

  1. requestPermission() was called, a permission dialog was shown, but the user declined the permission.

  2. requestPermission() was called, but because the permission was already denied in the past, no permission dialog was shown.

default

The current page does not have permission to show notifications. This can only mean one thing:

  • requestPermission() was called, a permission dialog was shown, but the user closed it without making a decision.

Putting everything together, we get the following code:

if (Notification.permission === "granted") {
  showNotification();
} else if (Notification.permission === "denied") {
  console.log("Can't show notification");
} else if (Notification.permission === "default") {
  Notification.requestPermission().then(function(permission) {
    if (permission === "granted") {
      showNotification();
    } else if (Notification.permission === "denied") {
      console.log("Can't show notification");
    } else if (Notification.permission === "default") {
      console.log("Can't show notification, but can ask for permission again.");
    }
  });
}

While in many cases you will want to specifically check the current status of the permission using Notification.permission before deciding how to proceed (as shown in the preceding code), in others it may be enough to just call requestPermission() and trust the browser to not show the permission dialog if it isn’t necessary. This allows us to simplify the previous code example to look like this:

Notification.requestPermission().then(function(permission) {
  if (permission === "granted") {
    showNotification();
  } else if (Notification.permission === "denied") {
    console.log("Can't show notification");
  } else if (Notification.permission === "default") {
    console.log("Can't show notification, but can ask for permission again.");
  }
});

Showing Notifications

Once you have the user’s permission, creating a notification is simply a matter of creating a new Notification object. Let’s give it a try:

Notification.requestPermission().then(function(permission) {
  if (permission === "granted") {
    new Notification("Shiny");
  }
});

If you run this code in your browser’s console, you should be asked for permission to show notifications, followed by a simple notification with the title "Shiny" (Figure 10-4).

The simplest desktop notification possible
Figure 10-4. The simplest desktop notification possible

Changing Your Notification Setting Permissions

If you did not get a permission dialog, it might be because you previously denied or granted notifications for this site.

Once you make a choice in the permission dialog, the browser remembers it and will not show another notification permission dialog in this origin. During development, you might want to reset this setting from time to time.

In Chrome on the desktop, this can be done by clicking the icon to the left of your site’s URL in the location bar and changing the notifications settings. In Chrome for Android, you can find the same setting by opening the browser menu, choosing Settings, and then clicking Site settings.

Unfortunately, the preceding code, which works great on the desktop, won’t work on mobile devices. To understand why, consider how a notification on mobile needs to behave. When your page creates a notification, it is rendered outside the browser—on the operating system level. This notification might remain visible, and the user might interact with it, long after she left your site. To make sure we can capture user interaction with the notification, the notification needs to reside at a higher level—the service worker.

To create notifications that work both on desktop and mobile, you will need to create your notifications through a service worker. Luckily this can be done quite easily from the page, without even modifying the service worker code, by using the service worker’s registration object.

With just a small modification to the code, we can call showNotification() on the service worker registration object. This receives exactly the same parameters as the Notification object method:

Notification.requestPermission().then(function(permission) {
  if (permission === "granted") {
    navigator.serviceWorker.ready.then(function(registration) {
      registration.showNotification("Shiny");
    });
  }
});

This mobile-friendly syntax will work equally well on mobile devices and desktops (Figure 10-5). From this point, our code will only use this syntax. In your own app, you may want to use both in order to support both modern browsers and browsers without service worker support.

The simplest mobile notification possible
Figure 10-5. The simplest mobile notification possible

Now that we know how to create a simple notification, let’s get fancy and look at some additional options to improve our notifications:

navigator.serviceWorker.ready.then(function(registration) {
  registration.showNotification("Quick Poll", {
    body: "Are progressive web apps awesome?",
    icon: "/img/reservation-gih.jpg",
    badge: "/img/icon-hotel.png",
    tag: "awesome-notification",
    actions: [
      {action: "confirm1", title: "Yes", icon: "/img/icon-confirm.png"},
      {action: "confirm2", title: "Hell Yes", icon: "/img/icon-cal.png"}
    ],
    vibrate:[500,110,500,110,450,110,200,110,170,40,450,110,200,110,170,40,500]
  });
});

The updated code demonstrates how showNotification() can receive an optional second argument containing an options object. These options can be used to further customize and modify the behavior of the notification.

Here is the complete list of options you can use when creating notifications. These are supported by both methods—registration.showNotification() and new Notification():

body

The main body of text in the notification.

icon

A URL to an image that will be displayed in the notification (the photo of the city shown in Figure 10-6).

Getting fancy with mobile notifications
Figure 10-6. Getting fancy with mobile notifications
badge

A URL to an image that represents the app sending the notification, or a category of notifications sent by that app. For example, a messaging app may always use its logo as the badge of all notifications, or it may choose to use different icons to represent different notifications, such as an icon for a new message notification, and a different icon when the user’s name is mentioned. The badge may be displayed when there is no room to display the entire notification, or inside the notification itself (as can be seen in Figure 10-6 in the bottom-right corner of the icon).

actions

By passing an array of action objects, you can add up to two buttons to the notification, allowing the user to take actions straight from the notification. This can be useful to allow the user to launch your web app or even take quick actions straight from the notification without opening the app. For example, a new message notification in a messaging app could include a Like button and a Reply button. The Like button could work without opening the app, while the Reply button would open the messaging app to the appropriate screen. We will look more closely at actions later in “Listening for Push Events and Showing Notifications”.

vibrate

For devices that support vibration, you can customize the vibration pattern that will play to alert the user to this new notification. vibrate receives an array of integers, each representing the number of milliseconds to vibrate and pause. For example, [200,100,300] will vibrate for 200 ms, pause for 100 ms, and then vibrate for another 300 ms. The vibration settings in the previous code example will play “The Imperial March.”

tag

A unique identifier representing this notification. If this tag is equal to the tag of a notification that is currently showing, the new notification will silently replace the old notification. This can often be preferable to creating multiple notifications and annoying the user. For example, if the user has one unread message in our messaging app, we might want to contain the text of that message in the notification. If five more messages arrive before the notification is dismissed, updating the notification to say “You have 6 new messages” might be preferable to showing six separate notifications.

The following code demonstrates creating a notification that gets silently updated every second with a new notification containing different text. This effectively creates a notification with a counter in it:

navigator.serviceWorker.ready.then(function(registration) {
  var count = 1;
  var createNotification = function() {
    registration.showNotification("Counter", {
      body: count,
      tag: "counter-notification"
    });
    count += 1;
  };
  setInterval(createNotification, 1000);
});

If you were to remove the tag, or change it at every iteration, the browser would create multiple notifications.

renotify

As we just saw, if you use the same tag to update an existing notification, the new notification will silently replace the old one. By setting renotify to true, you can force the device to draw the user’s attention to the updated notification (on mobile this is done by vibrating the phone again).

data

This can be used to attach any data that you would like to send along with the notification. Later in this chapter, we will see how you can react to notification events and access this data (see “Listening for Push Events and Showing Notifications”).

dir

The direction in which to display the text in the notification. By default, it adopts the browser’s language setting, but it can also be set to either force rtl (for right-to-left languages such as Arabic or Hebrew), or ltr (for left-to-right languages such as English and Portuguese).

lang

The primary language of the notification text. For example, en-US for American English or pt-BR for Brazilian Portuguese.

noscreen

A boolean specifying whether the device’s screen should be turned on by this notification. A value of true means the screen won’t be turned on. At the time of writing, this was not supported in any browser and uses the default value of false.

silent

A boolean specifying whether this notification should be made silently (i.e., without vibration or sound). At the time of writing, this was not supported in any browser and the default of false (not silent) is used.

sound

A URL for an audio file to play when the notification is created. At the time of writing, this was not supported in any browser.

Notification Playground

If you want to experiment with notifications, open /public/notifications.html in your favorite code editor and make changes within the <script> tag. Next, with the development server running (as explained in “The Current Offline Experience”), open http://localhost:8443/notifications.html in your browser.

Adding Notification Support to Gotham Imperial Hotel

Let’s go ahead and add notifications to the Gotham Imperial Hotel web app. Our goal is to ask the user for permission to send her notifications when she makes a new reservation at the Gotham Imperial Hotel. If the user grants us that permission, we will immediately show a notification letting her know that she will receive updates to any changes to her reservation.

Add the following code to my-account.js, just above the addReservation() function definition:

var showNewReservationNotification = function() {
  navigator.serviceWorker.ready.then(function(registration) {
    registration.showNotification("Reservation Received", {
      body:
        "Thank you for making a reservation with Gotham Imperial Hotel.
"+
        "You will receive a notification if there are any changes to "+
        "the reservation.",
      icon: "/img/reservation-gih.jpg",
      badge: "/img/icon-hotel.png",
      tag: "new-reservation"
    });
  });
};

var offerNotification = function() {
  if ("Notification" in window &&
      "serviceWorker" in navigator) {
    Notification.requestPermission().then(function(permission){
      if (permission === "granted") {
        showNewReservationNotification();
      }
    });
  }
};

Our new code defines two new functions:

showNewReservationNotification()

Shows a new notification when users create a new reservation. This function assumes the user has already granted our app the permission to show notifications.

offerNotification()

Makes sure both service workers and the Notification API are supported in the current browser. It then goes on to request permission to show notifications; and if it that permission is granted, it shows a notification using showNewReservationNotification().

Next, we need to call our new functions. Still in my-account.js, modify the addReservation() function, adding a call to showNewReservationNotification() after it creates a new reservation:

var addReservation = function(id, arrivalDate, nights, guests) {
  var reservationDetails = {
    id:           id,
    arrivalDate:  arrivalDate,
    nights:       nights,
    guests:       guests,
    status:       "Sending"
  };
  addToObjectStore("reservations", reservationDetails);
  renderReservation(reservationDetails);
  if ("serviceWorker" in navigator && "SyncManager" in window) {
    navigator.serviceWorker.ready.then(function(registration) {
      registration.sync.register("sync-reservations");
    });
  } else {
    $.getJSON("/make-reservation", reservationDetails, function(data) {
      updateReservationDisplay(data);
    });
  }
  showNewReservationNotification();
};

Now, every time the user makes a reservation, the addReservation() function will ask for notification permissions (if not already granted) and show a new notification (Figure 10-7).

New reservation notification
Figure 10-7. New reservation notification

Subscribing a User to Push Events

We have already made great progress with our first notification. But to really benefit our users, we will want to send them a notification after they have left our app. For this, we turn to the Push API.

Let’s go over the subscription process again (Figure 10-8).

First, our script needs to contact the messaging server, asking it to create a new subscription for this user. The messaging server then stores this new subscription and responds to our request with the details of this new subscription. Next, our script needs to store the details of this subscription on our server so that we can use it at a later time to send messages.

Creating and storing a push subscription
Figure 10-8. Creating and storing a push subscription

Before we can begin the process of creating and storing subscriptions, we need to take a moment to talk about encryption—don’t worry, it won’t take long.

When subscribing a user to push messages, the subscription details object returned by the messaging server contains all the information needed to send an unlimited number of messages to your users. Any malicious entity that has access to the subscription details on your server, or any malicious script or add-on in the browser that reads it while the user is subscribing, could then potentially send as many messages as it wants to your users.

To make sure only your server is allowed to send messages, the messaging server only accepts messages that are signed using a secret private key that you store on your server. To verify that messages were signed by the correct key, each private key has a corresponding public key. This public key is included in your script and is sent to the messaging server when it creates a new subscription. It is then stored in the messaging server along with the subscription details. This key is only used to verify that messages sent from your server to the messaging server were signed by the correct private key.

You can think of the private key as a royal seal that only your server has, and it can be used to sign messages to prove they came from your royal self. The public key, on the other hand, is a tool that anyone can have access to. It can’t sign messages; it only knows how to identify that a message was indeed signed with the correct royal seal.

Let’s look at the whole process again in plain English:

  1. When you create your app, you generate a public and a private key.

  2. The private key is kept secret and never leaves your server.

  3. The public key is included in your script and is sent to the messaging server when creating a subscription.

  4. The messaging server stores the public key along with the rest of the subscription details.

  5. When your server wants to send a message, it signs it using the private key, and then sends it to the messaging server.

  6. The messaging server uses the public key to verify that the message was signed with the correct private key. If it was, it sends the message to the user.

Looking at these steps, you can see that before we can even begin to create subscriptions and send push messages, we will need to generate a public and private key pair.

Generating Public and Private VAPID Keys

The keys used for signing and verifying push messages are called VAPID keys. In a creative leap not commonly associated with cryptographers, VAPID is an acronym for “Voluntary Application Server Identification for Web Push.”

To keep things as simple as possible, we won’t delve into the details of the cryptography going on behind the scenes, the specifics of generating VAPID keys, or how to sign payloads. Instead, we will use one of the more commonly used web-push libraries to hide this complexity from us. This book uses the web-push library for Node.js, but you can find similar libraries for many other languages (see https://pwabook.com/webpushlibs).

The first step is to install the web-push library in our project. From the project’s root directory, run the following command in the command line to install web-push, and add it to the list of dependencies used by our project:

npm install web-push --save-dev

Next, we will use web-push to generate a public key and a private key.

Create a new file in your project named generate-keys.js, and enter the following code in it:

var webpush = require("web-push");
console.log(
  webpush.generateVAPIDKeys()
);

Next, execute this file in the command line by running:

node generate-keys.js

This should output a new private and public key to your console:

$ node generate-keys.js
{ publicKey: 'yteswBFEx-JuJhyU7XsteR7xOo3nqygyR',
  privateKey: 'IuKbrkM4inNv2MzlzVRDV4YRw4N65N' }

You will want to store these keys somewhere safe.

For the Gotham Imperial Hotel, I have chosen to store both private and public keys in a file called push-keys.js inside the /server directory. You might also notice that I have added this file to the project’s .gitignore file. This means that when I commit any code, my private key doesn’t end up online. You should take similar care of your private keys.

For your convenience, I have included a script called generate-push-keys.js in the /server directory. When this script runs, it will generate a new push-keys.js file for you, and save your new keys in it.

Now that you know how to generate your own keys, you can delete the generate-keys.js file you just created, and run generate-push-keys.js by running the following command in the command line:

node server/generate-push-keys.js

This will create a new key pair and save it in push-keys.js for you, as can be seen in the next section. This file will later be used by our server to send messages.

Generating a GCM key

Unfortunately, the VAPID keys alone are not enough to send push messages to all browsers.

Before the Web Push Protocol was finalized and VAPID was agreed upon, some browsers went ahead and implemented push messages in a nonstandardized way. Between versions 42 and 51, Chrome used Google Cloud Messaging to deliver push messages, and both Opera and Samsung Browser adopted the same approach. In order for your push notifications to also work in older versions of these browsers, you will need to generate GCM API keys in addition to the VAPID keys.

You can obtain GCM API keys (also known as FCM API keys) from Google through the Firebase Cloud Messaging interface (previously known as Google Cloud Messaging):

  1. Visit the Firebase console at https://pwabook.com/firebaseconsole.

  2. Log in with a Google Account.

  3. Create a new project.

  4. Once you are on your project page, click the Settings icon next to the project name and go to Project settings.

  5. Inside Project settings, choose Cloud messaging.

  6. You should now see a Project credentials area and within it a link to Generate Key. Click Generate Key, and you will be rewarded with your very own GCM server key and Sender ID (Figure 10-9).

Generating GCM keys in the Firebase Console
Figure 10-9. Generating GCM keys in the Firebase console

Open the push-keys.js file in your /server directory and set the value of GCMAPIKey to the new GCM server key you just generated. While you are at it, enter the server administrator’s email address or a URL where you can be contacted as the subject (this provides a point of contact in case the messaging server needs to contact the message sender).

Your updated push-keys.js file should now look something like this (but with different values):

module.exports = {
  GCMAPIKey: "yBtCa6LClbdSb5dsPCuKM-hqx9WmOstWnvoFoh4",
  subject: "mailto:[email protected]",
  publicKey: "yteswBFEX-U7XsteR7x0o3nqygyR",
  privateKey: "IuKbrkM4inNv2MzlzVRDV4YRw4N65N"
};

Now that the server knows the GCM server key, it’s time to add the GCM sender ID to the client so that it can be used when creating new subscriptions.

Edit the site’s manifest.json file in the /public directory, and add a new setting to it with a key called gcm_sender_id, and a value equal to your GCM sender ID:

{
  "short_name": "Gotham Imperial",
  "name": "Gotham Imperial Hotel",
  "description": "Book your next stay, manage reservations, and explore Gotham",
  "start_url": "/my-account?utm_source=pwa",
  "display": "fullscreen",
  "icons": [
    {
      "src": "/img/app-icon-192.png",
      "type": "image/png",
      "sizes": "192x192"
    },
    {
      "src": "/img/app-icon-512.png",
      "type": "image/png",
      "sizes": "512x512"
    }
  ],
  "theme_color": "#242424",
  "background_color": "#242424",
  "gcm_sender_id": "3217212971"
}

Congratulations! You survived the cryptobabble section of the book. Now let’s get back to coding.

Creating a New Subscription

Now that we have our groundwork ready, we can finally turn our attention back to the browser and subscribe users to push messages.

We can use the service worker’s registration object to get the PushManager interface. This interface includes a number of useful methods for getting an existing subscription (getSubscription()), checking if the current page has permission to subscribe to push messages (permissionState()), and most importantly a subscribe() method for subscribing the user to push messages. All of these methods return promises:

var subscribeOptions = {
  userVisibleOnly: true
};

navigator.serviceWorker.ready.then(function(registration) {
  return registration.pushManager.subscribe(subscribeOptions);
}).then(function(subscription) {
  console.log(subscription);
});

The code begins by defining a subscription options object containing a single setting—userVisibleOnly. This setting means that all push messages must be made visible to the user (i.e., you agree to generate a notification for every push message). Since accepting messages in the service worker without letting the user know might endanger the user’s privacy, no browser currently supports setting userVisibleOnly to false. If you try to create a subscription without setting this value to true, the messaging server will return an error.

Next, the code gets the service worker registration object, then uses it to call the pushManager’s subscribe() method (passing it the subscription options object). This method returns a promise that resolves to an object with the subscription details sent from the messaging server.

Since this code does not include the VAPID key, it will only subscribe users in browsers that support messaging through GCM, and only if we include the GCM sender ID in our manifest.json file.

Let’s see what we need to do so that it works with VAPID if it is available, and GCM if it isn’t:

var urlBase64ToUint8Array = function(base64String) {
  var padding = "=".repeat((4 - base64String.length % 4) % 4);
  var base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
  var rawData = window.atob(base64);
  var outputArray = new Uint8Array(rawData.length);
  for (var i = 0; i < rawData.length; ++i) {
    outputArray[i] = rawData.charCodeAt(i);
  }
  return outputArray;
};

var subscribeOptions = {
  userVisibleOnly: true,
  applicationServerKey: urlBase64ToUint8Array("yteswBFEX-U7XsteR7x0o3nqygyR")
};

navigator.serviceWorker.ready.then(function(registration) {
  return registration.pushManager.subscribe(subscribeOptions);
}).then(function(subscription) {
  console.log(subscription);
});

Let’s go over this code from the bottom up.

We begin by modifying the subscription options object (subscribeOptions) to receive a second setting called applicationServerKey that will contain your public VAPID key (replace the random string in the code with your public key). Unfortunately, the pushManager won’t accept the VAPID key as is, and we need to convert it to a format it can understand. This conversion is up to the urlBase64ToUint8Array() function, which you can see at the top of the code. It converts the VAPID public key to a Uint8Array, which is the format pushManager requires. Unless you care deeply about cryptography, you don’t need to delve into the specifics of how it works. Just know that you call it with a string containing your VAPID public key, and it will return an array that pushManager understands.

Aside from the complexity of urlBase64ToUint8Array(), which we so elegantly ignored, the rest of the code hasn’t changed much between the previous two code examples. The only addition is a second attribute in the settings object containing our VAPID public key.

That’s it! The user is now subscribed to push messages, and you have the details of that subscription in the subscription variable.

At this point, you can send the subscription object to your server using an Ajax or fetch call and save it for future use.

Now that we understand how creating subscriptions work, let’s implement this in our app.

Subscribing Gotham Imperial Hotel Users to Push Messages

Earlier in the chapter we added notification support to the Gotham Imperial Hotel app—as soon as the user made a reservation, we asked him for permission to show him notifications.

Now, let’s modify that code to also create a new push subscription for users that gave us the notification permission and save that subscription to the server.

Modify the offerNotification() function in my-account.js:

var offerNotification = function() {
  if ("Notification" in window &&
      "PushManager" in window &&
      "serviceWorker" in navigator) {
    subscribeUserToNotifications();
  }
};

We have made two changes to offerNotification(). First, we added another condition to our if statement to make sure PushManager is supported by this browser. Next, we extracted all of the logic for requesting notification permission and for subscribing the user to push events to subscribeUserToNotifications(), a new function which we will write next.

Modify the last line of the addReservation() function so that it calls offerNotification() instead of showNewReservationNotification(). You can also delete the code for showNewReservationNotification(), as we will no longer show that notification—instead we will use push messages to show a notification once a reservation has been confirmed by the server.

Finally, add the following code above the offerNotification() function:

var urlBase64ToUint8Array = function(base64String) {
  var padding = "=".repeat((4 - base64String.length % 4) % 4);
  var base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
  var rawData = window.atob(base64);
  var outputArray = new Uint8Array(rawData.length);
  for (var i = 0; i < rawData.length; ++i) {
    outputArray[i] = rawData.charCodeAt(i);
  }
  return outputArray;
};

var subscribeUserToNotifications = function() {
  Notification.requestPermission().then(function(permission){
    if (permission === "granted") {
      var subscribeOptions = {
        userVisibleOnly: true,
        applicationServerKey: urlBase64ToUint8Array(
          "yteswBFEX-U7XsteR7x0o3nqygyR" // Replace with your public key
        )
      };
      navigator.serviceWorker.ready.then(function(registration) {
        return registration.pushManager.subscribe(subscribeOptions);
      }).then(function(subscription) {
        var fetchOptions = {
          method: "post",
          headers: new Headers({
            "Content-Type": "application/json"
          }),
          body: JSON.stringify(subscription)
        };
        return fetch("/add-subscription", fetchOptions);
      });
    }
  });
};

The code begins with our old friend, the urlBase64ToUint8Array() function.

Next, we define the subscribeUserToNotifications() function. This function will ask for notification permission, and if it gets it, it will create a new subscription and send it to our server.

It begins with a call to Notification.requestPermission() that asks the user for permission and returns a promise. When that promise resolves, we first make sure the permission was granted before we do anything else. Next, we define our subscription options using our public VAPID key as applicationServerKey and setting userVisibleOnly to true. Make sure to use your own public VAPID key, which you can find in server/push-keys.js. Next, we use navigator.serviceWorker.ready to get our service worker registration, and use it to call subscribe() on the pushManager. By the time this promise resolves and the next then block runs, our user has granted us the permission, and we have successfully subscribed her to push messages.

All we need to do now is send the details of that subscription to the server, where we will store it in our database. We do that by creating a new fetch request to /add-subscription, setting the request’s method to POST, adding a Content-Type header equal to application/json to let the server knows we are passing it JSON and finally converting the subscription object to a JSON string using JSON.stringify().

Let’s go through the entire process one more time:

  1. We make sure the browser supports service workers, the Notification API, and the Push API.

  2. We request permission for showing notifications, and continue only once it is granted.

  3. We create a new subscription with the messaging server using our VAPID public key (after converting it).

  4. Once we receive the subscription details back, we send them to our own server for safekeeping.

This just leaves one thing: the server-side code for saving the subscription details in our database on the server. Implementing this will vary greatly from app to app, server to server, and how you save and structure data on your server—but the premise is simple. You usually store the subscription details as a string in the users table or object store. When you want to send that user a notification, you read that string and convert it back to an object.

You can see an extremely simple and naïve implementation of this in server/index.js and server/subscriptions.js. Since our sample app has no concept of users (it only serves a single user) we simply save all subscriptions in a subscriptions object store with no connection to any users—you do not want to do that in a real app.

Sending Push Events from the Server

We now have everything we need to send a push message from our server to our users:

A VAPID private and public key

Used to sign messages and create subscriptions in browsers supporting VAPID.

A GCM API server key and sender ID

Fallbacks used to sign messages and create subscriptions in browsers that don’t yet support VAPID.

A subscription details object

This object was received from the messaging server and contains the details needed to send messages to a specific user subscription.

These details include a public key, an authentication secret, and an endpoint—which is literally just a URL to send the message to.

A message

The contents of the message you would like to send. This can be a simple string (e.g., "show-new-message-notification"), or an object with more details (e.g., {msg: "reservation-confirmation", reservationId: 19, date: "2021-12-19"}).

Using all of these details, we can build a request to the messaging server to send this message. This can get complicated quickly as it involves setting a number of HTTP headers on the request, such as an authorization header using JWT (JSON Web Token).

Luckily, we can once again bypass the complexities of encryption using the web-push library, which makes sending messages a (relative) breeze:

var webpush = require("web-push");

var pushKeys = {
  GCMAPIKey: "yBtCa6LClbdSb5dsPCuKM-hqx9WmOstWnvoFoh4",
  subject: "mailto:[email protected]",
  publicKey: "yteswBFEX-U7XsteR7x0o3nqygyR",
  privateKey: "IuKbrkM4inNv2MzlzVRDV4YRw4N65N"
};

var subscription = {
  endpoint: "https://fcm.googleapis.com/fcm/send/dQbqPBPWo_A:AHH91bHyhyrG9",
  keys: {
    p256dh: "BEJ_yK1xAC8DFrbXjiRKGVxCh8c8FImUyrNbm8rcVVIvDT3an18ab7011Jw=",
    auth: "o-hRay472334PuqppKq-lg=="
  }
};

var message = "show-notification";

webpush.setGCMAPIKey(pushKeys.GCMAPIKey);
webpush.setVapidDetails(
  pushKeys.subject,
  pushKeys.publicKey,
  pushKeys.privateKey
);

webpush.sendNotification(subscription, message).then(function() {
  console.log("Message sent");
}).catch(function() {
  console.log("Message failed");
});

Our code begins by requiring the web-push library. We then include all of the details described in the preceding list: VAPID and GCM keys, subscription details, and our message. Next, we can use webpush.setGCMAPIKey() and webpush.setVapidDetails() to configure web-push with these details. Finally, we use webpush.sendNotification() to send the message, passing it the subscription object, and our message. webpush.sendNotification() returns a promise that resolves if the messaging server determines the message can be queued for sending, or fails if anything went wrong.

Note that the promise returned by webpush.sendNotification() resolves when the messaging server determines the message can be sent. It does not mean it was successfully delivered to the user yet. The user may be currently offline, in which case the messaging server will keep trying to send the message, or she may have even revoked your app’s permissions to send her notifications (rare, but can happen).

The previous example uses a lot of hardcoded values, including the details of a single subscription and a simple textual message. A real-world example will probably have to be more flexible and dynamic. The VAPID and GCM details will be kept separate from the code, messages could be sent to multiple subscriptions retrieved from the database, and the message itself might include more details.

Let’s look at how this was implemented in the Gotham Imperial Hotel server.

Within subscriptions.js, you will see the following code:

var db = require("./db.js");
var webpush = require("web-push");
var pushKeys = require("./push-keys.js");

var notify = function(pushPayload) {
  pushPayload = JSON.stringify(pushPayload);
  webpush.setGCMAPIKey(pushKeys.GCMAPIKey);
  webpush.setVapidDetails(
    pushKeys.subject,
    pushKeys.publicKey,
    pushKeys.privateKey
  );

  var subscriptions = db.get("subscriptions").value();
  subscriptions.forEach(function(subscription) {
    webpush.sendNotification(subscription, pushPayload).then(function() {
      console.log("Notification sent");
    }).catch(function() {
      console.log("Notification failed");
    });
  });
};

This notify() function is later called from reservations.js to send a message when a reservation is confirmed:

subscriptions.notify({
  type: "reservation-confirmation",
  reservation: reservation
});

You can see that subscriptions.js uses a local database (defined in /server/db.js) and the web-push library. It also uses an external file called push-keys.js where the keys needed to send push messages are stored (the details of how we generated this file are covered in “Generating Public and Private VAPID Keys”).

You will also notice that it uses JSON.stringify() to turn the message it receives into a string. This makes sure we can pass an object as the message, as can be seen in the previous example code.

Finally, the subscription details objects are fetched from the database, and a forEach() loop makes sure the message is sent to all of them. In your app, you are more likely to send a message to just one subscriber at a time or customize the contents of each message for each user. To keep the code simple, our naïve server only knows how to handle a single user, and so every confirmed reservation notification is sent to all subscribers.

Note

For the first time in the book, this chapter looks at server-side code. I have kept this code as simple as possible to let the core concepts stand out. Our implementation uses Node.js and the web-push library to handle sending push messages. You can find similar libraries for many other programming languages.

If you are coding along with the Gotham Imperial Hotel exercises, there is no need to implement anything new in the server. All the server code shown is already implemented in the source code you downloaded.

Listening for Push Events and Showing Notifications

At this point, our frontend code knows how to get permission to show a notification to our users, create a subscription, and store it on the server; and our server knows how to send push messages to the user’s browser when a reservation is confirmed.

Next, we turn our attention back to the browser where our service worker can listen for these messages and act on them.

As we have seen, both the Push API and the Notification API require the same permission. This means that once a push message is received in the service worker, we know we have everything we need to show a notification, and our code can be as simple as the following example:

self.addEventListener("push", function() {
  self.registration.showNotification("Push message received");
});

When a push message arrives at the browser, a push event is triggered in the service worker. Even if the user hasn’t visited our site in weeks, the service worker will spring to action as soon as the message arrives; giving our app the chance to re-engage users with a notification (Figure 10-10).

A notification shown in response to a push event
Figure 10-10. A notification shown in response to a push event

The code for showing a notification from within the service worker is the same as the code we saw earlier in “Creating Notifications”. The only difference is that within the service worker we have easy access to the registration object using self.registration.

In the push event listener, you can access the contents of the push message through the data attribute of the PushEvent object (which is passed to the event listener as its first argument):

self.addEventListener("push", function(event) {
  var message = event.data.text();
  self.registration.showNotification("Push message received", {
    body: message
  });
});

As you can see in Figure 10-11, the PushEvent data attribute has a text() method that returns the message content as a simple string. It also has a json() method that will parse the message content as JSON, and return it as an object (Figure 10-12):

A notification shown on push event showing event.data.text()
Figure 10-11. A notification shown on push event showing event.data.text()
self.addEventListener("push", function(event) {
  var message = event.data.json();
  self.registration.showNotification("Push message received", {
    body: "Reservation for "+message.reservation.arrivalDate+" has been confirmed."
  });
});
A notification shown on push event showing event.data.json()
Figure 10-12. A notification shown on push event showing event.data.json()

Let’s build on everything we have learned so far to show a fancy notification to the Gotham Imperial Hotel’s users when their reservations are confirmed.

Edit serviceworker.js, adding the following code to the end of that file:

self.addEventListener("push", function(event) {
  var data = event.data.json();
  if (data.type === "reservation-confirmation") {
    var reservation = data.reservation;
    event.waitUntil(
      updateInObjectStore(
        "reservations",
        reservation.id,
        reservation)
      .then(function() {
        return self.registration.showNotification("Reservation Confirmed", {
          body:
            "Reservation for "+reservation.arrivalDate+" has been confirmed.",
          icon: "/img/reservation-gih.jpg",
          badge: "/img/icon-hotel.png",
          tag: "reservation-confirmation-"+reservation.id,
          actions: [
            {
              action: "details",
              title: "Show reservations",
              icon: "/img/icon-cal.png"
            }, {
              action: "confirm",
              title: "OK",
              icon: "/img/icon-confirm.png"
            },
          ],
          vibrate:
            [500,110,500,110,450,110,200,110,170,40,450,110,200,110,170,40,500]
        });
      })
    );
  }
});

Our new event listener waits patiently for push events. When such a push message arrives at the service worker, the event listener will retrieve the data contained within the PushEvent and decide how to act based on the type attribute it contains. If that type equals "reservation-confirmation", our code knows that it needs to update the reservation in IndexedDB using updateInObjectStore() and display a notification using self.registration.showNotification() (Figure 10-13).

The final look of the reservation confirmed notification
Figure 10-13. The final look of the Reservation Confirmed notification
Note

A quick reminder: the message sent from the Gotham Imperial Hotel server has the following structure:

{
  "type": "reservation-confirmation",
  "reservation": {
    "id": "79212418",
    "arrivalDate": "November 5th 2022",
    "nights": "3",
    "guests": "2",
    "status": "Confirmed",
    "bookedOn": "2016-10-31T15:40:41+02:00",
    "price": 636
  }
}

The choice to structure our push message data as an object with a type and a reservation details object was completely arbitrary. We could just as well have structured it without a type. We could even have made the entire message a simple string containing the final text of the notification, or a string such as "reservation-confirmation,79212418" which we would then parse in the service worker and get the reservation details for that ID from IndexedDB.

Let’s examine the new event listener code more closely.

First, you might notice that the push event listener code uses event.waitUntil() to make sure the event waits for both the IndexedDB update and the notification code to complete successfully before it considers the original push event complete. As we first saw in Chapter 3, waitUntil() extends the life of an event until the promise passed to it is resolved. In this case, we are passing it updateInObjectStore(), which returns a promise; this promise is then handed off to showNotification(), which also returns a promise.

If we had neglected to tell the PushEvent to wait for the code within it to finish, we might find that actions that take longer to execute (such as making network requests) are initiated, but since the browser might consider the PushEvent to have ended by the time the actions complete, the service worker might already be unavailable to act on the results.

Before we get to the business of showing the notification, the code begins by calling updateInObjectStore() and updating the reservation details in IndexedDB. By doing this from within the push event, we ensure that the local reservation data is kept up to date at all times. If a user receives a push notification notifying her that one of her reservations has been confirmed, then visits the app while she is offline, the latest reservation data (including the confirmed reservation) will be available and shown.

Next, the code calls showNotification(). The syntax used here should be familiar by now, but you may have noticed that in addition to the customized message, the fancy badge and icons, and the bumpin’ theme song played by the vibrate option, the notification includes two buttons. These are created using the actions attribute of the notification options object.

Each notification action is comprised of a title (the button text), an icon (shown next to the text), and an action (a name used to identify this action). It is clearly missing something: a way for it to actually do something.

Since notifications are a UI element that renders outside the browser (at the operating-system level), and a user might act upon them hours after they were created (e.g., think of a notification that pops up in the middle of the night), it wouldn’t make sense to keep a callback or a promise waiting for an action to happen. Instead, actions taken on a notification are dispatched to the service worker as separate events. By listening to these events, we can take actions based on how the user interacted with them (dismissed them or clicked one of the buttons).

Edit serviceworker.js, adding the following code to the end of that file:

self.addEventListener("notificationclick", function(event) {
  event.notification.close();
  if (event.action === "details") {
    event.waitUntil(
      self.clients.matchAll().then(function(activeClients) {
        if (activeClients.length > 0) {
          activeClients[0].navigate("http://localhost:8443/my-account");
        } else {
          self.clients.openWindow("http://localhost:8443/my-account");
        }
      })
    );
  }
});

This code will listen for notificationclick events. These events are dispatched every time any notification created by your app is clicked.

Our event listener begins by calling event.notification.close() to dismiss the notification. Once a user has interacted with our notification, there is no point in keeping it around. This also ensures a unified experience across devices, operating systems, and browsers—some of which dismiss notifications automatically as soon as they are clicked, while some only do it when you tell them to.

Next, we need to figure out how the user interacted with the notification. As we only have a single notification on our site so far, and we only care about what happens when the “Show reservations” button is clicked, we check the event’s action attribute. The action attribute will contain the name of the action that was clicked (or an empty string if no action was clicked). This is the same name we specified as the action attribute (which we named details and confirm). If the action the user clicked is details, we want to navigate to the My Account page of the app. At this point, we could simply open a new window by calling self.clients.openWindow(url), but as a way to offer a better user experience, we first check if there already is an active window showing our app. If there is, we will navigate to the My Account page on that window.

If the code used to examine open windows (self.clients.matchAll) is unfamiliar to you, check out Chapter 8.

Interrogating Notifications

The preceding use case is a very simple one. We only have a single notification type on our site (a reservation confirmation), so we don’t care which type of notification was clicked. In a more complicated example, we might open multiple notifications announcing new events, as well as multiple notifications for reservation confirmations, all at the same time.

What if we wanted to know what kind of notification triggered a notificationclick event (e.g., was it a new event or a reservation confirmation), and which specific notification was clicked (e.g., did the user click the RSVP button on the Halloween party notification or the New Year’s ball notification?)

There are several ways to determine what notification the user interacted with.

The simplest one is the one we have just seen. We simply check the name of the action that was clicked. This was good enough for our case, as we didn’t care about the type of notification (we only have one) or which specific reservation it was talking about.

Another way would be to read the name of the notification window. This name is the tag we previously assigned to the notification when we created it above. Here is one way we could use the notification’s tag to determine how to proceed:

self.addEventListener("notificationclick", function(event) {
  if (event.notification.tag === "event-announcement") {
    self.clients.openWindow("http://localhost:8443/events");
  } else if (event.notification.tag === "confirmation") {
    self.clients.openWindow("http://localhost:8443/my-account");
  }
});

This code would act one way when the notification clicked was tagged as an event-announcement, and another way if it was tagged as a confirmation notification.

Another way would be to pass data along with each notification:

self.addEventListener("push", function(event) {
  var data = event.data.json();
  var reservation = data.reservation;
  self.registration.showNotification("Reservation Confirmed", {
    tag: "reservation-confirmation",
    data: reservation
  });
});

self.addEventListener("notificationclick", function(event) {
  event.notification.close();
  if (event.notification.tag === "reservation-confirmation") {
    var reservation = event.notification.data;
    self.registration.showNotification("Notification clicked", {
      body:
      "Notification tag: "+event.notification.tag+"
"+
      "Notification reservation date: "+reservation.arrivalDate
    });
  }
});

This code sample shows how when we create a notification from within the push event, we can set its data attribute to contain the details of that reservation. Later when the notification is clicked, we can access that data through event.notification.data and use it to display a second notification (or open a specific page on the site for that specific reservation) (Figure 10-14).

Notification shown in response to a notificationclick event
Figure 10-14. Notification shown in response to a notificationclick event

Summary

The improvements made in this chapter to the Gotham Imperial Hotel web app really tie our progressive web app together.

We can now not only give our user the best possible experience, regardless of her connection; we can also keep her updated after she has left our app.

This ability to reach out to users with updates to their reservation, reminders before they arrive, and even suggestions for things to do while they are staying in Gotham allow us to improve the user experience dramatically.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset