Delivering iOS Push Notifications with Node.js

Jake Luer is a Node.js developer and consultant focused on building the next generation of mobile and web applications. He is logicalparadox on GitHub and @jakeluer on Twitter and you can also find him on Google Plus

Mobile is an incredibly important strategy when building applications in today's ecosystem. One of the major challenges facing all application builders, whether start-ups or enterprise, is keeping users engaged. Notifications are the first step in a long-checklist of tactics that can be used to do just that.

In today's tutorial we will be building a small Node.js application that covers all of the basics of working with the Apple Push Notification (APN) service. This will include connecting to Apple's unique streaming api, sending several types of notifications and listening to the unsubscribe feedback service.

This is a JavaScript/Node.js focused tutorial so it does not cover any iOS (Objective-C) programming. However, since we want to be able to test our notifications with an actual iPhone, a sample iOS project has also been prepared and released under an open-source MIT license.

Time Required:~2-3 hours

Tools Required:

  • Node.js v0.8 or v0.10.
  • Code editor of preference for javascript files.
  • iOS device (push notifications cannot be sent to simulator)
  • xCode (for working with sample iOS app)
  • Apple Developer Account with iOS Provisioning Portal access

Introduction to Apple Push Notification Service

The Apple Push Notifications service is actually a set of services that developers can use to send notifications from their server (the provider) to iOS devices.

In actuality the APN service is two separate components that provide different benefits to a provider. Implementing both in a production application is required.

__1. Gateway Component:__The gateway component is the TLS connection that a provider should established to send messages for Apple to process and then forward on to devices. Apple recommends that all providers maintain an “always-on” connection with the gateway service even if no messages are expected to be sent for long periods of time. The service implements a very specific protocol and will disconnect in the event of an error. The Node.js module we are using today handles all binary encoding and implements a number of systems to ease the burden of possible reconnects.

__2. Feedback Component:__The feedback component is the TLS connection that a provider should occasional establish to download a list of devices which are no longer accepting notifications for a specific applications. All providers will need to implement a feedback workflow before going to production as Apple monitors a provider's usage of this service to ensure it is not sending unnecessary notifications. The Node.js module we will be using makes it really easy to automate your feedback workflow.

Sample iOS Application

Before we get into creating a Node.js application we need to prepare for the moment we want to send a notification to an actual device. For this tutorial we will be using a sample iOS application that will allow us to inspect the notifications that are received by the device. We won't need to do any Objective-C coding but we do need to configure the application in xCode so we can run it on our device. Furthermore, prior to using APN Agent we will need SSL certificates to establish a secure connection with the APN or Feedback service. In addition to creating our new application and provisioning profile, we will also walk through generating these certificates in a format that APN Agent accepts.

For this section you will need an Apple Developer's Account with access to the iOS Provisioning Portal and the latest version of xCode installed on you local Mac development machine.

1. Application: Log in to the Apple iOS Provisioning Portal and create a new App ID by selecting “App IDs” from the side menu and then the “New App ID” button from top right. You will need to specify a Bundle Identifier; I suggest using apnagent as the appname segment of the this bundle id. For example, mine is com.qualiancy.apnagent. You will need to remember this for later.

Create Application

2. Enable APN: From the applications list for your newly created application select “Configure” from the action column. Check the box for “Enable for Apple Push Notification server”.

Enable

3. Configure: Select “Configure” for development environment. Follow the wizard's instructions for generating a CSR file. Make sure to save the CSR file in a safe place so that you can reuse it for future certificates.

Configure

4. Generate: After you have uploaded your CSR it will generate a “Apple Push Notification service SSL Certificate”. Download it to a safe place.

Generate

5. Import: Once downloaded, locate the file with Finder and double-click to import the certificate into “Keychain Access”. Use the filters on the left to locate your newly import certificate if it is not visible. It will be listed under the “login” keychain in the “Certificates” category. Once located, right-click and select “Export”

Import

6. Export: When the export dialog appears be sure that the “File Format” is set at “.p12”. Name the file according to the environment, such as playground-dev.p12 and save it to a safe place. You will be prompted to enter a password to secure the exported item. This is optional so leave it empty for this tutorial. You will then be asked for your login password.

7. Provision: Head back to the iOS Provisioning Portal and select “Provisioning” from the left menu and then the “New Profile” button in the top right to create a new Development provision. Make sure to select the correct App ID and Device. Once created, download the .mobileprovision file and double-click it in Finder to load it into xCode.

Note: If you have never done a provision before you may not have any “Certificates” or “Devices” listed when you go to create a Provisioning Profile. Consult Apple's documentation or the “Development Provisioning Assistant” from the iOS Provisioning Portal home page to fill in these missing pieces.

