apnagent

Node.js adapter for Apple Push Notification (APN) service.

View the Project on GitHub

qualiancy/apnagent
Build Status

Features

  • chainable message builder
  • persistent connection management
  • apn feedback service integration
  • feature complete mock agents for local-only testing/development

Starting Resources

Related Projects

  • apnagent-ios: Tiny iOS application for use with the example(s) and live tests.
  • apnagent-playground: Extended examples for connecting, error mitigation and available message types.

Quick Start Guide

apn agent is a Node.js module that facilitates sending notifications via the Apple Push Notification (APN) service. It is production ready and includes a number of a features that make it easy for developers to get running quickly. It also contains several mock helpers which can assist in testing an application or provide feature parity during development.

Installation

apnagent package is available through npm for Node.js v0.8.x and v0.10.x.

npm install apnagent

Generate Certificates

Before using apn agent you will need certificates to establish a secure connection with the APNs or Feedback service. The following steps will walk you through generating these certificates in all of the formats that apn agent supports.

1. Application: Log in the iOS Provision Portal from the Apple Developer website. Select "App IDs" from the left side menu, then "configure" next to the application you wish generate certificates for.

2. Enable: [Screenshot] If it is not already enabled, check the box for "Enable for Apple Push Notification server".

3. Configure: [Screenshot] Select "Configure" for the environment you want to generate certificates for. 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.

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

5. Import: [Screenshot] 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".

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 apnagent-dev.p12 and save it to your project directory. You will then be prompted to enter a password to secure the exported item. This is optional, however if you do specify one you will need to configure apn agent's classes to use that "passphrase". You will also be prompted for your login password.

7. Convert (optional): apn agent's classes support ".p12" files through the pfx configuration options to authenticate connections. The last step is optional but instructs on how to turn a ".p12" file into a "key" and "cert" pair (".pem" format).

Locate your ".p12" file in a terminal session and execute the following commands.

openssl pkcs12 -clcerts -nokeys -out apnagent-dev-cert.pem -in apnagent-dev.p12
openssl pkcs12 -nocerts -out apnagent-dev-key.pem -in apnagent-dev.p12

The first will generate a ".pem" file for use as the cert file. The second will generate a ".pem" file for use as the key file. The second requires a password be entered to secure the key. This can be removed by executing the following command.

openssl rsa -in apnagent-dev-key.pem -out apnagent-dev-key.pem

Agent Guide

The Agent class is used to provide a consistent connection to the APN service. In some cases this documentation will refer to this Agent as the "live agent" to differentiate itself from the MockAgent which has feature parity but does not make a real connection to the APN service.

Methodology

Apple insists that a connection "always" be established with the APN service even if there will be long periods without outbound messages. The Agent will maintain this connection and reconnect when it is needed.

The MockAgent implements all of the same methods and components as the Agent but it does not connect to the APN service; It does not need to connect to any service. By using a custom Node.js Stream the MockAgent can simulate an outbound socket without needing any network resources. Keeping things simple is incredibly adventageous for development and test environment, or applications that are deployed through continous integration.

One oddity of the APN service is that if an message error occurs on the service side the connection will respond with the error referencing which message it had a problem parsing and then disconnect. Any messages that were sent after the errored message will not be parsed by APNs and will need to be resent. apn agent uses a number of components to handle this scenario and ensure all messages are sent without additional intervention from the user.

Configuration

All settings and event are documented later on a per-component basis.

Settings

apn agent uses methods to provide granular setting configuraton. Value based Settings can be modified using .set() and boolean based settings cen be modified using .enable(), or .disable(). Furthermore, methods that modify settings are chainable. For example:

agent
  .set('cert file', join(__dirname, 'certs/cert.pem'))
  .set('key file', join(__dirname, 'certs/key.pem'))
  .enable('sandbox');

Core contributors to the apnagent module should familiarize themselves with the facet module.

Events

