Skip to content

Commit

Permalink
FCM support
Browse files Browse the repository at this point in the history
  • Loading branch information
bdubale committed Feb 16, 2024
1 parent 5536102 commit 04140e1
Show file tree
Hide file tree
Showing 3 changed files with 226 additions and 3 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"dependencies": {
"@parse/node-apn": "6.0.1",
"@parse/node-gcm": "1.0.2",
"firebase-admin": "11.10.1",
"npmlog": "7.0.1",
"parse": "4.2.0"
},
Expand Down
213 changes: 213 additions & 0 deletions src/FCM.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
"use strict";

import Parse from 'parse';
import log from 'npmlog';
import { initializeApp, cert, getApps, getApp } from 'firebase-admin/app';
import { getMessaging } from 'firebase-admin/messaging';
import { randomString } from './PushAdapterUtils';

const LOG_PREFIX = 'parse-server-push-adapter FCM';
const FCMRegistrationTokensMax = 500;
const FCMTimeToLiveMax = 4 * 7 * 24 * 60 * 60; // FCM allows a max of 4 weeks

export default function FCM(args) {
if (typeof args !== 'object' || !args.firebaseServiceAccount) {
throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED,
'FCM Configuration is invalid');
}

let app;
if (getApps().length === 0) {
app = initializeApp({credential: cert(args.firebaseServiceAccount)});
}
else {
app = getApp();
}
this.sender = getMessaging(app);
}

FCM.FCMRegistrationTokensMax = FCMRegistrationTokensMax;

/**
* Send fcm 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} Array of resolved promises
*/

FCM.prototype.send = function(data, devices) {
if (!data || !devices || !Array.isArray(devices)) {
log.warn(LOG_PREFIX, 'invalid push payload');
return;
}

// We can only have 500 recepients per send, so we need to slice devices to
// chunk if necessary
const slices = sliceDevices(devices, FCM.FCMRegistrationTokensMax);

const sendToDeviceSlice = (deviceSlice) => {
const pushId = randomString(10);
const timestamp = Date.now();

// Build a device map
const devicesMap = deviceSlice.reduce((memo, device) => {
memo[device.deviceToken] = device;
return memo;
}, {});

const deviceTokens = Object.keys(devicesMap);
const fcmPayload = generateFCMPayload(data, pushId, timestamp, deviceTokens);
const length = deviceTokens.length;
log.info(LOG_PREFIX, `sending push to ${length} devices`);

return this.sender.sendEachForMulticast(fcmPayload.data)
.then((response) => {
const promises = [];
const failedTokens = [];
const successfulTokens = [];

response.responses.forEach((resp, idx) => {
if (resp.success) {
successfulTokens.push(deviceTokens[idx]);
promises.push(createSuccessfulPromise(deviceTokens[idx], devicesMap[deviceTokens[idx]].deviceType));
} else {
failedTokens.push(deviceTokens[idx]);
promises.push(createErrorPromise(deviceTokens[idx], devicesMap[deviceTokens[idx]].deviceType, resp.error));
log.error(LOG_PREFIX, `failed to send to ${deviceTokens[idx]} with error: ${JSON.stringify(resp.error)}`);
}
});

if (failedTokens.length) {
log.error(LOG_PREFIX, `tokens with failed pushes: ${JSON.stringify(failedTokens)}`);
}

if (successfulTokens.length) {
log.verbose(LOG_PREFIX, `tokens with successful pushes: ${JSON.stringify(successfulTokens)}`);
}

return Promise.all(promises);
});
};

const allPromises = Promise.all(slices.map(sendToDeviceSlice))
.catch((err) => {
log.error(LOG_PREFIX, `error sending push: ${err}`);
});

return allPromises;
}