8. Clone: Next you will need to clone the apnagent-ios repository and open apnagent.xcodeproj in xCode.

git clone https://github.com/logicalparadox/apnagent-ios.git
open apnagent-ios/apnagent.xcodeproj

9. Configure Project: The final step is to configure the xCode project use your mobile provision. Under the “Build Settings” for apnagent change the User-Defined BUNDLE_ID setting to the bundle identifier you specified earlier. Then select the “Code Signing Identity” for that bundle identifier.

Configure Project

10. Run: To make sure you have everything configured correctly we are going to run the application. Connect your device and then in the top-left corner of xCode make sure you device is selected for “Scheme”. Then click “Run”. If you do not get any build errors and the xCode log displays your device token you have configured everything correctly.

Device Token

Node.js Module: APN Agent

APN Agent is a Node.js module that I developed to facilitate sending notifications via the APN service. It is production ready and includes a number of a features that make it easy for developers to implement mobile notifications quickly. It also contains several mock helpers which can assist in testing an application or provide feature parity during development. The major features you can expect to cover today are:

  • Maintaining a persistent connection with the APN gateway and configuring the system for auto-reconnect and error mitigation.
  • Using the chainable message builder to customize outbound messages for all scenarios that Apple accepts.
  • Using the feedback service to flag a device as no longer accepting push notifications.

This tutorial will cover a lot of ground to get a simple application together but might skim over topics that are only relevant in larger deployments. Keep an eye out for links to sections of the module documentation for a deep-dive into certain subjects.

Create Node.js Project

Now that we have our certificate we can begin work on the Node.js application. Today's application will be called apnagent-playground and it will only focus on how to send APN messages as opposed to building a fully flushed out user-centric application. At the end of this section we will accomplish:

  • Establish a connection with the APN gateway service.
  • Send a simple “Hello world” message to a device.
  • Explore the many different options for messages that can be sent.
  • Learn how to mitigate errors that might occur in multi-user applications.

###Project Skeleton

Download Project Skeleton (zip)

Here is the file structure we will be working with:

├── _cert
│   └── pfx.p12
└── agent
│   ├── _header.js
│   └── hello.js
└── feedback
│   ├── live.js
│   └── mock.js
├── device.js
└── package.json

1. Certificate: The first thing you will need to do is move your pfx.p12 file generated was generated earlier into the the _cert folder.

2. package.json: Next you will need to populate the package.json file. We will be working with apnagent version 1.0.x. Though this project is stable, when adding apnagent to your own project I encourage you to check the apnagent source-code change log for anything that might have changed since this release.

Here is the important parts of the package.json for those who did not download the skeleton.

{
  "private": true,
  "name": "apnagent-playground",
  "version": "0.0.0",
  "dependencies": {
    "apnagent": "1.0.x"
  }
}

Once your package.json file is populated run npm install to grab the dependencies.

3. Device Token: Since we will be sending messages to an actual device we need to have it easily accessible. Assuming you have the apnagent iOS application open in xCode, “Run” the application on your connected device. When the application opens it will display the device token in the xCode log. Copy and paste it into the device.js file. Mine looks like this:

module.exports = "<a1b56d2c 08f621d8 7060da2b c3887246 f17bb200 89a9d44b fb91c7d0 97416b30>";

###Making the Connection

Now that we have the skelton configured and our dependencies installed we can focus on establishing a connection. The first file we are going to work with is agent/_header.js. This file will handle loading our credentials and establishing a connection with the APN service. The first thing we need is to require all of our dependencies. We will construct a new apnagent.Agent and assign it to module.exports so we can access it from all or our different playground scenarios.

// Locate your certificate
var join = require('path').join
  , pfx = join(__dirname, '../_certs/pfx.p12');

// Create a new agent
var apnagent = require('apnagent')
  , agent = module.exports = new apnagent.Agent();

Now that we have created our agent we need to configure it with our authentication certificates and environment details.

// set our credentials
agent.set('pfx file', pfx);

// our credentials were for development
agent.enable('sandbox');

For more configuration options such as modifying the reconnect time or using different types of credentials, view the agent configuration documentation.

Finally, we need to establish our connection. apnagent uses custom Error objects whenever possible to best describe the context of a given problem. When using the .connect() method, a possible custom message is the “GatewayAuthorizationError” which could occur if Apple does not accept your credentials or if apnagent has a problem loading them. You can check for apnagent specific errors by checking the .name property of the Error object.

agent.connect(function (err) {
  // gracefully handle auth problems
  if (err &amp; err.name === 'GatewayAuthorizationError') {
    console.log('Authentication Error: %s', err.message);
    process.exit(1);
  }

  // handle any other err (not likely)
  else if (err) {
    throw err;
  }

  // it worked!
  var env = agent.enabled('sandbox')
    ? 'sandbox'
    : 'production';

  console.log('apnagent [%s] gateway connected', env);
});

