Push Notifications With Javascript

Push Notifications With Javascript

The web development community has a habit of explaining relatively easy-to-implement functionality in an over-complicated way, and push notifications is definitely one of those subjects. This is one of the more recent things that I've learned, and I've scoured the internet far and wide to put the pieces together into a less-complicated jigsaw, just for you. You're very welcome!

This tutorial will be useful for anyone trying to implement push notifications into their web app, I will however be showing you how to do the back-end functionality with Python and Django, so if you use another back-end framework you should research ways how to implement the same logic into your app like setting up your database models and using a web push notification library.

Before we get into the front-end stuff, let's set up our back-end and get prepared. Let's get our models.py set up and get our database ready by making a Notification_Subscriber model, and let's set up a Blog_Post model:



# ~/myproject/myapp/models.py

from django.db import models

class Notification_Subscriber(models.Model):
    data = models.JSONField()

class Blog_Post(models.Model):
    title = models.CharField(max_length=100)
    description = models.TextField()
    content = models.TextField()
    image = models.ImageField(upload_to='path/to/your/media/')
    send_to_subs_on_save = models.BooleanField(default=False)
    slug = models.SlugField()

Let's migrate these changes to our database back in our command line in our project's directory:



python manage.py makemigrations
python manage.py migrate

Database sorted. You can go ahead and make a quick blog post for test purposes now for use later down this tutorial. Next, let's go into our views.py and make a function that will save the input to our Notification_Subscriber model:



# ~/myproject/myapp/views.py

from django.http import JsonResponse
import json
from .models import Notification_Subscriber
from django.views.decorators.csrf import csrf_exempt

@csrf_exempt
def save_notification_subscriber(request):
    data = request.POST['data']
    data = json.loads(data)
    Notification_Subscriber.objects.create(data=data)
    return JsonResponse({'success': True})

Now just to make this view available by putting it in our urls.py



# ~/myproject/myapp/urls.py

from django.urls import path
from . import views

urlpatterns = [
    path('save-notification-subscriber/', views.save_notification_subscriber, name='save_notification_subscriber')
]

That's our back-end sorted for now. We will come back to it later to just add signals.py and configure that, but for now let's get saving some subscribers from our front-end Javascript code!

We will be making two scripts, one being the main page's script and another to be installed as a service worker. A service worker is a script that gets run in the background by your browser whether your website is open or closed, so will therefore listen for push notifications in this case. They're also useful for caching and cleanup tasks, but that's for another day.

We will go to our main script first and define 3 functions which will be pivotal for our script to work:



// ~/myproject/static/script.js

// Check if the browser is compatible, if so check notification permissions
const checkPermission = () => {
    if(!('serviceWorker' in navigator || 'Notification' in window)) {
        return null
    };
    return Notification.permission;
};

// Register your service worker that handles notifications
const registerServiceWorker = async () => {
    const registration = await navigator.serviceWorker.register('https://example.com/notification_sw.js');
    return registration;
};

// Ask user for permission to send notifications
const requestNotificationPermission = async () => {
    const permission = await Notification.requestPermission();
    if(permission != 'granted') {
        throw new Error('Permission not granted. Please check your browser notification settings.');
    }
}

Now we will be attaching an event listener to a button that will sequentially request the end user permission for notifications when it is clicked, and then register a service worker implementing the logic to successfully receive a notification when sent. If the browser is not compatible, the button will not display. If already granted permission, the button will not display AND a notification service worker will be automatically registered. Observe the code carefully and it should be self-explanatory:



// ~/myproject/static/script.js

const enableNotificationsButton = document.querySelector('#enable-notifications-button');

const permissionCheck = checkPermission();

if(permissionCheck == 'granted' || permissionCheck == null) {
    enableNotificationsButton.style.display = 'none'
    if(permissionCheck == 'granted') {
        registerServiceWorker();
    }
}
else {
    enableNotificationsButton.addEventListener('click', async () => {
        try {
            await requestNotificationPermission();
            if(checkPermission() == 'granted') {
                await registerServiceWorker();
                enableNotificationsButton.style.display = 'none';
            }
        }
        catch(error) {
            console.log(error);
            alert(error);
        }
    });
}

Nice! That's the main document script complete, now it's the service worker we have to implement.

Just before we get to that, we need to generate a VAPID (Voluntary Application Server Identification for Web Push) public and private key pair and save them as environment variables. VAPID keys ensure that no other server than yours can send push notifications to a client. You assign a public key that is safe to share when registering the client's browser for push notifications in your service worker, and you use your private key in your back end when you push out your notifications to the clients.

There are a few generators online for simplicity, but there's nothing better than generating your own for 100% certainty of your privacy. So let's go about generating them in Python.

It may already be installed, but for certainty let's install ECDSA (Elliptic Curve Digital Signature Algorithm) in our python environment:



pip install ecdsa

Now to just write a small python script that prints out the public and private key pair:



# ~/generate_vapid_keys.py
import base64
import ecdsa

def generate_vapid_keypair():
    pk = ecdsa.SigningKey.generate(curve=ecdsa.NIST256p)
    vk = pk.get_verifying_key()

    return {
        'private_key': base64.urlsafe_b64encode(pk.to_string()).strip(b"=").decode('utf-8'),
        'public_key': base64.urlsafe_b64encode(b"\x04" + vk.to_string()).strip(b"=").decode('utf-8')
    }

vapid_keypair = generate_vapid_keypair()
print('Public Key:', vapid_keypair['public_key'])
print('Private Key:', vapid_keypair['private_key'])

