Create your own VAPID Web Push Notifications server.

Web Push Notifications allow to send desktop notifications for a PWA or a Web Application as a useful and powerful tool to engage your audience. This is a little example how I created my own Web Push Notification service.

Since I think that PWA and WebApps still missing some features from native apps, WebPush Notifications can help to close the gap.

Web Push Notifications require 2 technologies:

  1. a push server (VAPID) that sends notifications
  2. a service worker on the client that shows the notifications

In order to implement a complete solution you need to know or learn both of them. In this article I will show how to implement a basic Web Push Notifications service using Feathersjs.

If you don’t know about Feathersjs, it’s a realtime API and REST api framework based on Nodejs/express. You can find all info here

Requirements

  • nodejs
  • npm
  • feathersjs
  • web-push (via npm)

Application features (basic)

Our app will be a basic app in order to manage:

  • authentication in order to secure our services
  • users that can send push notifications
  • notifications a collection of our notification messages
  • subscriptions the subscribers at our push notification service
  • push notifications (WebPush)

If you don’t have feathersjs installed

$ npm install @feathersjs/feathers --save

Install the CLI (the generator)

$ npm install @feathersjs/cli -g

Create a new app

First create a new folder for your app.

$ mkdir webpush
$ cd webpush

Using feathersjs CLI create a new app

$ feathers generate app

App Setup & Configuration

VAPID KEYS

In order to trigger push messages from your server you need to add to your app the web-push library.

$ npm install web-push -g

I suggest to install globally so you can use and test web push notifications from the command line, but most important for this example we will use to generate the vapid keys

$ web-push generate-vapid-keys --json

You will get a JSON object as

{
    "public": "BMIMGCxKLrSXKXaK1rxzU8Cy4NSkvw8k9XDM9lfxXPA16hDpvAMYj0bS....",
    "private": "...JRhFn47GWl4HDFkaUg6UK7GB.....",
}

Save them in a safe place and never release the private key.

Configuration settings

Open now from the root of your project the file config/default.json

This file has the global configuration parameters used by the app. Here you can change the port, DB access parameters, public folder, pagination, etc.

You can add you settings in order to get them available globally

Your config file should look something like this

