How to add push notifications to a progressive web app

Photo by Sara Kurfess on Unsplash

Introduction

This is the second post on building progressive web apps (PWA) using the new Vue CLI 3. So far, as a first step towards push notifications, we created a PWA that sends a (normal) notification when a user clicks the button. If you haven’t read this part, it is available here.

In this post push notifications will be introduced. The terminology is a bit difficult because we’ll use notifications, messages and so on, and it’s easy to get lost. Let me try to elaborate a bit more by showing a diagram that visualizes how push notifications work.

1] incoming: “new post published”, 2a] select subscribed users and create push notifications, 2b] send push notifications to unique endpoints, 3] send push event and 4] show notification in OS of user.

So let’s assume that a new post has been added on a blog website (for example Medium.com). Furthermore, assume that you follow the author and you have enabled notifications for new posts.

At the moment the new post is published by the author (1), all followers of that author need to get notified using push notifications. Hence, the code (running on the server) that handles the submission of the new post looks up all the followers (2a) and sends a push notification to each one of them (2b). Each push notification first ends up at the browser vendor and it is this browser vendor that sends the actual push event to the user’s browser (3). A service worker listens for this push event and creates a notification using the Notification API of the browser to show a message like ‘Hey we have a new post for you’ to the follower (4).

A short note on the browser vendor. I like to think about it as a server from the browser builder. So for the Chrome browser it is as server from Google. So the browser vendor and the user’s browser ar two different things here.

What’s in this post?

In this post we’ll build a simplified version of this flow: a button click will act as the trigger to send a push notification (comparable with the publication of a new post). To make the notification message a bit more dynamic we’ll add a textarea. This way you can send your own message in the push notification.

To be able to send a push notification, we need to have users. Hence, as soon as someone enables notifications, we create a dummy user (and subscription) on the server, and store the id of the new user on the client (e.g., in localStorage). With the users setup this way, we ensure that when type some text in the textarea and click the new notify button, a push notification showing this text will be sent.

To achieve this, we’ll set up a backend (API) for the demo app we build before. We create this API using Laravel. To make the push notifications work, we’ll use the webpush package for Laravel that extends the built in notification channels.

However, work needs to be done on the frontend as well. To be able to receive a push notifications, a user needs to be subscribed to push notifications. This is done by using the browser’s Push API.

It is going to be quite a long ride, so let’s summarise the steps:

  1. Install Laravel API
  2. Create an API endpoint to create a user
  3. Install the Webpush package that extends the Laravel notification channels
  4. Create two more API endpoints to store and retrieve subscriptions
  5. Create a subscription on the frontend using the browser’s Push API
  6. Add functionality to trigger a push notification
  7. Handle the push notification on the server
  8. Add a listener for the push event
  9. Show the notification to the user

The code is available on Github (Laravel API, Vue PWA). We also have a demo setup!

Install Laravel API

To build the API in Laravel, start by creating a fresh installation of Laravel as described in the docs.

For simplicity we forget about authentication for the moment (for a production app you better take care of this though). Hence, we change the existing migration for creating the users table:

Schema::create('users', function (Blueprint $table) { 
$table->increments('id');
$table->string('name');
$table->timestamps();
});

In order to create a subscription for a user, we need to have users. As mentioned before, a good moment to create a user is once they click the button ‘enable notifications’ in your app. A request should be send to the Laravel API to create a new user. Upon creation, the API should respond with the newly created user.

Create an API endpoint to create a user

In our Larevel API, we’ll create the following endpoint in routes/api.php

Route::post('user', 'UserController@createOrRetrieve');

We create the UserController using the artisan command

php artisan make:controller UserController

The store function simply creates (and returns) a new user with a unique random string name.