Like most Node.js modules, apn agent's Agent and MockAgent classes implement the Node.js-style EventEmitter paradigm. Event listeners can be added with .on or .addListener, removed with .off or .removeListener and emitted using .emit.

agent.on('message:error', function (err, msg) {
  // We'll discuss error mitigation later.
});

Core contributors to the apnagent module should familiarize themselves with the drip module's EnhancedEmitter class.

Gateway Component

The gateway component represents the connection to the APN service. For the live agent it is Node.js tls socket. For the mock agent it is a writable stream. A new gateway is constructed on every connect or reconnect attempt.

Core contributors to the apnagent module should familiarize themselves with the lotus module.

Settings

key file, cert file, ca file, pfx file {String} - Set security credentials for the APN service connection by filename. Must use full path. These options are ignored by the MockAgent.

key, cert, ca, pfx {Buffer} - Set the raw security credentials for connect to the APN service. These options are ignored by the MockAgent.

passphrase {String} - For use with certificates if they are secured with a password. This option ignored by the MockAgent.

sandbox {Boolean} (default: false) Should agent connect to the APN sandbox gateway. This option is ignored by the MockAgent.

reconnect delay {Number|String} (default: 3s) - Milliseconds after a disconnect that a reconnect should be attempted. Can also be set as string using s, m, h for seconds, minutes, hours) respectively.

Events

gateway:connect - Emitted every time a gateway connection is established. Since Apple closes the connection when it cannot process a message, this event may be emitted numerous times in an applications life-cycle.

gateway:reconnect - Emitted after a connection has been re-established after Apple had forcefully closed the connection. May be emitted numerous times in an application's life-cycle.

gateway:error (err) - Emitted if there is a client-side error with gateway. This includes authorization errors or TLS socket errors. An authorization error will be an instance of GatewayAuthorizationError.

Queue Component

The queue component stores outgoing messages until the socket is ready to written. Messages are serialized before being placed in the queue to minimize APNs-side errors.

User created messages are placed at the end of the queue such that it will operate with a first-in/first-out strategy. In the event of a connection error, messages that need to be resent will be placed in the beginning of the queue to best mimic the user's intended send order.

Core contributors to the apnagent module should familiarize themselves with the breeze-queue module.

Events

queue:drain - Emitted after the queue has process all messages through the gateway. This does not guarantee that all messages sent have been processed by the APN service or received by the device.

Cache Component

The cache component maintains a limited ordered history of all outgoing messages should they need to be resent. If the APN service deems a message invalid it will not process any further message, respond with the unique ID and error code for the message that failed, then disconnect. The agent will use the cache to requeue messages that were sent after the failed message.

To control memory usage the cache employs a time-to-live (ttl) mechanism to remove items which are beyond a certain age and therefor have presumably been processed successfully by Apple. The default value for this ttl is 10 minutes but it can be modified by configuring the cache ttl setting on the agent.

Settings

cache ttl {Number|String} (default: 10m) - The minimum number of milliseconds that a message should be present in the queue before considered a success and removed. Can also be set as string using s, m, h for seconds, minutes, hours) respectively.

Sending Messages

To create a new message invoke the .createMessage method from an agent. This will return a constructed message that can be modified via chainable methods and then sent.

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

Message Expiration

It is unnecissary to cover in this section all of the chainable methods that can be used to custom your outbound messages, but one that deserves a bit of attention is handling 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.

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

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

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

Send Confirmation

As you might have noticed in the above .createMessage() examples a callback was not specified for the .send() method though the Message#send() api allows for one to be set. Since the APN service does not provide confirmation that every message has been successfully parsed managing a callback flow can be tricky. Here are rules governing when the message .send(cb) callback will be invoked.

  1. When .send() is invoked it immediately attempts the serialize the message into a JSON payload. It then validates the payload based on a series of rules to mimimize APN-side errors. Issues such as a message missing a token or being to long to send will be captured as a new MessageSerializationError. If a .send() callback exists it will be invoked with the error as the first argument. This error will also be emitted on the agent as a message:error event.

  2. The message will then be placed in the queue and wait for itself to be sent. Once the message has been processed by the queue and been successfully written to and flushed from the gateway the callback will then be invoked without any arguments.

  3. If the message cannot be parsed on the APN-side, Apple will inform the agent. The agent will construct an GatewayMessageError based on Apple's response. The agent will also attempt to reconstruct the original message if it is still present in the cache. The error and possible message will then be emitted on the agent as the message:error event. The callback WILL NOT be invoked again.

