├── .prettierrc ├── pull_request_template.md ├── .github └── workflows │ ├── build.yml │ ├── publish.yml │ └── release.yml ├── utils.js ├── eslint.config.mjs ├── example.js ├── LICENSE ├── .gitignore ├── package.json ├── CHANGELOG.md ├── __tests__ ├── integration │ └── publish.test.js ├── deleteUser.test.js ├── generateToken.test.js ├── PushNotifications.test.js ├── publishToUsers.test.js └── publishToInterests.test.js ├── index.test-d.ts ├── README.md ├── push-notifications.js └── index.d.ts /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | Add a short description of the change. If this is related to an issue, please add a reference to the issue. 4 | 5 | ## CHANGELOG 6 | 7 | * [CHANGED] Describe your change here. Look at CHANGELOG.md to see the format. 8 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | node-version: [10.x, 12.x, 14.x, 15.x] 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v2 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | - run: yarn install 25 | - run: yarn test:unit 26 | - run: yarn test:integration 27 | 28 | -------------------------------------------------------------------------------- /utils.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | 3 | // function code taken from http://blog.tompawlak.org/how-to-generate-random-values-nodejs-javascript 4 | function randomValueHex(len) { 5 | return crypto 6 | .randomBytes(Math.ceil(len / 2)) 7 | .toString('hex') 8 | .slice(0, len) 9 | .toUpperCase(); 10 | } 11 | 12 | const USERS_STRING_MAX_LENGTH = 164; 13 | const INTEREST_STRING_MAX_LENGTH = 164; 14 | const USERS_ARRAY_MAX_LENGTH = 1000; 15 | const INTEREST_ARRAY_MAX_LENGTH = 100; 16 | 17 | module.exports = { 18 | randomValueHex, 19 | USERS_STRING_MAX_LENGTH, 20 | INTEREST_STRING_MAX_LENGTH, 21 | USERS_ARRAY_MAX_LENGTH, 22 | INTEREST_ARRAY_MAX_LENGTH, 23 | }; 24 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import path from "node:path"; 3 | import { fileURLToPath } from "node:url"; 4 | import js from "@eslint/js"; 5 | import { FlatCompat } from "@eslint/eslintrc"; 6 | 7 | const __filename = fileURLToPath(import.meta.url); 8 | const __dirname = path.dirname(__filename); 9 | const compat = new FlatCompat({ 10 | baseDirectory: __dirname, 11 | recommendedConfig: js.configs.recommended, 12 | allConfig: js.configs.all 13 | }); 14 | 15 | export default [...compat.extends("eslint:recommended", "prettier"), { 16 | languageOptions: { 17 | globals: { 18 | ...globals.node, 19 | ...globals.jest, 20 | }, 21 | }, 22 | 23 | rules: { 24 | "no-console": 0, 25 | indent: 2, 26 | quotes: [2, "single"], 27 | "linebreak-style": [2, "unix"], 28 | semi: [2, "always"], 29 | }, 30 | }]; -------------------------------------------------------------------------------- /example.js: -------------------------------------------------------------------------------- 1 | const PushNotifications = require('./push-notifications.js'); 2 | 3 | let beamsClient = new PushNotifications({ 4 | instanceId: '8f9a6e22-2483-49aa-8552-125f1a4c5781', 5 | secretKey: 'C54D42FB7CD2D408DDB22D7A0166F1D', 6 | }); 7 | 8 | beamsClient 9 | .publishToInterests(['donuts'], { 10 | apns: { 11 | aps: { 12 | alert: 'Hi!', 13 | }, 14 | }, 15 | }) 16 | .then((response) => { 17 | console.log('Response:', response); 18 | }) 19 | .catch((error) => { 20 | console.error('Error:', error); 21 | }); 22 | 23 | beamsClient 24 | .publishToUsers(['user-12345'], { 25 | fcm: { 26 | notification: { 27 | title: 'Hello, world!', 28 | notification: 'What a time to be alive', 29 | }, 30 | }, 31 | }) 32 | .then((response) => { 33 | console.log('Response:', response); 34 | }) 35 | .catch((error) => { 36 | console.error('Error:', error); 37 | }); 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License (MIT) 2 | 3 | Copyright (c) 2018 Pusher 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | # Pusher ignore 64 | yarn.lock 65 | package-lock.json 66 | tsconfig.json 67 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@pusher/push-notifications-server", 3 | "version": "1.2.7", 4 | "description": "NodeJS Server SDK for Pusher Push Notifications", 5 | "main": "push-notifications.js", 6 | "repository": "https://github.com/pusher/push-notifications-node", 7 | "author": "Pusher ", 8 | "license": "MIT", 9 | "devDependencies": { 10 | "eslint": "^9.17.0", 11 | "eslint-config-prettier": "^9.1.0", 12 | "jest": "^29.7.0", 13 | "nock": "^13.5.6", 14 | "prettier": "3.4.2", 15 | "tsd": "^0.31.2" 16 | }, 17 | "scripts": { 18 | "format": "prettier \"./{*.js,*.d.ts}\" --write", 19 | "lint": "npx eslint **/*.js && npx prettier \"./{*.js,*.d.ts}\" -l", 20 | "test": "yarn test:ts && yarn test:unit && yarn test:integration", 21 | "test:ts": "tsd .", 22 | "test:integration": "jest --testPathPattern='/__tests__/integration/'", 23 | "test:unit": "jest --testPathIgnorePatterns='/integration/'" 24 | }, 25 | "keywords": [ 26 | "push notifications", 27 | "notifications", 28 | "pusher", 29 | "realtime" 30 | ], 31 | "types": "index.d.ts", 32 | "dependencies": { 33 | "jsonwebtoken": "^9.0.2" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.2.7 4 | 5 | * [CHANGED] jsonwebtoken package version from v8 to v9 6 | 7 | ## 1.2.6 8 | 9 | * [FIXED] Fix the hide_notification_if_site_has_focus TypeScript 10 | * [FIXED] Fixes the incorrect TypeScript signature for the generateToken function 11 | 12 | ## 1.2.5 13 | 14 | * [CHANGED] Added `data` type for `ApnsPayload` 15 | 16 | ## 1.2.4 17 | 18 | * [CHANGED] Remove some redundant steps from release action 19 | 20 | ## 1.2.3 21 | 22 | * [CHANGED] Switch from Travis CI to GH actions 23 | 24 | ## 1.2.2 25 | 26 | * [REMOVED] `requests` dependency and replaced with standard library 27 | 28 | ## 1.2.1 29 | 30 | * [FIXED] Added missing TypeScript types for web push publish payload 31 | 32 | ## 1.2.0 33 | 34 | * [ADDED] More payload fields to TypeScript bindings (collapse\_key, expiration, etc.) 35 | 36 | ## 1.1.1 37 | 38 | * [FIXED] Fixes double JSON encoding 39 | 40 | ## 1.1.0 41 | 42 | * [ADDED] Support for publishing to Authenticated Users 43 | * [ADDED] TypeScript typings for Authenticated Users 44 | * [DEPRECATED] Deprecates `publish` in favour of `publishToInterests` 45 | 46 | ## 1.0.1 47 | 48 | * [FIXED] Accessing property on undefined object on non-json response 49 | 50 | ## 1.0.0 51 | 52 | * [ADDED] Changelog for GA release 53 | -------------------------------------------------------------------------------- /__tests__/integration/publish.test.js: -------------------------------------------------------------------------------- 1 | const PushNotifications = require('../../push-notifications'); 2 | 3 | const instanceId = '25c7b7c2-cabc-4e6b-b7d0-c9ad5c787e50'; 4 | const secretKey = 5 | 'A2DB402A49D7F7F45044D2049F5B2CDEB793564296B396A9B15A727ABE93EB50'; 6 | let beamsInstance; 7 | 8 | describe('publishToInterests', () => { 9 | beforeEach(() => { 10 | beamsInstance = new PushNotifications({ instanceId, secretKey }); 11 | }); 12 | test('Publish request succeeds', () => { 13 | const body = { 14 | apns: { 15 | aps: { 16 | alert: 'Hello there' 17 | } 18 | } 19 | }; 20 | 21 | return expect( 22 | beamsInstance.publishToInterests(['donuts'], body) 23 | ).resolves.toHaveProperty('publishId', expect.any(String)); 24 | }); 25 | }); 26 | 27 | describe('publishToUsers', () => { 28 | beforeEach(() => { 29 | beamsInstance = new PushNotifications({ instanceId, secretKey }); 30 | }); 31 | 32 | test('Publish request succeeds', () => { 33 | const body = { 34 | apns: { 35 | aps: { 36 | alert: 'Hello there' 37 | } 38 | } 39 | }; 40 | 41 | return expect( 42 | beamsInstance.publishToUsers(['user0001'], body) 43 | ).resolves.toHaveProperty('publishId', expect.any(String)); 44 | }); 45 | }); 46 | 47 | describe('deleteUsers', () => { 48 | beforeEach(() => { 49 | beamsInstance = new PushNotifications({ instanceId, secretKey }); 50 | }); 51 | 52 | test('Deletion request succeeds', () => { 53 | return expect( 54 | beamsInstance.deleteUser('user0001') 55 | ).resolves.toBeUndefined(); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /__tests__/deleteUser.test.js: -------------------------------------------------------------------------------- 1 | require('jest'); 2 | const nock = require('nock'); 3 | const PushNotifications = require('../push-notifications.js'); 4 | const { USERS_STRING_MAX_LENGTH } = require('../utils'); 5 | 6 | describe('deleteUser', () => { 7 | let pn; 8 | 9 | beforeEach(() => { 10 | pn = new PushNotifications({ 11 | instanceId: '1234', 12 | secretKey: '1234' 13 | }); 14 | }); 15 | 16 | afterEach(function() { 17 | nock.cleanAll(); 18 | }); 19 | 20 | it('should make the correct http request with valid params (no response body)', () => { 21 | nock(new RegExp('/.*/')) 22 | .delete(new RegExp('/.*/')) 23 | .reply(200); 24 | 25 | const userId = 'ron.weasley@hogwarts.ac.uk'; 26 | return expect(pn.deleteUser(userId)).resolves.toBe(undefined); 27 | }); 28 | 29 | it('should make the correct http request with valid params (with response body)', () => { 30 | nock(new RegExp('/.*/')) 31 | .delete(new RegExp('/.*/')) 32 | .reply(() => { 33 | return [200, JSON.stringify({ statusCode: 200 })]; 34 | }); 35 | 36 | const userId = 'ron.weasley@hogwarts.ac.uk'; 37 | return expect(pn.deleteUser(userId)).resolves.toEqual({ statusCode: 200 }); 38 | }); 39 | 40 | it('should fail if no user id is provided', () => { 41 | expect(pn.deleteUser()).rejects.toThrowError( 42 | 'User ID argument is required' 43 | ); 44 | }); 45 | 46 | it('should fail if the user id is too long', () => { 47 | const aVeryLongString = 'a'.repeat(USERS_STRING_MAX_LENGTH) + 'b'; 48 | return expect(pn.deleteUser(aVeryLongString)).rejects.toThrowError( 49 | 'User ID argument is too long' 50 | ); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - uses: actions/setup-node@v2 13 | with: 14 | node-version: 10.x 15 | - run: yarn install 16 | - run: yarn test:unit 17 | - run: yarn test:integration 18 | 19 | publish-npm: 20 | needs: build 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v2 24 | - uses: actions/setup-node@v2 25 | with: 26 | node-version: 10.x 27 | registry-url: https://registry.npmjs.org/ 28 | scope: '@pusher' 29 | - run: yarn install 30 | - run: yarn publish --verbose 31 | env: 32 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 33 | 34 | create-release: 35 | needs: publish-npm 36 | name: Create Release 37 | runs-on: ubuntu-latest 38 | steps: 39 | - name: Checkout code 40 | uses: actions/checkout@v2 41 | - name: Setup git 42 | run: | 43 | git config user.email "pusher-ci@pusher.com" 44 | git config user.name "Pusher CI" 45 | - name: Prepare description 46 | run: | 47 | csplit -s CHANGELOG.md "/##/" {1} 48 | cat xx01 > CHANGELOG.tmp 49 | - name: Prepare tag 50 | run: | 51 | export TAG=$(head -1 CHANGELOG.tmp | cut -d' ' -f2) 52 | echo "TAG=$TAG" >> $GITHUB_ENV 53 | - name: Create Release 54 | uses: actions/create-release@v1 55 | env: 56 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 57 | with: 58 | tag_name: ${{ env.TAG }} 59 | release_name: ${{ env.TAG }} 60 | body_path: CHANGELOG.tmp 61 | draft: false 62 | prerelease: false 63 | -------------------------------------------------------------------------------- /__tests__/generateToken.test.js: -------------------------------------------------------------------------------- 1 | require('jest'); 2 | const jwt = require('jsonwebtoken'); 3 | const PushNotifications = require('../push-notifications.js'); 4 | const { USERS_STRING_MAX_LENGTH } = require('../utils'); 5 | 6 | describe('generateToken', () => { 7 | let pn; 8 | beforeEach(() => { 9 | pn = new PushNotifications({ 10 | instanceId: '12345', 11 | secretKey: '12345' 12 | }); 13 | }); 14 | 15 | it('should fail if no user id is provided', () => { 16 | expect(() => pn.generateToken()).toThrow('userId argument is required'); 17 | }); 18 | 19 | it('should fail if no user id is the empty string', () => { 20 | expect(() => pn.generateToken('')).toThrow( 21 | 'userId cannot be the empty string' 22 | ); 23 | }); 24 | 25 | it('should fail if the user id exceeds the permitted max length', () => { 26 | expect(() => pn.generateToken('a'.repeat(165))).toThrow( 27 | `userId is longer than the maximum length of ${USERS_STRING_MAX_LENGTH}` 28 | ); 29 | }); 30 | 31 | it('should fail if the user if is not a string', () => { 32 | const userId = false; 33 | expect(() => pn.generateToken(userId).toThrow( 34 | 'userId must be a string' 35 | )); 36 | }); 37 | 38 | it('should return a valid JWT token if everything is correct', () => { 39 | const userId = 'hermione.granger@hogwarts.ac.uk'; 40 | const options = { 41 | expiresIn: '24h', 42 | issuer: `https://${pn.instanceId}.pushnotifications.pusher.com`, 43 | subject: userId 44 | }; 45 | const expected = { 46 | token: jwt.sign({}, pn.secretKey, options) 47 | }; 48 | const actual = pn.generateToken(userId); 49 | expect(expected).toEqual(actual); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /index.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectType } from 'tsd'; 2 | import { createSecretKey, randomFillSync} from 'crypto'; 3 | 4 | import PushNotifications = require('.'); 5 | 6 | const secretKey = createSecretKey(randomFillSync(Buffer.alloc(32))).export().toString('hex'); 7 | 8 | // Create a client 9 | const client = new PushNotifications({ 10 | instanceId: 'some-instance-id', 11 | secretKey: secretKey 12 | }); 13 | 14 | // Publish to interests 15 | expectType>( 16 | client.publishToInterests(['hello'], { 17 | apns: { 18 | aps: { 19 | alert: { 20 | title: 'title', 21 | body: 'body' 22 | } 23 | } 24 | }, 25 | fcm: { 26 | notification: { 27 | title: 'title', 28 | body: 'body' 29 | } 30 | }, 31 | web: { 32 | notification: { 33 | title: 'title', 34 | body: 'body' 35 | } 36 | } 37 | }) 38 | ); 39 | 40 | // Publish to users 41 | expectType>( 42 | client.publishToUsers(['user-alice'], { 43 | apns: { 44 | aps: { 45 | alert: { 46 | title: 'title', 47 | body: 'body' 48 | } 49 | } 50 | }, 51 | fcm: { 52 | notification: { 53 | title: 'title', 54 | body: 'body' 55 | } 56 | }, 57 | web: { 58 | notification: { 59 | title: 'title', 60 | body: 'body' 61 | } 62 | } 63 | }) 64 | ); 65 | 66 | // Generate Beams token 67 | expectType<{token: PushNotifications.Token}>(client.generateToken('user-alice')); 68 | 69 | // Delete User 70 | expectType>(client.deleteUser('user-alice')); 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build](https://github.com/pusher/push-notifications-node/actions/workflows/build.yml/badge.svg)](https://github.com/pusher/push-notifications-node/actions/workflows/build.yml) [![npm](https://img.shields.io/npm/v/@pusher/push-notifications-server)](https://www.npmjs.com/package/@pusher/push-notifications-server) [![npm](https://img.shields.io/npm/dm/@pusher/push-notifications-server)](https://www.npmjs.com/package/@pusher/push-notifications-server) 2 | # Pusher Beams Node.js Server SDK 3 | Full documentation for this SDK can be found [here](https://pusher.com/docs/beams/reference/server-sdk-node/) 4 | 5 | ## Installation 6 | The Beams Node.js server SDK is available on npm [here](https://www.npmjs.com/package/@pusher/push-notifications-server). 7 | 8 | You can install this SDK by using [npm](https://npmjs.com): 9 | ```bash 10 | $ npm install @pusher/push-notifications-server --save 11 | ``` 12 | 13 | Or [yarn](https://yarnpkg.com/) if you prefer: 14 | ```bash 15 | $ yarn add @pusher/push-notifications-server 16 | ``` 17 | 18 | ## Usage 19 | ### Configuring the SDK for Your Instance 20 | Use your instance id and secret (you can get these from the [dashboard](https://dash.pusher.com/beams)) to create a Beams PushNotifications instance: 21 | ```javascript 22 | const PushNotifications = require('@pusher/push-notifications-server'); 23 | 24 | let pushNotifications = new PushNotifications({ 25 | instanceId: 'YOUR_INSTANCE_ID_HERE', 26 | secretKey: 'YOUR_SECRET_KEY_HERE' 27 | }); 28 | ``` 29 | 30 | ### Publishing a Notification 31 | Once you have created your Beams PushNotifications instance, you can immediately publish a push notification to your devices, using [Device Interests](https://pusher.com/docs/beams/concepts/device-interests/): 32 | ```javascript 33 | pushNotifications.publishToInterests(['hello'], { 34 | apns: { 35 | aps: { 36 | alert: 'Hello!' 37 | } 38 | }, 39 | fcm: { 40 | notification: { 41 | title: 'Hello', 42 | body: 'Hello, world!' 43 | } 44 | } 45 | }).then((publishResponse) => { 46 | console.log('Just published:', publishResponse.publishId); 47 | }).catch((error) => { 48 | console.log('Error:', error); 49 | }); 50 | ``` 51 | -------------------------------------------------------------------------------- /__tests__/PushNotifications.test.js: -------------------------------------------------------------------------------- 1 | require('jest'); 2 | const PushNotifications = require('../push-notifications.js'); 3 | 4 | describe('PushNotifications Constructor', () => { 5 | it('should accept valid parameters', () => { 6 | new PushNotifications({ 7 | instanceId: '12345', 8 | secretKey: '12345' 9 | }); 10 | }); 11 | 12 | it('should fail if no options passed', () => { 13 | expect(() => PushNotifications()).toThrow( 14 | 'PushNotifications options object is required' 15 | ); 16 | expect(() => PushNotifications(null)).toThrow( 17 | 'PushNotifications options object is required' 18 | ); 19 | }); 20 | 21 | it('should fail if no instanceId passed', () => { 22 | expect(() => PushNotifications({ secretKey: '1234' })).toThrow( 23 | '"instanceId" is required in PushNotifications options' 24 | ); 25 | }); 26 | 27 | it('should fail if no secretKey passed', () => { 28 | expect(() => PushNotifications({ instanceId: '1234' })).toThrow( 29 | '"secretKey" is required in PushNotifications options' 30 | ); 31 | }); 32 | 33 | it('should fail if instanceId is not a string', () => { 34 | expect(() => 35 | PushNotifications({ instanceId: false, secretKey: '1234' }) 36 | ).toThrow(); 37 | }); 38 | 39 | it('should fail if secretKey is not a string', () => { 40 | expect(() => 41 | PushNotifications({ instanceId: '1234', secretKey: false }) 42 | ).toThrow(); 43 | }); 44 | 45 | it('should fail if endpoint is not a string', () => { 46 | expect(() => 47 | PushNotifications({ 48 | instanceId: '1234', 49 | secretKey: '1234', 50 | endpoint: false 51 | }) 52 | ).toThrow(); 53 | }); 54 | 55 | it('should set endpoint to the correct default', () => { 56 | const pn = new PushNotifications({ 57 | instanceId: 'INSTANCE_ID', 58 | secretKey: 'SECRET_KEY' 59 | }); 60 | expect(pn.endpoint).toEqual( 61 | 'INSTANCE_ID.pushnotifications.pusher.com' 62 | ); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | pull_request: 5 | types: [ labeled ] 6 | branches: 7 | - master 8 | 9 | jobs: 10 | prepare-release: 11 | name: Prepare release 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Set major release 16 | if: ${{ github.event.label.name == 'release-major' }} 17 | run: echo "RELEASE=major" >> $GITHUB_ENV 18 | - name: Set minor release 19 | if: ${{ github.event.label.name == 'release-minor' }} 20 | run: echo "RELEASE=minor" >> $GITHUB_ENV 21 | - name: Set patch release 22 | if: ${{ github.event.label.name == 'release-patch' }} 23 | run: echo "RELEASE=patch" >> $GITHUB_ENV 24 | - name: Check release env 25 | run: | 26 | if [[ -z "${{ env.RELEASE }}" ]]; 27 | then 28 | echo "You need to set a release label on PRs to the main branch" 29 | exit 1 30 | else 31 | exit 0 32 | fi 33 | - name: Install semver-tool 34 | run: | 35 | export DIR=$(mktemp -d) 36 | cd $DIR 37 | curl https://github.com/fsaintjacques/semver-tool/archive/3.2.0.tar.gz -L -o semver.tar.gz 38 | tar -xvf semver.tar.gz 39 | sudo cp semver-tool-3.2.0/src/semver /usr/local/bin 40 | - name: Bump version 41 | run: | 42 | export CURRENT=$(npm show @pusher/push-notifications-server | grep latest | cut -d':' -f 2 | xargs) 43 | export NEW_VERSION=$(semver bump ${{ env.RELEASE }} $CURRENT) 44 | echo "VERSION=$NEW_VERSION" >> $GITHUB_ENV 45 | - name: Checkout code 46 | uses: actions/checkout@v2 47 | - name: Setup git 48 | run: | 49 | git config user.email "pusher-ci@pusher.com" 50 | git config user.name "Pusher CI" 51 | git fetch 52 | git checkout ${{ github.event.pull_request.head.ref }} 53 | - name: Prepare CHANGELOG 54 | run: | 55 | echo "${{ github.event.pull_request.body }}" | csplit -s - "/##/" 56 | echo "# Changelog 57 | 58 | ## ${{ env.VERSION }} 59 | " >> CHANGELOG.tmp 60 | grep "^*" xx01 >> CHANGELOG.tmp 61 | grep -v "^# " CHANGELOG.md >> CHANGELOG.tmp 62 | cp CHANGELOG.tmp CHANGELOG.md 63 | - name: Prepare README 64 | run: | 65 | export MAJOR=$(echo "${{ env.VERSION }}" | cut -d'.' -f1) 66 | export MINOR=$(echo "${{ env.VERSION }}" | cut -d'.' -f2) 67 | - name: Prepare update 68 | run: | 69 | sed -i "s|\"version\": \"[^\"]*\"|\"version\": \"${{ env.VERSION }}\"|" package.json 70 | sed -i "s|const SDK_VERSION = '[^']*'|const SDK_VERSION = '${{ env.VERSION }}'|" push-notifications.js 71 | sed -i "s|'pusher-push-notifications-node [^']*'|'pusher-push-notifications-node ${{ env.VERSION }}'|g" __tests__/*.js 72 | - name: Commit changes 73 | run: | 74 | git add CHANGELOG.md README.md package.json push-notifications.js __tests__/*.js 75 | git commit -m "Bump to version ${{ env.VERSION }}" 76 | - name: Push 77 | run: git push 78 | 79 | -------------------------------------------------------------------------------- /__tests__/publishToUsers.test.js: -------------------------------------------------------------------------------- 1 | require('jest'); 2 | const nock = require('nock'); 3 | const PushNotifications = require('../push-notifications.js'); 4 | const { randomValueHex, USERS_ARRAY_MAX_LENGTH } = require('../utils'); 5 | 6 | describe('publishToUsers', () => { 7 | let pn; 8 | 9 | beforeEach(function() { 10 | pn = new PushNotifications({ 11 | instanceId: '1234', 12 | secretKey: '1234' 13 | }); 14 | }); 15 | 16 | afterEach(function() { 17 | nock.cleanAll(); 18 | }); 19 | 20 | it('should make the correct http request with valid params', () => { 21 | let uri, headers, body; 22 | nock(new RegExp('/.*/')) 23 | .post(new RegExp('/.*/')) 24 | .reply(function(u, b) { 25 | uri = u; 26 | headers = this.req.headers; 27 | body = b; 28 | return [ 29 | 200, 30 | { 31 | publishId: '123456' 32 | } 33 | ]; 34 | }); 35 | 36 | const pn = new PushNotifications({ 37 | instanceId: 'INSTANCE_ID', 38 | secretKey: 'SECRET_KEY' 39 | }); 40 | const response = pn.publishToUsers(['harry.potter@hogwarts.ac.uk'], { 41 | apns: { 42 | aps: { 43 | alert: 'Hi!' 44 | } 45 | } 46 | }); 47 | 48 | return response.then(data => { 49 | expect(data).toEqual({ 50 | publishId: '123456' 51 | }); 52 | expect(uri).toEqual( 53 | '/publish_api/v1/instances/INSTANCE_ID/publishes/users' 54 | ); 55 | expect(headers).toEqual({ 56 | accept: 'application/json', 57 | 'content-type': 'application/json', 58 | 'content-length': 72, 59 | authorization: 'Bearer SECRET_KEY', 60 | 'x-pusher-library': 'pusher-push-notifications-node 1.2.7', 61 | host: 'instance_id.pushnotifications.pusher.com' 62 | }); 63 | expect(body).toEqual({ 64 | users: ['harry.potter@hogwarts.ac.uk'], 65 | apns: { 66 | aps: { 67 | alert: 'Hi!' 68 | } 69 | } 70 | }); 71 | }); 72 | }); 73 | 74 | it('should succeed if there are 1000 users', () => { 75 | nock(new RegExp('/.*/')) 76 | .post(new RegExp('/.*/')) 77 | .reply(() => { 78 | return [200, { publishId: '123456' }]; 79 | }); 80 | 81 | let users = []; 82 | for (let i = 0; i < USERS_ARRAY_MAX_LENGTH; i++) { 83 | users.push(randomValueHex(15)); 84 | } 85 | return expect(pn.publishToUsers(users, {})).resolves.toBeTruthy(); 86 | }); 87 | 88 | it('should fail if no users nor publishRequest are passed', () => { 89 | return expect(pn.publishToUsers()).rejects.toThrow( 90 | 'users argument is required' 91 | ); 92 | }); 93 | 94 | it('should fail if users parameter passed is not an array', () => { 95 | return expect( 96 | pn.publishToUsers('harry.potter@hogwarts.ac.uk') 97 | ).rejects.toThrowError('users argument is must be an array'); 98 | }); 99 | 100 | it('should fail if no publishRequest is passed', () => { 101 | return expect( 102 | pn.publishToUsers(['harry.potter@hogwarts.ac.uk']) 103 | ).rejects.toThrowError('publishRequest argument is required'); 104 | }); 105 | 106 | it('should fail if too many users are passed', () => { 107 | let users = []; 108 | for (let i = 0; i < USERS_ARRAY_MAX_LENGTH + 1; i++) { 109 | users.push(randomValueHex(15)); 110 | } 111 | return expect(pn.publishToUsers(users, {})).rejects.toThrow(); 112 | }); 113 | 114 | it('should fail if too few users are passed', () => { 115 | let users = []; 116 | return expect(pn.publishToUsers(users, {})).rejects.toThrow(); 117 | }); 118 | 119 | it('should fail if a user is not a string', () => { 120 | return expect( 121 | pn.publishToUsers(['good_user', false], {}) 122 | ).rejects.toThrow(); 123 | }); 124 | }); 125 | -------------------------------------------------------------------------------- /__tests__/publishToInterests.test.js: -------------------------------------------------------------------------------- 1 | require('jest'); 2 | const nock = require('nock'); 3 | const PushNotifications = require('../push-notifications.js'); 4 | const { randomValueHex, INTEREST_ARRAY_MAX_LENGTH } = require('../utils'); 5 | 6 | describe('publishToInterests', () => { 7 | let pn; 8 | 9 | beforeEach(function() { 10 | nock.cleanAll(); 11 | pn = new PushNotifications({ 12 | instanceId: '1234', 13 | secretKey: '1234' 14 | }); 15 | }); 16 | 17 | afterEach(function() { 18 | nock.cleanAll(); 19 | nock.enableNetConnect(); 20 | }); 21 | 22 | it('should make the correct http request with valid params', () => { 23 | let uri, headers, body; 24 | 25 | nock(new RegExp('/.*/')) 26 | .post(new RegExp('/.*/')) 27 | .reply(function(u, b) { 28 | uri = u; 29 | headers = this.req.headers; 30 | body = b; 31 | return [ 32 | 200, 33 | { 34 | publishId: '123456' 35 | } 36 | ]; 37 | }); 38 | 39 | const pn = new PushNotifications({ 40 | instanceId: 'INSTANCE_ID', 41 | secretKey: 'SECRET_KEY' 42 | }); 43 | const response = pn.publishToInterests(['donuts'], { 44 | apns: { 45 | aps: { 46 | alert: 'Hi!' 47 | } 48 | } 49 | }); 50 | 51 | return response.then(data => { 52 | expect(data).toEqual({ 53 | publishId: '123456' 54 | }); 55 | expect(uri).toEqual( 56 | '/publish_api/v1/instances/INSTANCE_ID/publishes/interests' 57 | ); 58 | expect(headers).toEqual({ 59 | authorization: 'Bearer SECRET_KEY', 60 | accept: 'application/json', 61 | 'x-pusher-library': 'pusher-push-notifications-node 1.2.7', 62 | host: 'instance_id.pushnotifications.pusher.com', 63 | 'content-type': 'application/json', 64 | 'content-length': 55 65 | }); 66 | expect(body).toEqual({ 67 | interests: ['donuts'], 68 | apns: { 69 | aps: { 70 | alert: 'Hi!' 71 | } 72 | } 73 | }); 74 | }); 75 | }); 76 | 77 | it('should work with the `publish` alias', () => { 78 | let uri, headers, body; 79 | nock(new RegExp('/.*/')) 80 | .post(new RegExp('/.*/')) 81 | .reply(function(u, b) { 82 | uri = u; 83 | headers = this.req.headers; 84 | body = b; 85 | return [ 86 | 200, 87 | { 88 | publishId: '123456' 89 | } 90 | ]; 91 | }); 92 | 93 | const pn = new PushNotifications({ 94 | instanceId: 'INSTANCE_ID', 95 | secretKey: 'SECRET_KEY' 96 | }); 97 | const response = pn.publishToInterests(['donuts'], { 98 | apns: { 99 | aps: { 100 | alert: 'Hi!' 101 | } 102 | } 103 | }); 104 | 105 | return response.then(data => { 106 | expect(data).toEqual({ 107 | publishId: '123456' 108 | }); 109 | expect(uri).toEqual( 110 | '/publish_api/v1/instances/INSTANCE_ID/publishes/interests' 111 | ); 112 | expect(headers).toEqual({ 113 | accept: 'application/json', 114 | 'content-type': 'application/json', 115 | 'content-length': 55, 116 | authorization: 'Bearer SECRET_KEY', 117 | 'x-pusher-library': 'pusher-push-notifications-node 1.2.7', 118 | host: 'instance_id.pushnotifications.pusher.com' 119 | }); 120 | expect(body).toEqual({ 121 | interests: ['donuts'], 122 | apns: { 123 | aps: { 124 | alert: 'Hi!' 125 | } 126 | } 127 | }); 128 | }); 129 | }); 130 | 131 | it('should fail if no interests nor publishRequest are passed', () => { 132 | return expect(pn.publishToInterests()).rejects.toThrowError( 133 | 'interests argument is required' 134 | ); 135 | }); 136 | 137 | it('should fail if interests parameter passed is not an array', () => { 138 | return expect(pn.publishToInterests('donuts')).rejects.toThrowError( 139 | 'interests argument is must be an array' 140 | ); 141 | }); 142 | 143 | it('should fail if no publishRequest is passed', () => { 144 | return expect(pn.publishToInterests(['donuts'])).rejects.toThrowError( 145 | 'publishRequest argument is required' 146 | ); 147 | }); 148 | 149 | it('should fail if no interests are passed', () => { 150 | return expect(pn.publishToInterests([], {})).rejects.toThrowError( 151 | 'Publish requests must target at least one interest to be delivered' 152 | ); 153 | }); 154 | 155 | it('should fail if too many interests are passed', () => { 156 | let interests = []; 157 | for (let i = 0; i < INTEREST_ARRAY_MAX_LENGTH + 1; i++) { 158 | interests.push(randomValueHex(15)); 159 | } 160 | return expect( 161 | pn.publishToInterests(interests, {}) 162 | ).rejects.toThrowError( 163 | `Number of interests (${INTEREST_ARRAY_MAX_LENGTH + 164 | 1}) exceeds maximum of ${INTEREST_ARRAY_MAX_LENGTH}` 165 | ); 166 | }); 167 | 168 | it('should succeed if there are 100 interests', () => { 169 | let uri, headers, body; 170 | nock(new RegExp('/.*/')) 171 | .post(new RegExp('/.*/')) 172 | .reply(function(u, b) { 173 | uri = u; 174 | headers = this.req.headers; 175 | body = b; 176 | return [ 177 | 200, 178 | { 179 | publishId: '123456' 180 | } 181 | ]; 182 | }); 183 | 184 | let interests = []; 185 | for (let i = 0; i < 100; i++) { 186 | interests.push(randomValueHex(15)); 187 | } 188 | return pn 189 | .publishToInterests(interests, { 190 | apns: { 191 | aps: { 192 | alert: 'Hi!' 193 | } 194 | } 195 | }) 196 | .then(res => { 197 | expect(uri).toEqual( 198 | '/publish_api/v1/instances/1234/publishes/interests' 199 | ); 200 | expect(headers).toEqual({ 201 | accept: 'application/json', 202 | 'content-type': 'application/json', 203 | 'content-length': 1846, 204 | authorization: 'Bearer 1234', 205 | 'x-pusher-library': 'pusher-push-notifications-node 1.2.7', 206 | host: '1234.pushnotifications.pusher.com' 207 | }); 208 | expect(body).toEqual({ 209 | interests, 210 | apns: { 211 | aps: { 212 | alert: 'Hi!' 213 | } 214 | } 215 | }); 216 | expect(res).toEqual({ publishId: '123456' }); 217 | }); 218 | }); 219 | 220 | it('should fail if an interest is not a string', () => { 221 | return expect( 222 | pn.publishToInterests(['good_interest', false], {}) 223 | ).rejects.toThrowError('interest false is not a string'); 224 | }); 225 | 226 | it('should fail if an interest is too long', () => { 227 | return expect( 228 | pn.publishToInterests(['good_interest', 'a'.repeat(165)], {}) 229 | ).rejects.toThrowError(/is longer than the maximum of 164 characters/); 230 | }); 231 | 232 | it('should fail if an interest contains invalid characters', () => { 233 | return pn 234 | .publishToInterests(['good-interest', 'bad|interest'], {}) 235 | .catch(error => { 236 | expect(error.message).toMatch(/contains a forbidden character/); 237 | return pn.publishToInterests(['good-interest', 'bad:interest'], {}); 238 | }) 239 | .catch(error => { 240 | expect(error.message).toMatch(/contains a forbidden character/); 241 | }); 242 | }); 243 | 244 | it('should reject the returned promise on network error', () => { 245 | nock.disableNetConnect(); 246 | return expect( 247 | pn.publishToInterests(['donuts'], {}) 248 | ).rejects.toThrowError(); 249 | }); 250 | 251 | it('should reject the returned promise on HTTP error', () => { 252 | nock(new RegExp('/.*/')) 253 | .post(new RegExp('/.*/')) 254 | .reply(400, { 255 | error: 'Fake error', 256 | description: 'A fake error.' 257 | }); 258 | 259 | return expect( 260 | pn.publishToInterests(['donuts'], {}) 261 | ).rejects.toThrowError('A fake error'); 262 | }); 263 | 264 | it('should reject the returned promise if response is not JSON (success)', () => { 265 | nock(new RegExp('/.*/')) 266 | .post(new RegExp('/.*/')) 267 | .reply(200, 'thisisnotjson{{{{{'); 268 | 269 | return expect( 270 | pn.publishToInterests(['donuts'], {}) 271 | ).rejects.toThrowError('Could not parse response body'); 272 | }); 273 | 274 | it('should reject the returned promise if response is not JSON (failure)', () => { 275 | nock(new RegExp('/.*/')) 276 | .post(new RegExp('/.*/')) 277 | .reply(500, 'thisisnotjson{{{{{'); 278 | 279 | return pn.publishToInterests(['donuts'], {}).catch(e => { 280 | expect(e).toEqual(new Error('Could not parse response body')); 281 | }); 282 | }); 283 | 284 | it('should reject the returned promise if error response has an unexpected schema', () => { 285 | nock(new RegExp('/.*/')) 286 | .post(new RegExp('/.*/')) 287 | .reply(500, { notError: 'nope', noDescription: 'here' }); 288 | 289 | return pn.publishToInterests(['donuts'], {}).catch(e => { 290 | expect(e).toEqual(new Error('Could not parse response body')); 291 | }); 292 | }); 293 | }); 294 | -------------------------------------------------------------------------------- /push-notifications.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken'); 2 | const http = require('http'); 3 | const https = require('https'); 4 | 5 | const SDK_VERSION = '1.2.7'; 6 | const INTERESTS_REGEX = new RegExp('^(_|\\-|=|@|,|\\.|;|[A-Z]|[a-z]|[0-9])*$'); 7 | const { 8 | INTEREST_STRING_MAX_LENGTH, 9 | INTEREST_ARRAY_MAX_LENGTH, 10 | USERS_ARRAY_MAX_LENGTH, 11 | USERS_STRING_MAX_LENGTH, 12 | } = require('./utils'); 13 | 14 | function PushNotifications(options) { 15 | if (options === null || typeof options !== 'object') { 16 | throw new Error('PushNotifications options object is required'); 17 | } 18 | if (!options.hasOwnProperty('instanceId')) { 19 | throw new Error( 20 | '"instanceId" is required in PushNotifications options', 21 | ); 22 | } 23 | if (typeof options.instanceId !== 'string') { 24 | throw new Error('"instanceId" must be a string'); 25 | } 26 | if (!options.hasOwnProperty('secretKey')) { 27 | throw new Error('"secretKey" is required in PushNotifications options'); 28 | } 29 | if (typeof options.secretKey !== 'string') { 30 | throw new Error('"secretKey" must be a string'); 31 | } 32 | this.instanceId = options.instanceId; 33 | this.secretKey = options.secretKey; 34 | 35 | this.port = options.port || null; 36 | 37 | if (!options.hasOwnProperty('endpoint')) { 38 | this.protocol = 'https'; 39 | this.endpoint = `${this.instanceId}.pushnotifications.pusher.com`; 40 | } else if (typeof options.endpoint !== 'string') { 41 | throw new Error('endpoint must be a string'); 42 | } else { 43 | this.protocol = 'http'; 44 | this.endpoint = options.endpoint; 45 | } 46 | } 47 | 48 | /** 49 | * Alias of publishToInterests 50 | * @deprecated Use publishToInterests instead 51 | */ 52 | 53 | PushNotifications.prototype.publish = function (interests, publishRequest) { 54 | console.warn('`publish` is deprecated. Use `publishToInterests` instead.'); 55 | return this.publishToInterests(interests, publishRequest); 56 | }; 57 | 58 | PushNotifications.prototype.generateToken = function (userId) { 59 | if (userId === undefined || userId === null) { 60 | throw new Error('userId argument is required'); 61 | } 62 | if (userId === '') { 63 | throw new Error('userId cannot be the empty string'); 64 | } 65 | if (typeof userId !== 'string') { 66 | throw new Error('userId must be a string'); 67 | } 68 | if (userId.length > USERS_STRING_MAX_LENGTH) { 69 | throw new Error( 70 | `userId is longer than the maximum length of ${USERS_STRING_MAX_LENGTH}`, 71 | ); 72 | } 73 | const options = { 74 | expiresIn: '24h', 75 | issuer: `https://${this.instanceId}.pushnotifications.pusher.com`, 76 | subject: userId, 77 | allowInsecureKeySizes: true, 78 | }; 79 | const token = jwt.sign({}, this.secretKey, options); 80 | 81 | return { 82 | token: token, 83 | }; 84 | }; 85 | 86 | PushNotifications.prototype.publishToInterests = function ( 87 | interests, 88 | publishRequest, 89 | ) { 90 | if (interests === undefined || interests === null) { 91 | return Promise.reject(new Error('interests argument is required')); 92 | } 93 | if (!(interests instanceof Array)) { 94 | return Promise.reject( 95 | new Error('interests argument is must be an array'), 96 | ); 97 | } 98 | if (interests.length < 1) { 99 | return Promise.reject( 100 | new Error( 101 | 'Publish requests must target at least one interest to be delivered', 102 | ), 103 | ); 104 | } 105 | if (interests.length > INTEREST_ARRAY_MAX_LENGTH) { 106 | return Promise.reject( 107 | new Error( 108 | `Number of interests (${ 109 | interests.length 110 | }) exceeds maximum of ${INTEREST_ARRAY_MAX_LENGTH}.`, 111 | ), 112 | ); 113 | } 114 | if (publishRequest === undefined || publishRequest === null) { 115 | return Promise.reject(new Error('publishRequest argument is required')); 116 | } 117 | for (const interest of interests) { 118 | if (typeof interest !== 'string') { 119 | return Promise.reject( 120 | new Error(`interest ${interest} is not a string`), 121 | ); 122 | } 123 | if (interest.length > INTEREST_STRING_MAX_LENGTH) { 124 | return Promise.reject( 125 | new Error( 126 | `interest ${interest} is longer than the maximum of ` + 127 | `${INTEREST_STRING_MAX_LENGTH} characters`, 128 | ), 129 | ); 130 | } 131 | if (!INTERESTS_REGEX.test(interest)) { 132 | return Promise.reject( 133 | new Error( 134 | `interest "${interest}" contains a forbidden character. ` + 135 | 'Allowed characters are: ASCII upper/lower-case letters, ' + 136 | 'numbers or one of _-=@,.;', 137 | ), 138 | ); 139 | } 140 | } 141 | 142 | const body = Object.assign({}, publishRequest, { interests }); 143 | 144 | const options = { 145 | path: `/publish_api/v1/instances/${ 146 | this.instanceId 147 | }/publishes/interests`, 148 | method: 'POST', 149 | body, 150 | }; 151 | 152 | return this._doRequest(options); 153 | }; 154 | 155 | PushNotifications.prototype.publishToUsers = function (users, publishRequest) { 156 | if (users === undefined || users === null) { 157 | return Promise.reject(new Error('users argument is required')); 158 | } 159 | if (!(users instanceof Array)) { 160 | return Promise.reject(new Error('users argument is must be an array')); 161 | } 162 | if (users.length < 1) { 163 | return Promise.reject( 164 | new Error( 165 | 'Publish requests must target at least one interest to be delivered', 166 | ), 167 | ); 168 | } 169 | if (users.length > USERS_ARRAY_MAX_LENGTH) { 170 | return Promise.reject( 171 | new Error( 172 | `Number of users (${ 173 | users.length 174 | }) exceeds maximum of ${USERS_ARRAY_MAX_LENGTH}.`, 175 | ), 176 | ); 177 | } 178 | if (publishRequest === undefined || publishRequest === null) { 179 | return Promise.reject(new Error('publishRequest argument is required')); 180 | } 181 | for (const user of users) { 182 | if (typeof user !== 'string') { 183 | return Promise.reject(new Error(`user ${user} is not a string`)); 184 | } 185 | if (user.length > INTEREST_STRING_MAX_LENGTH) { 186 | return Promise.reject( 187 | new Error( 188 | `user ${user} is longer than the maximum of ` + 189 | `${INTEREST_STRING_MAX_LENGTH} characters`, 190 | ), 191 | ); 192 | } 193 | } 194 | 195 | const body = Object.assign({}, publishRequest, { users }); 196 | const options = { 197 | path: `/publish_api/v1/instances/${this.instanceId}/publishes/users`, 198 | method: 'POST', 199 | body, 200 | }; 201 | 202 | return this._doRequest(options); 203 | }; 204 | 205 | PushNotifications.prototype.deleteUser = function (userId) { 206 | if (userId === undefined || userId === null) { 207 | return Promise.reject(new Error('User ID argument is required')); 208 | } 209 | if (typeof userId !== 'string') { 210 | return Promise.reject(new Error('User ID argument must be a string')); 211 | } 212 | if (userId.length > USERS_STRING_MAX_LENGTH) { 213 | return Promise.reject(new Error('User ID argument is too long')); 214 | } 215 | 216 | const options = { 217 | path: `/user_api/v1/instances/${ 218 | this.instanceId 219 | }/users/${encodeURIComponent(userId)}`, 220 | method: 'DELETE', 221 | }; 222 | return this._doRequest(options); 223 | }; 224 | 225 | PushNotifications.prototype._doRequest = function (options) { 226 | const httpLib = this.protocol === 'http' ? http : https; 227 | const reqOptions = { 228 | method: options.method, 229 | path: options.path, 230 | host: this.endpoint, 231 | headers: { 232 | Accept: 'application/json', 233 | Authorization: `Bearer ${this.secretKey}`, 234 | 'X-Pusher-Library': `pusher-push-notifications-node ${SDK_VERSION}`, 235 | }, 236 | }; 237 | 238 | if (this.port) { 239 | reqOptions.port = this.port; 240 | } 241 | 242 | let reqBodyStr; 243 | if (options.body) { 244 | reqBodyStr = JSON.stringify(options.body); 245 | reqOptions.headers['Content-Type'] = 'application/json'; 246 | reqOptions.headers['Content-Length'] = Buffer.byteLength(reqBodyStr); 247 | } 248 | 249 | return new Promise(function (resolve, reject) { 250 | try { 251 | const req = httpLib.request(reqOptions, function (response) { 252 | let resBodyStr = ''; 253 | response.on('data', function (chunk) { 254 | resBodyStr += chunk; 255 | }); 256 | 257 | response.on('end', function () { 258 | let resBody; 259 | 260 | if (resBodyStr) { 261 | try { 262 | resBody = JSON.parse(resBodyStr); 263 | } catch (_) { 264 | reject(new Error('Could not parse response body')); 265 | return; 266 | } 267 | } 268 | 269 | if ( 270 | response.statusCode >= 200 && 271 | response.statusCode < 300 272 | ) { 273 | resolve(resBody); 274 | } else { 275 | if (!resBody.error || !resBody.description) { 276 | reject(new Error('Could not parse response body')); 277 | } else { 278 | reject( 279 | new Error( 280 | `${response.statusCode} ${ 281 | resBody.error 282 | } - ${resBody.description}`, 283 | ), 284 | ); 285 | } 286 | } 287 | }); 288 | }); 289 | 290 | req.on('error', function (error) { 291 | reject(error); 292 | }); 293 | 294 | if (reqBodyStr) { 295 | req.write(reqBodyStr); 296 | } 297 | req.end(); 298 | } catch (e) { 299 | reject(e); 300 | } 301 | }); 302 | }; 303 | 304 | module.exports = PushNotifications; 305 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace PushNotifications { 2 | interface Options { 3 | instanceId: string; 4 | secretKey: string; 5 | endpoint?: string; 6 | } 7 | 8 | interface PublishRequestBase { 9 | /* A URL to which we will send webhooks at key points throughout the publishing process. E.g when the publish finishes */ 10 | webhookUrl?: string; 11 | } 12 | 13 | interface PublishRequestWithApns extends PublishRequestBase { 14 | apns: ApnsPayload; 15 | } 16 | 17 | interface PublishRequestWithFcm extends PublishRequestBase { 18 | fcm: FcmPayload; 19 | } 20 | 21 | interface PublishRequestWithWeb extends PublishRequestBase { 22 | web: WebPayload; 23 | } 24 | 25 | interface PublishRequestWithApnsAndFcm extends PublishRequestBase { 26 | apns: ApnsPayload; 27 | fcm: FcmPayload; 28 | } 29 | 30 | interface PublishRequestWithApnsAndWeb extends PublishRequestBase { 31 | apns: ApnsPayload; 32 | web: WebPayload; 33 | } 34 | 35 | interface PublishRequestWithFcmAndWeb extends PublishRequestBase { 36 | fcm: FcmPayload; 37 | web: WebPayload; 38 | } 39 | 40 | interface PublishRequestWithAllPlatforms extends PublishRequestBase { 41 | apns: ApnsPayload; 42 | fcm: FcmPayload; 43 | web: WebPayload; 44 | } 45 | 46 | type PublishRequest = 47 | | PublishRequestWithApns 48 | | PublishRequestWithFcm 49 | | PublishRequestWithWeb 50 | | PublishRequestWithApnsAndFcm 51 | | PublishRequestWithApnsAndWeb 52 | | PublishRequestWithFcmAndWeb 53 | | PublishRequestWithAllPlatforms; 54 | 55 | interface FcmPayload { 56 | /** 57 | * Number of seconds that can pass before the notification is considered invalid and can be discarded. 58 | */ 59 | time_to_live?: number; 60 | /** 61 | * Multiple notifications that have the same collapse_key will be displayed to the user as a single notification. 62 | * The most recent notification will be shown. 63 | */ 64 | collapse_key?: string; 65 | /** 66 | * Priority of the notification. Can be one of the following values: 67 | * - "normal": May be delayed to conserve device resources. (DEFAULT) 68 | * - "high": Will attempt to deliver immediately. 69 | * Notifications that are not displayed in the system tray may be downgraded to "normal" if sent too quickly. 70 | */ 71 | priority?: 'normal' | 'high'; 72 | /** 73 | * Specifies the predefined, user-visible key-value pairs of the notification payload 74 | */ 75 | notification?: FcmNotificationPayload; 76 | /** 77 | * Specifies the custom key-value pairs of the message's payload 78 | */ 79 | data?: object; 80 | } 81 | 82 | // See: https://firebase.google.com/docs/cloud-messaging/http-server-ref#notification-payload-support 83 | interface FcmNotificationPayload { 84 | /** 85 | * The notification's title. 86 | */ 87 | title?: string; 88 | /** 89 | * The notification's body text. 90 | */ 91 | body?: string; 92 | /** 93 | * The notification's channel id (new in Android O). 94 | * The app must create a channel with this channel ID before any notification with this channel ID is received. 95 | * If you don't send this channel ID in the request, or if the channel ID provided has not yet been created by the app, 96 | * FCM uses the channel ID specified in the app manifest. 97 | */ 98 | android_channel_id?: string; 99 | /** 100 | * The notification's icon. 101 | * Sets the notification icon to myicon for drawable resource myicon. 102 | * If you don't send this key in the request, FCM displays the launcher icon specified in your app manifest. 103 | */ 104 | icon?: string; 105 | /** 106 | * The sound to play when the device receives the notification. 107 | * Supports "default" or the filename of a sound resource bundled in the app. Sound files must reside in /res/raw/. 108 | */ 109 | sound?: string; 110 | /** 111 | * Identifier used to replace existing notifications in the notification drawer. 112 | * If not specified, each request creates a new notification. 113 | * If specified and a notification with the same tag is already being shown, the new notification replaces the existing one in the notification drawer. 114 | */ 115 | tag?: string; 116 | /** 117 | * The notification's icon color, expressed in #rrggbb format 118 | */ 119 | color?: string; 120 | /** 121 | * The action associated with a user click on the notification. 122 | * If specified, an activity with a matching intent filter is launched when a user clicks on the notification. 123 | */ 124 | click_action?: string; 125 | /** 126 | * The key to the body string in the app's string resources to use to localize the body text to the user's current localization. 127 | */ 128 | body_loc_key?: string; 129 | /** 130 | * Variable string values to be used in place of the format specifiers in body_loc_key to use to localize the body text to the user's current localization. 131 | */ 132 | body_loc_args?: string[]; 133 | /** 134 | * The key to the title string in the app's string resources to use to localize the title text to the user's current localization. 135 | */ 136 | title_loc_key?: string; 137 | /** 138 | * Variable string values to be used in place of the format specifiers in title_loc_key to use to localize the title text to the user's current localization. 139 | */ 140 | title_loc_args?: string[]; 141 | } 142 | 143 | interface ApnsPayload { 144 | /** 145 | * Multiple notifications that have the same collapse_id will be displayed to the user as a single notification. 146 | */ 147 | collapse_id?: string; 148 | /** 149 | * Timestamp after which this notification is considered invalid and can be discarded (time since unix epoch in seconds) 150 | */ 151 | expiration?: number; 152 | /** 153 | * The priority of the notification. Can be one of the following values: 154 | * - 10: Send the notification immediately (DEFAULT) 155 | * - 5: Send the notification opportunistically, taking into account efficiency concerns such as battery usage 156 | */ 157 | priority?: 5 | 10; 158 | aps: ApsPayload; 159 | /** 160 | * Specifies the custom key-value pairs of the message's payload 161 | */ 162 | data?: object; 163 | } 164 | 165 | // See: https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/PayloadKeyReference.html 166 | interface ApsPayload { 167 | /** 168 | * Include this key when you want the system to display a standard alert or a banner. The notification settings 169 | * for your app on the user’s device determine whether an alert or banner is displayed. 170 | */ 171 | alert?: string | AlertPayload; 172 | /** 173 | * Include this key when you want the system to modify the badge of your app icon. 174 | * If this key is not included in the dictionary, the badge is not changed. To remove 175 | * the badge, set the value of this key to 0 176 | */ 177 | badge?: number; 178 | /** 179 | * Include this key when you want the system to play a sound. The value of this key is the name of a sound file 180 | * in your app’s main bundle or in the Library/Sounds folder of your app’s data container. If the sound file cannot 181 | * be found, or if you specify defaultfor the value, the system plays the default alert sound. 182 | */ 183 | sound?: string; 184 | /** 185 | * Include this key with a value of 1 to configure a background update notification. 186 | * When this key is present, the system wakes up your app in the background and delivers the notification to its app delegate. 187 | */ 188 | 'content-available'?: string; 189 | /** 190 | * Provide this key with a string value that represents the notification’s type. This value corresponds to the 191 | * value in the identifier property of one of your app’s registered categories. 192 | */ 193 | category?: string; 194 | /** 195 | * Provide this key with a string value that represents the app-specific identifier for grouping notifications. 196 | * If you provide a Notification Content app extension, you can use this value to group your notifications together. 197 | */ 198 | 'thread-id'?: string; 199 | } 200 | 201 | interface AlertPayload { 202 | /** 203 | * A short string describing the purpose of the notification. Apple Watch displays this string as part of the notification interface. 204 | * This string is displayed only briefly and should be crafted so that it can be understood quickly. This key was added in iOS 8.2. 205 | */ 206 | title: string; 207 | /** 208 | * The text of the alert message 209 | */ 210 | body: string; 211 | /** 212 | * The key to a title string in the Localizable.strings file for the current localization. 213 | * The key string can be formatted with %@ and %n$@ specifiers to take the variables specified in the title-loc-args array. 214 | */ 215 | 'title-loc-key'?: string; 216 | /** 217 | * Variable string values to appear in place of the format specifiers in title-loc-key. 218 | */ 219 | 'title-loc-args'?: string[] | null; 220 | /** 221 | * If a string is specified, the system displays an alert that includes the Close and View buttons. 222 | * The string is used as a key to get a localized string in the current localization to use for the right button’s title instead of “View”. 223 | */ 224 | 'action-loc-key'?: string | null; 225 | /** 226 | * A key to an alert-message string in a Localizable.strings file for the current localization (which is set by the user’s language preference). 227 | * The key string can be formatted with %@ and %n$@ specifiers to take the variables specified in the loc-args array. 228 | */ 229 | 'loc-key'?: string; 230 | /** 231 | * Variable string values to appear in place of the format specifiers in loc-key. 232 | */ 233 | 'loc-args'?: string[]; 234 | /** 235 | * The filename of an image file in the app bundle, with or without the filename extension. 236 | * The image is used as the launch image when users tap the action button or move the action slider. 237 | * If this property is not specified, the system either uses the previous snapshot, 238 | * uses the image identified by the UILaunchImageFile key in the app’s Info.plist file, or falls back to Default.png. 239 | */ 240 | 'launch-image'?: string; 241 | } 242 | 243 | interface WebPayload { 244 | /** 245 | * The number of seconds the web push gateway should store the notification for whilst the user is offline. 246 | * Max: 2419200; Default: 4 weeks. 247 | */ 248 | time_to_live?: number; 249 | /** 250 | * Specifies the predefined, user-visible key-value pairs of the notification payload 251 | */ 252 | notification?: WebNotificationPayload; 253 | /** 254 | * Specifies the custom key-value pairs of the message's payload 255 | */ 256 | data?: object; 257 | } 258 | 259 | interface WebNotificationPayload { 260 | /** 261 | * The title shown when the notification is displayed to the user. 262 | */ 263 | title?: string; 264 | /** 265 | * The body shown when the notification is displayed to the user. 266 | */ 267 | body?: string; 268 | /** 269 | * URL of the image shown as the notification icon when the notification is displayed. 270 | */ 271 | icon?: string; 272 | /** 273 | * If provided, this URL will be opened in a new tab when the notification is clicked. 274 | */ 275 | deep_link?: string; 276 | /** 277 | * If set to true, the notification will not be shown if your site has focus. 278 | * Default: false. 279 | */ 280 | hide_notification_if_site_has_focus?: boolean; 281 | } 282 | 283 | interface PublishResponse { 284 | /** 285 | * Unique string used to identify this publish request 286 | */ 287 | publishId: string; 288 | } 289 | 290 | type Token = string; 291 | } 292 | 293 | declare class PushNotifications { 294 | constructor(options: PushNotifications.Options); 295 | /** 296 | * Generate a Pusher Beams auth token. 297 | * @param userId The given user id for which to generate a Pusher Beams auth token. 298 | * @returns a Pusher Beams auth token 299 | */ 300 | generateToken(userId: string): { token: PushNotifications.Token }; 301 | 302 | /** 303 | * Publish a notification to device interest(s). 304 | * @param interests Array of interests to send the push notification to, ranging from 1 to 100 per publish request. 305 | * @param publishRequest 306 | */ 307 | publishToInterests( 308 | interests: string[], 309 | publishRequest: PushNotifications.PublishRequest, 310 | ): Promise; 311 | 312 | /** 313 | * Publish a notification to an authenticated user. 314 | * @param users Array of user ids to send the push notification to, ranging from 1 to 1000 per publish request. 315 | * @param publishRequest 316 | */ 317 | publishToUsers( 318 | users: string[], 319 | publishRequest: PushNotifications.PublishRequest, 320 | ): Promise; 321 | 322 | /** 323 | * Delete a user from the Pusher Beams system, and all their associated devices. 324 | * @param userId The given user id to delete from the Pusher Beams service. 325 | */ 326 | deleteUser(userId: string): Promise; 327 | 328 | /** 329 | * Publish a notification to device interest(s) 330 | * @deprecated Use `publishToInterests` instead. 331 | * @param interests Array of interests to send the push notification to, ranging from 1 to 100 per publish request. 332 | * @param publishRequest 333 | */ 334 | publish( 335 | interests: string[], 336 | publishRequest: T, 337 | ): Promise; 338 | } 339 | 340 | export = PushNotifications; 341 | --------------------------------------------------------------------------------