Thursday, July 2, 2015

Annoy mobile & desktop users through push notifications through the browser!

To say the least, the new power present in the combination of Google Chrome, Google Cloud Messaging (GCM), and a new HTML5 concept called service workers has the power to help you develop robust applications involving push notifications without the requiring the user to install an app from the App Store.  Do not abuse it.  You will make your users angry, and when you do that, then you make me angry. :-P  Be careful when you devise use cases for the new processes outlined below.

Here's the scoop: GCM now works with a relatively nascent feature in modern Web browsers called service workers.  Service workers allow processes to continue running in the background even after a webpage is no longer open, thus allowing them to show push notifications, cache data, run background computations, and monitor various other system states.  Below, you will see the very basic mechanism for sending a plain push notification in the browser.  As you go along with this tutorial, you will build up more functionality and eventually make it quite robust through three different phases (Basic, Pizzazz, and Spreading Wings).

But First, An Important Note About Proxies


Some corporate proxies are relatively dumb, and block access to specific ports.
  GCM is configured to work using the XMPP protocol on ports 5228-5230.  Other corporate proxies are smarter and will block traffic based on the source IP.  In these events, you will need to disconnect from the proxy in order to receive the message.  This applies no matter how far along you are in the tutorial.

It also makes Spreading Wings a little bit difficult to pull off, as you will need to be on the proxy to load the initial page, off the proxy to receive the push notification, and then possibly back on the proxy in order to talk to the API that adds the extra data to your push notification, especially if your API exists on a server inside a corporate firewall.