Since any error occurance will be emitted as a message:error it may not always be necissary to specify a .send() callback unless your flow needs to wait until the message has confirmed valid serialization. If possible, keep your code DRY and use the message:error event instead.

Mock Agent Enhancement

One unfortunate downside to APNs implementation is that it cannot be determined when a message has been successfully parsed by Apple, only when a message has failed. To minimize the occurance of APN-side errors each message passes through a set of validation rules prior to being transferred over the wire. As a result of this implementation the reasonable assumption is that any message that is transferred over the wire when using the MockAgent class can be assumed to be a successful message.

The only difference between the Agent and MockAgent implementations is that when data is written to the MockAgent's gateway, that data will be decoded into a JSON object that represents the original payload. This object will then be emitted on the agent as the mock:message event. Users can listen for this event during tests to confirm that their applications have successfully implemented sending mechanisms.

Settings

expires {String|Number} (default: 0) - Set this value on the agent to modify the default message expiration value. Can also be set as string using s, m, h, d for seconds, minutes, hours), days respectively.

agent.set('expires', '1d');

Events

message:error (err[, msg]) - Listen for this event on the agent for when a message cannot be sent or has errored after sending. Visit Error Mitigation for a complex example of how to work with the error emitted.

mock:message (obj) - Listen for this event on a constructed MockAgent. Will be emitted when the mock gateway stream has decoded a message that was sent over the "wire".

Full Example

The following demonstrates the intended usage of both an Agent and MockAgent within the context of an express.js based RESTful application. Since we can use an Agent and MockAgent interchangably, we can configure this application to use different agents depending on the active NODE_ENV.

/**
 * Example dependencies / constants
 */

var apnagent = require('apnagent')
  , express = require('express')
  , join = require('path').join
  , port = process.env.PORT || 8080;

/**
 * Create application
 */

var app = express()
  , server = require('http').createServer(app);

/**
 * Configure Express
 */

app.configure(function () {
  app.use(express.bodyParser());
});

/**
 * Use a MockAgent for dev/test envs
 */

app.configure('development', 'test', function () {
  var agent = new apnagent.MockAgent();

  // no configuration needed

  // mount to app
  app
    .set('apn', agent)
    .set('apn-env', 'mock');
});

/**
 * Usa a live Agent with sandbox certificates
 * for our staging environment.
 */

app.configure('staging', function () {
  var agent = new apnagent.Agent();

  // configure agent
  agent 
    .set('cert file', join(__dirname, 'certs/apn/dev-cert.pem'))
    .set('key file', join(__dirname, 'certs/apn/dev-key.pem'))
    .enable('sandbox');

  // mount to app
  app
    .set('apn', agent)
    .set('apn-env', 'live-sandbox');
});

/**
 * Use a live Agent with production certificates
 * for our production environment.
 */

app.configure('production', function () {
  var agent = new apnagent.Agent();

  // configure agent
  agent 
    .set('cert file', join(__dirname, 'certs/apn/prod-cert.pem'))
    .set('key file', join(__dirname, 'certs/apn/prod-key.pem'));

  // mount to app
  app
    .set('apn', agent)
    .set('apn-env', 'live-production');
});

/**
 * Set our environment independant configuration
 * and event listeners.
 */

