Push notifications

comment

Push API combined with Notifications API allows displaying notifications about background events that happen even when the web page is closed.

A practical example is a subscription service that does not require leaving any contact data or installing third-party applications. All that is needed is a browser that implements these two APIs and the user’s consent.

Currently only Chrome on Desktop and Chrome on Android support all these features. Additionally the push service is always Google Cloud Messaging but that will change with the introduction of Web Push protocol.

To run the example your browser has to support:

Registration

The sample code uses small identifier stored client-side in the localStorage. This token is used to identify subscriptions on the server.

var userToken = btoa(user.getToken() + ':');

Subscribe function uses userVisibleOnly property to indicate that the push message will show notification. This parameter is currently required. After the subscription succeeds the endpoint URL is sent to the server along with the user token.

var serviceWorker = navigator.serviceWorker;

function subscribe() {
    return serviceWorker.ready.then(function(registration) {
        return registration.pushManager.subscribe({
            userVisibleOnly: true
        });
    }).then(function(subscription) {
        return fetch('subscriptions/webpush', {
            method: 'put',
            headers: {
                'Content-Type': 'application/json',
                'Authorization': 'Basic ' + userToken
            },
            body: JSON.stringify({
                endpoint: getEndpoint(subscription)
            })
        });
    }).then(function(response) {
        if (!response.ok) {
            throw new Error('Subscription error: ' + response.statusText);
        }
    });
}

Chrome before version 44 separates the endpoint and the subscription identifier. Since version 44 all this information is stored in the endpoint property.

function getEndpoint(subscription) {
    var endpoint = subscription.endpoint;
    // for Chrome 43
    if (endpoint === 'https://android.googleapis.com/gcm/send' &&
        'subscriptionId' in subscription) {
        return endpoint + '/' + subscription.subscriptionId;
    }
    return endpoint;
}

Unsubscribe removes the subscription from user’s browser and deletes the subscription on the server.

function getSubscription() {
    return serviceWorker.ready.then(function(registration) {
        return registration.pushManager.getSubscription();
    });
}

function unsubscribe() {
    return getSubscription().then(function(subscription) {
        return subscription.unsubscribe();
    }).then(function(){
        return fetch('subscriptions/webpush', {
            method: 'delete',
            headers: {
                'Authorization': 'Basic ' + userToken
            }
        });
    });
}

Finally the service worker is registered and then the subscription status is updated.

serviceWorker.register('./service-worker.js')
    .then(getSubscription)
    .then(ui.update)
    .catch(function(e) {
        ui.showError('Cannot register ServiceWorker: ' + e);
    });

Service worker

Push notifications are received as a push events inside the service worker.

The service worker retrieves the Atom feed and displays the first entry in the feed.

self.addEventListener('push', function(e) {
    var title = 'New blog post on Curiosity driven!';
    e.waitUntil(
      fetch('.')
        .then(function(response) {
            if (!response.ok) {
              throw new Error('Cannot fetch feed.');
            }
            return response.text();
        })
        .then(function(text) {
            var feed = parseFeed(text);
            return self.registration.showNotification(title, {
                body: feed.entry.title + '\n' + feed.entry.summary,
                icon: feed.icon,
                tag: feed.entry.link.replace(/feed$/, 'webpush')
            });
        })
        .catch(function(e) {
            console.error('Error: ', e);
            return self.registration.showNotification(title, {
              body: 'Show list of recent articles.',
              icon: 'https://curiosity-driven.org/icon.png',
              tag: 'https://curiosity-driven.org/#articles'
            });
        })
    );
});

Because during registration the userVisibleOnly parameter was set to true the listener function is required to display a notification. If it fails to do so a default notification will be displayed by the browser.

As service workers do not have access to all DOM APIs like DOMParser this simple function is used to parse the feed XML.

function parseFeed(text) {
    function replaceEntities(text) {
        return text
          .replace(/"/g, '"')
          .replace(/'/g, '\'')
          .replace(/&lt;/g, '<')
          .replace(/&gt;/g, '>')
          .replace(/&amp;/g, '&');
    }

    var iconRx = /<icon>([^<]+)<\/icon>/;
    var entryRx = /<entry>([\s\S]*?)<\/entry>/;
    var titleRx = /<title>([^<]+)<\/title>/;
    var summaryRx = /<summary>([^<]+)<\/summary>/;
    var linkRx = /<link href="([^"]+)"/;

    var entry = entryRx.exec(text)[1];

    return {
        icon: replaceEntities(iconRx.exec(text)[1]),
        entry: {
            title: replaceEntities(titleRx.exec(entry)[1]),
            summary: replaceEntities(summaryRx.exec(entry)[1]),
            link: replaceEntities(linkRx.exec(entry)[1])
        }
    };
};