Now we can test our connection by running the _header.js file. If you receive any message other than “gateway connected” you should revisit the previous steps to ensure you have everything configured successfully. Once you confirm a connection press CTRL-C to stop the process.

$ node agent/_header.js
# apnagent [sandbox] gateway connected

To see this file in full, view it on GitHub: agent/_header.js.

###Sending Your First Notification

Now that we have a connection we can send our first message. We will be using a seperate file in the agent folder for each message scenario. Our first one is agent/hello.js. First we need to import our header and device. You will need to do this for all scenarios.

var agent = require('./_header')
  , device = require('../device');

Requiring the _header file will automatically connect to the APN service. Now we can create our first message using the .createMessage() method from our agent. This will create a new message and provide a chainable API to specify message properties. Once we specify all our properties for that message we invoke .send() to dispatch it.

agent.createMessage()
  .device(device)
  .alert('Hello Universe!')
  .send();

To see this file in full, view it on GitHub: agent/hello.js.

Now we need to run this scenario. Make sure that apnagent-ios is running on your device, then:

$ node agent/hello.js

Within moments you should see your notification received: Screenshot Hello Universe

If you don't receive a notification on your device jump a few paragraphs down to the “Error Mitigation” section for code on how to debug these kinds of issues.

###Other Types of Notifications.

Badge Numbers

In this next scenario we will set the badge number. The code is rather simple for agent/badge.js:

// Create a badge message
agent.createMessage()
  .device(device)
  .alert('Time to set the badge number to 3.')
  .badge(3)
  .send();

View on GitHub: agent/badge.js.

Keep in mind different versions of iOS handle badge number messages different. In iOS v6, the badge will not be displayed automatically if the application is in the foreground. By included an alert body we can see the icon badge change but also inspect the payload in apnagent-ios. Try sending the message while the application is in different states.

Badge Screenshot

Custom Payload Variables

One of the strongest features of the APN gateway service is the ability to include custom variables in your messages. Even though you should not rely on APNs for mission critical information, custom variables provide a way to associate an incoming message with something in your data store.

agent.createMessage()
  .device(device)
  .alert('Custom variables')
  .set('id_1', 12345)
  .set('id_2', 'abcdef')
  .send();

View on GitHub: agent/custom.js.

The .set() method allows you to include your own key/value pairs. These pairs will then be available to the receiving client application. Custom Screenshot

Message Expiration

By default all messages have an expiration value of 0 (zero). This indicates to Apple that if you cannot deliver the message immediately after processing then it should be discarded. For example, if the default is kept then messages to devices which are off or out of service range would not be delivered. Though useful in some application contexts there are many cases where it is not. A social networking application may wish to deliver at any time or a calendar application for an event that occurs within the next hour. For this you may modify the default expiration value or change it on a per-message basis. Here is our agent/expires.js scenario.

// set default to one day
agent.set('expires', '1d');

// send using default 1d
agent.createMessage()
  .device(device)
  .alert('You were invited to a new event.')
  .send();

// use custom for 1 hour
agent.createMessage()
  .device(device)
  .alert('New Event @ 4pm')
  .expires('1h')
  .send();

// set custom no expiration
agent.createMessage()
  .device(device)
  .alert('Event happening now!')
  .expires(0)
  .send();

View on GitHub: agent/expires.js.

###Error Mitigation

One behavior of the APN service is that it does not respond for every message sent to confirm it has been received. Instead it only responds on an error specifying what error on which message there was a problem. Furthermore, when an error occurs the service will disconnect and flush it's backlog of received data refusing to process further until a clean connection is made. Any message that was dispatched through the outbound socket after the invalid message will need to be sent again after once a new connection has been established in order for it to be delivered. Don't panic! apnagent handles all of this for you.

As you might have noticed in the above .createMessage() examples a callback was not specified for the .send() method though the api allows for one to be set. This callback is invoked when a message has been successfully encoded for transfer over the wire but since the APN service does not provide confirmation that every message has been successfully parsed managing a callback flow can be tricky. Instead, any errors that the APN service reports will be emitted as the event message:error. Code best demonstrates all of the possible scenarios.

This goes in our agent/_header.js file before we make a connection.