{
  "host": "localhost",
  "port": 3030,
  "public": "../public/",
  "paginate": {
    "default": 10,
    "max": 50
  },
  "nedb": "../data",
  ...

Add now the vapid keys generated before.

"vapid" : {
  "public" : "BMIMGCxKLrSXKXaK1rxzU8Cy4NSkvw8k9XDM9lfxXPA16hDpvAMYj0bS....",
  "private" : "...JRhFn47GWl4HDFkaUg6UK7GB.....",
  "subject" : "mailto:a_valid_email_address"
}

We added the subject parameter that is required by the standard application of a VAPID server.

Authentication service

In order to protect you app we will add an authentication system.

$ feathers generate authentication

Generating an authentication system you will asked which service will be used to manage the users (default will be /users) and which provider/strategy you plan to deploy for you app. You have the following options (you can select all)

  • local (JWT Token)
  • Auth0
  • Google
  • Facebook
  • Github

The generator will ask you which endpoint you want to generate in order to manage users for authentication. Leave the default value suggested by the generator. For the DB in order to make this simple demo we will use as database NeDB, but you can set your own adapter since Feathersjs can manage different database adapters

Try to run your app in order to check that everything is ok. I am using nodemon in order to restart the server on every change applied to our app.

$ nodemon src/index.js

You should get in the console window

info: Feathers application started on http://localhost:3030

You can now click Ctrl+C to get back to the console. We will add the services that we need for our demo app.

Notifications Service

endpoint : http://localhost:3030/notifications

We will create a database of notifications to send to the users. In this way we can reuse them for different sending without the need to write a new one everytime. We need to create a service. From the console

$ feathers generate service

? What kind of service is it? (Use arrow keys)
  A custom service
  In Memory
> NeDB
  MongoDB
  Mongoose
  Sequelize
  KnexJS
(Move up and down to reveal more choices)

? What kind of service is it? NeDB
? What is the name of the service? notifications
? Which path should the service be registered on? (/notifications)
? Does the service require authentication? (y/N) Y

Our service to manage notifications is ready. Restart the server nodemon src/index.js to control that there are no errors.

The Web Push Notification API, that we will use to send the notification is very simple.

showNotification(title,options)

Where title is the message title and options can be any of the following

{
  "body": "<String>",
  "icon": "<URL String>",
  "image": "<URL String>",
  "badge": "<URL String>",
  "vibrate": "<Array of Integers>",
  "sound": "<URL String>",
  "dir": "<String of 'auto' | 'ltr' | 'rtl'>",
  "//": "Behavioural Options",
  "tag": "<String>",
  "data": "<Anything>",
  "requireInteraction": "<boolean>",
  "renotify": "<Boolean>",
  "silent": "<Boolean>",
  "//": "Both Visual & Behavioural Options",
  "actions": "<Array of Strings>",
  "//": "Information Option. No visual affect.",
  "timestamp": "<Long>"
}

We should follow the above schema in order to create and manage our notifications, but since we are using NeDB we can save notifications with different schema or including only the fields we will use. Remember also that not all the options are supported by different platforms (chrome, firefox, etc.) so will keep our notification model as simple in order to get supported by different platforms.

{
  title: title of the notification,
  body: message body,
  image: image URL to show (when supported),
  icon: icon URL to show,
  url: url to redirect when the user clicks on the notification received
}

image/icon url should be served thru CDN or other reliable services.

In order to set a createdAt and updatedAt for each record we need to change something in our notifications model.

Open src/models/notifications.model.js and add timestampData: true as shown below

  ....
  const Model = new NeDB({
    filename: path.join(dbPath, 'notifications.db'),
    autoload: true,
    timestampData: true //automatically add createdAt and updatedAt timestamp data fields
  });
  ...

Subscriptions Service

endpoint: http://localhost:3030/subscription

WebPush Notifications can send notifications only to subscribers thru a VAPID server.

In order to send our notification to subscribed users we need to collect and manage them. This means that we need to create, patch, update and delete subscriptions. We need to create a service. Press Ctrl+C in the console in order to stop your app and type

$ feathers generate service

? What kind of service is it? (Use arrow keys)
  A custom service
  In Memory
> NeDB
  MongoDB
  Mongoose
  Sequelize
  KnexJS
(Move up and down to reveal more choices)

? What kind of service is it? NeDB
? What is the name of the service? subscription
? Which path should the service be registered on? (/subscription)
? Does the service require authentication? (y/N) N

We will not require authentication since the user can unsuscribe any time without any authentication as an opt-out option. Our service to manage subscriptions is ready. Restart the server nodemon src/index.js.

How to create subscriptions?

Subscriptions can only be generated by the client side and they depends on service-workers. Process to create a subscription is:

  1. Register a Service Worker
  2. Request user Permission
  3. Subscribe a user using PushManager

So in order to create, register or remove subscription we need to call the service from the browser.

You can use REST API or websocket. Our service supports both.

In order to register subscriptions in our database you have invoke a POST method to the service endpoint.

Example of subscription script (client)

  ...
  function subscribe(){

    return navigator.serviceWorker.register('service-worker.js')
      .then(function(registration) {
        const subscribeOptions = {
          userVisibleOnly: true,
          applicationServerKey:
          urlB64ToUint8Array(VAPID_PUBLIC_KEY) //your VAPID public key created before
        };
        return registration.pushManager.subscribe(subscribeOptions);
      })
      .then(function(pushSubscription) {
        console.log('PushSubscription: ', JSON.stringify(pushSubscription));
        //post to our subscriptions service
        //http://localhost:3030/subscriptions
        return pushSubscription;
    })
  }    
  ...

VAPID_PUBLIC_KEY is the key we generated before and identifies our Web Push Notification Server (ApplicationServerKey)

Note the function urlB64ToUint8Array is a custom function used for conversion of a base64String to a Uint8Array that is required to manage subscriptions correctly.

function urlB64ToUint8Array(base64String) {
  const padding = '='.repeat((4 - base64String.length % 4) % 4);
  const base64 = (base64String + padding)
    .replace(/\-/g, '+')
    .replace(/_/g, '/');

  const rawData = window.atob(base64);
  const outputArray = new Uint8Array(rawData.length);

  for (let i = 0; i < rawData.length; ++i) {
    outputArray[i] = rawData.charCodeAt(i);
  }
  return outputArray;
},

The subscription generated by the browser is a JSON Object that will look something like this

Mozilla

{
  "endpoint":"https://updates.push.services.mozilla.com/wpush/v2/gAAAAABch......",
  "keys":
    {
      "auth":".........",
      "p256dh":".......HgPik7lB2HxrsAhKgXrfFw-EE3hsNJD-84-PEm0gK0qnNuGVZT7vT........"
   }
 }

Chrome

{
  "endpoint":"https://fcm.googleapis.com/fcm/send/e7Vi4ajhcsA .......",
  "expirationTime":null,
  "keys":
    {
      "p256dh":"...........-CPsXi_e9NvnhaxpuMhn-OJ4MX6ojmckzVRts.......",
      "auth":"....f6nMs30v76w...."
    },
}

For a detail guide about the subscription generation please read here

You will find also an article on this blog to create a Vue app in order to test our Web Push Notification Service.

Push Service

endpoint http://localhost:3030/push

$ feathers generate service
? What kind of service is it? (Use arrow keys)
  A custom service
  In Memory
> NeDB
  MongoDB
  Mongoose
  Sequelize
  KnexJS
(Move up and down to reveal more choices)

? What kind of service is it? NeDB
? What is the name of the service? subscriptions
? Which path should the service be registered on? (/push)
? Does the service require authentication? (y/N) Y

This will create a standard feathersjs service with the following API

- find
- get
- create
- update
- patch
- remove

We will use these standards API in order to register the notifications we generated and sent to subscribers (to analyze them)

In order to use our API to send subscriptions we need to add a hook in order to:

  • select which notification to send from our database
  • send to subscribers
  • save to db our web push action

Open src/services/push/push.hooks.js

const { authenticate } = require('@feathersjs/authentication').hooks;

module.exports = {
  before: {
    all: [authenticate('jwt')],
    find: [],
    get: [],
    create: [],
    update: [],
    patch: [],
    remove: []
  },

  after: {
    all: [],
    find: [],
    get: [],
    create: [],
    update: [],
    patch: [],
    remove: []
  },

  error: {
    all: [],
    find: [],
    get: [],
    create: [],
    update: [],
    patch: [],
    remove: []
  }
};

After the first line add

const webpush = require('../../hooks/webpush');

Locate the section after and change as below

...
after: {
  all: [],
  find: [],
  get: [],
  create: [webpush()],
  update: [],
  patch: [],
  remove: []
},
...

What does it mean?

  1. import a webpush.js
  2. after a POST action or create action to our service call webpush hook that will send our notifications

Now we need to code our webpush hook. Create a file in src/hooks/webpush.js

We first import our web-push library that we installed before

const webpush = require('web-push')

Then we will create trigger function that will be fired on every notification to be sent. This is our main function in order to send our notifications

const triggerPushMsg = function(subscription, dataToSend) {
  //send notification to subscriber
  return webpush.sendNotification(subscription, dataToSend)
  .then(response=>{
    //check everything ok only for test
    console.log ( 'msg sent' , response )
  })
  .catch((err) => {
    //add log features for production
    console.log('Subscription is no longer valid: ', err);
  });
};

And then our hook. Remember that we will send a notification saved in our db so we need to POST a valid ID.

module.exports = function (options = {}) {
  return async context => {
    //get the notification by the notification parameter sent thru our POST
    context.app.service('notifications').get(context.data.notification).then(result=>{
      return result
    }).then(notification=>{
      //create the message to send based on our notification from DB
      const dataToSend = {
        "notification" : {
          "title" : notification.title,
          "body"  : notification.body,
          "image"  : notification.image ? notification.image : context.app.settings.image ,
          "icon"  : context.app.settings.logo,
          "data" : notification.url ? { "url" : notification.url } : ''
        }
      }

      //recall the vapid keys
      const vapidKeys = {
        subject: context.app.settings.vapid.subject,
        publicKey: context.app.settings.vapid.public,
        privateKey: context.app.settings.vapid.private
      };

      //set our vapid keys
      webpush.setVapidDetails(
        vapidKeys.subject,
        vapidKeys.publicKey,
        vapidKeys.privateKey
      );

      //find all subscribers in our DB
      context.app.service('subscription').find().then(result=>{
        //create a promise
        let promiseChain = Promise.resolve();
        //loop thru our subscribers
        for (let i = 0; i < result.data.length; i++) {
          const subscription = result.data[i];
          promiseChain = promiseChain.then(() => {
            //push our message
            return triggerPushMsg(subscription, JSON.stringify(dataToSend));
          });
        }
        return promiseChain;
      })

    })
    return context;
  };
};

In order to send a notification we need :

  1. A notification saved in our DB
  2. At least a valid subscription saved in our DB

When we will set both of above conditions we can start to send our notifications with a POST to http://localhost:3030/push with following data :

{
  notification: '3kQ9LDv9NBQxKOR7' //_id of the notification to send
}

Our app should be now ready to register subscriptions, save notifications and send web push notifications to subscribers.

In order to test our app we need to:

  1. add a user (admin)
  2. authenticate the user in order to get the JWT-token to authorize to use our services that require authentication
  3. add some notifications in order to test them
  4. register at least one subscriber to our db

We can use Postman for our testing (we can’t register subscriptions since this process can be run only in a client browser)

Before to start using Postman you need to start your server

$ nodemon src/index.js

Verify that everything is ok and you don’t get any error from the console.

Add a user

In Postman set a POST action and input

POST http://localhost:3030/users

and the following data (you can use form-data)

  "email" : "an email address like me@example.com or a username",
  "password" : "a password of you choice",
  "name" : "your name"

Click on Send. You will get a response with the info you submitted. Remember that Feathersjs protect the password without exposing it by default. Password will never get exposed from any API calling to the users service.

IMPORTANT: email and password will be the keys used to authenticate users

You can add different information for each user since we are using a schema less database

You created your first user.

Authentication

POST http://localhost:3030/authenticate
email : email/user created before
password: password
strategy: 'local'

We are using a local strategy. In order to authenticate you need to tell feathersjs which strategy you are using for authentication.

You will get as response a JWT-Token and Copy the value in the clipboard.


{
    "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6ImFjY2VzcyJ9.eyJ1c2VySWQiOiJvcWkxbzhzOE93MFo1d0hFIiwiaWF0IjoxNTUyMTcxOTU1LCJleHAiOjE1NTIyNTgzNTUsImF1ZCI6Imh0dHBzOi8veW91cmRvbWFpbi5jb20iLCJpc3MiOiJmZWF0aGVycyIsInN1YiI6ImFub255....."
}

In order to use the services that requires authentication you need too add in the Headers of Postman

Authorization : JWT-Token you got from the authentication

Check users

GET http://localhost:3030/users

Add a Notification

POST http://localhost:3030/notifications

Keys/Values or Fields/Value (from Postman)

  "title" : "First notification"
  "body" : "My first notification",
  "image" : image URL or '',
  "logo" : icon URL or '',
  "url" : URL to redirect on click or ''

All fields are strings.

Click on Send. Our first notification has been created. You will get something like this

{
  "title" : "First notification"
  "body" : "My first notification",
  "image" : image URL or '',
  "logo" : icon URL or '',
  "url" : URL to redirect on click or ''
  "createdAt": "2019-02-27T10:57:37.820Z",
  "updatedAt": "2019-02-27T10:57:37.820Z"
}

In order to start sending notifications we need a client that submit a subscription to our service.

The Client Example

In order to test our Web Push Notification service create in an empty folder 2 files:

  • service-worker.js
  • index.html

Above example must be served over HTTP so you need a web server like Chrome Web Server.

service-worker.js

let click_open_url
self.addEventListener('push', function(event) {
  let push_message = event.data.json()
  // push notification can send event.data.json() as well
  click_open_url = push_message.notification.data.url
  const options = {
    body: push_message.notification.body,
    icon: push_message.notification.icon,
    image: push_message.notification.image,
    tag: 'alert'
  };
  event.waitUntil(self.registration.showNotification(push_message.notification.title, options));
});

self.addEventListener('notificationclick', function(event) {
  const clickedNotification = event.notification;
  clickedNotification.close();
  if ( click_open_url ){
    const promiseChain = clients.openWindow(click_open_url);
    event.waitUntil(promiseChain);
  }
});

Following is a simple single page subscription to our service that we will use to

  • register our service worker
  • ask permission for subscriptions
  • create a subscription
  • submit our subscription to the server
  • check on load if user is subscribed

Remember to add you VAPID PUBLIC KEY at the top of the script

index.html

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta name="description" content="">
  <meta name="author" content="">
  <script type="text/javascript" src="//cdnjs.cloudflare.com/ajax/libs/core-js/2.1.4/core.min.js"></script>
  <script src="//unpkg.com/@feathersjs/client@^3.0.0/dist/feathers.js"></script>
  <script src="//unpkg.com/socket.io-client@1.7.3/dist/socket.io.js"></script>
  <title>Demo for WebPush Notification - Client</title>
  <style>
  body {
    font-family: Arial, Helvetica, sans-serif;
  }

  .hide {
    display:none;
  }
  button {
    border:1px solid #eaeaea;
    border-radius:5px;
    padding:5px;
    color:white;
  }
  #btnSubscribe {
    background: green;
  }
  #btnUnsubscribe {
    background: red;
  }
  </style>