/** 
* If the request contains a username, retrieve this user from
* the database and return it.
* Otherwise, create a new user with a random name and return it.
* @return new user object as json
*/
public function createOrRetrieve(Request $request) {
if ($request->has('username') && !is_null($request->username)) {
$user = User::where(['name' => $request->username])->first(); }
else {
// create a new user instance.
// name of the user is just a hashed string
$user = User::create(['name' => Str::uuid()]);
}
return response()->json(compact('user'));
}

We can test this API function using Postman and see that a new user has been created.

The api endpoint ‘user’ return a new user.

Installing the Webpush package

The next step is to setup the subscriptions in order for people to get notified when ‘something happens’. Subscriptions are part of the ‘Web push notifications channel for Laravel’ package, so we follow their installation guide all the way to (and including) generation of the VAPID keys. VAPID keys are the de facto way of encrypting your push notifications and without them it is not possible to send additional data with your notifications. I’ll explain more on this in a bit.

After installation, running the migrations and generating the VAPID keys we have a new table push_subscriptions in the database. The package offers two methods to write to this table (updatePushSubscription and deletePushSubscription), we’ll make use of these methods in the next step.

With this package installed, we can add two more endpoints to our API. The first one to add/ update a subscription, the second one to delete a subscription. Again, we can test these endpoints using postman, but be sure to pass the correct payload (outlined in the controller code below) to your requests. Don’t forget to create the SubscriptionController.

// create or update a subscription for a user Route::post('subscription', 'SubscriptionController@store'); 
// delete a subscription for a user Route::post('subscription/delete', SubscriptionController@destroy');

With these two endpoints we conclude the backend part for now. It is time to setup the subscription in the frontend. That is, we are going to talk to the ‘user’ endpoint to create a user, then we create a subscription on the frontend (using the VAPID public key) and finally, we’ll talk to the ‘subscription’ endpoint to create a subscription on the backend.

In the end we have all ingredients in place to be able to trigger a real push notification.

Creating subscriptions on the frontend

In my previous post, I described how I started with the development of the frontend. We implemented a rather simple app that triggers a Notifications once the user clicks the button. So far, we handled the click on the green button ‘Enable notifications’ in the Home component. Two methods were defined for this component, askPermission and showNotification. Here we’ll extend the behavior. Let me explain what we are after here.

Users visiting the page either visit for the first time, or they come back and have (or have not) enabled the notifications in an earlier visit. Hence, for returning users we have to check if they have notifications enabled. We’ll do this in the mounted hook of the Home component, which fires once the component is mounted on the page. If notifications are enabled, the text on the button needs to be changed from ‘Enable notifications’ (the default) to ‘Disable notifications’. This way, notifications can be turned off by the user. If notifications are not enabled, the user is treated as a first time visitor.

For a user, everything starts with clicking the button. Let’s assume the user clicks ‘Enable notifications’, which steps are involved?

  1. We have to check if notifications and service workers are supported by the browser;
  2. We have to ask the user for permission to show notifications;
  3. We have to create a subscription using the PushManager;
  4. We have to create a user and store the subscription on the backend;
  5. We have to show a notification to the user letting him know what happened.

These steps are implemented in the toggleSubscription method of the Home component shown below.

