WebPush Notifications with Feathersjs
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:
- a push server (VAPID) that sends notifications
- 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
- 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:
- Register a Service Worker
- Request user Permission
- 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?
- import a webpush.js
- 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 :
- A notification saved in our DB
- 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:
- add a user (admin)
- authenticate the user in order to get the JWT-token to authorize to use our services that require authentication
- add some notifications in order to test them
- 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