</head>

<body>
  <div style="position:fixed;top:10px;right:5px;">
    <span id="error" style="background:#f2f2f2;padding:4px;border-radius:15px;"></span>

  </div>

  <div id="isSubscribed" style="font-size:.8rem;"></div>

  <div style="margin:4rem auto;text-align:center;">
    <h2>Web Push Notifications Demo</h2>
    <button onclick="subscribe()" id="btnSubscribe">Subscribe</button>
    <button onclick="subscribe()" id="btnUnsubscribe" class="hide">Unsubscribe</button>
  </div>

<script type="text/javascript">
//-----------------   ADD YOUR VAPID PUBLIC KEY HERE --------//
const appServerKey = '' //Your VAPID Public Key
//-----------------------------------------------------------//
// Socket.io is exposed as the `io` global.
var socket = io('http://localhost:3030');
// @feathersjs/client is exposed as the `feathers` global.
var app = feathers();

app.configure(feathers.socketio(socket));


//register service worker
if ('serviceWorker' in navigator) {
  window.addEventListener('load', function() {
    navigator.serviceWorker.register('service-worker.js')
    permissionRequest()
  });
}


function isSubscribed(){
  navigator.serviceWorker.ready.then(function(reg) {
    reg.pushManager.getSubscription().then(function(subscription) {
      console.log(subscription)

      app.service('subscription').find({
        query: {
          endpoint: subscription.endpoint
        }
      }).then(result=>{
        console.log ( 'issubscribed=>' , result )
        document.getElementById('isSubscribed').innerHTML = 'You need to susbscribe in order to receive notifications'
        document.getElementById('btnSubscribe').className = ''
        document.getElementById('btnUnsubscribe').className = 'hide'
        if ( result.total ) {
          document.getElementById('isSubscribed').innerHTML = 'You are subscribed!'
          document.getElementById('btnSubscribe').className = 'hide'
          document.getElementById('btnUnsubscribe').className = ''
        }
      }).catch(error=>{
        console.log ( error )
      })
    }).catch(error=>{
      console.log ( error )
      //_self.mysubscription = error
    })
  }).catch(error=>{
    console.log ( error )
    //_self.mysubscription = error
  })
}