Some explanation of this code is required, so let’s go through each of the steps mentioned above.

  1. Support for notifications and service workers is implemented in the created hook of the component (lines 162–164). We simply toggle the notificationsSupported flag based on support of the browser.
  2. Asking the user permission is something we already had in the original version of this code. It’s nothing more than awaiting the result of the promise Notification.requestPermission() (line 36). Only when the result equals ‘granted’ we may continue.
  3. Creating the subscription for a user requires a bit more work. I put the code in a method of its own called createSubscription (lines 88–99).
    In this method, we first need to retrieve the active service worker registration by calling the promise based function navigator.serviceWorker.ready. Note that this function only ‘works’ in production. That is, the service worker is only registered in production builds. When the promise resolves, it gives us the active service worker. I put in a data variable in my component to prevent having to retrieve it again later on. Actually, we check if the data variable is already set before we start waiting for the promise.
    Once we have the active service worker we call the subscribe method (lines 104–116). This is the point where the VAPID keys we set up before on the backend come into play. Here we need access to the VAPID public key. Therefore, I put it in my client side env file (in my case .env.production.local). To be able to use the key, it must start with VUE_APP. my key is called VUE_APP_VAPID_PUBLIC_KEY. We retrieve it using process.env.VUE_APP_VAPID_PUBLIC_KEY. You can read more on modes and env variables here.
    The active service worker registration’s pushManager has a subscribe method (line 110). For security reasons, we need to pass two options to the method which are described in more detail here. If we don’t set the subscription up this way, we won’t be able to send additional data with our push messages and without that they are rather pointless in my opinion. The first option is a simple boolean, userVisibleOnly: true. However, the second one, applicationServerKey needs to be a “A Base64-encoded DOMString or ArrayBuffer containing an ECDSA P-256 public key that the push server will use to authenticate your application server.” This basically means we have to convert our VAPID public key. I’ll leave out all the details, you’ll find the code in the method urlBase64ToUint8Array (lines 146–159) inside the component. With this in place, the user is subscribed to the pushManager. Again, this is a promise. Once it resolves we obtain the subscription which consists of an endpoint. It is this endpoint we’ll send the push messages to. An endpoint for the Chrome browser looks like this: https://fcm.googleapis.com/fcm/send/dNZuZWNGTXM:APA91bHeSVSHi29sdTI9_igvIwUN-LhUWVbsdnftom4nMRc51QSAg5RElfiSoCRo3XFCkUOR7YY9jrcYa2emHjqvkOKpUsn-wygwduRhqBvPn8DNvekHPyaXh2-A4LyiESwPLApyp3r4.
  4. We store the subscription locally in the component and it is time to store the subscription on the backend as well (lines 41–58). As we learned earlier in this post, on the backend we need a user to connect the subscription. Hence, we create a user (and store the generated username in localStorage). With the user in hand, we store the subscription on the backend. Here we use our API endpoints created before.
  5. Finally, the user is subscribed to receive push messages and we show a notification (lines 59–63).

A returning user and disabling notifications

A returning user may have enabled the notifications in a previous visit. Hence, the flow for this user type is

  1. Check if a subscription is available (on the frontend, using pushManager);
  2. If subscription exists, change the button text to ‘Disable notifications’;
  3. If no subscription was found, nothing happens.

It becomes more interesting when a user clicks ‘disable notifications’. In that case we need to unsubscribe, both on the client and on the server. This is also coded in the toggleSubscription method of the component:

  1. The subscription that was found using the Pushmanager is used to identify the user on the backend. We use the ‘endpoint’ field in the subscription as a key, since such an endpoint is unique for each subscription. We call the API endpoint ‘subscription/delete’ passing the ‘endpoint’ field.
  2. This is followed by calling the unsubscribe method of the pushManager on the active service worker registration.
  3. Once that is done, the button text is updated accordingly.

With these flows in place a user can now toggle and untoggle the button several times. Initially a user is created. Because we store the username on the frontend, subsequent subscriptions use this user on the backend. Keep in mind that this app doesn’t have authentication, because we use it only to demonstrate the technique behind push notifications. In a real application we would implement a proper login flow and users would need to login before enabling the notifications.

A note on client side subscriptions

The subscription we create on the frontend using the pushManager is unique for the combination of device and browser. This means that when visiting the page in another browser or on another device no subscription will be found when executing the getSubscription method of the pushManager.

Furthermore, a subscription is also connected to the active service worker. If a user clears his cookies and other browser settings including the service worker, the subscription is also lost.

Trigger a push notification