app.configure(function () {
  var agent = app.get('apn')
    , env = app.get('apn-env');

  // common settings
  agent
    .set('expires', '1d')
    .set('reconnect delay', '1s')
    .set('cache ttl', '30m');

  // see error mitigation section
  agent.on('message:error', function (err, msg) {
    // ...
  });

  // connect needed to start message processing
  agent.connect(function (err) {
    if (err) throw err;
    console.log('[%s] apn agent running', env);
  });
});

/**
 * Sample endpoint
 */

app.post('/apn', function (req, res) {
  var agent = app.get('apn')
    , alert = req.body.alert
    , token = req.body.token;

  agent.createMessage()
    .device(token)
    .alert(alert)
    .send(function (err) {
      // handle apnagent custom errors
      if (err && err.toJSON) {
        res.json(400, { error: err.toJSON(false) });
      } 

      // handle anything else (not likely)
      else if (err) {
        res.json(400, { error: err.message });
      }

      // it was a success
      else {
        res.json({ success: true });
      }
    });
});

/**
 * Start server
 */

server.listen(port, function () {
  console.log('http started on port %d', server.address().port);
});

Agent API

This API section covers both the Agent and MockAgent classes. The architecture was developed to provide feature parity for not just available methods but the events emitted and processing methodology. Any significant differences have already been outlined in the Agent Guide.

.createMessage ([encoding])

Creates a message that can be further modified through chaining. Do not provide arguments unless you know what you are doing.

.send (message[, callback])

Serialize the message and catch any validation errors that might occur. If validation passes add the message to the send queue. Messages can also be sent by invoking the message instance's .send() method.

var message = agent.createMessage();

message
  .device(token)
  .alert('Hello Universe');

agent.send(message);

.connect ([ callback ])

  • @param{ Function }callback
  • @return{ this }for chaining

Open an active gateway connection. Once the connection is established the outgoing message queue will begin to process items.

.close ([ callback ])

  • @param{ Function }callback
  • @return{ this }for chaining

Close the active gateway connection or cancel further reconnect attempts. If the queue is currently processing a message it will wait for the current message to finish before closing.

Apple recommends that a connection always remains open even when there are no messages to process. Production deployments should use this sparingly.

Message Builder API

A message encapsulates all data points that will be encoded and sent through the wire to APNS. The message builder is a chainable API that provides full feature coverage of the Apple Push Notification specifications.

The preferred method of composing messages is directly from a constructed Agent or MockAgent.

var msg = agent.createMessage();

.alert (key, value)

  • @param{ String | Object }alertbody, string key or object of alert settings
  • @param{ Mixed }value(when first argument is a string)
  • @return{this} for chaining

Sets variables to be included in the alert dictionary for the aps portion of the payload. If you wish to set a singlular message, set the body key. You may also set any of the other values outlined in the APNS documentation and the codecs will optimize the payload format for delivery.

Allowed keys:
  • body
  • action-loc-key
  • loc-key
  • loc-args
  • launch-image
// just set body
msg.alert('Hello Universe');

// set multiple values
msg.alert({
    body: 'Hello Universe'
  , 'launch-image': 'notif.png'
});

// chainable
msg
  .alert('Hello Universe')
  .alert('launch-image', 'notif.png');

.set (key, value)

  • @param{ String | Object }stringkey or object of custom settings
  • @param{ Mixed }value(when first argument is string)
  • @return{this} for chaining

Set extra key values that will be incuded as part of the payload. aps is reserved by Apple and enc is reserved by apnagent.

Is a key/value you pair is provided it will be set. If an object is provided, all data points will be merged into the current payload.

// single value
msg.set('key', 'value');

// multiple values
msg.set({
    key1: 'value1'
  , key2: 'value2'
});

// or chainable
msg
  .set('key1', 'value1')
  .set('key2', 'value2');

.device (token)

  • @param{ apnagent.Device | String | Buffer }devicetoken
  • @return{this} for chaining

Set the device that this message is to be delivered to. Device can be provided as a string or buffer. If provided as a string, it will be sanitized of spaces and extra characters (such as < and >).