function permissionRequest(){
  return new Promise(function(resolve, reject) {
    const permissionResult = Notification.requestPermission(function(result) {
      resolve(result)
    });

    if (permissionResult) {
      permissionResult.then(resolve, reject);
    }
  })
  .then(function(permissionResult) {
    if (permissionResult !== 'granted') {
      throw new Error('We weren\'t granted permission.')
    }
  });
}


function urlB64ToUint8Array(base64String) {
  console.log ( base64String )
  const padding = '='.repeat((4 - base64String.length % 4) % 4);
  const base64 = (base64String + padding)
    .replace(/\-/g, '+')
    .replace(/_/g, '/');

  const rawData = window.atob(base64);
  const outputArray = new Uint8Array(rawData.length);

  for (let i = 0; i < rawData.length; ++i) {
    outputArray[i] = rawData.charCodeAt(i);
  }
  return outputArray;
}

function subscribe(){
  let self = this
  return navigator.serviceWorker.register('service-worker.js')
    .then(function(registration) {
      const subscribeOptions = {
        userVisibleOnly: true,
        applicationServerKey: urlB64ToUint8Array(appServerKey)
      };
      return registration.pushManager.subscribe(subscribeOptions);
    }).then(function(pushSubscription) {
      console.log('Received PushSubscription: ', JSON.stringify(pushSubscription));
      app.service('subscription').create(pushSubscription)
      .then(function (response) {
        console.log(response);
      })
      .catch(function (error) {
        console.log(error);
      });
      return pushSubscription;
    })
}

isSubscribed()

</script>
</body>
</html>

Send your first notification

After you have subscribed with the above example to back to Postman

GET http://localhost:3030/subscription

You should get you subscription posted by the browser.

Now get a notification that we created before

GET http://localhost:3030/notifications

Copy the _id to the clipboard

POST http://localhost:3030/push

  KEY           VALUE
  notification  'dasd1233123331' //your notification _id

You will receive your first Push Notification!!!

See on Github