diff --git a/package.json b/package.json index c386e69..9fe9699 100644 --- a/package.json +++ b/package.json @@ -32,10 +32,10 @@ "jasmine": "^2.4.1" }, "dependencies": { - "apn": "^1.7.8", + "apn": "^2.1.3", "node-gcm": "^0.14.0", - "npmlog": "^2.0.3", - "parse": "^1.8.1" + "npmlog": "^4.0.2", + "parse": "^1.9.2" }, "engines": { "node": ">= 4.6.0" diff --git a/spec/APNS.spec.js b/spec/APNS.spec.js index 69e61f9..be89356 100644 --- a/spec/APNS.spec.js +++ b/spec/APNS.spec.js @@ -1,22 +1,22 @@ -var APNS = require('../src/APNS'); +const APNS = require('../src/APNS'); describe('APNS', () => { - it('can initialize with single cert', (done) => { - var args = { - cert: 'prodCert.pem', - key: 'prodKey.pem', + it('can initialize with cert', (done) => { + let args = { + cert: '-----BEGIN CERTIFICATE-----fPEYJtQrEMXLC9JtFUJ6emXAWv2QdKu93QE+6o5htM+Eu/2oNFIEj2A71WUBu7kA-----END CERTIFICATE-----', + key: new Buffer('testKey'), production: true, - bundleId: 'bundleId' - } - var apns = new APNS(args); + topic: 'topic' + }; + let apns = new APNS(args); - expect(apns.conns.length).toBe(1); - var apnsConnection = apns.conns[0]; - expect(apnsConnection.index).toBe(0); - expect(apnsConnection.bundleId).toBe(args.bundleId); + expect(apns.providers.length).toBe(1); + let apnsProvider = apns.providers[0]; + expect(apnsProvider.index).toBe(0); + expect(apnsProvider.topic).toBe(args.topic); // TODO: Remove this checking onec we inject APNS - var prodApnsOptions = apnsConnection.options; + let prodApnsOptions = apnsProvider.client.config; expect(prodApnsOptions.cert).toBe(args.cert); expect(prodApnsOptions.key).toBe(args.key); expect(prodApnsOptions.production).toBe(args.production); @@ -24,45 +24,48 @@ describe('APNS', () => { }); it('can initialize with multiple certs', (done) => { - var args = [ + let args = [ { - cert: 'devCert.pem', - key: 'devKey.pem', + cert: '-----BEGIN CERTIFICATE-----fPEYJtQrEMXLC9JtFUJ6emXAWv2QdKu93QE+6o5htM+Eu/2oNFIEj2A71WUBu7kA-----END CERTIFICATE-----', + key: new Buffer('testKey'), production: false, - bundleId: 'bundleId' + topic: 'topic' }, { - cert: 'prodCert.pem', - key: 'prodKey.pem', + cert: '-----BEGIN CERTIFICATE-----fPEYJtQrEMXLC9JtFUJ6emXAWv2QdKu93QE+6o5htM+Eu/2oNFIEj2A71WUBu7kA-----END CERTIFICATE-----', + key: new Buffer('testKey'), production: true, - bundleId: 'bundleIdAgain' + topic: 'topicAgain' } - ] + ]; + + let apns = new APNS(args); - 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(apns.providers.length).toBe(2); + let devApnsProvider = apns.providers[1]; + expect(devApnsProvider.index).toBe(1); + expect(devApnsProvider.topic).toBe(args[0].topic); + + let devApnsOptions = devApnsProvider.client.config; 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 prodApnsConnection = apns.conns[0]; - expect(prodApnsConnection.index).toBe(0); + let prodApnsProvider = apns.providers[0]; + expect(prodApnsProvider.index).toBe(0); + expect(prodApnsProvider.topic).toBe(args[1].topic); + // TODO: Remove this checking onec we inject APNS - var prodApnsOptions = prodApnsConnection.options; + let prodApnsOptions = prodApnsProvider.client.config; 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(); }); it('can generate APNS notification', (done) => { //Mock request data - var data = { + let data = { 'alert': 'alert', 'badge': 100, 'sound': 'test', @@ -72,335 +75,191 @@ describe('APNS', () => { 'key': 'value', 'keyAgain': 'valueAgain' }; - var expirationTime = 1454571491354 + let expirationTime = 1454571491354; - var notification = APNS.generateNotification(data, expirationTime); + let 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.mutableContent).toEqual(1); - expect(notification.category).toEqual(data.category); + 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['mutable-content']).toEqual(1); + expect(notification.aps.category).toEqual(data.category); expect(notification.payload).toEqual({ 'key': 'value', 'keyAgain': 'valueAgain' }); - expect(notification.expiry).toEqual(expirationTime/1000); + expect(notification.expiry).toEqual(expirationTime / 1000); done(); }); - it('can choose conns for device without appIdentifier', (done) => { - // Mock conns - var conns = [ + it('can choose providers for device with valid appIdentifier', (done) => { + let appIdentifier = 'topic'; + // Mock providers + let providers = [ { - bundleId: 'bundleId' + topic: appIdentifier }, { - bundleId: 'bundleIdAgain' + topic: 'topicAgain' } ]; - // 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(); - }); - - it('can choose conns for device with invalid appIdentifier', (done) => { - // Mock conns - var conns = [ - { - bundleId: 'bundleId' - }, - { - bundleId: 'bundleIdAgain' - } - ]; - // Mock device - var device = { - appIdentifier: 'invalid' - }; - - 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(); + let qualifiedProviders = APNS.prototype._chooseProviders.call({providers: providers}, appIdentifier); + expect(qualifiedProviders).toEqual([{ + topic: 'topic' + }]); done(); }); - it('can handle transmission error when there is no other qualified conns', (done) => { - // Mock conns - var conns = [ + it('can choose providers for device with invalid appIdentifier', (done) => { + let appIdentifier = 'invalid'; + // Mock providers + let providers = [ { - pushNotification: jasmine.createSpy('pushNotification'), - bundleId: 'bundleId1' + topic: 'bundleId' }, { - pushNotification: jasmine.createSpy('pushNotification'), - bundleId: 'bundleId1' - }, - { - pushNotification: jasmine.createSpy('pushNotification'), - bundleId: 'bundleId1' - }, - { - pushNotification: jasmine.createSpy('pushNotification'), - bundleId: 'bundleId2' - }, - { - pushNotification: jasmine.createSpy('pushNotification'), - bundleId: 'bundleId1' + topic: 'bundleIdAgain' } ]; - 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(); + let qualifiedProviders = APNS.prototype._chooseProviders.call({providers: providers}, appIdentifier); + expect(qualifiedProviders).toEqual([]); done(); }); it('can send APNS notification', (done) => { - var args = { - cert: 'prodCert.pem', - key: 'prodKey.pem', + let args = { + cert: new Buffer('testCert'), + key: new Buffer('testKey'), production: true, - bundleId: 'bundleId' - } - var apns = new APNS(args); - var conn = { - pushNotification: jasmine.createSpy('send'), - bundleId: 'bundleId' + topic: 'topic' }; - apns.conns = [ conn ]; + let apns = new APNS(args); + let provider = apns.providers[0]; + spyOn(provider, 'send').and.callFake((notification, devices) => { + return Promise.resolve({ + sent: devices, + failed: [] + }) + }); // Mock data - var expirationTime = 1454571491354 - var data = { + let expirationTime = 1454571491354; + let data = { 'expiration_time': expirationTime, 'data': { 'alert': 'alert' } - } + }; // Mock devices - var devices = [ + let mockedDevices = [ { deviceToken: '112233', - appIdentifier: 'bundleId' + appIdentifier: 'topic' }, { deviceToken: '112234', - appIdentifier: 'bundleId' + appIdentifier: 'topic' }, { deviceToken: '112235', - appIdentifier: 'bundleId' + appIdentifier: 'topic' }, { deviceToken: '112236', - appIdentifier: 'bundleId' + appIdentifier: 'topic' } ]; - - 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']/1000); - var apnDevices = args[1]; - apnDevices.forEach((apnDevice) => { - expect(apnDevice.connIndex).toEqual(0); - expect(apnDevice.appIdentifier).toEqual('bundleId'); - }) + let promise = apns.send(data, mockedDevices); + expect(provider.send).toHaveBeenCalled(); + let calledArgs = provider.send.calls.first().args; + let notification = calledArgs[0]; + expect(notification.aps.alert).toEqual(data.data.alert); + expect(notification.expiry).toEqual(data['expiration_time'] / 1000); + let apnDevices = calledArgs[1]; + expect(apnDevices.length).toEqual(4); done(); }); it('can send APNS notification to multiple bundles', (done) => { - var args = [{ - cert: 'prodCert.pem', - key: 'prodKey.pem', + let args = [{ + cert: new Buffer('testCert'), + key: new Buffer('testKey'), production: true, - bundleId: 'bundleId' - },{ - cert: 'devCert.pem', - key: 'devKey.pem', + topic: 'topic' + }, { + cert: new Buffer('testCert'), + key: new Buffer('testKey'), production: false, - bundleId: 'bundleId.dev' + topic: 'topic.dev' }]; - var apns = new APNS(args); - var conn = { - pushNotification: jasmine.createSpy('send'), - bundleId: 'bundleId' - }; - var conndev = { - pushNotification: jasmine.createSpy('send'), - bundleId: 'bundleId.dev' - }; - apns.conns = [ conn, conndev ]; + let apns = new APNS(args); + let provider = apns.providers[0]; + spyOn(provider, 'send').and.callFake((notification, devices) => { + return Promise.resolve({ + sent: devices, + failed: [] + }) + }); + let providerDev = apns.providers[1]; + spyOn(providerDev, 'send').and.callFake((notification, devices) => { + return Promise.resolve({ + sent: devices, + failed: [] + }) + }); + apns.providers = [provider, providerDev]; // Mock data - var expirationTime = 1454571491354 - var data = { + let expirationTime = 1454571491354; + let data = { 'expiration_time': expirationTime, 'data': { 'alert': 'alert' } - } + }; // Mock devices - var devices = [ + let mockedDevices = [ { deviceToken: '112233', - appIdentifier: 'bundleId' + appIdentifier: 'topic' }, { deviceToken: '112234', - appIdentifier: 'bundleId' + appIdentifier: 'topic' }, { deviceToken: '112235', - appIdentifier: 'bundleId' + appIdentifier: 'topic' }, { deviceToken: '112235', - appIdentifier: 'bundleId.dev' + appIdentifier: 'topic.dev' }, { deviceToken: '112236', - appIdentifier: 'bundleId.dev' + appIdentifier: 'topic.dev' } ]; - var promise = apns.send(data, devices); + let promise = apns.send(data, mockedDevices); - 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']/1000); - var apnDevices = args[1]; + expect(provider.send).toHaveBeenCalled(); + let calledArgs = provider.send.calls.first().args; + let notification = calledArgs[0]; + expect(notification.aps.alert).toEqual(data.data.alert); + expect(notification.expiry).toEqual(data['expiration_time'] / 1000); + let apnDevices = calledArgs[1]; expect(apnDevices.length).toBe(3); - apnDevices.forEach((apnDevice) => { - expect(apnDevice.connIndex).toEqual(0); - expect(apnDevice.appIdentifier).toEqual('bundleId'); - }) - expect(conndev.pushNotification).toHaveBeenCalled(); - args = conndev.pushNotification.calls.first().args; - notification = args[0]; - expect(notification.alert).toEqual(data.data.alert); - expect(notification.expiry).toEqual(data['expiration_time']/1000); - apnDevices = args[1]; + expect(providerDev.send).toHaveBeenCalled(); + calledArgs = providerDev.send.calls.first().args; + notification = calledArgs[0]; + expect(notification.aps.alert).toEqual(data.data.alert); + expect(notification.expiry).toEqual(data['expiration_time'] / 1000); + apnDevices = calledArgs[1]; expect(apnDevices.length).toBe(2); - apnDevices.forEach((apnDevice) => { - expect(apnDevice.connIndex).toEqual(1); - expect(apnDevice.appIdentifier).toEqual('bundleId.dev'); - }); done(); }); }); diff --git a/spec/ParsePushAdapter.spec.js b/spec/ParsePushAdapter.spec.js index 69712ef..36a395b 100644 --- a/spec/ParsePushAdapter.spec.js +++ b/spec/ParsePushAdapter.spec.js @@ -12,16 +12,16 @@ describe('ParsePushAdapter', () => { }, ios: [ { - cert: 'prodCert.pem', - key: 'prodKey.pem', + cert: new Buffer('testCert'), + key: new Buffer('testKey'), production: true, - bundleId: 'bundleId' + topic: 'topic' }, { - cert: 'devCert.pem', - key: 'devKey.pem', + cert: new Buffer('testCert'), + key: new Buffer('testKey'), production: false, - bundleId: 'bundleIdAgain' + topic: 'topicAgain' } ] }; @@ -45,7 +45,7 @@ describe('ParsePushAdapter', () => { } }; - expect(function() { + expect(function () { new ParsePushAdapter(pushConfig); }).toThrow(); done(); @@ -222,7 +222,7 @@ describe('ParsePushAdapter', () => { done(); }); - it('reports properly results', (done) => { + it('reports properly results', (done) => { var pushConfig = { android: { senderId: 'senderId', @@ -230,10 +230,10 @@ describe('ParsePushAdapter', () => { }, ios: [ { - cert: 'cert.cer', - key: 'key.pem', + cert: new Buffer('testCert'), + key: new Buffer('testKey'), production: false, - bundleId: 'bundleId' + topic: 'topic' } ] }; @@ -263,19 +263,19 @@ describe('ParsePushAdapter', () => { ]; var parsePushAdapter = new ParsePushAdapter(pushConfig); - parsePushAdapter.send({data: {alert: 'some'}}, installations).then((results) => { + parsePushAdapter.send({ data: { alert: 'some' } }, installations).then((results) => { expect(Array.isArray(results)).toBe(true); // 2x iOS, 1x android expect(results.length).toBe(3); - results.forEach((result) => { + results.forEach((result) => { expect(result.transmitted).toBe(false); expect(typeof result.device).toBe('object'); expect(typeof result.device.deviceType).toBe('string'); expect(typeof result.device.deviceToken).toBe('string'); }) done(); - }).catch((err) => { + }).catch((err) => { fail('Should not fail'); done(); }) diff --git a/src/APNS.js b/src/APNS.js index fee8b23..5cb40ae 100644 --- a/src/APNS.js +++ b/src/APNS.js @@ -1,263 +1,252 @@ -"use strict"; +'use strict'; -// 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. import apn from 'apn'; import Parse from 'parse'; import log from 'npmlog'; const LOG_PREFIX = 'parse-server-push-adapter APNS'; -/** - * Create a new connection to the APN service. - * @constructor - * @param {Object|Array} args An argument or a list of arguments to config APNS connection - * @param {String} args.cert The filename of the connection certificate to load from disk - * @param {String} args.key The filename of the connection key to load from disk - * @param {String} args.pfx The filename for private key, certificate and CA certs in PFX or PKCS12 format, it will overwrite cert and key - * @param {String} args.passphrase The passphrase for the connection key, if required - * @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(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'); - } - - 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); - } - conn.bundleId = apnsArgs.bundleId; - // Set the priority of the conns, prod cert has higher priority - if (apnsArgs.production) { - conn.priority = 0; +export class APNS { + + /** + * Create a new provider for the APN service. + * @constructor + * @param {Object|Array} args An argument or a list of arguments to config APNS provider + * @param {Object} args.token {Object} Configuration for Provider Authentication Tokens. (Defaults to: null i.e. fallback to Certificates) + * @param {Buffer|String} args.token.key The filename of the provider token key (as supplied by Apple) to load from disk, or a Buffer/String containing the key data. + * @param {String} args.token.keyId The ID of the key issued by Apple + * @param {String} args.token.teamId ID of the team associated with the provider token key + * @param {Buffer|String} args.cert The filename of the connection certificate to load from disk, or a Buffer/String containing the certificate data. + * @param {Buffer|String} args.key {Buffer|String} The filename of the connection key to load from disk, or a Buffer/String containing the key data. + * @param {Buffer|String} args.pfx path for private key, certificate and CA certs in PFX or PKCS12 format, or a Buffer containing the PFX data. If supplied will always be used instead of certificate and key above. + * @param {String} args.passphrase The passphrase for the provider key, if required + * @param {Boolean} args.production Specifies which environment to connect to: Production (if true) or Sandbox + * @param {String} args.topic Specififies an App-Id for this Provider + * @param {String} args.bundleId DEPRECATED: Specifies an App-ID for this Provider + * @param {Number} args.connectionRetryLimit The maximum number of connection failures that will be tolerated before apn.Provider will "give up". (Defaults to: 3) + */ + constructor(args = []) { + // Define class members + this.providers = []; + + // 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 { - conn.priority = 1; + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, 'APNS Configuration is invalid'); } - // Set apns client callbacks - conn.on('connected', () => { - log.verbose(LOG_PREFIX, 'APNS Connection %d Connected', conn.index); - }); + // Create Provider from each arg-object + for (let apnsArgs of apnsArgsList) { - conn.on('transmissionError', (errCode, notification, apnDevice) => { - handleTransmissionError(this.conns, errCode, notification, apnDevice); - }); + // rewrite bundleId to topic for backward-compatibility + if (apnsArgs.bundleId) { + log.warn(LOG_PREFIX, 'bundleId is deprecated, use topic instead'); + apnsArgs.topic = apnsArgs.bundleId + } - conn.on('timeout', () => { - log.verbose(LOG_PREFIX, 'APNS Connection %d Timeout', conn.index); - }); + let provider = APNS._createProvider(apnsArgs); + this.providers.push(provider); + } - conn.on('disconnected', () => { - log.verbose(LOG_PREFIX, 'APNS Connection %d Disconnected', conn.index); + // Sort the providers based on priority ascending, high pri first + this.providers.sort((s1, s2) => { + return s1.priority - s2.priority; }); - conn.on('socketError', () => { - log.verbose(LOG_PREFIX, 'APNS Connection %d Socket Error', conn.index); - }); + // Set index-property of providers + for (let index = 0; index < this.providers.length; index++) { + this.providers[index].index = index; + } + } - conn.on('transmitted', function(notification, device) { - if (device.callback) { - device.callback({ - notification: notification, - transmitted: true, - device: { - deviceType: 'ios', - deviceToken: device.token.toString('hex') - } - }); - } - log.verbose(LOG_PREFIX, 'APNS Connection %d Notification transmitted to %s', conn.index, device.token.toString('hex')); + /** + * Send apns request. + * + * @param {Object} data The data we need to send, the format is the same with api request body + * @param {Array} allDevices An array of devices + * @returns {Object} A promise which is resolved immediately + */ + send(data, allDevices) { + let coreData = data.data; + let expirationTime = data['expiration_time']; + let allPromises = []; + + let devicesPerAppIdentifier = {}; + + // Start by clustering the devices per appIdentifier + allDevices.forEach(device => { + let appIdentifier = device.appIdentifier; + devicesPerAppIdentifier[appIdentifier] = devicesPerAppIdentifier[appIdentifier] || []; + devicesPerAppIdentifier[appIdentifier].push(device); }); - 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; - } -} + for (let key in devicesPerAppIdentifier) { + let devices = devicesPerAppIdentifier[key]; -/** - * 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 allPromises = []; - let devicesPerConnIndex = {}; - // Start by clustering the devices per connections - devices.forEach((device) => { - let qualifiedConnIndexs = chooseConns(this.conns, device); - if (qualifiedConnIndexs.length == 0) { - log.error(LOG_PREFIX, 'no qualified connections for %s %s', device.appIdentifier, device.deviceToken); - let promise = Promise.resolve({ - transmitted: false, - device: { - deviceToken: device.deviceToken, - deviceType: 'ios' - }, - result: {error: 'No connection available'} - }); - allPromises.push(promise); - } else { - let apnDevice = new apn.Device(device.deviceToken); - apnDevice.connIndex = qualifiedConnIndexs[0]; - if (device.appIdentifier) { - apnDevice.appIdentifier = device.appIdentifier; - } - devicesPerConnIndex[apnDevice.connIndex] = devicesPerConnIndex[apnDevice.connIndex] || []; - devicesPerConnIndex[apnDevice.connIndex].push(apnDevice); - } - }) + let appIdentifier = devices[0].appIdentifier; + let providers = this._chooseProviders(appIdentifier); - allPromises = Object.keys(devicesPerConnIndex).reduce((memo, connIndex) => { - let devices = devicesPerConnIndex[connIndex]; - // Create a promise, attach the callback - let promises = devices.map((apnDevice) => { - return new Promise((resolve, reject) => { - apnDevice.callback = resolve; - }); - }); - let conn = this.conns[connIndex]; - conn.pushNotification(notification, devices); - return memo.concat(promises); - }, allPromises); + // No Providers found + if (!providers || providers.length === 0) { + let errorPromises = devices.map(device => APNS._createErrorPromise(device.deviceToken, 'No Provider found')); + allPromises = allPromises.concat(errorPromises); + continue; + } - return Promise.all(allPromises); -} + let notification = APNS._generateNotification(coreData, expirationTime, appIdentifier); + let promise = providers[0] + .send(notification, devices.map(device => device.deviceToken)) + .then(this._handlePromise.bind(this)); + allPromises.push(promise); + } -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 Promise.all(allPromises); } - // 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; + /** + * Creates an Provider base on apnsArgs. + */ + static _createProvider(apnsArgs) { + let provider = new apn.Provider(apnsArgs); - 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; + // if using certificate, then topic must be defined + if ((apnsArgs.cert || apnsArgs.key || apnsArgs.pfx) && !apnsArgs.topic) { + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, 'topic is mssing for %j', apnsArgs); } - } - // There is no more available conns, we give up in this case - if (newConnIndex < 0 || newConnIndex >= conns.length) { - if (apnDevice.callback) { - log.error(LOG_PREFIX, `cannot find vaild connection for ${apnDevice.token.toString('hex')}`); - apnDevice.callback({ - response: {error: `APNS can not find vaild connection for ${apnDevice.token.toString('hex')}`, code: errCode}, - status: errCode, - transmitted: false, - device: { - deviceType: 'ios', - deviceToken: apnDevice.token.toString('hex') - } - }); + // Sets the topic on this provider + provider.topic = apnsArgs.topic; + + // Set the priority of the providers, prod cert has higher priority + if (apnsArgs.production) { + provider.priority = 0; + } else { + provider.priority = 1; } - return; - } - let newConn = conns[newConnIndex]; - // Update device conn info - apnDevice.connIndex = newConnIndex; - // Use the new conn to send the notification - newConn.pushNotification(notification, apnDevice); -} + return provider; + } -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); + /** + * Generate the apns Notification from the data we get from api request. + * @param {Object} coreData The data field under api request body + * @param {number} expirationTime The expiration time in milliseconds since Jan 1 1970 + * @param {String} topic Topic the Notification is sent to + * @returns {Object} A apns Notification + */ + static _generateNotification(coreData, expirationTime, topic) { + let notification = new apn.Notification(); + let payload = {}; + for (let key in coreData) { + switch (key) { + case 'alert': + notification.setAlert(coreData.alert); + break; + case 'badge': + notification.setBadge(coreData.badge); + break; + case 'sound': + notification.setSound(coreData.sound); + break; + case 'content-available': + let isAvailable = coreData['content-available'] === 1; + notification.setContentAvailable(isAvailable); + break; + case 'mutable-content': + let isMutable = coreData['mutable-content'] === 1; + notification.setMutableContent(isMutable); + break; + case 'category': + notification.setCategory(coreData.category); + break; + default: + payload[key] = coreData[key]; + break; + } } + notification.topic = topic; + notification.payload = payload; + notification.expiry = expirationTime / 1000; + return notification; } - return qualifiedConns; -} -/** - * Generate the apns notification from the data we get from api request. - * @param {Object} coreData The data field under api request body - * @param {number} expirationTime The expiration time in milliseconds since Jan 1 1970 - * @returns {Object} A apns notification - */ -function generateNotification(coreData, expirationTime) { - let notification = new apn.notification(); - let payload = {}; - for (let key in coreData) { - switch (key) { - case 'alert': - notification.setAlertText(coreData.alert); - break; - case 'badge': - notification.badge = coreData.badge; - break; - case 'sound': - notification.sound = coreData.sound; - break; - case 'content-available': - notification.setNewsstandAvailable(true); - let isAvailable = coreData['content-available'] === 1; - notification.setContentAvailable(isAvailable); - break; - case 'mutable-content': - let isMutable = coreData['mutable-content'] === 1; - notification.setMutableContent(isMutable); - break; - case 'category': - notification.category = coreData.category; - break; - default: - payload[key] = coreData[key]; - break; + /** + * Choose appropriate providers based on device appIdentifier. + * + * @param {String} appIdentifier appIdentifier for required provider + * @returns {Array} Returns Array with appropriate providers + */ + _chooseProviders(appIdentifier) { + // If the device we need to send to does not have appIdentifier, any provider could be a qualified provider + /*if (!appIdentifier || appIdentifier === '') { + return this.providers.map((provider) => provider.index); + }*/ + + // Otherwise we try to match the appIdentifier with topic on provider + let qualifiedProviders = this.providers.filter((provider) => appIdentifier === provider.topic); + + if (qualifiedProviders.length > 0) { + return qualifiedProviders; } + + // If qualifiedProviders empty, add all providers without topic + return this.providers + .filter((provider) => !provider.topic || provider.topic === ''); + } + + _handlePromise(response) { + let promises = []; + response.sent.forEach((token) => { + log.verbose(LOG_PREFIX, 'APNS transmitted to %s', token.device); + promises.push(APNS._createSuccesfullPromise(token.device)); + }); + response.failed.forEach((failure) => { + if (failure.error) { + log.error(LOG_PREFIX, 'APNS error transmitting to device %s with error %s', failure.device, failure.error); + promises.push(APNS._createErrorPromise(failure.device, failure.error)); + } else if (failure.status && failure.response && failure.response.reason) { + log.error(LOG_PREFIX, 'APNS error transmitting to device %s with status %s and reason %s', failure.device, failure.status, failure.response.reason); + promises.push(APNS._createErrorPromise(failure.device, failure.response.reason)); + } + }); + return Promise.all(promises); } - notification.payload = payload; - notification.expiry = expirationTime / 1000; - return notification; -} -APNS.generateNotification = generateNotification; + /** + * Creates an errorPromise for return. + * + * @param {String} token Device-Token + * @param {String} errorMessage ErrrorMessage as string + */ + static _createErrorPromise(token, errorMessage) { + return Promise.resolve({ + transmitted: false, + device: { + deviceToken: token, + deviceType: 'ios' + }, + result: { error: errorMessage } + }); + } -if (process.env.TESTING) { - APNS.chooseConns = chooseConns; - APNS.handleTransmissionError = handleTransmissionError; + /** + * Creates an successfulPromise for return. + * + * @param {String} token Device-Token + */ + static _createSuccesfullPromise(token) { + return Promise.resolve({ + transmitted: true, + device: { + deviceToken: token, + deviceType: 'ios' + } + }); + } } + module.exports = APNS; export default APNS;