Now let's just execute this file:



python3 ~/generate_vapid_keys.py

Great! Now just to store the generated keys away safely, and keep your private key... private! In our next snippets of code, assume that I have stored the private key as an environment variable called VAPID_PRIVATE_KEY

Now over to our service worker. In this service worker, we will be adding event listeners to subscribe the user's browser with pushManager to notifications on activation of the service worker, show the notification to the user on push, and to open the webpage (in this case, the blog post) to be shown on notification click. There is also a function that converts our VAPID public key into an Uint8Array buffer as this is how the pushManager expects to receive the public key:



// ~/myproject/static/notification_sw.js

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

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

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


const pkArrayBuffer = urlBase64ToUint8Array('{{YOUR VAPID PUBLIC KEY}}');

self.addEventListener('activate', async (e) => {
    // Options specifying that notifications will only have a visible effect for the user and the public key
    const options = {
        userVisibleOnly: true,
        applicationServerKey: pkArrayBuffer,
    }
    // Subscribe user to notifications, returns data object that we need to save in our Notification_Subscriber model
    const sub = await self.registration.pushManager.subscribe(options);
    Send the subscriber data to our URL designed to handle it
    let data = await fetch('https://example.com/save-notification-subscriber/', {
        method: 'POST',
        body: JSON.stringify(sub),
        headers: {
            'Content-Type': 'application/json',
        }
    });
    data = await data.json();
    console.log(data.status);
});

self.addEventListener('push', async (e) => {
    // This will be the data sent from our back-end
    const data = await e.data.json();
    const options = {
        body: data.body, // The main contents eg. Blog description
        icon: data.icon, // Main image eg. Blog post image
        badge: data.badge, Badge that appears to show there's a notification eg. Company logo
        data: data.redirect_url, // A string of any additional data you want associated with the notification for other event listeners eg. A redirect URL when notification is clicked
        vibrate: [300, 100, 300, 100, 150, 50, 150, 50, 300], // Vibration lengths and pauses in milliseconds
    }
    // Calling the notification to pop up with title and options
    self.registration.showNotification(data.title, options);
});


self.addEventListener('notificationclick', e => {
    let url = e.notification.data; // This is the data we passed in the self.registration.showNotification options
    e.notification.close(); // Android needs explicit close.
    e.waitUntil(
        clients.matchAll({type: 'window'}).then( windowClients => {
            // Check if there is already a window/tab open with the target URL
            for (var i = 0; i < windowClients.length; i++) {
                var client = windowClients[i];
                // If so, just focus it.
                if (client.url === url && 'focus' in client) {
                    return client.focus();
                }
            }
            // If not, then open the target URL in a new window/tab.
            if (clients.openWindow) {
                return clients.openWindow(url);
            }
        })
    );
});

That's the service worker done and configured, ready to be registered by our main script!

Back-end time again. What we will be doing now is setting up our signals.py file in our Django project (again, if you use a different back-end framework, you'll need to find out how to do the equivalent from here on). If you haven't set up a signals.py file in your Django project before, it's pretty simple to implement and it allows you to perform actions pre and post save and delete on selected database models.

Let's install a python package before we get into it called pywebpush:



pip install pywebpush

Now in our signals.py file, we will be listening for when our Blog_Post model is saved and checking if the send_to_subs_on_save field is True. If so, a notification will be sent to all Notification_Subscribers and then reset the send_to_subs_on_save field to False.



# ~/myproject/myapp/signals.py

from django.db.models.signals import pre_save
from django.dispatch import receiver
from myapp.models import Blog_Post, Notification_Subscriber
import os
from pywebpush import webpush, WebPushException
import json

@receiver(pre_save, sender=Blog_Post)
def send_blog_post_to_subs(sender, instance, **kwargs):
    stsos = instance.send_to_subs_on_save
    if stsos == True:
        subs = Notification_Subscriber.objects.all()
        badge = 'https://example.com/images/logo.png'
        icon = f'https://example.com{instance.image.url}'
        payload = {
            'title': instance.title,
            'body': instance.description,
            'icon': icon,
            'badge': badge,
            'redirect_url': f'https://example.com/blog/{instance.slug}/',
        }
        payload = json.dumps(payload)
        # For each Notification_Subscriber, we will try to send the relevant payload of data for our blog post.
        for sub in subs:
            try:
                webpush(
                    subscription_info=sub.data,
                    data=payload,
                
    vapid_private_key=os.environ['VAPID_PRIVATE_KEY'],
                    vapid_claims={
                        'sub': 'mailto:myemail@example.com',
                    },
                )
            except WebPushException as err:
                print(repr(err))
        # Reset the Blog_Post instance field
        instance.send_to_subs_on_save = False

Now just to import our signals in our apps.py's "ready" method in the app's class:



from django.apps import AppConfig

class MyappConfig(AppConfig):
    default_auto_field = 'django.db.models.BigAutoField'
    name = 'myapp'

    def ready(self):
        import myapp.signals #noqa

And voilá! Your signals and push notifications should be ready to go. Go ahead and give it a try out by subscribing to notifications, accessing your admin panel, ticking your send_to_subs_on_save field in your Blog_Post model and pressing save!

I may just add, if you have an iOS device, if you are struggling to subscribe on there, you've done nothing wrong as in true Apple fashion, they've added extra steps before you can enable push notifications. You must enable the Notifications API in Safari's advanced settings and also install your web app onto the iOS device as a PWA which takes some setting up, but that's a tutorial for another day! For now, I hope my guide on how to set up push notifications has helped you and happy coding! 😁