We were recently developing an app with a real-time notification component. Since we could be confident of the devices being used by end users, we decided to forego long-polling and websockets, and look into using service-worker-powered browser notifications.
Browser notifications
Since browser notifications are powered by service workers, they have the advantage (over other methods) of being able to work even without the browser running. They also have the advantage of using the native interface for notifications, meaning that notifications can remain on-screen until dismissed, as well as easily supporting periods of being offline (notifications will be delivered once the device reconnects, without you having to write the logic yourself to support this).
The app
As an accompaniment to this blog post, I’ve created a simple Rails 5 app to test out browser notifications. On the homepage, users can either create a new account, which gives them a 5-letter PIN, or connect to an existing account by inputting said PIN. Once ‘signed in’ to an account, users can register their device for notifications, then send a message – comprising a title and body – to all their registered devices. Users can also unsubscribe a device from notifications.
Getting started
The best place to start with a walkthrough of setting up browser notifications is Google’s tutorial, supplemented with their introductory blog post. However, this walkthrough is a little outdated, since it only covers payload-less “ping” notifications.
Notification payloads
Fortunately, the state of the art has progressed to allow payloads for notifications, although these must be encrypted.
To this end, when subscribing to receive notifications, Chrome (and Firefox) now return an additional set of keys (p256dh
and auth
) along with the subscription URL. These keys aren’t visible by logging the subscription to the console, but can be accessed by transforming the subscription to JSON (via toJSON()
or JSON.stringify
) and should be saved alongside the subscription URL.
The addition of these encryption keys means that we can no longer send a “multicast” notification to several receipent_ids
as recommended by the Google walkthrough, since the payload for each subscription must be encrypted with its own keys. This is no problem, since we can just loop over the required URLs in our code. It also means we no longer need to treat Chrome subscriptions specially (by extracting the subscription ID from the URL). An added bonus is that we can process Firefox subscriptions in the same loop, since both browsers use the same URL and encryption keys structure (hooray for standards!).
In the example app, we use the webpush gem to handle sending notifications – it handles encrypting the payload, as well as submitting our Google Cloud Messaging key for Chrome notifications (Firefox doesn’t require a key to send notifications). We wrap the particulars of this gem up in the Notifier
service object.
Saving subscriptions
We follow Google’s advice in saving/deleting subscriptions at various points of the subscription lifecycle. In the example app, subscriptions are saved as Device
records – along with the required endpoint
, p256dh
and auth
attributes, we also save the user agent, so that we can show the user which devices/browsers have been connected, with the use of the browser gem to parse the user agent string into something understandable.
Since subscriptions are only identified by their URL in the browser – which knows nothing about the Device
id we assign it in the database – we have to Base64-encode (via btoa()
) the URL in order to produce a valid URL that Rails understands. This is only within the constraints of Rails’ RESTful resources
routing, however, so you could probably transmit the URL within a query string param as plain text.
Displaying notifications
Much of this code is taken from Google’s walkthrough, with the addition of retrieving the notification payload from event.data
. The object returned has various accessors to get the payload in different formats: we use json()
to get a JS object parsed from the notification JSON we submitted, but text()
and blob
are also available. As Chrome will only pass keys it recognises (e.g. body
, tag
) to further notification events, we have to put the URL into the data
key in order for it to pass through unscathed.
Should the notification not have any payload, we also provide some defaults.
For handling the notificationclick
event, we reuse Google’s code, with the addition of taking the URL to open from the notification payload.
When opening the URL we also use the navigate()
function on the window client in order to refresh the page once focused. Technically, we don’t need to do this for the example app (since messages can’t change once sent), but you may be sending a notification for a listings page that needs to be refreshed to display new items, for example. It’s also worth bearing in mind, when trying to find matching tabs, that Chrome reports window client URLs with a trailing slash.
Serving the service worker
We use the serviceworker-rails gem to serve our service-worker.js
file without the asset-pipeline-appended MD5 fingerprint – since Chrome will periodically check for updates to the service-worker.js
file, we need to serve it from an unchanging URL. However, I had issues getting it to work in a production environment (possibly a Rails 5 issue), so we use our own controller for serving the file in production.