From 120f23c7910859694e06ea2d7082b9c4b6ba0193 Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Mon, 14 Mar 2016 08:15:38 -0400 Subject: [PATCH] reverts to use binary APNs --- package.json | 2 +- spec/APNS.spec.js | 261 +++++++++++++++++++++++++----- spec/ParsePushAdapter.spec.js | 4 +- src/APNS.js | 292 +++++++++++++++++++--------------- 4 files changed, 383 insertions(+), 176 deletions(-) diff --git a/package.json b/package.json index 019ab031..703fb401 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ ], "license": "BSD-3-Clause", "dependencies": { + "apn": "^1.7.5", "aws-sdk": "~2.2.33", "babel-polyfill": "^6.5.0", "babel-runtime": "^6.5.0", @@ -28,7 +29,6 @@ "deepcopy": "^0.6.1", "express": "^4.13.4", "gcloud": "^0.28.0", - "http2": "flovilmart/node-http2", "mailgun-js": "^0.7.7", "mime": "^1.3.4", "mongodb": "~2.1.0", diff --git a/spec/APNS.spec.js b/spec/APNS.spec.js index 459071ed..c56e35d5 100644 --- a/spec/APNS.spec.js +++ b/spec/APNS.spec.js @@ -1,4 +1,3 @@ -'use strict'; var APNS = require('../src/APNS'); describe('APNS', () => { @@ -10,13 +9,17 @@ describe('APNS', () => { production: true, bundleId: 'bundleId' } - var apns = APNS(args); + var apns = new APNS(args); - var apnsConfiguration = apns.getConfiguration(); - expect(apnsConfiguration.bundleId).toBe(args.bundleId); - expect(apnsConfiguration.cert).toBe(args.cert); - expect(apnsConfiguration.key).toBe(args.key); - expect(apnsConfiguration.production).toBe(args.production); + expect(apns.conns.length).toBe(1); + var apnsConnection = apns.conns[0]; + expect(apnsConnection.index).toBe(0); + expect(apnsConnection.bundleId).toBe(args.bundleId); + // TODO: Remove this checking onec we inject APNS + var prodApnsOptions = apnsConnection.options; + expect(prodApnsOptions.cert).toBe(args.cert); + expect(prodApnsOptions.key).toBe(args.key); + expect(prodApnsOptions.production).toBe(args.production); done(); }); @@ -36,18 +39,24 @@ describe('APNS', () => { } ] - var apns = APNS(args); - var devApnsConfiguration = apns.getConfiguration('bundleId'); - expect(devApnsConfiguration.cert).toBe(args[0].cert); - expect(devApnsConfiguration.key).toBe(args[0].key); - expect(devApnsConfiguration.production).toBe(args[0].production); - expect(devApnsConfiguration.bundleId).toBe(args[0].bundleId); + var apns = new APNS(args); + expect(apns.conns.length).toBe(2); + var devApnsConnection = apns.conns[1]; + expect(devApnsConnection.index).toBe(1); + var devApnsOptions = devApnsConnection.options; + expect(devApnsOptions.cert).toBe(args[0].cert); + expect(devApnsOptions.key).toBe(args[0].key); + expect(devApnsOptions.production).toBe(args[0].production); + expect(devApnsConnection.bundleId).toBe(args[0].bundleId); - var prodApnsConfiguration = apns.getConfiguration('bundleIdAgain'); - expect(prodApnsConfiguration.cert).toBe(args[1].cert); - expect(prodApnsConfiguration.key).toBe(args[1].key); - expect(prodApnsConfiguration.production).toBe(args[1].production); - expect(prodApnsConfiguration.bundleId).toBe(args[1].bundleId); + var prodApnsConnection = apns.conns[0]; + expect(prodApnsConnection.index).toBe(0); + // TODO: Remove this checking onec we inject APNS + var prodApnsOptions = prodApnsConnection.options; + expect(prodApnsOptions.cert).toBe(args[1].cert); + expect(prodApnsOptions.key).toBe(args[1].key); + expect(prodApnsOptions.production).toBe(args[1].production); + expect(prodApnsOptions.bundleId).toBe(args[1].bundleId); done(); }); @@ -64,14 +73,56 @@ describe('APNS', () => { }; var expirationTime = 1454571491354 - var notification = APNS.generateNotification(data); - expect(notification.aps.alert).toEqual(data.alert); - expect(notification.aps.badge).toEqual(data.badge); - expect(notification.aps.sound).toEqual(data.sound); - expect(notification.aps['content-available']).toEqual(1); - expect(notification.aps.category).toEqual(data.category); - expect(notification.key).toEqual('value'); - expect(notification.keyAgain).toEqual('valueAgain'); + var notification = APNS.generateNotification(data, expirationTime); + + expect(notification.alert).toEqual(data.alert); + expect(notification.badge).toEqual(data.badge); + expect(notification.sound).toEqual(data.sound); + expect(notification.contentAvailable).toEqual(1); + expect(notification.category).toEqual(data.category); + expect(notification.payload).toEqual({ + 'key': 'value', + 'keyAgain': 'valueAgain' + }); + expect(notification.expiry).toEqual(expirationTime); + done(); + }); + + it('can choose conns for device without appIdentifier', (done) => { + // Mock conns + var conns = [ + { + bundleId: 'bundleId' + }, + { + bundleId: 'bundleIdAgain' + } + ]; + // Mock device + var device = {}; + + var qualifiedConns = APNS.chooseConns(conns, device); + expect(qualifiedConns).toEqual([0, 1]); + done(); + }); + + it('can choose conns for device with valid appIdentifier', (done) => { + // Mock conns + var conns = [ + { + bundleId: 'bundleId' + }, + { + bundleId: 'bundleIdAgain' + } + ]; + // Mock device + var device = { + appIdentifier: 'bundleId' + }; + + var qualifiedConns = APNS.chooseConns(conns, device); + expect(qualifiedConns).toEqual([0]); done(); }); @@ -79,7 +130,7 @@ describe('APNS', () => { // Mock conns var conns = [ { - bundleId: 'bundleId', + bundleId: 'bundleId' }, { bundleId: 'bundleIdAgain' @@ -89,18 +140,143 @@ describe('APNS', () => { var device = { appIdentifier: 'invalid' }; - let apns = APNS(conns); - var config = apns.getConfiguration(device.appIdentifier); - expect(config).toBeUndefined(); + + var qualifiedConns = APNS.chooseConns(conns, device); + expect(qualifiedConns).toEqual([]); + done(); + }); + + it('can handle transmission error when notification is not in cache or device is missing', (done) => { + // Mock conns + var conns = []; + var errorCode = 1; + var notification = undefined; + var device = {}; + + APNS.handleTransmissionError(conns, errorCode, notification, device); + + var notification = {}; + var device = undefined; + + APNS.handleTransmissionError(conns, errorCode, notification, device); + done(); + }); + + it('can handle transmission error when there are other qualified conns', (done) => { + // Mock conns + var conns = [ + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId1' + }, + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId1' + }, + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId2' + }, + ]; + var errorCode = 1; + var notification = {}; + var apnDevice = { + connIndex: 0, + appIdentifier: 'bundleId1' + }; + + APNS.handleTransmissionError(conns, errorCode, notification, apnDevice); + + expect(conns[0].pushNotification).not.toHaveBeenCalled(); + expect(conns[1].pushNotification).toHaveBeenCalled(); + expect(conns[2].pushNotification).not.toHaveBeenCalled(); + done(); + }); + + it('can handle transmission error when there is no other qualified conns', (done) => { + // Mock conns + var conns = [ + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId1' + }, + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId1' + }, + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId1' + }, + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId2' + }, + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId1' + } + ]; + var errorCode = 1; + var notification = {}; + var apnDevice = { + connIndex: 2, + appIdentifier: 'bundleId1' + }; + + APNS.handleTransmissionError(conns, errorCode, notification, apnDevice); + + expect(conns[0].pushNotification).not.toHaveBeenCalled(); + expect(conns[1].pushNotification).not.toHaveBeenCalled(); + expect(conns[2].pushNotification).not.toHaveBeenCalled(); + expect(conns[3].pushNotification).not.toHaveBeenCalled(); + expect(conns[4].pushNotification).toHaveBeenCalled(); + done(); + }); + + it('can handle transmission error when device has no appIdentifier', (done) => { + // Mock conns + var conns = [ + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId1' + }, + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId2' + }, + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId3' + }, + ]; + var errorCode = 1; + var notification = {}; + var apnDevice = { + connIndex: 1, + }; + + APNS.handleTransmissionError(conns, errorCode, notification, apnDevice); + + expect(conns[0].pushNotification).not.toHaveBeenCalled(); + expect(conns[1].pushNotification).not.toHaveBeenCalled(); + expect(conns[2].pushNotification).toHaveBeenCalled(); done(); }); it('can send APNS notification', (done) => { var args = { + cert: 'prodCert.pem', + key: 'prodKey.pem', production: true, bundleId: 'bundleId' } - var apns = APNS(args); + var apns = new APNS(args); + var conn = { + pushNotification: jasmine.createSpy('send'), + bundleId: 'bundleId' + }; + apns.conns = [ conn ]; // Mock data var expirationTime = 1454571491354 var data = { @@ -117,18 +293,15 @@ describe('APNS', () => { } ]; - apns.send(data, devices).then((results) => { - let isArray = Array.isArray(results); - expect(isArray).toBe(true); - expect(results.length).toBe(1); - // No provided certificates - expect(results[0].status).toBe(403); - expect(results[0].device).toEqual(devices[0]); - expect(results[0].transmitted).toBe(false); - done(); - }, (err) => { - fail('should not fail'); - done(); - }); + var promise = apns.send(data, devices); + expect(conn.pushNotification).toHaveBeenCalled(); + var args = conn.pushNotification.calls.first().args; + var notification = args[0]; + expect(notification.alert).toEqual(data.data.alert); + expect(notification.expiry).toEqual(data['expiration_time']); + var apnDevice = args[1] + expect(apnDevice.connIndex).toEqual(0); + expect(apnDevice.appIdentifier).toEqual('bundleId'); + done(); }); }); diff --git a/spec/ParsePushAdapter.spec.js b/spec/ParsePushAdapter.spec.js index cb08845c..e21a9dbb 100644 --- a/spec/ParsePushAdapter.spec.js +++ b/spec/ParsePushAdapter.spec.js @@ -29,9 +29,7 @@ describe('ParsePushAdapter', () => { var parsePushAdapter = new ParsePushAdapter(pushConfig); // Check ios var iosSender = parsePushAdapter.senderMap['ios']; - expect(iosSender).not.toBe(undefined); - expect(typeof iosSender.send).toEqual('function'); - expect(typeof iosSender.getConfiguration).toEqual('function'); + expect(iosSender instanceof APNS).toBe(true); // Check android var androidSender = parsePushAdapter.senderMap['android']; expect(androidSender instanceof GCM).toBe(true); diff --git a/src/APNS.js b/src/APNS.js index f95a6929..69389ce8 100644 --- a/src/APNS.js +++ b/src/APNS.js @@ -1,30 +1,9 @@ "use strict"; const Parse = require('parse/node').Parse; -const http = require('http2'); -const fs = require('fs'); -const path = require('path'); -const urlParse = require('url').parse; - -const DEV_PUSH_SERVER = 'api.development.push.apple.com'; -const PROD_PUSH_SERVER = 'api.push.apple.com'; - -const createRequestOptions = (opts, device, body) => { - let domain = opts.production === true ? PROD_PUSH_SERVER : DEV_PUSH_SERVER; - var options = urlParse(`https://${domain}/3/device/${device.deviceToken}`); - options.method = 'POST'; - options.headers = { - 'apns-expiration': opts.expiration || 0, - 'apns-priority': opts.priority || 10, - 'apns-topic': opts.bundleId || opts['apns-topic'], - 'content-length': body.length - }; - options.key = opts.key; - options.cert = opts.cert; - options.pfx = opts.pfx; - options.passphrase = opts.passphrase; - return Object.assign({}, options); -} +// TODO: apn does not support the new HTTP/2 protocal. It is fine to use it in V1, +// but probably we will replace it in the future. +const apn = require('apn'); /** * Create a new connection to the APN service. @@ -37,115 +16,170 @@ const createRequestOptions = (opts, device, body) => { * @param {String} args.bundleId The bundleId for cert * @param {Boolean} args.production Specifies which environment to connect to: Production (if true) or Sandbox */ -function APNS(options) { - - if (!Array.isArray(options)) { - options = [options]; +function APNS(args) { + // Since for ios, there maybe multiple cert/key pairs, + // typePushConfig can be an array. + let apnsArgsList = []; + if (Array.isArray(args)) { + apnsArgsList = apnsArgsList.concat(args); + } else if (typeof args === 'object') { + apnsArgsList.push(args); + } else { + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, + 'APNS Configuration is invalid'); } - let agents = {}; - - let optionsByBundle = options.reduce((memo, option) => { - try { - if (option.key && option.cert) { - option.key = fs.readFileSync(option.key); - option.cert = fs.readFileSync(option.cert); - } else if (option.pfx) { - option.pfx = fs.readFileSync(option.pfx); - } else { - throw 'Either cert AND key, OR pfx is required' - } - } catch(e) { - if (!process.env.NODE_ENV == 'test' || options.enforceCertificates) { - throw e; - } + this.conns = []; + for (let apnsArgs of apnsArgsList) { + let conn = new apn.Connection(apnsArgs); + if (!apnsArgs.bundleId) { + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, + 'BundleId is mssing for %j', apnsArgs); } - option.agent = new http.Agent({ - key: option.key, - cert: option.cert, - pfx: option.pfx, - passphrase: option.passphrase + conn.bundleId = apnsArgs.bundleId; + // Set the priority of the conns, prod cert has higher priority + if (apnsArgs.production) { + conn.priority = 0; + } else { + conn.priority = 1; + } + + // Set apns client callbacks + conn.on('connected', () => { + console.log('APNS Connection %d Connected', conn.index); }); - memo[option.bundleId] = option; - return memo; - }, {}); - let getConfiguration = (bundleIdentifier) => { - let configuration; - if (bundleIdentifier) { - configuration = optionsByBundle[bundleIdentifier]; - if (!configuration) { - return; - } - } - if (!configuration) { - configuration = options[0]; - } - return configuration; - } + conn.on('transmissionError', (errCode, notification, apnDevice) => { + handleTransmissionError(this.conns, errCode, notification, apnDevice); + }); - /** - * Send apns request. - * @param {Object} data The data we need to send, the format is the same with api request body - * @param {Array} devices A array of device tokens - * @returns {Object} A promises that resolves with each notificaiton sending promise - */ - let send = function(data, devices) { - // Make sure devices are in an array - if (!Array.isArray(devices)) { - devices = [devices]; - } + conn.on('timeout', () => { + console.log('APNS Connection %d Timeout', conn.index); + }); - let coreData = data.data; - let expirationTime = data['expiration_time']; - let notification = generateNotification(coreData); - let notificationString = JSON.stringify(notification); - let buffer = new Buffer(notificationString); + conn.on('disconnected', () => { + console.log('APNS Connection %d Disconnected', conn.index); + }); - let promises = devices.map((device) => { - return new Promise((resolve, reject) => { - let configuration = getConfiguration(device.appIdentifier); - if (!configuration) { - return Promise.reject({ - status: -1, - device: device, - response: {"error": "No configuration set for that appIdentifier"}, - transmitted: false - }) - } - configuration = Object.assign({}, configuration, {expiration: expirationTime }) - let requestOptions = createRequestOptions(configuration, device, buffer); - let req = configuration.agent.request(requestOptions, (response) => { - response.setEncoding('utf8'); - var chunks = ""; - response.on('data', (chunk) => { - chunks+=chunk; - }); - response.on('end', () => { - let body; - try{ - body = JSON.parse(chunks); - } catch (e) { - body = {}; - } - resolve({ status: response.statusCode, - response: body, - headers: response.headers, - device: device, - transmitted: response.statusCode == 200 }); - }); + conn.on('socketError', () => { + console.log('APNS Connection %d Socket Error', conn.index); + }); + + conn.on('transmitted', function(notification, device) { + if (device.callback) { + device.callback({ + notification: notification, + transmitted: true, + device: device }); - req.write(buffer); - req.end(); - }); + } + console.log('APNS Connection %d Notification transmitted to %s', conn.index, device.token.toString('hex')); }); - return Promise.all(promises); + + this.conns.push(conn); + } + // Sort the conn based on priority ascending, high pri first + this.conns.sort((s1, s2) => { + return s1.priority - s2.priority; + }); + // Set index of conns + for (let index = 0; index < this.conns.length; index++) { + this.conns[index].index = index; + } +} + +/** + * Send apns request. + * @param {Object} data The data we need to send, the format is the same with api request body + * @param {Array} devices A array of devices + * @returns {Object} A promise which is resolved immediately + */ +APNS.prototype.send = function(data, devices) { + let coreData = data.data; + let expirationTime = data['expiration_time']; + let notification = generateNotification(coreData, expirationTime); + + let promises = devices.map((device) => { + let qualifiedConnIndexs = chooseConns(this.conns, device); + // We can not find a valid conn, just ignore this device + if (qualifiedConnIndexs.length == 0) { + return Promise.resolve({ + transmitted: false, + result: {error: 'No connection available'} + }); + } + let conn = this.conns[qualifiedConnIndexs[0]]; + let apnDevice = new apn.Device(device.deviceToken); + apnDevice.connIndex = qualifiedConnIndexs[0]; + // Add additional appIdentifier info to apn device instance + if (device.appIdentifier) { + apnDevice.appIdentifier = device.appIdentifier; + } + return new Promise((resolve, reject) => { + apnDevice.callback = resolve; + conn.pushNotification(notification, apnDevice); + }); + }); + return Parse.Promise.when(promises); +} + +function handleTransmissionError(conns, errCode, notification, apnDevice) { + // This means the error notification is not in the cache anymore or the recepient is missing, + // we just ignore this case + if (!notification || !apnDevice) { + return } - return Object.freeze({ - send: send, - getConfiguration: getConfiguration - }) + // If currentConn can not send the push notification, we try to use the next available conn. + // Since conns is sorted by priority, the next conn means the next low pri conn. + // If there is no conn available, we give up on sending the notification to that device. + let qualifiedConnIndexs = chooseConns(conns, apnDevice); + let currentConnIndex = apnDevice.connIndex; + + let newConnIndex = -1; + // Find the next element of currentConnIndex in qualifiedConnIndexs + for (let index = 0; index < qualifiedConnIndexs.length - 1; index++) { + if (qualifiedConnIndexs[index] === currentConnIndex) { + newConnIndex = qualifiedConnIndexs[index + 1]; + break; + } + } + // There is no more available conns, we give up in this case + if (newConnIndex < 0 || newConnIndex >= conns.length) { + if (apnDevice.callback) { + apnDevice.callback({ + response: {error: `APNS can not find vaild connection for ${apnDevice.token}`, code: errCode}, + status: errCode, + transmitted: false + }); + } + return; + } + + let newConn = conns[newConnIndex]; + // Update device conn info + apnDevice.connIndex = newConnIndex; + // Use the new conn to send the notification + newConn.pushNotification(notification, apnDevice); +} + +function chooseConns(conns, device) { + // If device does not have appIdentifier, all conns maybe proper connections. + // Otherwise we try to match the appIdentifier with bundleId + let qualifiedConns = []; + for (let index = 0; index < conns.length; index++) { + let conn = conns[index]; + // If the device we need to send to does not have + // appIdentifier, any conn could be a qualified connection + if (!device.appIdentifier || device.appIdentifier === '') { + qualifiedConns.push(index); + continue; + } + if (device.appIdentifier === conn.bundleId) { + qualifiedConns.push(index); + } + } + return qualifiedConns; } /** @@ -154,12 +188,12 @@ function APNS(options) { * @returns {Object} A apns notification */ function generateNotification(coreData, expirationTime) { + let notification = new apn.notification(); let payload = {}; - let notification = {}; for (let key in coreData) { switch (key) { case 'alert': - notification.alert = coreData.alert; + notification.setAlertText(coreData.alert); break; case 'badge': notification.badge = coreData.badge; @@ -168,10 +202,9 @@ function generateNotification(coreData, expirationTime) { notification.sound = coreData.sound; break; case 'content-available': + notification.setNewsstandAvailable(true); let isAvailable = coreData['content-available'] === 1; - if (isAvailable) { - notification['content-available'] = 1; - } + notification.setContentAvailable(isAvailable); break; case 'category': notification.category = coreData.category; @@ -181,11 +214,14 @@ function generateNotification(coreData, expirationTime) { break; } } - payload.aps = notification; - return payload; + notification.payload = payload; + notification.expiry = expirationTime; + return notification; } if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') { APNS.generateNotification = generateNotification; + APNS.chooseConns = chooseConns; + APNS.handleTransmissionError = handleTransmissionError; } module.exports = APNS;