Using Web Push Notifications with VAPID
Sending push notifications in Ruby or Node.js on the open web
Push messages from mobile and desktop browsers are now a thing on the open web.
Why use the Push API? It allows us to use free, third-party services to notify our users of events, even when they're not actively engaged with our site. It's not meant to replace other methods of pushing data to clients, like WebSockets or Server Sent Events, but can be useful for sending small, infrequent payloads to keep users engaged. Think: a build has finished successfully, a new post was published, a touchdown was scored. What's common place on our smartphones from installed apps is now possible from the browser.
Though only supported in Chrome and Firefox on the desktop and in Chrome on Android at the time of this writing, it'll be more widespread soon enough. While I previously wrote about this topic, there have been recent changes in the Chrome implementation to make the API consistent with Firefox, which we'll describe here.
In this post, we'll walk through setting up a Ruby or Node.js web application to use the Push API with the Voluntary Application server Identification (VAPID). Use of VAPID for push requests is optional, but primarily a security benefit. Application servers use VAPID to identify themselves to the push servers so push subscriptions can be properly restricted to their origin app servers. In other words, VAPID could theoretically prevent an attacker from stealing a user PushSubscription
and sending push messages to that recipient from another server. Down the road, push services may be able to provide analytics and debugging assistance for app servers using the VAPID protocol. Another benefit: in Chrome, it is no longer necessary to register our web apps through the Google Developer Console and pass around Google app credentials in web push requests.
Overview
There are three parties involved in delivering a push message.
- Your application server
- Your user
- A push server, e.g., Google or Mozilla
Before a push message can be delivered with VAPID, a few criteria should be satisfied:
- Your application server has generated a set of VAPID keys that will be used to sign Push API requests. This is a one-time step (at least until we decide to reset the keys).
- A
manifest.json
file, linked from a page on our website, identifies our app settings. - In the user's web browser, a service worker script is installed and activated. The
pushManager
property of theServiceWorkerRegistration
is subscribed to push events with our VAPID public key, with creates asubscription
JSON object on the client side. - Your server makes an API request to a push server (likely using a server-side library) to send a notification with the
subscription
obtained from the client and an optional payload (the message). - Your service worker is set up to receive
'push'
events. To trigger a desktop notification, the user has accepted the prompt to receive notifications from our site.
Generating VAPID keys
To take advantage of the VAPID protocol, we would generate a public/private VAPID key pair to store on our server to be used for all user subscriptions.
In Ruby, we can use the webpush
gem to generate a VAPID key that has both a public_key
and private_key
attribute to be saved on the server side.
# Gemfile
gem 'webpush'
In a Ruby console:
require 'webpush'
# One-time, on the server
vapid_key = Webpush.generate_key
# Save these in our application server settings
vapid_key.public_key
# => "BC1mp...HQ="
vapid_key.private_key
# => "XhGUr...Kec"
In Node.js, we can use the web-push
package:
npm install web-push --save
In the node REPL:
const webpush = require('web-push');
const vapidKeys = webpush.generateVAPIDKeys()
vapidKeys.publicKey
'BDO0P...eoH'
vapidKeys.privateKey
'3J303..r4I'
The keys returned will both be Base64-encoded byte strings. Only the public key will be shared, both with the user's browser and the push server as we'll see later.
Declaring manifest.json
Add a manifest.json
file served at the scope of our app (or above), like at the root to describe our client application for use with the Push API.
{
"name": "My App",
"short_name": "my-app",
"start_url": "/",
"icons": [
{
"src": "/images/my-push-logo-192x192.png",
"sizes": "192x192",
"type": "image/png"
}
]
}
Link to it somewhere in the <head>
tag:
<!-- index.html -->
<link rel="manifest" href="/manifest.json" />
Installing a service worker
Your application javascript must register a service worker script at an appropriate scope (we're sticking with the root).
// application.js
// Register the serviceWorker script at /serviceworker.js from our server if supported
if (navigator.serviceWorker) {
navigator.serviceWorker.register('/serviceworker.js')
.then(function(reg) {
console.log('Service worker change, registered the service worker');
});
}
// Otherwise, no push notifications :(
else {
console.error('Service worker is not supported in this browser');
}
For Rails developers, we may want to look at the serviceworker-rails
gem and this helpful tutorial to integrate service worker scripts with the Rails asset pipeline.
Subscribing to push notifications
The VAPID public key we generated earlier is made available to the client as a Uint8Array
. To do this, one way would be to expose the urlsafe-decoded bytes from Ruby to JavaScript when rendering the HTML template.
In Ruby, we might embed the key as raw bytes from the application ENV
or some other application settings mechanism into an HTML template with help from the Base64
module in the standard library. Global variables are used here for simplicity.
# server
@decodedVapidPublicKey = Base64.urlsafe_decode64(ENV['VAPID_PUBLIC_KEY']).bytes
<!-- html template -->
<script>
window.vapidPublicKey = new Uint8Array(<%= @decodedVapidPublicKey %>);
</script>
In Node.js, we could use the urlsafe-base64
package to decode the public key and convert it to raw bytes:
// server
const urlsafeBase64 = require('urlsafe-base64');
const decodedVapidPublicKey = urlsafeBase64.decode(process.env.VAPID_PUBLIC_KEY);
<!-- html template -->
<script>
window.vapidPublicKey = new Uint8Array(<%= decodedVapidPublicKey %>);
</script>
Your application javascript would then use the pushManager
property to subscribe to push notifications, passing the VAPID public key to the subscription settings.
// application.js
// When serviceWorker is supported, installed, and activated,
// subscribe the pushManager property with the vapidPublicKey
navigator.serviceWorker.ready.then((serviceWorkerRegistration) => {
serviceWorkerRegistration.pushManager
.subscribe({
userVisibleOnly: true,
applicationServerKey: window.vapidPublicKey
});
});
Triggering a web push notification
The web push library we're using on the backend will be responsible for packaging up the request to the subscription's endpoint and handling encryption, so the user's push subscription must be sent from the client to the application server at some point.
In the example below, we send the JSON generated subscription object to our backend with a message when a button on the page is clicked.
// application.js
$('.webpush-button').on('click', (e) => {
navigator.serviceWorker.ready
.then((serviceWorkerRegistration) => {
serviceWorkerRegistration.pushManager.getSubscription()
.then((subscription) => {
$.post('/push', {
subscription: subscription.toJSON(),
message: 'You clicked a button!'
});
});
});
});
The call to pushManager.getSubscription()
returns a Promise that provides the
PushSubscription
instance with all the information the push service needs to
send a push message to this user's browser. This includes an endpoint
, the URL
on the push server where we'll send the push request, and a pair of keys
labelled as p256dh
and auth
required to encrypt the push message payload. If interested to learn more about how this encryption works, check out this detailed summary on web push payload encryption.
// subscription.toJSON();
{
endpoint: "https://android.googleapis.com/gcm/send/a-subscription-id",
keys: {
auth: 'AEl35...7fG',
p256dh: 'Fg5t8...2rC'
}
}
Imagine a Ruby app endpoint that responds to the request by triggering notification through the webpush
gem. VAPID details include a URL or mailto address for our website and the Base64-encoded public/private VAPID key pair we generated earlier.
# app.rb
post '/push' do
Webpush.payload_send(
message: params[:message]
endpoint: params[:subscription][:endpoint],
p256dh: params[:subscription][:keys][:p256dh],
auth: params[:subscription][:keys][:auth],
ttl: 24 * 60 * 60,
vapid: {
subject: 'mailto:[email protected]',
public_key: ENV['VAPID_PUBLIC_KEY'],
private_key: ENV['VAPID_PRIVATE_KEY']
}
)
end
In Node.js, usage of the web-push
package might look like this:
# index.js
const webpush = require('web-push');
// ...
app.post('/push', function(request, response) {
const subscription = request.param('subscription');
const message = request.param('message');
setTimeout(() => {
const options = {
TTL: 24 * 60 * 60,
vapidDetails: {
subject: 'mailto:[email protected]',
publicKey: process.env.VAPID_PUBLIC_KEY,
privateKey: process.env.VAPID_PRIVATE_KEY
},
}
webpush.sendNotification(
subscription,
message,
options
);
}, 0);
response.send('OK');
});
Receiving the push event
Your /serviceworker.js
script can respond to 'push'
events to trigger desktop notifications by calling showNotification
on the registration
property.
// serviceworker.js
// The serviceworker context can respond to 'push' events and trigger
// notifications on the registration property
self.addEventListener("push", (event) => {
let title = (event.data && event.data.text()) || "Yay a message";
let body = "We have received a push message";
let tag = "push-simple-demo-notification-tag";
let icon = '/assets/my-logo-120x120.png';
event.waitUntil(
self.registration.showNotification(title, { body, icon, tag })
)
});
Before the notifications can be displayed, the user must grant permission for notifications in a browser prompt, using something like the example below.
// application.js
// Let's check if the browser supports notifications
if (!("Notification" in window)) {
console.error("This browser does not support desktop notification");
}
// Let's check whether notification permissions have already been granted
else if (Notification.permission === "granted") {
console.log("Permission to receive notifications has been granted");
}
// Otherwise, we need to ask the user for permission
else if (Notification.permission !== 'denied') {
Notification.requestPermission(function (permission) {
// If the user accepts, let's create a notification
if (permission === "granted") {
console.log("Permission to receive notifications has been granted");
}
});
}
After all that setup, we should see a browser notification triggered via the Push API.
As this is still an emerging technology, things are rapidly changing. I'd be interested to hear how things are working out for folks integrating web push into their web apps.