agent.on('message:error', function (err, msg) {
  switch (err.name) {
    // This error occurs when Apple reports an issue parsing the message.
    case 'GatewayNotificationError':
      console.log('[message:error] GatewayNotificationError: %s', err.message);

      // The err.code is the number that Apple reports.
      // Example: 8 means the token supplied is invalid or not subscribed
      // to notifications for your application.
      if (err.code === 8) {
        console.log('    > %s', msg.device().toString());
        // In production you should flag this token as invalid and not
        // send any futher messages to it until you confirm validity
      }

      break;

    // This happens when apnagent has a problem encoding the message for transfer
    case 'SerializationError':
      console.log('[message:error] SerializationError: %s', err.message);
      break;

    // unlikely, but could occur if trying to send over a dead socket
    default:
      console.log('[message:error] other error: %s', err.message);
      break;
  }
});

As you can see there is a lot that can go on here; too much to cover in this article. For more information view Apple's APNs documentation for all possible response codes.

Using the Feedback Service

The Feedback Service is the method by which Apple informs a developer which devices should no longer receive push notifications. The primary reason to cease notifications is that the application has been uninstalled from the device.

###Working with Feedback Events

APN Agent's Feedback Interface will periodically connect the APN Feedback Service and download a list of devices that should be marked for unsub. Each “row” in the download consists of the device token and the timestamp that the uninstall occurred. I recommend that when you gather your token from a device you also store the timestamp for the most recent time that token has been reported. This allows you to compare the timestamps to determine if the application was reinstalled since the feedback unsubscribe notice.

There is one “gotcha” that developers should be aware of. The connection that the device maintains to the APN service is disconnected when there are no applications installed that are configured to receive push notifications. The side-effect is that if your application is the last one to be uninstalled the device will NOT notify the APN Feedback service that it was uninstalled. In production, this is highly unlikely to occur, but if you are developing an application and using the sandbox connection, and are the only sandbox application, this behavior will also occur. You have been warned!

Making the Connection

Luckily, APN Agent also has a Mock Feedback interface so we can easily simulate feedback behavior and test our code.

var apnagent = require('apnagent')
  , feedback = new apnagent.MockFeedback();

feedback
  .set('interval', '30s') // default is 30 minutes?
  .connect();

The .connect() method for the apnagent.MockFeedback simulates the same behavior as the real apnagent.Feedback. It will perform the initial connection and retrieve the unsubscribed list. Each row will be parsed and added to the to-be-processed queue. Once Apple has finished sending the list they will disconnect and the Feedback interface will schedule the next download to occur after the set interval time has elapsed.

Handling Unsubscribed Devices

Once the Feedback interface has received a list of devices it will place each response into a throttled processing queue. Since we have no idea how long this list will be and reacting to feedback is not as mission-critical as responding to an http request, this throttled queue helps us avoid bottle-necks in any of our node application's finite resources. By default this queue will process up to ten items in parallel, but for our testing we are going to change the concurrency to 1.

feedback.set('concurrency', 1);

Now we need to instruct the feedback service how to handle any device that has been marked as unsubscribed. The following example is pseudo-code so we can't run it directly. You are welcome to adapt it for your database of choice.

/*!
 * @param {apnagent.Device} device token
 * @param {Date} timestamp of unsub
 * @param {Function} done callback
 */

feedback.use(function (device, timestamp, done) {
  var token = device.toString()
    , ts = timestamp.getTime();

  // psuedo db code
  db.query('devices', { token: token, active: true }, function (err, devices) {
    if (err) return done(); // bail
    if (!devices.length) return done(); // no devices match

    // async forEach
    devices.forEach(function (device, next) {
      // this device hasn't pinged our api since it unsubscribed
      if (device.get('timestamp') <= ts) {
        device.set('active', false);
        device.save(next);
      }

      // we have seen this device recently so we don't need to deactive it
      else {
        next();
      }
    }, done);
  });
});

###Testing Feedback Events

Live feedback events are difficult to trigger as they require the right conditions and Apple's block-box logic might not recognize those conditions for some time. That is why we are using the MockFeedback class for our example. To make it easy to test this scenarios we can push in our own device-timestamp pairs. Here is an example that will unsubscribe your device:

// pull in your device
var device = require('../device');

// unsub it as of 30 minutes ago
feedback.unsub(device, '-30m');

This will not invoke our .use() statement immediately. Since it fully emulates the like Feedback class it will wait until the next simulated connection to the feedback service. Since we changed our interval to 30 seconds we won't have to wait very long.

If you have adapted this example to use your own database then you can run it to see what happens. If you would like to see a full-featured example the apnagent-playground repository has this in it's master branch.

Closing Remarks

Today's tutorial covered a lot of ground: connecting to APNs, sending messages and handling feedback. If you are ready to take this to next step the apnagent documentation is the best place to start. For example, there is a full express.js application that implements the MockAgent or live Agent depending on environment which can serve as the foundation of many production applications.

Please let me know if you have any questions in the comments below. Alternatively, if you run into specific issues with any of the code used in this tutorial, please report them under their GitHub Issues.

apnagent

apnagent-ios

apnagent-playground