msg.device('a1b2c3');
msg.device('<a1b2c3>');

.expires (time)

  • @param{ Number | String }timeuntil expiration
  • @return{this} for chaining

Set the message expiration date when being used with the enhanced codec. The default value is 0 which will indicate to Apple to only attempt to deliver the message once.

Should be provided as the number of ms until expiration or as a string that can be converted, such as 1d.

// set to specific time in future
msg.expires('30m'); // 30 minutes
msg.expires('1d'); // 1 day

// reset to default value
msg.expires(0);
msg.expires(true);

.badge (number)

  • @param{ Number }badgecount
  • @return{this} for chaining

Set the badge number to be displayed.

msg.badge(4);

.sound (file)

  • @param{ String }soundfile
  • @return{this} for chaining

Set the sound file to be played when this message is delivered if the app is closed.

msg.sound('bingbong.aiff');

.send (cb)

  • @param{ Function }callback

Send the message through the connected agent. The cb function will be invoked with an error if there is a problem serializing the message for transport.

If there are no serialization errors, the callback will be invoked when the message has been flushed through the socket. This does NOT mean the message has be received by the device or that Apple has accepted the message. If Apple has a problem with the message it will be emitted on the agent's message:error event.

msg.send(function (err) {
  if (err) {
    // handle it
  }
});

Feedback API

This is the feedback api.

.use (fn)

  • @param{ Function }fnto add to stack

.connect ([ callback ])

  • @param{ Function }callback
  • @return{ this }for chaining

.close ([ callback ])

  • @param{ Function }callback
  • @return{ this }for chaining

Close the active gateway connection or cancel further reconnect attempts. If the queue is currently processing a message it will wait for the current message to finish before closing.

Apple recommends that a connection always remains open even when there are no messages to process. Production deployments should use this sparingly.

Device API

A small constructor to easily encapsulate a device token so it can be passed around and converted to whatever type is needed for given scenario.

If a Device is constructed without parameters, a token can be assigned later by setting the .token property to either a string or buffer.

The most common usage for the Device constructor is to sanitize a device token for storage in a database.

var Device = require('apnagent').Device
  , device = new Device('<a1b56d2c 08f621d8 7060da2b>');

.toBuffer ()

  • @return{ Buffer }

Convert the stored device token to a buffer.

var buf = device.toBuffer();

.toString ()

  • @return{ String }

Convert the stored device token to a string. The string will be sanitized and thus not include spaces or extra characters.

var str = device.toString();

.equal (device)

  • @param{ Mixed }instanceof Device, String, or Buffer
  • @return{ Boolean }device tokens equal

Compare the stored device token to another device, string, or buffer. Will also return false if both Devices do not have tokens associated with them.

// testing string
device.equal('a1b56d2c08f621d87060da2b').should.be.true;

// testing another device
var dev2 = new Device('feedface');
device.equal(dev2).should.be.false;

Resources

Dependencies

The following dependencies play a significant role in the apn agent architecture. This is not a complete list but a recommended research list for those interested in contributing to apn agent.

breeze-queue

drip

facet

lotus

tea-error

Testing

Tests are writting in Mocha using the Chai should BDD assertion library. Clone this repo and install development dependencies using npm install.

Normal Testing

The normal testing life-cyle will test all features that do not require a live connection to the APN service. All tests that require a live connection will have a status of "pending". These are the tests that are run by travis-ci.

make test

Live Testing

In order to perform the live tests you will need to provide your key, cert, and device token. These can be provided within the test folder. Here is the expected folder structure and file names:

tests
├── bootstrap
│   └── ...
├── certs
│   ├── apnagent-cert.pem
│   ├── apnagent-key-noenc.pem
│   └── device.txt
├── common
│   └── ...
├── *.js

The key/cert pair should be for a sandbox based application. Furthermore, you should have the application installed on you device. If you do not have one then use apnagent-ios.

make test-live

License

(The MIT License)