/**
* Generate the fcm payload from the data we get from api request.
* @param {Object} requestData The request body
* @param {String} pushId A random string
* @param {Number} timeStamp A number in milliseconds since the Unix Epoch
* @returns {Object} A payload for FCM
*/
function generateFCMPayload(requestData, pushId, timeStamp, deviceTokens) {
delete requestData['where'];

const payloadToUse = {
data: {},
push_id: pushId,
time: new Date(timeStamp).toISOString()
};

// Use rawPayload instead of the GCM implementation if it exists
if (requestData.hasOwnProperty('rawPayload')) {
payloadToUse.data = {
...requestData.rawPayload,
tokens: deviceTokens
};
} else {
// Android payload according to GCM implementation
const androidPayload = {
android: {
priority: 'high'
},
tokens: deviceTokens
};

if (requestData.hasOwnProperty('notification')) {
androidPayload.notification = requestData.notification;
}

if (requestData.hasOwnProperty('data')) {
androidPayload.data = requestData.data;
}

if (requestData['expiration_time']) {
const expirationTime = requestData['expiration_time'];
// Convert to seconds
let timeToLive = Math.floor((expirationTime - timeStamp) / 1000);
if (timeToLive < 0) {
timeToLive = 0;
}
if (timeToLive >= FCMTimeToLiveMax) {
timeToLive = FCMTimeToLiveMax;
}

androidPayload.android.ttl = timeToLive;
}

payloadToUse.data = androidPayload;
}

return payloadToUse;
}

/**
* Slice a list of devices to several list of devices with fixed chunk size.
* @param {Array} devices An array of devices
* @param {Number} chunkSize The size of the a chunk
* @returns {Array} An array which contains several arrays of devices with fixed chunk size
*/
function sliceDevices(devices, chunkSize) {
const chunkDevices = [];
while (devices.length > 0) {
chunkDevices.push(devices.splice(0, chunkSize));
}
return chunkDevices;
}

/**
* Creates an errorPromise for return.
*
* @param {String} token Device-Token
* @param {String} deviceType Device-Type
* @param {String} errorMessage ErrrorMessage as string
*/
function createErrorPromise(token, deviceType, errorMessage) {
return Promise.resolve({
transmitted: false,
device: {
deviceToken: token,
deviceType: deviceType
},
response: { error: errorMessage }
});
}

/**
* Creates an successfulPromise for return.
*
* @param {String} token Device-Token
* @param {String} deviceType Device-Type
*/
function createSuccessfulPromise(token, deviceType) {
return Promise.resolve({
transmitted: true,
device: {
deviceToken: token,
deviceType: deviceType
}
});
}


FCM.generateFCMPayload = generateFCMPayload;

/* istanbul ignore else */
if (process.env.TESTING) {
FCM.sliceDevices = sliceDevices;
}
15 changes: 12 additions & 3 deletions src/ParsePushAdapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Parse from 'parse';
import log from 'npmlog';
import APNS from './APNS';
import GCM from './GCM';
import FCM from './FCM';
import { classifyInstallations } from './PushAdapterUtils';

const LOG_PREFIX = 'parse-server-push-adapter';
Expand Down Expand Up @@ -30,11 +31,19 @@ export default class ParsePushAdapter {
case 'ios':
case 'tvos':
case 'osx':
this.senderMap[pushType] = new APNS(pushConfig[pushType]);
if (pushConfig[pushType].hasOwnProperty('firebaseServiceAccount')) {
this.senderMap[pushType] = new FCM(pushConfig[pushType]);
} else {
this.senderMap[pushType] = new APNS(pushConfig[pushType]);
}
break;
case 'android':
case 'fcm':
this.senderMap[pushType] = new GCM(pushConfig[pushType]);
if (pushConfig[pushType].hasOwnProperty('firebaseServiceAccount')) {
this.senderMap[pushType] = new FCM(pushConfig[pushType]);
} else {
this.senderMap[pushType] = new GCM(pushConfig[pushType]);
}
break;
}
}
Expand Down Expand Up @@ -76,4 +85,4 @@ export default class ParsePushAdapter {
return [].concat.apply([], promises);
})
}
}
}

0 comments on commit 04140e1

Please sign in to comment.