We have the subscriptions in place, let’s move on to the fun part of this post: sending push messages. We need a way to trigger a push notification. In this simple setup we just add another button, which is shown as soon as there is a valid subscription found for the user. In a real live application, the push notification would be triggered when someone adds a new post to a blog or someone else comments on a post. Clicking this button triggers the server to send a push message. The server uses the endpoint found in the subscription (which was stored in the database on the backend, i.e., https://fcm.googleapis.com/fcm/send/dkjhsdkfj ) and creates a new notification. The notification will be send to the endpoint, which in turn will send it to the client (as a push event). On the client, it is the service worker that needs to listen to this push event to be able to create a notification for the user. Let’s see this in action.

In the Home component we add an extra block (lines 6–11) that is only shown when notifications are enabled. It gives the user the possibility to enter some text in a textarea and send it to the backend by clicking the button ‘Notify with Push’. Our app now looks like this:

The click on ‘Notify with Push’ triggers the createPushNotification method (lines 135–145). This method simply sends the username (which was stored in localStorage and is used to identify the correct user to be notified) and the message to the server using a new API endpoint ‘notify’. When the promise resolves, the textarea is cleared.

Creating a push notification on the server

We have to create the new endpoint ‘notify’ and do so in routes/api.php in the Laravel API. This endpoint is handled by the NotificationController which we add as well.

In this controller we add a function notify. This function starts by looking at the passed username. It is required and should not be empty, otherwise we can’t find the corresponding user in the database. Assuming that we do find this user, we create a message (the body of the Push notification) by looking at the text passed with the request. If it is empty we come up with a standard text ourselves, otherwise we just use the passed in text. Finally, we use the notification system in Laravel to notify the user.

First, we create a notification (SayHello). Next we call the notify method on the user instance and pass this notification. A notification is simply created using an artisan command

php artisan make:notification SayHello

The notification can be send via various channels, and WebPush is just one of those channels. For testing purposes, we also like to add the notification to the database. We don’t want to send it by mail though, so we get rid of that in the via method. Here is the code of the SayHello notification:

Please note the two use statements (lines 10,11) at the top of the file regarding WebPushMessage and WebPushChannel.

The constructor accepts the message, this is the message the user send with the request. As discussed, the via method is updated to send via WebPush and to add a record to the database.

Therefore, we need a toDatabase method (which simply returns the message) and a toWebPush method. Here we set up the push notification by returning a new WebPushMessage (lines 47–55). In this example we simply add a title and a body but we can add a lot more typical notification properties as you can see in the repository of the package.

With this setup, the API endpoint is finished.

Listening for a push event

When the notification is sent to the WebPush channel, it will be forwarded to the frontend. The service worker is responsible to catch this event and does so by implementing the listener for the push event. Hence, we add this to the service worker code:

// Listen to Push 
self.addEventListener('push', (e) => {
let data
if (e.data) {
data = e.data.json()
}
   console.log('data for notification', data); 
   const options = { 
body: data.body,
icon: '/img/icons/android-chrome-192x192.png',
image: '/img/autumn-forest.png', vibrate: [300, 200, 300],
badge: '/img/icons/plint-badge-96x96.png',
}

console.log('options passed to Notification', options);
   e.waitUntil(self.registration.showNotification(data.title, options)) 
})

The incoming event (e) may contain data which we retrieve using the e.data.json() method. Then we create a notification options object which is passed to the showNotification method.

Build for production and test

Now all we have to do is build the app for production and try it out. Cross your fingers and type some text in the textarea and hit the button…

Concluding remarks

This concludes this post on setting up Push notifications using the new Vue CLI with a Laravel API. I tried to keep everything as simple and to the point as possible, but in a real world app there are many things that you also have to consider which might serve as a subject for future posts. Some of these things include

  • How do we handle updates of the service worker code
  • What about offline capabilities of the app
  • Improve the Lighthouse score (currently at 69)
  • A proper authentication and login flow

and many more.

The code for this post can be found on GitHub where I have made a second repository for the Laravel API. For the frontend app, I just build upon the repo created for the previous post. The code for this post is tagged v0.2.

As before, the demo is available at our demo website.


Originally published at www.blog.plint-sites.nl on November 21, 2018.