diff --git a/.nvmrc b/.nvmrc index 486db33..6d80269 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -8.9.1 +18.16.0 diff --git a/README.md b/README.md index 4283f8d..832c868 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,9 @@ const parseServerOptions = { android: { /* Android push options */ } + web: { + /* Web push options */ + } }) }, /* Other Parse Server options */ diff --git a/package-lock.json b/package-lock.json index aa4033a..40e54f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,8 @@ "@parse/node-gcm": "1.0.2", "firebase-admin": "12.0.0", "npmlog": "7.0.1", - "parse": "5.0.0" + "parse": "5.0.0", + "web-push": "3.6.7" }, "devDependencies": { "@semantic-release/changelog": "5.0.1", @@ -1843,6 +1844,17 @@ "safer-buffer": "~2.1.0" } }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", @@ -3059,6 +3071,11 @@ "file-uri-to-path": "1.0.0" } }, + "node_modules/bn.js": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", + "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==" + }, "node_modules/bottleneck": { "version": "2.19.5", "resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz", @@ -5394,6 +5411,14 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "engines": { + "node": ">=16" + } + }, "node_modules/http-parser-js": { "version": "0.5.8", "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.8.tgz", @@ -5559,8 +5584,7 @@ "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "devOptional": true + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/ini": { "version": "1.3.8", @@ -6775,6 +6799,11 @@ "node": ">=4" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -6790,8 +6819,7 @@ "node_modules/minimist": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", - "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", - "dev": true + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==" }, "node_modules/minimist-options": { "version": "4.1.0", @@ -13400,6 +13428,82 @@ "node": ">=0.6.0" } }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, + "node_modules/web-push/node_modules/agent-base": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/web-push/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/web-push/node_modules/https-proxy-agent": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz", + "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==", + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/web-push/node_modules/jwa": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", + "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/web-push/node_modules/jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -15217,6 +15321,17 @@ "safer-buffer": "~2.1.0" } }, + "asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "requires": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", @@ -16357,6 +16472,11 @@ "file-uri-to-path": "1.0.0" } }, + "bn.js": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", + "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==" + }, "bottleneck": { "version": "2.19.5", "resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz", @@ -18192,6 +18312,11 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, + "http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==" + }, "http-parser-js": { "version": "0.5.8", "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.8.tgz", @@ -18311,8 +18436,7 @@ "inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "devOptional": true + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "ini": { "version": "1.3.8", @@ -19273,6 +19397,11 @@ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", "dev": true }, + "minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" + }, "minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -19285,8 +19414,7 @@ "minimist": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", - "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", - "dev": true + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==" }, "minimist-options": { "version": "4.1.0", @@ -24297,6 +24425,64 @@ "extsprintf": "^1.2.0" } }, + "web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "requires": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "dependencies": { + "agent-base": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "requires": { + "debug": "^4.3.4" + } + }, + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "requires": { + "ms": "2.1.2" + } + }, + "https-proxy-agent": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz", + "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==", + "requires": { + "agent-base": "^7.0.2", + "debug": "4" + } + }, + "jwa": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", + "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "requires": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "requires": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + } + } + }, "webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/package.json b/package.json index 3ec347e..7f3b9ee 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "@parse/node-gcm": "1.0.2", "firebase-admin": "12.0.0", "npmlog": "7.0.1", - "parse": "5.0.0" + "parse": "5.0.0", + "web-push": "3.6.7" }, "devDependencies": { "@semantic-release/changelog": "5.0.1", diff --git a/spec/ParsePushAdapter.spec.js b/spec/ParsePushAdapter.spec.js index 79c63d7..17fd80f 100644 --- a/spec/ParsePushAdapter.spec.js +++ b/spec/ParsePushAdapter.spec.js @@ -3,6 +3,7 @@ var ParsePushAdapter = ParsePushAdapterPackage.ParsePushAdapter; var randomString = require('../src/PushAdapterUtils').randomString; var APNS = require('../src/APNS').default; var GCM = require('../src/GCM').default; +var WEB = require('../src/WEB').default; var MockAPNProvider = require('./MockAPNProvider'); var FCM = require('../src/FCM').default const path = require('path'); @@ -22,12 +23,20 @@ describe('ParsePushAdapter', () => { expect(typeof ParsePushAdapterPackage.ParsePushAdapter).toBe('function'); expect(typeof ParsePushAdapterPackage.APNS).toBe('function'); expect(typeof ParsePushAdapterPackage.GCM).toBe('function'); + expect(typeof ParsePushAdapterPackage.WEB).toBe('function'); expect(typeof ParsePushAdapterPackage.utils).toBe('object'); }); it('can be initialized', (done) => { // Make mock config var pushConfig = { + web: { + vapidDetails: { + subject: 'test@example.com', + publicKey: 'publicKey', + privateKey: 'privateKey', + }, + }, android: { senderId: 'senderId', apiKey: 'apiKey' @@ -55,6 +64,9 @@ describe('ParsePushAdapter', () => { // Check android var androidSender = parsePushAdapter.senderMap['android']; expect(androidSender instanceof GCM).toBe(true); + // Check web + var webSender = parsePushAdapter.senderMap['web']; + expect(webSender instanceof WEB).toBe(true); done(); }); @@ -142,13 +154,13 @@ describe('ParsePushAdapter', () => { it('can get valid push types', (done) => { var parsePushAdapter = new ParsePushAdapter(); - expect(parsePushAdapter.getValidPushTypes()).toEqual(['ios', 'osx', 'tvos', 'android', 'fcm']); + expect(parsePushAdapter.getValidPushTypes()).toEqual(['ios', 'osx', 'tvos', 'android', 'fcm', 'web']); done(); }); it('can classify installation', (done) => { // Mock installations - var validPushTypes = ['ios', 'osx', 'tvos', 'android', 'fcm']; + var validPushTypes = ['ios', 'osx', 'tvos', 'android', 'fcm', 'web']; var installations = [ { deviceType: 'android', @@ -170,6 +182,10 @@ describe('ParsePushAdapter', () => { deviceType: 'win', deviceToken: 'winToken' }, + { + deviceType: 'web', + deviceToken: 'webToken' + }, { deviceType: 'android', deviceToken: undefined @@ -181,6 +197,7 @@ describe('ParsePushAdapter', () => { expect(deviceMap['ios']).toEqual([makeDevice('iosToken', 'ios')]); expect(deviceMap['osx']).toEqual([makeDevice('osxToken', 'osx')]); expect(deviceMap['tvos']).toEqual([makeDevice('tvosToken', 'tvos')]); + expect(deviceMap['web']).toEqual([makeDevice('webToken', 'web')]); expect(deviceMap['win']).toBe(undefined); done(); }); @@ -198,10 +215,14 @@ describe('ParsePushAdapter', () => { var osxSender = { send: jasmine.createSpy('send') } + var webSender = { + send: jasmine.createSpy('send') + } var senderMap = { osx: osxSender, ios: iosSender, - android: androidSender + android: androidSender, + web: webSender, }; parsePushAdapter.senderMap = senderMap; // Mock installations @@ -218,6 +239,10 @@ describe('ParsePushAdapter', () => { deviceType: 'osx', deviceToken: 'osxToken' }, + { + deviceType: 'web', + deviceToken: 'webToken' + }, { deviceType: 'win', deviceToken: 'winToken' @@ -251,6 +276,13 @@ describe('ParsePushAdapter', () => { expect(args[1]).toEqual([ makeDevice('osxToken', 'osx') ]); + // Check web sender + expect(webSender.send).toHaveBeenCalled(); + args = webSender.send.calls.first().args; + expect(args[0]).toEqual(data); + expect(args[1]).toEqual([ + makeDevice('webToken', 'web') + ]); done(); }); @@ -337,6 +369,13 @@ describe('ParsePushAdapter', () => { it('reports properly results', (done) => { var pushConfig = { + web: { + vapidDetails: { + subject: 'test@example.com', + publicKey: 'publicKey', + privateKey: 'privateKey', + }, + }, android: { senderId: 'senderId', apiKey: 'apiKey' @@ -383,6 +422,10 @@ describe('ParsePushAdapter', () => { deviceToken: '3e72a1baa92a2febd9a254cbd6584f750c70b2350af5fc9052d1d12584b738e6', appIdentifier: 'iosbundleId' // ios and tvos share the same bundleid }, + { + deviceType: 'web', + deviceToken: JSON.stringify({ endpoint: 'https://fcm.googleapis.com/fcm/send/123' }), + }, { deviceType: 'win', deviceToken: 'winToken' @@ -397,8 +440,8 @@ describe('ParsePushAdapter', () => { parsePushAdapter.send({ data: { alert: 'some' } }, installations).then((results) => { expect(Array.isArray(results)).toBe(true); - // 2x iOS, 1x android, 1x osx, 1x tvos - expect(results.length).toBe(5); + // 2x iOS, 1x android, 1x osx, 1x tvos, 1x web + expect(results.length).toBe(6); results.forEach((result) => { expect(typeof result.device).toBe('object'); if (!result.device) { @@ -408,7 +451,7 @@ describe('ParsePushAdapter', () => { const device = result.device; expect(typeof device.deviceType).toBe('string'); expect(typeof device.deviceToken).toBe('string'); - if (device.deviceType === 'ios' || device.deviceType === 'osx') { + if (['ios', 'osx', 'web'].includes(device.deviceType)) { expect(result.transmitted).toBe(true); } else { expect(result.transmitted).toBe(false); @@ -496,7 +539,7 @@ 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 iOS expect(results.length).toBe(1); const result = results[0]; expect(typeof result.device).toBe('object'); diff --git a/spec/WEB.spec.js b/spec/WEB.spec.js new file mode 100644 index 0000000..00d0e2f --- /dev/null +++ b/spec/WEB.spec.js @@ -0,0 +1,199 @@ +const WEB = require('../src/WEB').default; +const webpush = require('web-push'); + +const pushSubscription = { + endpoint: '', + keys: { + p256dh: '', + auth: '', + }, +}; + +const vapidDetails = { + subject: 'test@example.com', + publicKey: 'publicKey', + privateKey: 'privateKey', +}; + +function mockSender() { + return spyOn(WEB, 'sendNotifications').and.callFake((payload, tokens, options) => { + const { success } = options; + const response = { + sent: success ? tokens.length : 0, + failed: !success ? tokens.length : 0, + results: tokens.map(() => { + return { + result: success ? 201 : undefined, + error: !success ? 'push subscription has unsubscribed or expired.' : undefined, + }; + }), + }; + return Promise.resolve(response); + }); +} + +function mockWebPush(success) { + return spyOn(webpush, 'sendNotification').and.callFake((deviceToken, payload, options) => { + if (success) { + return Promise.resolve({ statusCode: 201 }); + } + return Promise.reject({ body: 'push subscription has unsubscribed or expired.' }); + }); +} + +describe('WEB', () => { + it('can initialize', () => { + const args = { vapidDetails }; + const web = new WEB(args); + expect(web.options.vapidDetails).toBe(args.vapidDetails); + }); + + it('can throw on initializing with invalid args', () => { + expect(function() { new WEB(123); }).toThrow(); + expect(function() { new WEB({ apisKey: 'apiKey' }); }).toThrow(); + expect(function() { new WEB(undefined); }).toThrow(); + }); + + it('does log on invalid APNS notification', async () => { + const log = require('npmlog'); + const spy = spyOn(log, 'warn'); + const web = new WEB({ vapidDetails }); + web.send(); + expect(spy).toHaveBeenCalled(); + }); + + it('can send successful WEB request', async () => { + const log = require('npmlog'); + const spy = spyOn(log, 'verbose'); + + const web = new WEB({ vapidDetails: 'apiKey' }); + spyOn(WEB, 'sendNotifications').and.callFake(() => { + return Promise.resolve({ + sent: 1, + failed: 0, + results: [{ result: 201 }], + }); + }); + const data = { data: { alert: 'alert' } }; + const devices = [{ deviceToken: 'token' }]; + const response = await web.send(data, devices); + expect(WEB.sendNotifications).toHaveBeenCalled(); + const args = WEB.sendNotifications.calls.first().args; + expect(args.length).toEqual(3); + expect(args[0]).toEqual(data.data); + expect(args[1]).toEqual(['token']); + expect(args[2].vapidDetails).toEqual('apiKey'); + expect(spy).toHaveBeenCalled(); + expect(response).toEqual([{ + device: { deviceToken: 'token', deviceType: 'web' }, + response: 201, + transmitted: true + }]); + }); + + it('can send failed WEB request', async () => { + const log = require('npmlog'); + const spy = spyOn(log, 'error'); + + const web = new WEB({ vapidDetails: 'apiKey' }); + spyOn(WEB, 'sendNotifications').and.callFake(() => { + return Promise.resolve({ + sent: 0, + failed: 1, + results: [{ error: 'push subscription has unsubscribed or expired.' }], + }); + }); + const data = { data: { alert: 'alert' } }; + const devices = [{ deviceToken: 'token' }]; + const response = await web.send(data, devices); + expect(WEB.sendNotifications).toHaveBeenCalled(); + const args = WEB.sendNotifications.calls.first().args; + expect(args.length).toEqual(3); + expect(args[0]).toEqual(data.data); + expect(args[1]).toEqual(['token']); + expect(args[2].vapidDetails).toEqual('apiKey'); + expect(spy).toHaveBeenCalled(); + expect(response).toEqual([{ + device: { deviceToken: 'token', deviceType: 'web' }, + response: 'push subscription has unsubscribed or expired.', + transmitted: false + }]); + }); + + it('can send multiple successful WEB request', async () => { + const web = new WEB({ vapidDetails: 'apiKey', success: true }); + const data = { data: { alert: 'alert' } }; + const devices = [ + { deviceToken: 'token1' }, + { deviceToken: 'token2' }, + { deviceToken: 'token3' }, + { deviceToken: 'token4' }, + { deviceToken: 'token5' }, + ]; + mockSender(); + const response = await web.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).toEqual(devices[index]); + }); + }); + + it('can send multiple failed WEB request', async () => { + const log = require('npmlog'); + const spy = spyOn(log, 'error'); + + const web = new WEB({ vapidDetails: 'apiKey', success: false }); + const data = { data: { alert: 'alert' } }; + const devices = [ + { deviceToken: 'token1' }, + { deviceToken: 'token2' }, + { deviceToken: 'token3' }, + { deviceToken: 'token4' }, + { deviceToken: 'token5' }, + ]; + mockSender(); + const response = await web.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).toEqual(devices[index]); + }); + expect(spy).toHaveBeenCalledWith('parse-server-push-adapter WEB', 'send errored: %d out of %d failed with error %s', 5, 5, 'push subscription has unsubscribed or expired.'); + }); + + it('can run successful payload', async () => { + const payload = { alert: 'alert' }; + const deviceTokens = [JSON.stringify(pushSubscription)]; + mockWebPush(true); + const response = await WEB.sendNotifications(payload, deviceTokens); + expect(response.sent).toEqual(1); + expect(response.failed).toEqual(0); + expect(response.results.length).toEqual(1); + expect(response.results[0].result).toEqual(201); + }); + + it('can run failed payload', async () => { + const payload = { alert: 'alert' }; + const deviceTokens = [JSON.stringify(pushSubscription)]; + mockWebPush(false); + const response = await WEB.sendNotifications(payload, deviceTokens); + expect(response.sent).toEqual(0); + expect(response.failed).toEqual(1); + expect(response.results.length).toEqual(1); + expect(response.results[0].error).toEqual('push subscription has unsubscribed or expired.'); + }); + + it('can run successful payload with wrong types', async () => { + const payload = JSON.stringify({ alert: 'alert' }); + const deviceTokens = [pushSubscription]; + mockWebPush(true); + const response = await WEB.sendNotifications(payload, deviceTokens); + expect(response.sent).toEqual(1); + expect(response.failed).toEqual(0); + expect(response.results.length).toEqual(1); + expect(response.results[0].result).toEqual(201); + }); +}); diff --git a/src/ParsePushAdapter.js b/src/ParsePushAdapter.js index 446fd85..2e35d74 100644 --- a/src/ParsePushAdapter.js +++ b/src/ParsePushAdapter.js @@ -4,6 +4,7 @@ import log from 'npmlog'; import APNS from './APNS'; import GCM from './GCM'; import FCM from './FCM'; +import WEB from './WEB'; import { classifyInstallations } from './PushAdapterUtils'; const LOG_PREFIX = 'parse-server-push-adapter'; @@ -13,7 +14,7 @@ export default class ParsePushAdapter { supportsPushTracking = true; constructor(pushConfig = {}) { - this.validPushTypes = ['ios', 'osx', 'tvos', 'android', 'fcm']; + this.validPushTypes = ['ios', 'osx', 'tvos', 'android', 'fcm', 'web']; this.senderMap = {}; // used in PushController for Dashboard Features this.feature = { @@ -37,6 +38,9 @@ export default class ParsePushAdapter { this.senderMap[pushType] = new APNS(pushConfig[pushType]); } break; + case 'web': + this.senderMap[pushType] = new WEB(pushConfig[pushType]); + break; case 'android': case 'fcm': if (pushConfig[pushType].hasOwnProperty('firebaseServiceAccount')) { diff --git a/src/WEB.js b/src/WEB.js new file mode 100644 index 0000000..503438e --- /dev/null +++ b/src/WEB.js @@ -0,0 +1,106 @@ +"use strict"; + +import Parse from 'parse'; +import log from 'npmlog'; +import webpush from 'web-push'; + +const LOG_PREFIX = 'parse-server-push-adapter WEB'; + +export class WEB { + /** + * Create a new WEB push adapter. + * + * @param {Object} args https://github.com/web-push-libs/web-push#api-reference + */ + constructor(args) { + if (typeof args !== 'object' || !args.vapidDetails) { + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, 'WEB Push Configuration is invalid'); + } + 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 WEB.sendNotifications(coreData, deviceTokens, this.options); + const { results, sent, failed } = response; + if (sent) { + log.verbose(LOG_PREFIX, `WEB Response: %d out of %d sent successfully`, sent, results.length); + } + if (failed) { + log.error(LOG_PREFIX, `send errored: %d out of %d failed with error %s`, failed, results.length, 'push subscription has unsubscribed or expired.'); + } + deviceTokens.forEach((token, index) => { + const resolve = resolvers[index]; + const { result, error } = results[index]; + const device = devicesMap[token]; + device.deviceType = 'web'; + const resolution = { + device, + response: error || result, + transmitted: !error, + }; + 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 + */ + static async sendNotifications(payload, deviceTokens, options) { + const promises = deviceTokens.map((deviceToken) => { + if (typeof deviceToken === 'string') { + deviceToken = JSON.parse(deviceToken); + } + if (typeof payload === 'object') { + payload = JSON.stringify(payload); + } + return webpush.sendNotification(deviceToken, payload, options); + }); + const allResults = await Promise.allSettled(promises); + const response = { + sent: 0, + failed: 0, + results: [], + }; + allResults.forEach((result) => { + if (result.status === 'fulfilled') { + response.sent += 1; + response.results.push({ result: result.value.statusCode }); + } else { + response.failed += 1; + response.results.push({ error: result.reason.body }); + } + }); + return response; + } +} + +export default WEB; diff --git a/src/index.js b/src/index.js index b50e3ac..8360c4b 100644 --- a/src/index.js +++ b/src/index.js @@ -1,7 +1,7 @@ "use strict"; // ParsePushAdapter is the default implementation of -// PushAdapter, it uses GCM for android push and APNS -// for ios push. +// PushAdapter, it uses GCM for android push, APNS for ios push. +// WEB for web push. import log from 'npmlog'; /* istanbul ignore if */ @@ -12,7 +12,8 @@ if (process.env.VERBOSE || process.env.VERBOSE_PARSE_SERVER_PUSH_ADAPTER) { import ParsePushAdapter from './ParsePushAdapter'; import GCM from './GCM'; import APNS from './APNS'; +import WEB from './WEB'; import * as utils from './PushAdapterUtils'; export default ParsePushAdapter; -export { ParsePushAdapter, APNS, GCM, utils }; +export { ParsePushAdapter, APNS, GCM, WEB, utils };