When the notification is clicked the notificationclick listener is invoked. In this case clicking the notification opens the latest article.

self.addEventListener('notificationclick', function(e) {
    e.notification.close();
    e.waitUntil(clients.openWindow(e.notification.tag));
});

Demo

Subscribe via Web Push

This demo will work only on Chrome for Desktop and Chrome for Android.

Once you are subscribed you can try to send the notification to yourself using fetch:

var token = localStorage['curiosity-driven.annotations.username'];

fetch('/notifications', {
    method: 'post',
    headers: {
        'Authorization': 'Basic ' + btoa(token + ':')
    }
}).then(function(response) {
    if (!response.ok) {
        throw new Error('Request failed: ' + response.statusText);
    }
    return response.text();
}).then(console.log.bind(console), console.error.bind(console));

Or from the command line using curl:

curl -i -X POST -u : https://curiosity-driven.org/notifications

Server

The backend application uses either a MongoDB or a local data store to save the subscriptions.

if (option('MONGOLAB_URI')) {
    require('mongodb');
    var monk = require('monk');
    var db = monk(option('MONGOLAB_URI')).get('subscriptions');
} else {
    var Datastore = require('nedb');
    var db = new Datastore({ filename: 'store.db', autoload: true });
}

To active Google Cloud Messaging create new project. The two required parameters for the server are:

  1. GCM_SENDER_ID — the project number,
  2. GCM_SENDER_KEY — API key from the Creadentials screen.

Additionally two APIs need to be enabled: Google Cloud Messaging for Chrome and Google Cloud Messaging for Android.

Registration of new subscription requires that the page has a <link> to the Web App Manifest.

var app = express();

app.use(express.static(__dirname + '/public'));
app.use(require('body-parser').json());

app.get('/manifest.json', function(req, resp) {
    resp.json({
        name: 'Push notifications',
        short_name: 'Push notifications',
        start_url: '/',
        display: 'standalone',
        gcm_sender_id: option('GCM_SENDER_ID'),
        gcm_user_visible_only: true
    });
});

Subscription creation and deletion directly modify the data store but they require a user name.

var auth = function (req, res, next) {
    var user = basicAuth(req);

    if (!user || !user.name) {
        res.set('WWW-Authenticate', 'Basic realm=Authorization Required');
        return res.status(401);
    }

    return next();
};

app.put('/subscriptions/webpush', auth, function (req, res) {
    db.insert({
        user: basicAuth(req).name,
        data: req.body
    }, function(err) {
        res.status(err ? 500 : 204).send();
    });
});

app.delete('/subscriptions/webpush', auth, function (req, res) {
    db.remove({
        user: basicAuth(req).name
    }, function(err) {
        res.status(err ? 500 : 204).send({ result: 'ok' });
    });
});

Sending notifications to Google Cloud Messaging involves retrieving saved JSON data and converting endpoint URLs to registration identifiers.

var GCM_URL = 'https://android.googleapis.com/gcm/send';

app.post('/notifications', function(req, res) {
    var query = { };
    var user = basicAuth(req);
    if (user && user.name) {
        query.user = user.name;
    }
    db.find(query, function(err, docs) {
        if (err) {
            return res.status(500).send({ error: 'db query failed' });
        }
        var registrationIds = docs.map(function(doc) {
            return doc.data.endpoint;
        }).filter(function(endpoint) {
            return endpoint.indexOf(GCM_URL) === 0;
        }).map(function(endpoint) {
            return endpoint.substring(GCM_URL.length + 1);
        });
        if (registrationIds.length === 0) {
            return res.send({ info: 'No registrations.' });
        }
        request.post(GCM_URL, {
            json: {
                registration_ids: registrationIds
            },
            headers: {
                'Authorization': 'key=' + option('GCM_SENDER_KEY'),
                'Content-Type': 'application/json'
            }
        }).pipe(res);
    });
});

function option(name) {
    var env = process.env;
    return env['npm_config_' + name] || env[name.toUpperCase()] || env['npm_package_config_' + name];
}

require('http').createServer(app).listen(option('port'));

console.info('Open: http://localhost:' + option('port'));

For a complete working project see sample project on GitHub.

Comments

cancel

Revisions

  1. Initial version.