@parse/push-adapter
Version:
Base parse-server-push-adapter
360 lines (322 loc) • 13.3 kB
JavaScript
'use strict';
import apn from '@parse/node-apn';
import Parse from 'parse/node';
import log from 'npmlog';
const LOG_PREFIX = 'parse-server-push-adapter APNS';
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 {
throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, 'APNS Configuration is invalid');
}
// Create Provider from each arg-object
for (const apnsArgs of apnsArgsList) {
// rewrite bundleId to topic for backward-compatibility
if (apnsArgs.bundleId) {
log.warn(LOG_PREFIX, 'bundleId is deprecated, use topic instead');
apnsArgs.topic = apnsArgs.bundleId
}
const provider = APNS._createProvider(apnsArgs);
this.providers.push(provider);
}
// Sort the providers based on priority ascending, high pri first
this.providers.sort((s1, s2) => {
return s1.priority - s2.priority;
});
// Set index-property of providers
for (let index = 0; index < this.providers.length; index++) {
this.providers[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} allDevices An array of devices
* @returns {Object} A promise which is resolved immediately
*/
send(data, allDevices) {
const coreData = data && data.data;
if (!coreData || !allDevices || !Array.isArray(allDevices)) {
log.warn(LOG_PREFIX, 'invalid push payload');
return;
}
const expirationTime = data['expiration_time'] || coreData['expiration_time'];
const collapseId = data['collapse_id'] || coreData['collapse_id'];
const pushType = data['push_type'] || coreData['push_type'];
const priority = data['priority'] || coreData['priority'];
let allPromises = [];
const devicesPerAppIdentifier = {};
// Start by clustering the devices per appIdentifier
allDevices.forEach(device => {
const appIdentifier = device.appIdentifier;
devicesPerAppIdentifier[appIdentifier] = devicesPerAppIdentifier[appIdentifier] || [];
devicesPerAppIdentifier[appIdentifier].push(device);
});
for (const key in devicesPerAppIdentifier) {
const devices = devicesPerAppIdentifier[key];
const appIdentifier = devices[0].appIdentifier;
const providers = this._chooseProviders(appIdentifier);
// No Providers found
if (!providers || providers.length === 0) {
const errorPromises = devices.map(device => APNS._createErrorPromise(device.deviceToken, 'No Provider found'));
allPromises = allPromises.concat(errorPromises);
continue;
}
const headers = { expirationTime: expirationTime, topic: appIdentifier, collapseId: collapseId, pushType: pushType, priority: priority }
const notification = APNS._generateNotification(coreData, headers);
const deviceIds = devices.map(device => device.deviceToken);
const promise = this.sendThroughProvider(notification, deviceIds, providers);
allPromises.push(promise.then(this._handlePromise.bind(this)));
}
return Promise.all(allPromises).then((results) => {
// flatten all
return [].concat.apply([], results);
});
}
sendThroughProvider(notification, devices, providers) {
return providers[0]
.send(notification, devices)
.then((response) => {
if (response.failed
&& response.failed.length > 0
&& providers && providers.length > 1) {
const devices = response.failed.map((failure) => { return failure.device; });
// Reset the failures as we'll try next connection
response.failed = [];
return this.sendThroughProvider(notification,
devices,
providers.slice(1, providers.length)).then((retryResponse) => {
response.failed = response.failed.concat(retryResponse.failed);
response.sent = response.sent.concat(retryResponse.sent);
return response;
});
} else {
return response;
}
});
}
static _validateAPNArgs(apnsArgs) {
if (apnsArgs.topic) {
return true;
}
return !(apnsArgs.cert || apnsArgs.key || apnsArgs.pfx);
}
/**
* Creates an Provider base on apnsArgs.
*/
static _createProvider(apnsArgs) {
// if using certificate, then topic must be defined
if (!APNS._validateAPNArgs(apnsArgs)) {
throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, 'topic is missing for %j', apnsArgs);
}
const provider = new apn.Provider(apnsArgs);
// 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 provider;
}
/**
* Generate the apns Notification from the data we get from api request.
* @param {Object} coreData The data field under api request body
* @param {Object} headers The header properties for the notification (topic, expirationTime, collapseId, pushType, priority)
* @returns {Object} A apns Notification
*/
static _generateNotification(coreData, headers) {
const notification = new apn.Notification();
const payload = {};
for (const key in coreData) {
switch (key) {
case 'aps':
notification.aps = coreData.aps;
break;
case 'alert':
notification.setAlert(coreData.alert);
break;
case 'title':
notification.setTitle(coreData.title);
break;
case 'badge':
notification.setBadge(coreData.badge);
break;
case 'sound':
notification.setSound(coreData.sound);
break;
case 'content-available':
notification.setContentAvailable(coreData['content-available'] === 1);
break;
case 'mutable-content':
notification.setMutableContent(coreData['mutable-content'] === 1);
break;
case 'targetContentIdentifier':
notification.setTargetContentIdentifier(coreData.targetContentIdentifier);
break;
case 'interruptionLevel':
notification.setInterruptionLevel(coreData.interruptionLevel);
break;
case 'category':
notification.setCategory(coreData.category);
break;
case 'threadId':
notification.setThreadId(coreData.threadId);
break;
case 'id':
case 'collapseId':
case 'channelId':
case 'requestId':
case 'pushType':
case 'topic':
case 'expiry':
case 'priority':
// Header information is skipped and added later.
break;
default:
payload[key] = coreData[key];
break;
}
}
notification.payload = payload;
// Update header information if necessary.
notification.id = coreData.id ?? headers.id;
notification.collapseId = coreData.collapseId ?? headers.collapseId;
notification.requestId = coreData.requestId ?? headers.requestId;
notification.channelId = coreData.channelId ?? headers.channelId;
// set alert as default push type. If push type is not set notifications are not delivered to devices running iOS 13, watchOS 6 and later.
const pushType = coreData.pushType ?? headers.pushType ?? 'alert';
notification.pushType = pushType;
const topic = coreData.topic ?? APNS._determineTopic(headers.topic, pushType);
notification.topic = topic;
let expiry = notification.expiry;
if (headers.expirationTime) {
expiry = Math.round(headers.expirationTime / 1000);
}
notification.expiry = coreData.expiry ?? expiry;
// if headers priority is not set 'node-apn' defaults it to notification's default value. Required value for background pushes to launch the app in background.
notification.priority = coreData.priority ?? headers.priority ?? notification.priority;
return notification;
}
/**
* Updates the topic based on the pushType.
*
* @param {String} topic The current topic to append additional information to for required provider
* @param {any} pushType The current push type of the notification
* @returns {String} Returns the updated topic
*/
static _determineTopic(topic, pushType) {
switch(pushType) {
case 'location':
return topic + '.location-query';
case 'voip':
return topic + '.voip';
case 'complication':
return topic + '.complication';
case 'fileprovider':
return topic + '.pushkit.fileprovider';
case 'liveactivity':
return topic + '.push-type.liveactivity';
case 'pushtotalk':
return topic + '.voip-ptt';
default:
return topic;
}
}
/**
* Choose appropriate providers based on device appIdentifier.
*
* @param {String} appIdentifier appIdentifier for required provider
* @returns {Array} Returns Array with appropriate providers
*/
_chooseProviders(appIdentifier) {
// Otherwise we try to match the appIdentifier with topic on provider
const 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) {
const 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) => {
promises.push(APNS._handlePushFailure(failure));
});
return Promise.all(promises);
}
static _handlePushFailure(failure) {
if (failure.error) {
log.error(LOG_PREFIX, 'APNS error transmitting to device %s with error %s', failure.device, failure.error);
return 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);
return APNS._createErrorPromise(failure.device, failure.response.reason);
} else {
log.error(LOG_PREFIX, 'APNS error transmitting to device with unknown error');
return APNS._createErrorPromise(failure.device, 'Unknown status');
}
}
/**
* 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'
},
response: { error: errorMessage }
});
}
/**
* Creates an successfulPromise for return.
*
* @param {String} token Device-Token
*/
static _createSuccesfullPromise(token) {
return Promise.resolve({
transmitted: true,
device: {
deviceToken: token,
deviceType: 'ios'
}
});
}
}
export default APNS;