Creating a NodeJS Push Notification System with Service Workers

Intro

When I started Fjolt I was quickly faced with the conundrum of how to notify users when a new article is published. Most importantly, I wanted to do all of this without depending on a third party service. I thought this would be a great opportunity to use web notifications.

Native browser notifications only really work when a user has the website open. I wanted all users to get a notification from the server when something new happened, whether the website was open or not.

But if the user has the page closed, how can we get a notification back to them? To do that, we have to use service workers, and we need to store our subscriber’s details. In this article, I will be covering how to do those things and ultimately create your own push notification system.

Quick Intro to Service Workers

For our purposes a service worker boils down to a file which can capture push events from the server even when the website is closed. That means we can have Javascript running when the page is closed, but the browser is open. This has a lot of uses (i.e. preload resources so the web page loads faster), but critically we can use it as a device to send our notifications through.

A user has to consent to allowing this whole process to happen, so we have to request this access. That request process looks a little like this for us:

Creating a NodeJS Push Notification System with Service Workers

0. MongoDB

Since this article uses mongoDB, please ensure you have installed it through the instructions here before continuing. If you have another storage system, then no worries, just make sure you adjust the models and NPM packages accordingly.

1. Request Vapid Keys

Vapid keys are what we use to validate that only the web server we are using can send notifications. There are other mechanisms to do this, but lets focus on vapid keys, since it is the most straight forward. The first thing we will do is install the main JS package we will be using for this tutorial. To do that, run this in terminal:

npm i web-push -g
web-push generate-vapid-keys

The second line above should output a private and public key. These are your Vapid keys, and we’ll be using them in the next few steps.

2. Client Side ‘On Click’ Event

The next step is to set up a piece of code on the client side which sends a request to the server. On our server, we will create a /subscribe route to handle all subscriptions. For now, lets take a look at the client side. Note, we need to insert our vapid public key here at the top.

We only do three things here when the user clicks the ‘subscribe’ button:

  • We register the service worker.
  • We use the pushManager Javascript API to create a new subscription for the user. For each user a unique subscription object is produced by using this method.
  • We send the subscription to our server:

    const vapidKey = 'Your Public Vapid Key';
    
    document.getElementById('subscribe').addEventListener('click', async function(e) {
        const registration = await navigator.serviceWorker.register('worker.js', {scope: '/'});
        const subscription = await registration.pushManager.subscribe({
            userVisibleOnly: true,
            applicationServerKey: urlBase64ToUint8Array(vapidKey)
        });
        await fetch('/subscribe', {
            method: 'POST',
            body: JSON.stringify(subscription),
            headers: {
                'content-type': 'application/json'
            }
        });
    });
    
    const urlBase64ToUint8Array = function(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 application URL needs to be converted to a specific form, which is what the second function is helping us do. Apart from that, we are sending a request back to the server (to /subscribe) when the user clicks the subscribe button, where we will process the request.

On line 4 we reference a worker.js file. This is our service worker. We need to create that file as well — I won’t list it here, but it can be found in the GitHub Repo. It is roughly 8 lines, and it processes incoming messages from our server and outputs them as web notifications.

3. Server Side ‘Store Subscription’

I have created a model in mongoDB for subscriptions. This is accessible through the GitHub Repo, and is relatively straightforward. Let’s take a look then at the /subscribe route. We do a few things here:

  • We set the Vapid keys.
  • We hash the subscription object to use as our unique key.
  • We check if that subscription hash exists in the database already.
  • We create the subscription document in the database if it doesn’t exist.
  • Otherwise we respond with appropriate messages.

// Service Worker Notifications
const publicVapidKey = 'Public Vapid Key';
const privateVapidKey = 'Private Vapid Key';

webpush.setVapidDetails('mailto:someEmail@emailSite.com', publicVapidKey, privateVapidKey);

app.post('/subscribe', jsonParser, async function(req, res) {
    try {
        let hash = objectHash(req.body);
        let subscription = req.body;
        let checkSubscription = await Subscription.Subscription.find({ 'hash' : hash });
        
        let theMessage = JSON.stringify({ title: 'You have already subscribed', body: 'Some body text here.' });
        if(checkSubscription.length == 0) {
            const newSubscription = new Subscription.Subscription({
                hash: hash,
                subscriptionEl: subscription
            });
            newSubscription.save(function (err) {
                if (err) {
                    theMessage = JSON.stringify({ title: 'We ran into an error', body: 'Please try again later' });
                    webpush.sendNotification(subscription, payload).catch(function(error) {
                        console.error(error.stack);
                    });
                    res.status(400);
                } else {
                    theMessage = JSON.stringify({ title: 'Thank you for Subscribing!', body: 'Some body text here' });
                    webpush.sendNotification(subscription, payload).catch(function(error) {
                        console.error(error.stack);
                    });                    
                    res.status(201);
                }
            });
        } else {
            webpush.sendNotification(subscription, theMessage).catch(function(error) {
                console.error(error.stack);
            });
            res.status(400);
        }
    } catch(e) {
        console.log(e);
    }
});

Great, so now we have a subscription route. All of our subscriptions are stored in our MongoDB database, and we can send notifications to them when we want. Now lets try sending something.

4. Sending Notifications

Remember, since we loaded the server worker, our server can now interact with the user’s browser, even when the page is closed.

Now we’ve collected our subscribers, we want to send something to them. All we have to do is iterate over them and produce a notification for everyone:

    const sendNotifications = async function() {

        const allSubscriptions = await Subscription.Subscription.find();
      
        allSubscriptions.forEach(function(item) {
            let ourMessage = JSON.stringify({
                'title' : req.body.titles[0].title,
                'body' : req.body.description
            });
            webpush.sendNotification(item.subscriptionEl, ourMessage).catch(function(error) {
                console.error(error.stack);
            });
        });
      
      }

5. Add on Security

If you are running this, it’s important you add in the right security as is appropriate for your setup. For instance, you might want to use the express-rate-limit package to prevent someone spamming subscription route requests.

Similarly, you will want to ensure there is a security check on the server when you send notifications, i.e. a username and password at the very least.

If you are running the notification service as an API, you may want to follow guidelines for securing APIs. What we don’t want happen is someone maliciously sending notifications to all our subscribers.

This tutorial focuses on how to create the push notification service, but setting up your own user authentication system will be better documented in other tutorials.

Conclusion

I hope this gives you an idea of not only how to make a notification system, but also the power of service workers.

src: Creating a NodeJS Push Notification System with Service Workers


author: Johnny Simpson


img src: Sending Push Notifications from NodeJS backend App to Flutter Android App