From 184a10dd57656379c22e6fcb4d890e7f61f80b22 Mon Sep 17 00:00:00 2001 From: Morten Moeller Date: Wed, 8 May 2024 15:56:54 -0500 Subject: [PATCH 1/7] Expo push notification support for Parse Server `pushType='expo'` --- package-lock.json | 95 +++++++++++++++++++++++++++++++++++------ package.json | 1 + src/EXPO.js | 91 +++++++++++++++++++++++++++++++++++++++ src/ParsePushAdapter.js | 6 ++- src/index.js | 3 +- 5 files changed, 181 insertions(+), 15 deletions(-) create mode 100644 src/EXPO.js diff --git a/package-lock.json b/package-lock.json index 43e91da..51bc12f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "dependencies": { "@parse/node-apn": "6.0.1", "@parse/node-gcm": "1.0.2", - "firebase-admin": "^12.1.0", + "expo-server-sdk": "^3.10.0", + "firebase-admin": "12.1.0", "npmlog": "7.0.1", "parse": "5.0.0", "web-push": "3.6.7" @@ -4197,6 +4198,11 @@ "node": ">=10.17.0" } }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==" + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -4377,6 +4383,16 @@ "node": ">=6" } }, + "node_modules/expo-server-sdk": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/expo-server-sdk/-/expo-server-sdk-3.10.0.tgz", + "integrity": "sha512-isymUVz18Syp9G+TPs2MVZ6WdMoyLw8hDLhpywOd8JqM6iGTka6Dr8Dzq7mjGQ8C8486rxLawZx/W+ps+vkjLQ==", + "dependencies": { + "node-fetch": "^2.6.0", + "promise-limit": "^2.7.0", + "promise-retry": "^2.0.1" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -7142,7 +7158,6 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "devOptional": true, "dependencies": { "whatwg-url": "^5.0.0" }, @@ -10949,6 +10964,31 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true }, + "node_modules/promise-limit": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/promise-limit/-/promise-limit-2.7.0.tgz", + "integrity": "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==" + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/promise-retry/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "engines": { + "node": ">= 4" + } + }, "node_modules/proto3-json-serializer": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.1.tgz", @@ -13391,8 +13431,7 @@ "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=", - "devOptional": true + "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=" }, "node_modules/traverse": { "version": "0.6.6", @@ -13752,8 +13791,7 @@ "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=", - "devOptional": true + "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=" }, "node_modules/websocket-driver": { "version": "0.7.4", @@ -13780,7 +13818,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=", - "devOptional": true, "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" @@ -17601,6 +17638,11 @@ } } }, + "err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==" + }, "error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -17734,6 +17776,16 @@ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==" }, + "expo-server-sdk": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/expo-server-sdk/-/expo-server-sdk-3.10.0.tgz", + "integrity": "sha512-isymUVz18Syp9G+TPs2MVZ6WdMoyLw8hDLhpywOd8JqM6iGTka6Dr8Dzq7mjGQ8C8486rxLawZx/W+ps+vkjLQ==", + "requires": { + "node-fetch": "^2.6.0", + "promise-limit": "^2.7.0", + "promise-retry": "^2.0.1" + } + }, "extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -19911,7 +19963,6 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "devOptional": true, "requires": { "whatwg-url": "^5.0.0" } @@ -22665,6 +22716,27 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true }, + "promise-limit": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/promise-limit/-/promise-limit-2.7.0.tgz", + "integrity": "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==" + }, + "promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "requires": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "dependencies": { + "retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==" + } + } + }, "proto3-json-serializer": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.1.tgz", @@ -24611,8 +24683,7 @@ "tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=", - "devOptional": true + "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=" }, "traverse": { "version": "0.6.6", @@ -24893,8 +24964,7 @@ "webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=", - "devOptional": true + "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=" }, "websocket-driver": { "version": "0.7.4", @@ -24915,7 +24985,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=", - "devOptional": true, "requires": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" diff --git a/package.json b/package.json index 10017b9..c4bfb45 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "dependencies": { "@parse/node-apn": "6.0.1", "@parse/node-gcm": "1.0.2", + "expo-server-sdk": "^3.10.0", "firebase-admin": "12.1.0", "npmlog": "7.0.1", "parse": "5.0.0", diff --git a/src/EXPO.js b/src/EXPO.js new file mode 100644 index 0000000..d649574 --- /dev/null +++ b/src/EXPO.js @@ -0,0 +1,91 @@ +"use strict"; + +import Parse from 'parse'; +import log from 'npmlog'; +import { Expo, ExpoPushMessage, ExpoPushTicket } from 'expo-server-sdk'; + +const LOG_PREFIX = 'parse-server-push-adapter EXPO'; + +export class EXPO { + expo = undefined; + /** + * Create a new EXPO push adapter. Based on Web Adapter + * + * @param {Object} args https://github.com/expo/expo-server-sdk-node / https://docs.expo.dev/push-notifications/sending-notifications/ + */ + constructor(args) { + if (typeof args !== 'object') { + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, 'EXPO Push Configuration is invalid'); + } + + this.expo = new Expo(args) + this.options = args; + } + + /** + * Send web push notification request. + * + * @param {Object} data The data we need to send, the format is the same with api request body + * @param {Array} devices An array of devices + * @returns {Object} A promise which is resolved immediately + */ + async send(data, devices) { + const coreData = data && data.data; + + if (!coreData || !devices || !Array.isArray(devices)) { + log.warn(LOG_PREFIX, 'invalid push payload'); + return; + } + const devicesMap = devices.reduce((memo, device) => { + memo[device.deviceToken] = device; + return memo; + }, {}); + const deviceTokens = Object.keys(devicesMap); + + const resolvers = []; + const promises = deviceTokens.map(() => new Promise(resolve => resolvers.push(resolve))); + let length = deviceTokens.length; + + log.verbose(LOG_PREFIX, `sending to ${length} ${length > 1 ? 'devices' : 'device'}`); + + const response = await this.sendNotifications(coreData, deviceTokens, this.options); + + log.verbose(LOG_PREFIX, `EXPO Response: %d sent`, response.length); + + deviceTokens.forEach((token, index) => { + const resolve = resolvers[index]; + const result = response[index]; + const device = devicesMap[token]; + const resolution = { + transmitted: result.status === 'ok', + device: { + deviceToken: token + }, + response: result, + }; + resolve(resolution); + }); + return Promise.all(promises); + } + + /** + * Send multiple web push notification request. + * + * @param {Object} payload The data we need to send, the format is the same with api request body + * @param {Array} deviceTokens An array of devicesTokens + * @param {Object} options The options for the request + * @returns {Object} A promise which is resolved immediately + */ + async sendNotifications({alert, title, body, ...payload}, deviceTokens, options) { + const messages = deviceTokens.map((token) => ({ + to: token, + title: title, + body: body || alert, + ...payload + })); + + return await this.expo.sendPushNotificationsAsync(messages); + } +} + +export default EXPO; diff --git a/src/ParsePushAdapter.js b/src/ParsePushAdapter.js index 2e35d74..0c2aed0 100644 --- a/src/ParsePushAdapter.js +++ b/src/ParsePushAdapter.js @@ -5,6 +5,7 @@ import APNS from './APNS'; import GCM from './GCM'; import FCM from './FCM'; import WEB from './WEB'; +import EXPO from './EXPO'; import { classifyInstallations } from './PushAdapterUtils'; const LOG_PREFIX = 'parse-server-push-adapter'; @@ -14,7 +15,7 @@ export default class ParsePushAdapter { supportsPushTracking = true; constructor(pushConfig = {}) { - this.validPushTypes = ['ios', 'osx', 'tvos', 'android', 'fcm', 'web']; + this.validPushTypes = ['ios', 'osx', 'tvos', 'android', 'fcm', 'web', 'expo']; this.senderMap = {}; // used in PushController for Dashboard Features this.feature = { @@ -41,6 +42,9 @@ export default class ParsePushAdapter { case 'web': this.senderMap[pushType] = new WEB(pushConfig[pushType]); break; + case 'expo': + this.senderMap[pushType] = new EXPO(pushConfig[pushType]); + break; case 'android': case 'fcm': if (pushConfig[pushType].hasOwnProperty('firebaseServiceAccount')) { diff --git a/src/index.js b/src/index.js index 8360c4b..45769e8 100644 --- a/src/index.js +++ b/src/index.js @@ -13,7 +13,8 @@ import ParsePushAdapter from './ParsePushAdapter'; import GCM from './GCM'; import APNS from './APNS'; import WEB from './WEB'; +import EXPO from './EXPO'; import * as utils from './PushAdapterUtils'; export default ParsePushAdapter; -export { ParsePushAdapter, APNS, GCM, WEB, utils }; +export { ParsePushAdapter, APNS, GCM, WEB, EXPO, utils }; From 6ed0491eb370f309bcb7379a1d345b2c97b0eced Mon Sep 17 00:00:00 2001 From: Morten Moeller Date: Wed, 8 May 2024 15:59:34 -0500 Subject: [PATCH 2/7] typos --- src/EXPO.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/EXPO.js b/src/EXPO.js index d649574..1526646 100644 --- a/src/EXPO.js +++ b/src/EXPO.js @@ -9,7 +9,7 @@ const LOG_PREFIX = 'parse-server-push-adapter EXPO'; export class EXPO { expo = undefined; /** - * Create a new EXPO push adapter. Based on Web Adapter + * Create a new EXPO push adapter. Based on Web Adapter. * * @param {Object} args https://github.com/expo/expo-server-sdk-node / https://docs.expo.dev/push-notifications/sending-notifications/ */ @@ -23,7 +23,7 @@ export class EXPO { } /** - * Send web push notification request. + * Send Expo push notification request. * * @param {Object} data The data we need to send, the format is the same with api request body * @param {Array} devices An array of devices @@ -69,7 +69,7 @@ export class EXPO { } /** - * Send multiple web push notification request. + * Send multiple Expo push notification request. * * @param {Object} payload The data we need to send, the format is the same with api request body * @param {Array} deviceTokens An array of devicesTokens From 187983b60c8ed71c5babae60581981532d264778 Mon Sep 17 00:00:00 2001 From: Morten Moeller Date: Wed, 8 May 2024 20:59:35 -0500 Subject: [PATCH 3/7] Exact import --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 51bc12f..c7f922b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@parse/node-apn": "6.0.1", "@parse/node-gcm": "1.0.2", - "expo-server-sdk": "^3.10.0", + "expo-server-sdk": "3.10.0", "firebase-admin": "12.1.0", "npmlog": "7.0.1", "parse": "5.0.0", diff --git a/package.json b/package.json index c4bfb45..4a420a7 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "dependencies": { "@parse/node-apn": "6.0.1", "@parse/node-gcm": "1.0.2", - "expo-server-sdk": "^3.10.0", + "expo-server-sdk": "3.10.0", "firebase-admin": "12.1.0", "npmlog": "7.0.1", "parse": "5.0.0", From 826275247af7b3c92b6021cca9947a59f88c7108 Mon Sep 17 00:00:00 2001 From: Morten Moeller Date: Thu, 9 May 2024 09:41:15 -0500 Subject: [PATCH 4/7] Add Expo to readme --- README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 832c868..83aed8b 100644 --- a/README.md +++ b/README.md @@ -61,9 +61,14 @@ const parseServerOptions = { }, android: { /* Android push options */ - } + }, web: { /* Web push options */ + }, + expo: { + /* Expo push options (https://docs.expo.dev/push-notifications/overview/) + * If you setup access token, add `accessToken: ''`. + */ } }) }, From 6df45a28e352564af6341f278328e127d0e58b6e Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Fri, 10 May 2024 14:49:20 +0200 Subject: [PATCH 5/7] Update README.md --- README.md | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 83aed8b..0d02ba1 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ The official Push Notification adapter for Parse Server. See [Parse Server Push - [Using a Custom Version on Parse Server](#using-a-custom-version-on-parse-server) - [Install Push Adapter](#install-push-adapter) - [Configure Parse Server](#configure-parse-server) + - [Expo Push Options](#expo-push-options) # Silent Notifications @@ -57,7 +58,7 @@ const parseServerOptions = { push: { adapter: new PushAdapter({ ios: { - /* Apple push notification options */ + /* Apple push options */ }, android: { /* Android push options */ @@ -66,12 +67,23 @@ const parseServerOptions = { /* Web push options */ }, expo: { - /* Expo push options (https://docs.expo.dev/push-notifications/overview/) - * If you setup access token, add `accessToken: ''`. - */ - } - }) + /* Expo push options */ + }, + }), }, /* Other Parse Server options */ } ``` + +### Expo Push Options + +Example options: + +```js +expo: { + accessToken: '', +}, +``` + +For more information see the [Expo docs](https://docs.expo.dev/push-notifications/overview/). + \ No newline at end of file From 1ed2fc720b333a45ea256d417a71061572b999d7 Mon Sep 17 00:00:00 2001 From: Morten Moeller Date: Fri, 10 May 2024 10:01:26 -0500 Subject: [PATCH 6/7] Tests for Expo --- spec/EXPO.spec.js | 143 ++++++++++++++++++++++++++++++++++ spec/ParsePushAdapter.spec.js | 51 ++++++++++-- src/EXPO.js | 10 +-- 3 files changed, 194 insertions(+), 10 deletions(-) create mode 100644 spec/EXPO.spec.js diff --git a/spec/EXPO.spec.js b/spec/EXPO.spec.js new file mode 100644 index 0000000..65e0f3d --- /dev/null +++ b/spec/EXPO.spec.js @@ -0,0 +1,143 @@ +const EXPO = require('../src/EXPO').default; +const Expo = require('expo-server-sdk').Expo; + +function mockSender(success) { + return spyOn(EXPO.prototype, 'sendNotifications').and.callFake((payload, tokens) => { + return Promise.resolve(tokens.map(() => ({ status: success ? 'ok' : 'error' }))); + }); +} + +function mockExpoPush(success) { + return spyOn(Expo.prototype, 'sendPushNotificationsAsync').and.callFake((deviceToken) => { + if (success) { + return Promise.resolve(deviceToken.map(() => ({ status: 'ok' }))); + } + return Promise.resolve(deviceToken.map(() => ({ status: 'error', message: 'Failed to send' }))); + }); +} + +describe('EXPO', () => { + it('can initialize', () => { + const args = { }; + new EXPO(args); + }); + + it('can throw on initializing with invalid args', () => { + expect(function() { new EXPO(123); }).toThrow(); + expect(function() { new EXPO(undefined); }).toThrow(); + }); + + it('can send successful EXPO request', async () => { + const log = require('npmlog'); + const spy = spyOn(log, 'verbose'); + + const expo = new EXPO({ vapidDetails: 'apiKey' }); + spyOn(EXPO.prototype, 'sendNotifications').and.callFake(() => { + return Promise.resolve([{ status: 'ok' }]); + }); + const data = { data: { alert: 'alert' } }; + const devices = [{ deviceToken: 'token' }]; + const response = await expo.send(data, devices); + expect(EXPO.prototype.sendNotifications).toHaveBeenCalled(); + const args = EXPO.prototype.sendNotifications.calls.first().args; + expect(args.length).toEqual(2); + expect(args[0]).toEqual(data.data); + expect(args[1]).toEqual(['token']); + expect(spy).toHaveBeenCalled(); + expect(response).toEqual([{ + device: { deviceToken: 'token', pushType: 'expo' }, + response: { status: 'ok' }, + transmitted: true + }]); + }); + + it('can send failed EXPO request', async () => { + const log = require('npmlog'); + const expo = new EXPO({ vapidDetails: 'apiKey' }); + spyOn(EXPO.prototype, 'sendNotifications').and.callFake(() => { + return Promise.resolve([{ status: 'error', message: 'Failed' }])}); + const data = { data: { alert: 'alert' } }; + const devices = [{ deviceToken: 'token' }]; + const response = await expo.send(data, devices); + + expect(EXPO.prototype.sendNotifications).toHaveBeenCalled(); + const args = EXPO.prototype.sendNotifications.calls.first().args; + expect(args.length).toEqual(2); + expect(args[0]).toEqual(data.data); + expect(args[1]).toEqual(['token']); + + expect(response).toEqual([{ + device: { deviceToken: 'token', pushType: 'expo' }, + response: { status: 'error', message: 'Failed' }, + transmitted: false + }]); + }); + + it('can send multiple successful EXPO request', async () => { + const expo = new EXPO({ }); + const data = { data: { alert: 'alert' } }; + const devices = [ + { deviceToken: 'token1', deviceType: 'ios' }, + { deviceToken: 'token2', deviceType: 'ios' }, + { deviceToken: 'token3', deviceType: 'ios' }, + { deviceToken: 'token4', deviceType: 'ios' }, + { deviceToken: 'token5', deviceType: 'ios' }, + ]; + mockSender(true); + const response = await expo.send(data, devices); + + expect(Array.isArray(response)).toBe(true); + expect(response.length).toEqual(devices.length); + response.forEach((res, index) => { + expect(res.transmitted).toEqual(true); + expect(res.device.deviceToken).toEqual(devices[index].deviceToken); + }); + }); + + it('can send multiple failed EXPO request', async () => { + const expo = new EXPO({ }); + const data = { data: { alert: 'alert' } }; + const devices = [ + { deviceToken: 'token1' }, + { deviceToken: 'token2' }, + { deviceToken: 'token3' }, + { deviceToken: 'token4' }, + { deviceToken: 'token5' }, + ]; + mockSender(false); + const response = await expo.send(data, devices); + expect(Array.isArray(response)).toBe(true); + expect(response.length).toEqual(devices.length); + response.forEach((res, index) => { + expect(res.transmitted).toEqual(false); + expect(res.device.deviceToken).toEqual(devices[index].deviceToken); + }); + }); + + it('can run successful payload', async () => { + const payload = { alert: 'alert' }; + const deviceTokens = ['ExpoPush[1]']; + mockExpoPush(true); + const response = await new EXPO({}).sendNotifications(payload, deviceTokens); + expect(response.length).toEqual(1); + expect(response[0].status).toEqual('ok'); + }); + + it('can run failed payload', async () => { + const payload = { alert: 'alert' }; + const deviceTokens = ['ExpoPush[1]']; + mockExpoPush(false); + const response = await new EXPO({}).sendNotifications(payload, deviceTokens); + expect(response.length).toEqual(1); + expect(response[0].status).toEqual('error'); + }); + + it('can run successful payload with wrong types', async () => { + const payload = JSON.stringify({ alert: 'alert' }); + const deviceTokens = ['ExpoPush[1]']; + mockExpoPush(true); + const response = await new EXPO({}).sendNotifications(payload, deviceTokens); + expect(response.length).toEqual(1); + expect(response[0].status).toEqual('ok'); + }); +}); diff --git a/spec/ParsePushAdapter.spec.js b/spec/ParsePushAdapter.spec.js index 17fd80f..c4c6fb9 100644 --- a/spec/ParsePushAdapter.spec.js +++ b/spec/ParsePushAdapter.spec.js @@ -6,6 +6,7 @@ var GCM = require('../src/GCM').default; var WEB = require('../src/WEB').default; var MockAPNProvider = require('./MockAPNProvider'); var FCM = require('../src/FCM').default +var EXPO = require('../src/EXPO').default const path = require('path'); describe('ParsePushAdapter', () => { @@ -24,6 +25,7 @@ describe('ParsePushAdapter', () => { expect(typeof ParsePushAdapterPackage.APNS).toBe('function'); expect(typeof ParsePushAdapterPackage.GCM).toBe('function'); expect(typeof ParsePushAdapterPackage.WEB).toBe('function'); + expect(typeof ParsePushAdapterPackage.EXPO).toBe('function'); expect(typeof ParsePushAdapterPackage.utils).toBe('object'); }); @@ -41,6 +43,9 @@ describe('ParsePushAdapter', () => { senderId: 'senderId', apiKey: 'apiKey' }, + expo: { + apiKey: 'key' + }, ios: [ { cert: new Buffer('testCert'), @@ -67,6 +72,9 @@ describe('ParsePushAdapter', () => { // Check web var webSender = parsePushAdapter.senderMap['web']; expect(webSender instanceof WEB).toBe(true); + // Check expo + var expoSender = parsePushAdapter.senderMap['expo']; + expect(expoSender instanceof EXPO).toBe(true); done(); }); @@ -154,13 +162,13 @@ describe('ParsePushAdapter', () => { it('can get valid push types', (done) => { var parsePushAdapter = new ParsePushAdapter(); - expect(parsePushAdapter.getValidPushTypes()).toEqual(['ios', 'osx', 'tvos', 'android', 'fcm', 'web']); + expect(parsePushAdapter.getValidPushTypes()).toEqual(['ios', 'osx', 'tvos', 'android', 'fcm', 'web', 'expo']); done(); }); it('can classify installation', (done) => { // Mock installations - var validPushTypes = ['ios', 'osx', 'tvos', 'android', 'fcm', 'web']; + var validPushTypes = ['ios', 'osx', 'tvos', 'android', 'fcm', 'web', 'expo']; var installations = [ { deviceType: 'android', @@ -189,6 +197,11 @@ describe('ParsePushAdapter', () => { { deviceType: 'android', deviceToken: undefined + }, + { + deviceType: 'ios', + pushType: 'expo', + deviceToken: 'expoToken' } ]; @@ -199,6 +212,7 @@ describe('ParsePushAdapter', () => { expect(deviceMap['tvos']).toEqual([makeDevice('tvosToken', 'tvos')]); expect(deviceMap['web']).toEqual([makeDevice('webToken', 'web')]); expect(deviceMap['win']).toBe(undefined); + expect(deviceMap['expo']).toEqual([makeDevice('expoToken', 'ios')]); done(); }); @@ -218,11 +232,15 @@ describe('ParsePushAdapter', () => { var webSender = { send: jasmine.createSpy('send') } + var expoSender = { + send: jasmine.createSpy('send') + } var senderMap = { osx: osxSender, ios: iosSender, android: androidSender, web: webSender, + expo: expoSender, }; parsePushAdapter.senderMap = senderMap; // Mock installations @@ -250,6 +268,11 @@ describe('ParsePushAdapter', () => { { deviceType: 'android', deviceToken: undefined + }, + { + deviceType: 'ios', + pushType: 'expo', + deviceToken: 'expoToken' } ]; var data = {}; @@ -283,6 +306,13 @@ describe('ParsePushAdapter', () => { expect(args[1]).toEqual([ makeDevice('webToken', 'web') ]); + // Check expo sender + expect(expoSender.send).toHaveBeenCalled(); + args = expoSender.send.calls.first().args; + expect(args[0]).toEqual(data); + expect(args[1]).toEqual([ + makeDevice('expoToken', 'ios') + ]); done(); }); @@ -375,6 +405,9 @@ describe('ParsePushAdapter', () => { publicKey: 'publicKey', privateKey: 'privateKey', }, + }, + expo: { + }, android: { senderId: 'senderId', @@ -433,6 +466,11 @@ describe('ParsePushAdapter', () => { { deviceType: 'android', deviceToken: undefined + }, + { + deviceType: 'android', + pushType: 'expo', + deviceToken: 'expoToken' } ]; @@ -440,15 +478,18 @@ describe('ParsePushAdapter', () => { parsePushAdapter.send({ data: { alert: 'some' } }, installations).then((results) => { expect(Array.isArray(results)).toBe(true); - // 2x iOS, 1x android, 1x osx, 1x tvos, 1x web - expect(results.length).toBe(6); - results.forEach((result) => { + // 2x iOS, 1x android, 1x osx, 1x tvos, 1x web, 1x expo + expect(results.length).toBe(7); + results.forEach((result) => { expect(typeof result.device).toBe('object'); if (!result.device) { fail('result should have device'); return; } const device = result.device; + if (device.pushType) { + expect(typeof device.pushType).toBe('string'); + } expect(typeof device.deviceType).toBe('string'); expect(typeof device.deviceToken).toBe('string'); if (['ios', 'osx', 'web'].includes(device.deviceType)) { diff --git a/src/EXPO.js b/src/EXPO.js index 1526646..195ba2c 100644 --- a/src/EXPO.js +++ b/src/EXPO.js @@ -2,7 +2,7 @@ import Parse from 'parse'; import log from 'npmlog'; -import { Expo, ExpoPushMessage, ExpoPushTicket } from 'expo-server-sdk'; +import { Expo } from 'expo-server-sdk'; const LOG_PREFIX = 'parse-server-push-adapter EXPO'; @@ -48,7 +48,7 @@ export class EXPO { log.verbose(LOG_PREFIX, `sending to ${length} ${length > 1 ? 'devices' : 'device'}`); - const response = await this.sendNotifications(coreData, deviceTokens, this.options); + const response = await this.sendNotifications(coreData, deviceTokens); log.verbose(LOG_PREFIX, `EXPO Response: %d sent`, response.length); @@ -59,7 +59,8 @@ export class EXPO { const resolution = { transmitted: result.status === 'ok', device: { - deviceToken: token + ...device, + pushType: 'expo' }, response: result, }; @@ -76,10 +77,9 @@ export class EXPO { * @param {Object} options The options for the request * @returns {Object} A promise which is resolved immediately */ - async sendNotifications({alert, title, body, ...payload}, deviceTokens, options) { + async sendNotifications({alert, body, ...payload}, deviceTokens) { const messages = deviceTokens.map((token) => ({ to: token, - title: title, body: body || alert, ...payload })); From c14ff289a4681b3d1df2ed89bcd2b495f4942288 Mon Sep 17 00:00:00 2001 From: Morten Moeller Date: Sat, 11 May 2024 08:27:59 -0500 Subject: [PATCH 7/7] support ParseServer existing error codes to support automatic cleanup through PARSE_SERVER_CLEANUP_INVALID_INSTALLATIONS --- spec/EXPO.spec.js | 4 ++-- src/EXPO.js | 18 +++++++++++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/spec/EXPO.spec.js b/spec/EXPO.spec.js index 65e0f3d..12d2721 100644 --- a/spec/EXPO.spec.js +++ b/spec/EXPO.spec.js @@ -55,7 +55,7 @@ describe('EXPO', () => { const log = require('npmlog'); const expo = new EXPO({ vapidDetails: 'apiKey' }); spyOn(EXPO.prototype, 'sendNotifications').and.callFake(() => { - return Promise.resolve([{ status: 'error', message: 'Failed' }])}); + return Promise.resolve([{ status: 'error', message: 'DeviceNotRegistered' }])}); const data = { data: { alert: 'alert' } }; const devices = [{ deviceToken: 'token' }]; const response = await expo.send(data, devices); @@ -68,7 +68,7 @@ describe('EXPO', () => { expect(response).toEqual([{ device: { deviceToken: 'token', pushType: 'expo' }, - response: { status: 'error', message: 'Failed' }, + response: { status: 'error', message: 'DeviceNotRegistered', error: 'NotRegistered' }, transmitted: false }]); }); diff --git a/src/EXPO.js b/src/EXPO.js index 195ba2c..34a5fa6 100644 --- a/src/EXPO.js +++ b/src/EXPO.js @@ -6,6 +6,22 @@ import { Expo } from 'expo-server-sdk'; const LOG_PREFIX = 'parse-server-push-adapter EXPO'; +function expoResultToParseResponse(result) { + if (result.status === 'ok') { + return result; + } else { + // ParseServer looks for "error", and supports ceratin codes like 'NotRegistered' for + // cleanup. Expo returns slighyly different ones so changing to match what is expected + // This can be taken out if the responsibility gets moved to the adapter itself. + const error = result.message === 'DeviceNotRegistered' ? + 'NotRegistered' : result.message; + return { + error, + ...result + } + } +} + export class EXPO { expo = undefined; /** @@ -62,7 +78,7 @@ export class EXPO { ...device, pushType: 'expo' }, - response: result, + response: expoResultToParseResponse(result), }; resolve(resolution); });