Let’s Begin


  1. Pre-work:
    1. Install Node.js and npm (Node package manager).
    2. Set up an application in the Google Developers Console https://console.developers.google.com/.  In this application, enable the “Google Cloud Messaging for Android" API in the “API” -> “APIs & auth” section.  Then, in the subsection below (“Credentials”), set up a server API key for public API access.
  2. Install the following gulp modules (conveniently listed for easy copy-paste) into an empty directory (we’ll call this your server-root directory):
    1. For Basic: gulp gulp-connect
    2. For Pizzazz: body-parser child_process express proxy-middleware url
    3. For Spreading Wings: fs
  3. Create a new directory inside your server-root directory called dist.  Clone the Google push notification example repo https://github.com/GoogleChrome/samples/tree/gh-pages/push-messaging-and-notifications into dist.  This contains the user-facing Web app you will use to enable/disable push notifications, as well as the service worker that handles the background tasks.  The files you should get are:
    1. config.js - this is where you provide your GCM Server API Key in a dictionary called window.GoogleSamples.Config, key name is gcmAPIKey.
    2. demo.js - A file they included that provides extra logging features on the page and causes things to happen on load.  Probably not too important.
    3. index.html - This is your UI.  It needs to reference at least config.js, main.js, and your manifest, and contain the <button> one toggles to control push notifications (unless you want to modify all the UI management going on in main.js).
    4. main.js - Definitely the longest file.  Contains the JavaScript to initialize the service worker, plus the logic to run when the <button> is toggled.
    5. manifest.json - Permissions for your application.  Special parameters here include the gcm_sender_id (your 12-digit Project Number, as visible from “Overview” in your Project Dashboard) and the gcm_user_visible_only parameter (evidently Chrome 44+ won't need this parameter).
    6. service-worker.js - This file contains the two event listeners waiting for push (the signal from GCM) and notificationclick (an action to take when the user clicks on the notification).  Set up the appearance of your push notification here.  One good thing to do upon click is to actually close the notification (event.notification.close()), since apparently current versions of Chrome do not do this automatically.
  4. In case you glossed over the description of each file above, I shall reiterate the changes you need to make to two files.  First, tweak config.js so that the gcmAPIKey field is equivalent to the server API key you generated in step 1.2.  Also, tweak manifest.json so that your gcm_sender_id is equivalent to your application number, as seen on “Overview” in your Project Dashboard.
  5. Create your Gulpfile in the server-root directory so that you can serve up your new Web app.  In case you're not familiar with that, this is what your super-basic Gulpfile should look like now:


// gulpfile.js
var gulp = require('gulp');
var connect = require('gulp-connect');

gulp.task('default', function () {
    connect.server({
        root: 'dist/',
        port: 8888
    });
});

Time to try it!  Run your application by navigating to your server-root directory and running ./node_modules/.bin/gulp (usually I just make a symlink to this file in the server-root directory).

At this point, you can run your in-browser GCM push notification code from within Chrome 42 or newer at http://localhost/<port number you selected in the Gulpfile>.  Use cURL, as instructed, to send the push notification to your device.  As long as you downloaded your code from the GitHub repo and did not modify it except for adding your correct GCM server API key and Project Number, it should simply work with no further intervention.

Try it out for a little while, and you will quickly grow tired of its rather limited functionality.  I’m sure you would like to spice it up a bit more than just getting one generic push notification with predefined text set in the code.  Since support for embedding data into push notifications is as yet unsupported (unlike when using GCM on Android), you can add some pizzazz by wiring up an external server to provide custom displays based on exactly what registered device is receiving the push.  And, of course, you can set up such a server quite easily using Node.

  1. Make a new directory off the server-root called api, and cd into it.
  2. Make a server.js file that leverages express, body-parser, and http.
    1. To keep things simple, make routes that represent the GCM registration IDs you expect to serve to.  Since we’re building an API rather than a website, a “route” in the context of express will be equivalent to an API endpoint.  I made my endpoints in the form “/push/*”; this way, everything in the * is parameterized, and I am expecting the registration ID to exist in req.params[0].  Take this value and make some conditional logic that will return differing responses based on what value was provided.  Later on, you can take out this logic and pass the value directly into some sort of database that can help you generate the desired response.  That piece, however, is out of the scope of this tutorial since I’m trying to use Javascript only throughout this example.
    2. Make sure you set the port number to something different than where you are running the Web server for your application’s user interface.  I have chosen to run the UI at port 8888 and the API at port 8078 (for HTTP) / 8079 (for HTTPS); this avoids having to run gulp as root, which is required if you want to run the server on a standard port such as 80 or 443.
  3. Modify your service worker so it can parse the registration ID from the push notification.  You will use this as your “key” to look up exactly what the push notification will say.
  4. Modify your service worker so it can make requests to your new API, process them, and actually display different content based on the API’s response.
  5. Set up your Gulpfile to start this back-end server before launching the UI server.  Also, in your UI server connection logic, use proxy-middleware so that it appears to actually serve the API from within the webapp.  This will help you avoid errors with cross-site scripting.  Also, out of convenience, you should modify the default task to simply call two other tasks that individually start the API and UI servers; this will come in handy shortly.

For reference, this is what your server.js file should resemble:

var fs = require('fs');
var express    = require('express');        // call express
var app        = express();                 // define our app using express
var bodyParser = require('body-parser');
var http = require("http");

// ROUTES FOR OUR API
// =============================================================================
var router = express.Router();              // get an instance of the express Router

//***************************************************************
//Push data route (accessed at GET http://localhost:8888/api/v1/push/*)
//***************************************************************

router.get('/push/*', function(req, res) {
  user = req.params[0];
if (user == '[Google GCM user registration ID 1]') {
      res.json({"notification":{"title":"Check this out","message":"Here's your notification you asked for!","icon":"http://localhost/some-picture.png"}});
  } else if ... <etc>
});

// REGISTER OUR ROUTES -------------------------------
// all of our routes will be prefixed with /api
app.use('/api/v1', router);

// START THE SERVER
// =============================================================================
var httpServer = http.createServer(app);
httpServer.listen(8078);
console.log('Magic happens on port 8078');

And this is what your Gulpfile will look like, assuming you split your tasks:

gulp.task('connectApi', function (cb) {
  var exec = require('child_process').exec;
  exec('node api/https-server.js', function (err, stdout, stderr) {
      console.log(stdout);
      console.log(stderr);
      cb(err);
  });
});

gulp.task('connectHtml', function () {
    var url = require('url');
    var proxy = require('proxy-middleware');
    var options = url.parse('http://localhost:8078/api');
    options.route = '/api';

    connect.server({
        root: 'dist/',
        port: 8888
        middleware: function (connect, o) {
            return [proxy(options)];
        }
    });
});

gulp.task('default', ['connectApi', 'connectHtml']);

At this point, make sure to stop gulp and node so that your original server is torn down.  Close Chrome.  Restart gulp (or at least the UI portion), and then restart Chrome.  Navigate to chrome://serviceworkerinternals and make sure nothing is listed there.  If there’s something present, try to remove it and then restart Chrome again.  (I haven’t figured out how to more conveniently refresh service workers; it’s kind of a hassle.  However, it's possible that Chrome Canary does an even better job at discarding old settings than does regular Chrome.)  Now, navigate to your UI and re-enable push messages.  Copy down your device’s new registration ID and put that into your API server.  Unfortunately, this will require yet another restart of the API server (or at least starting it, if you didn’t do so already).  However, upon using cURL as instructed to send the push notification to your device, it should now be serving you custom content through your API!

Spreading Wings


Now I’m sure you want to take your demo beyond localhost and put it on a real server somewhere where you can access it from any computer on your local computer or possibly the Internet.  Google requires that push notification apps out in the wild that leverage GCM through web workers be served through HTTPS, so you will need a certificate.  If you are lucky enough to have an SSL certificate through a trusted authority for your Web site, then you should be able to make very simple modifications to your server and Gulpfile in order to run the app on a real domain name.

On the other hand, if you’re cheap or just doing this for testing, there are two ways you can go about serving your app over HTTPS while still using the API server on a separate port.  First, you can continue to use proxy-middleware to serve the API through a path on the UI server, or you can separate those two out and make a request directly from one server to the other.  Both require you to create your own self-signed certificate anyway, so let’s think of the benefits & drawbacks of each implementation:

Sticking with proxy-middleware:
  • +1 You’ve already made it this way, so why change now
  • -1 It requires configuration changes that need to be removed when you plan to put your app in production


Ditching proxy-middleware:
  • +1 The configuration change allows just one cross-domain server to make requests, rather than letting through any unscrupulous certificate
  • -1 You’ll have to take out a lot of logic you wrote to get your app Pizzazz’d
  • +1 The changes actually reduce dependencies on libraries and make the Gulpfile a little bit shorter again


So, without any further ado, here’s the security pre-work you need to do, depending on what route you want to take:

Way 0: You already bought an SSL certificate that verifies your domain name.


I’ll tell you how to make your UI and API server secure below.

Not-Way-0 Prerequisite: Make your certificate(s)


Make your security certificate.  Hopefully you have access to OpenSSL on your machine.  Run this command:

openssl req -newkey rsa:2048 -new -nodes -x509 -days 3650 -keyout key.pem -out cert.pem

This will generate a certificate suitable for your PC or Mac.  Fill out each of the prompts.  For the Common Name, I used the FQDN of my server computer.  The instructions for installing it vary across different OSes.  Then, run:

openssl pkcs12 -export -out cert.pfx -inkey key.pem -in cert.pem -certfile cert.pem

This will generate a certificate compatible with Android in case you’re interested in trying your demo on a real mobile device.

Way 1: Keep using proxy-middleware


In the realm of HTTPS, the server and the client need to present certificates.  The browser will complain if the server & client present certificates with the same Distinguished Name (DN). However, notice the code in service-worker.js; the fetch() command does not provide you with a place to provide a client certificate.  Thus, you need to add a setting into the connectHtml task of your Gulpfile so that Node will ignore certificates with errors:

process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";

Note this is normally set in the settings object you pass to https when you initialize it (the key is rejectUnauthorized), but for some reason, I couldn’t get that particular key to work; setting the environment variable programmatically is what did the trick.  When you run your server in production, you will definitely want to remove this line (as it should reject unauthorized certificates by default).

Way 2: Ditch proxy-middleware and make the request from your service worker directly to the API server.


Due to many years of devastating cross-site-scripting (XSS) attacks perpetrated on Internet users, browsers & servers typically forbid a script on one site to request content from another.  Node.js makes it fairly easy to whitelist your service worker by allowing you to set the Access-Control-Allow-Origin HTTP header in the response.  Make sure to set this to the exact host and port that your service worker lives on, or else the request will fail (you will see the failure in the console of the service worker).  Here’s a short code snippet of how you do that:

router.get('/push/*', function(req, res) {
    user = req.params[0];
    res.header("Access-Control-Allow-Origin", "https://yourhost.yourdomain.com:8888");
    etc...

Changing your application code to take advantage of your security pre-work


Ok, now that we’re done with the security pre-work, here is how you set all that up in your application code.

  1. Your API server should now rely on the https module rather than http.  This is a simple change; just change require("http") to require("https").  You will feed it a dictionary of secure settings, including your private key, certificate, CA, and an option called requestCert that will ask the browser for a certificate.  Note that If you did not self-sign your key, it might not be necessary to include the CA field.
  2. If you kept proxy-middleware, be sure to add that programmatic environment variable setting for NODE_TLS_REJECT_UNAUTHORIZED as mentioned above.
  3. If you ditched proxy-middleware, make sure to set the Access-Control-Allow-Origin HTTP header in each response you return from the API server (see example above), or else the response will never be properly received by the service worker.
  4. Your Gulpfile will also need to support starting the UI server with HTTPS support.  See the code snippet below.  Again, if you purchased your certificate, you might not need to provide the CA field.


Here are the important changes to your server.js file:

var privateKey  = fs.readFileSync('./key.pem', 'utf8');
var certificate = fs.readFileSync('./cert.pem', 'utf8');

var secureSettings = {
  key: privateKey,
  cert: certificate,
  ca: certificate,
  requestCert: true
};
...
var httpsServer = https.createServer(secureSettings, app);
...
httpsServer.listen(8079);
console.log('Magic happens on ports 8078 (HTTP) & 8079 (HTTPS)');

Make the following modifications to your gulpfile's connectHtml task:

    process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";

    connect.server({
        root: 'dist/',
        port: 8888,
        https: {
            key: fs.readFileSync(__dirname + '/api/key.pem'),
            cert: fs.readFileSync(__dirname + '/api/cert.pem'),
            ca: fs.readFileSync(__dirname + '/api/cert.pem'),
            rejectUnauthorized: false
        },
    ...

Before using your new secure site on any devices (including your localhost), you will need to indicate trust of your new certificate and CA (assuming you didn’t just buy one outright).  The steps to do this differ across operating systems, so I will not go into that here.  Just note that telling your browser to ignore errors with the security certificate will not solve your problem, as the GCM service will be disabled until all the trust issues are fully worked out.  If you’re having trouble moving your certificate to another device, remember you can set up a file server cheaply with express too.  To serve files from a particular directory, just add this:

app.use('/files', express.static('path/to/files'));

Now when you visit http[s]://<hostname>:<port>/files/ (note the host name & port number pertains to your API server, not your UI server), you will have access to any files in the designated path on your system.  However, this will not help you if you’re running Chrome browser on iOS, as it manages its own trusted store and there’s no easy way for you to modify it.

And finally, it should go without saying, but in case you forget: restart your servers and restart Chrome to clear out your old data!

Two things that might help you troubleshoot problems getting an external device talking to your server:
  • It is possible that you need to sign into your Google account in order for this to work.
  • If nothing seems to be happening, check your other device carefully (especially if it is a phone).  Phones might only show you the prompt to "accept push notifications from this website" if you are scrolled up to the top of the page.  Of course, you have to accept that if any of this is to work.


Customize Notifications Per User


Earlier, you saw how to make your own backend Node server to serve custom data for your push notifications.  However, upon implementing this is the "Spreading Wings" context (i.e. once you started accessing the services and receiving pushes on multiple devices), you probably grew tired with seeing the same push notification appear on every device after a while.  Here is how you mitigate that:

  1. Use the Promise-returning getSubscription() method to get the Subscription object.
  2. Upon receiving the result of that operation, you get a lot of details about the subscription for the device that just received the push notification.  Typically, you would want to access the actual subscription ID from the return value's subscriptionId parameter, but (as described in the comment below), that method didn't seem to exist when I tried this for myself in Chrome Canary 45.  Since this code is currently being used with GCM only, it is OK to make some assumptions about the endpoint URL to help parse it so you can get the subscription ID.
  3. Pass this subscription ID along to your API, which can then serve custom data tailored to each specific device registered with it.
See the code example below.  This goes into your service worker (notice where the call to fetch() is, now inside the return function for getSubscription()):

self.registration.pushManager.getSubscription().then(function(pushSubscription) {
  // The usual code for getting the subscription ID
  // (PushSubscription.subscriptionId)
  // returns null, so do some string parsing on the "endpoint" returned
  var subscrID = pushSubscription.endpoint.split("/").pop();
  // Wait for the HTTP REST call to come back with a response
  fetch("https://yourhost.yourdomain.com:8888/api/v1/push/" + subscrID).then(function(response) {
    ... <your own logic>

Epilogue


Keep in mind that service workers are not widely supported across the World Wide Web today.  Currently, the functionality only exists in Chrome 42+, Opera 29+, Chrome Mobile 42+ (but not Chrome for iOS because it does not support service workers), and is an experimental feature in Firefox that must be turned on manually.  Over time, you can check on the supportability of service workers at this website: http://caniuse.com/#feat=serviceworkers

The above code is great for demonstration purposes.  While it is probably robust enough to take into production, you may wish to consider using a tried and true backend framework if you are working on an enterprise-scale project.  (Or, maybe just buy a bunch of time on the cloud and use a really good load balancer.)


Source: https://developers.google.com/web/updates/2015/03/push-notificatons-on-the-open-web?hl=en

No comments:

Post a Comment