├── .github └── workflows │ └── run-tests.yaml ├── .gitignore ├── .prettierrc ├── .publishrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── RELEASING.md ├── babel.config.js ├── end-to-end-tests ├── .gitignore ├── errol-client.js ├── sdk-get-registration-state.test.js ├── sdk-set-user-id.test.js ├── sdk-start.test.js ├── sdk-stop.test.js ├── test-app │ └── server.js └── test-utils.js ├── eslint.config.mjs ├── example.html ├── index.d.ts ├── index.test-d.ts ├── jest.config.js ├── package-lock.json ├── package.json ├── rollup ├── cdn.js ├── esm.js └── service-worker.js ├── running-end-to-end-tests.md ├── scripts └── add-version-to-service-worker.js ├── service-worker.js ├── src ├── device-state-store.js ├── do-request.js ├── do-request.test.js ├── push-notifications.js ├── push-notifications.test.js ├── service-worker.js ├── service-worker.test.js └── token-provider.js ├── test-setup.js ├── test-utils └── fake-device-state-store.js └── tsconfig.json /.github/workflows/run-tests.yaml: -------------------------------------------------------------------------------- 1 | name: run-tests 2 | on: 3 | push: 4 | branches: 5 | - '*' 6 | pull_request: 7 | branches: 8 | - master 9 | jobs: 10 | lint-and-test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: actions/setup-node@v2 15 | with: 16 | node-version: '22' 17 | - uses: nanasess/setup-chromedriver@master 18 | - name: "Install deps" 19 | run: npm install 20 | - name: "Lint" 21 | run: npm run lint 22 | - name: "Run unit tests" 23 | run: npm run test:unit 24 | - name: "Run end-to-end tests" 25 | run: | 26 | google-chrome --version 27 | chromedriver --version 28 | export DISPLAY=:99 29 | chromedriver --url-base=/wd/hub & 30 | sudo Xvfb -ac :99 -screen 0 1280x1024x24 > /dev/null 2>&1 & 31 | npm run build:cdn && npm run test:e2e 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .cache/ 4 | .idea/ 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "singleQuote": true, 4 | "trailingComma": "es5" 5 | } 6 | -------------------------------------------------------------------------------- /.publishrc: -------------------------------------------------------------------------------- 1 | { 2 | "validations": { 3 | "vulnerableDependencies": false, 4 | "uncommittedChanges": true, 5 | "untrackedFiles": true, 6 | "sensitiveData": false, 7 | "branch": "master", 8 | "gitTag": true 9 | }, 10 | "confirm": true, 11 | "publishCommand": "npm publish", 12 | "publishTag": "latest", 13 | "prePublishScript": "npm run prepublishchecks", 14 | "postPublishScript": false 15 | } 16 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 6 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased](https://github.com/pusher/push-notifications-web/compare/2.1.0...HEAD) 9 | 10 | ## [2.1.0](https://github.com/pusher/push-notifications-web/compare/2.0.0...2.1.0) - 2025-01-17 11 | - Safari is now explicitly supported for Web Push notifications, leveraging the Web Push standard APIs. This is made possible by server-side updates to handle Safari-specific requirements. 12 | - Replaced browser type checks with direct feature detection for required Web Push APIs (Notification, PushManager, and serviceWorker). 13 | 14 | ## [2.0.0](https://github.com/pusher/push-notifications-web/compare/1.1.0...2.0.0) - 2022-12-05 15 | - getDeviceInterests now accepts limit and cursor parameters and returns Object instead of an Array. 16 | 17 | ## [1.1.0](https://github.com/pusher/push-notifications-web/compare/1.0.3...1.1.0) - 2020-09-16 18 | - Allow the fetch `credentials` option to be overidden in the default TokenProvider 19 | implementation. 20 | 21 | ## [1.0.3](https://github.com/pusher/push-notifications-web/compare/1.0.2...1.0.3) - 2020-09-10 22 | - Fix bug in SDK where we weren't waiting for custom Service Workers to become 23 | ready before starting the SDK 24 | - Update out of date TypeScript type definitions & add static check to CI 25 | to ensure they remain correct. 26 | 27 | ## [1.0.2](https://github.com/pusher/push-notifications-web/compare/1.0.1...1.0.2) - 2020-08-24 28 | - Fix bug in service worker where analytics events would cause runtime errors 29 | if a notification had been overridden using the `onNotificationReceived` handler 30 | 31 | ## [1.0.1](https://github.com/pusher/push-notifications-web/compare/1.0.0...1.0.1) - 2020-07-22 32 | - Fix bug in service worker which generated invalid open/delivery events due to 33 | non-integer timestamps 34 | 35 | ## [1.0.0](https://github.com/pusher/push-notifications-web/compare/0.9.0...1.0.0) - 2020-07-22 36 | - General availability (GA) release. 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 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 all 13 | 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 THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Push Notifications Web 2 | [![Build Status](https://github.com/pusher/push-notifications-web/workflows/run-tests/badge.svg)](https://github.com/pusher/push-notifications-web/actions?query=branch%3Amaster) 3 | [![Twitter](https://img.shields.io/badge/twitter-@Pusher-blue.svg?style=flat)](http://twitter.com/Pusher) 4 | 5 | This is the web SDK for the [Pusher Beams](https://pusher.com/beams) service. 6 | 7 | ## Getting started 8 | 9 | You can find the getting started guide [here](https://pusher.com/docs/beams/getting-started/web/sdk-integration). 10 | 11 | ## Documentation 12 | 13 | You can find our up-to-date documentation in [here](https://pusher.com/docs/beams/). 14 | 15 | ## Communication 16 | 17 | - Found a bug? Please open an [issue](https://github.com/pusher/push-notifications-web/issues). 18 | - Have a feature request. Please open an [issue](https://github.com/pusher/push-notifications-web/issues). 19 | - If you want to contribute, please submit a [pull request](https://github.com/pusher/push-notifications-web/pulls) (preferably with some tests). 20 | 21 | ## Credits 22 | 23 | Pusher Beams is owned and maintained by [Pusher](https://pusher.com). 24 | 25 | ## License 26 | 27 | This library is released under the MIT license. See [LICENSE](https://github.com/pusher/push-notifications-web/blob/master/LICENSE) for details. 28 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Release Process 2 | 1. Update version in package manifest 3 | 2. Update version in changelog 4 | 3. Commit 5 | 4. Check you are part of the @pusher npm org 6 | 5. Check you are logged in to npm `npm whoami` 7 | 6. If not, login via `npm login` 8 | 7. `git tag ` 9 | 8. `git push` 10 | 8. `git push --tags` 11 | 9. `npm run publish-please` 12 | 10. Upload `./dist/push-notifications-cdn` to the appropriate S3 buckets: 13 | - Major/minor version: 14 | - `/pusher-js-cloudfront/beams/.` 15 | - `/pusher-js-cloudfront/beams/..0` 16 | - Patch version: 17 | - `/pusher-js-cloudfront/beams/.` 18 | - `/pusher-js-cloudfront/beams/..` 19 | 11. If any changes have been made to the service worker: 20 | - `npm run build:sw` 21 | - Upload `./dist/service-worker.js` to S3: 22 | - `/pusher-js-cloudfront/beams/service-worker.js` 23 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [['@babel/preset-env', { targets: { node: 'current' } }]] 3 | }; 4 | -------------------------------------------------------------------------------- /end-to-end-tests/.gitignore: -------------------------------------------------------------------------------- 1 | temp/ 2 | -------------------------------------------------------------------------------- /end-to-end-tests/errol-client.js: -------------------------------------------------------------------------------- 1 | const request = require('request-promise-native'); 2 | 3 | class ErrolTestClient { 4 | constructor(instanceId) { 5 | this.instanceId = instanceId; 6 | } 7 | 8 | apiRequest({ headers = {}, method, path = '', body }) { 9 | const reqUrl = `https://${ 10 | this.instanceId 11 | }.pushnotifications.pusher.com${path}`; 12 | const requestOptions = { 13 | headers, 14 | method, 15 | url: reqUrl, 16 | body, 17 | resolveWithFullResponse: true, 18 | simple: false, 19 | }; 20 | 21 | return request(requestOptions); 22 | } 23 | 24 | deviceApiRequest({ headers, method, path, body }) { 25 | const qualifiedPath = `/device_api/v1/instances/${this.instanceId}${path}`; 26 | return this.apiRequest({ 27 | headers, 28 | method, 29 | path: qualifiedPath, 30 | body, 31 | }); 32 | } 33 | 34 | customerApiRequest({ method, path, body }) { 35 | const qualifiedPath = `/customer_api/v1/instances/${ 36 | this.instanceId 37 | }${path}`; 38 | return this.apiRequest({ 39 | headers: { 40 | Authorization: 41 | 'Bearer F8AC0B756E50DF235F642D6F0DC2CDE0328CD9184B3874C5E91AB2189BB722FE', 42 | }, 43 | method, 44 | path: qualifiedPath, 45 | body, 46 | }); 47 | } 48 | 49 | async getWebDevice(deviceId) { 50 | return this.deviceApiRequest({ 51 | method: 'GET', 52 | path: `/devices/web/${deviceId}`, 53 | }); 54 | } 55 | 56 | async deleteUser(userId) { 57 | return this.customerApiRequest({ 58 | method: 'DELETE', 59 | path: `/users/${encodeURIComponent(userId)}`, 60 | }); 61 | } 62 | } 63 | 64 | module.exports = { 65 | ErrolTestClient, 66 | }; 67 | -------------------------------------------------------------------------------- /end-to-end-tests/sdk-get-registration-state.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | launchServer, 3 | createChromeWebDriver, 4 | NOTIFICATIONS_DEFAULT, 5 | NOTIFICATIONS_GRANTED, 6 | NOTIFICATIONS_DENIED, 7 | unregisterServiceWorker, 8 | SCRIPT_TIMEOUT_MS 9 | } from './test-utils'; 10 | import * as PusherPushNotifications from '../src/push-notifications'; 11 | 12 | let killServer = null; 13 | let chromeDriver = null; 14 | 15 | beforeAll(async () => { 16 | killServer = await launchServer() 17 | }) 18 | 19 | async function prepareServer(notificationPermission) { 20 | chromeDriver = await createChromeWebDriver(notificationPermission) 21 | await chromeDriver.get('http://localhost:3000'); 22 | await chromeDriver.wait(() => { 23 | return chromeDriver.getTitle().then(title => title.includes('Test Page')); 24 | }, 2000); 25 | } 26 | 27 | test('.getState should return PERMISSION_GRANTED_REGISTERED_WITH_BEAMS if start has been called and permissions are granted', async () => { 28 | await prepareServer(NOTIFICATIONS_GRANTED) 29 | 30 | const state = await chromeDriver.executeAsyncScript(() => { 31 | const asyncScriptReturnCallback = arguments[arguments.length - 1]; 32 | const instanceId = 'deadc0de-2ce6-46e3-ad9a-5c02d0ab119b'; 33 | let beamsClient = new PusherPushNotifications.Client({ 34 | instanceId, 35 | }) 36 | return beamsClient.start() 37 | .then(beamsClient => beamsClient.getRegistrationState()) 38 | .then(state => asyncScriptReturnCallback(state)) 39 | .catch(e => asyncScriptReturnCallback(e.message)); 40 | }); 41 | 42 | expect(state).toBe(PusherPushNotifications.RegistrationState.PERMISSION_GRANTED_REGISTERED_WITH_BEAMS); 43 | }, SCRIPT_TIMEOUT_MS); 44 | 45 | test('.getState should return PERMISSION_PROMPT_REQUIRED if start has not been called and permissions are default', async () => { 46 | await prepareServer(NOTIFICATIONS_DEFAULT) 47 | 48 | const state = await chromeDriver.executeAsyncScript(() => { 49 | const asyncScriptReturnCallback = arguments[arguments.length - 1]; 50 | const instanceId = 'deadc0de-2ce6-46e3-ad9a-5c02d0ab119b'; 51 | let beamsClient = new PusherPushNotifications.Client({ 52 | instanceId, 53 | }) 54 | return beamsClient.getRegistrationState() 55 | .then(state => asyncScriptReturnCallback(state)) 56 | .catch(e => asyncScriptReturnCallback(e.message)); 57 | }); 58 | 59 | expect(state).toBe(PusherPushNotifications.RegistrationState.PERMISSION_PROMPT_REQUIRED); 60 | }); 61 | 62 | test('.getState should return PERMISSION_GRANTED_NOT_REGISTERED_WITH_BEAMS if start has not been called and permissions are granted', async () => { 63 | await prepareServer(NOTIFICATIONS_GRANTED) 64 | 65 | const state = await chromeDriver.executeAsyncScript(() => { 66 | const asyncScriptReturnCallback = arguments[arguments.length - 1]; 67 | const instanceId = 'deadc0de-2ce6-46e3-ad9a-5c02d0ab119b'; 68 | let beamsClient = new PusherPushNotifications.Client({ 69 | instanceId, 70 | }) 71 | return beamsClient.getRegistrationState() 72 | .then(state => asyncScriptReturnCallback(state)) 73 | .catch(e => asyncScriptReturnCallback(e.message)); 74 | }); 75 | 76 | expect(state).toBe(PusherPushNotifications.RegistrationState.PERMISSION_GRANTED_NOT_REGISTERED_WITH_BEAMS); 77 | }); 78 | 79 | test('.getState should return PERMISSION_DENIED if start has not been called and permissions are denied', async () => { 80 | await prepareServer(NOTIFICATIONS_DENIED) 81 | 82 | const state = await chromeDriver.executeAsyncScript(() => { 83 | const asyncScriptReturnCallback = arguments[arguments.length - 1]; 84 | const instanceId = 'deadc0de-2ce6-46e3-ad9a-5c02d0ab119b'; 85 | let beamsClient = new PusherPushNotifications.Client({ 86 | instanceId, 87 | }) 88 | return beamsClient.getRegistrationState() 89 | .then(state => asyncScriptReturnCallback(state)) 90 | .catch(e => asyncScriptReturnCallback(e.message)); 91 | }); 92 | 93 | expect(state).toBe(PusherPushNotifications.RegistrationState.PERMISSION_DENIED); 94 | }); 95 | 96 | afterEach(async () => { 97 | if (chromeDriver) { 98 | await unregisterServiceWorker(chromeDriver) 99 | await chromeDriver.quit(); 100 | } 101 | }) 102 | 103 | afterAll(() => { 104 | if (killServer) { 105 | killServer(); 106 | } 107 | }); 108 | -------------------------------------------------------------------------------- /end-to-end-tests/sdk-set-user-id.test.js: -------------------------------------------------------------------------------- 1 | import { ErrolTestClient } from './errol-client'; 2 | import { 3 | launchServer, 4 | createChromeWebDriver, 5 | NOTIFICATIONS_GRANTED, 6 | unregisterServiceWorker, 7 | SCRIPT_TIMEOUT_MS 8 | } from './test-utils'; 9 | 10 | jest.setTimeout(SCRIPT_TIMEOUT_MS); 11 | 12 | let killServer = null; 13 | let chromeDriver = null; 14 | 15 | beforeAll(async () => { 16 | killServer = await launchServer(); 17 | chromeDriver = await createChromeWebDriver(NOTIFICATIONS_GRANTED); 18 | const errolClient = new ErrolTestClient( 19 | '1b880590-6301-4bb5-b34f-45db1c5f5644' 20 | ); 21 | const response = await errolClient.deleteUser('cucas'); 22 | expect(response.statusCode).toBe(200); 23 | }); 24 | 25 | beforeEach(async () => { 26 | await chromeDriver.get('http://localhost:3000'); 27 | 28 | await chromeDriver.wait(async () => { 29 | const title = await chromeDriver.getTitle(); 30 | return title.includes('Test Page'); 31 | }, 32 | 2000 33 | ); 34 | 35 | await chromeDriver.executeAsyncScript(() => { 36 | const callback = arguments[arguments.length - 1]; 37 | const req = window.indexedDB.deleteDatabase( 38 | 'beams-1b880590-6301-4bb5-b34f-45db1c5f5644' 39 | ); 40 | req.onsuccess = callback; 41 | req.onerror = callback; 42 | }); 43 | }); 44 | 45 | afterEach(async () => { 46 | await unregisterServiceWorker(chromeDriver); 47 | }); 48 | 49 | test('SDK should set user id with errol', async () => { 50 | await chromeDriver.get('http://localhost:3000'); 51 | await chromeDriver.wait(() => { 52 | return chromeDriver.getTitle().then(title => title.includes('Test Page')); 53 | }, 2000); 54 | 55 | const deviceId = await chromeDriver.executeAsyncScript(() => { 56 | const asyncScriptReturnCallback = arguments[arguments.length - 1]; 57 | 58 | const beamsClient = new PusherPushNotifications.Client({ 59 | instanceId: '1b880590-6301-4bb5-b34f-45db1c5f5644', 60 | }); 61 | return beamsClient 62 | .start() 63 | .then(() => beamsClient.getDeviceId()) 64 | .then(deviceId => asyncScriptReturnCallback(deviceId)) 65 | .catch(e => asyncScriptReturnCallback(e.message)); 66 | }); 67 | 68 | expect(deviceId).toContain('web-'); 69 | const setUserIdError = await chromeDriver.executeAsyncScript(() => { 70 | const asyncScriptReturnCallback = arguments[arguments.length - 1]; 71 | 72 | // Fake local TokenProvider that just returns a token signed for 73 | // the user 'cucas' with a long expiry. Since the hardcoded token 74 | // is signed for 'cucas' we throw an exception if another user ID 75 | // is requested. 76 | let tokenProvider = { 77 | fetchToken: userId => { 78 | if (userId !== 'cucas') { 79 | throw new Error( 80 | 'Unexpected user ID ' + 81 | userId + 82 | ', this token provider is hardcoded to "cucas"' 83 | ); 84 | } else { 85 | return { 86 | token: 87 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjQ3MDc5OTIzMDIsImlzcyI6Imh0dHBzOi8vMWI4ODA1OTAtNjMwMS00YmI1LWIzNGYtNDVkYjFjNWY1NjQ0LnB1c2hub3RpZmljYXRpb25zLnB1c2hlci5jb20iLCJzdWIiOiJjdWNhcyJ9.CTtrDXh7vae3rSSKBKf5X0y4RQpFg7YvIlirmBQqJn4', 88 | }; 89 | } 90 | }, 91 | }; 92 | 93 | const beamsClient = new PusherPushNotifications.Client({ 94 | instanceId: '1b880590-6301-4bb5-b34f-45db1c5f5644', 95 | }); 96 | return beamsClient 97 | .start() 98 | .then(() => beamsClient.setUserId('cucas', tokenProvider)) 99 | .then(() => asyncScriptReturnCallback('')) 100 | .catch(e => asyncScriptReturnCallback(e.message)); 101 | }); 102 | 103 | expect(setUserIdError).toBe(''); 104 | 105 | const errolClient = new ErrolTestClient( 106 | '1b880590-6301-4bb5-b34f-45db1c5f5644' 107 | ); 108 | 109 | const response = await errolClient.getWebDevice(deviceId); 110 | expect(response.statusCode).toBe(200); 111 | expect(JSON.parse(response.body).userId).toBe('cucas'); 112 | }); 113 | 114 | test('SDK should return an error if we try to reassign the user id', async () => { 115 | await chromeDriver.get('http://localhost:3000'); 116 | await chromeDriver.wait(() => { 117 | return chromeDriver.getTitle().then(title => title.includes('Test Page')); 118 | }, 2000); 119 | 120 | const deviceId = await chromeDriver.executeAsyncScript(() => { 121 | const asyncScriptReturnCallback = arguments[arguments.length - 1]; 122 | 123 | const beamsClient = new PusherPushNotifications.Client({ 124 | instanceId: '1b880590-6301-4bb5-b34f-45db1c5f5644', 125 | }); 126 | return beamsClient 127 | .start() 128 | .then(() => beamsClient.getDeviceId()) 129 | .then(deviceId => asyncScriptReturnCallback(deviceId)) 130 | .catch(e => asyncScriptReturnCallback(e.message)); 131 | }); 132 | 133 | expect(deviceId).toContain('web-'); 134 | 135 | const setUserIdError = await chromeDriver.executeAsyncScript(() => { 136 | const asyncScriptReturnCallback = arguments[arguments.length - 1]; 137 | 138 | let tokenProvider = { 139 | fetchToken: () => ({ 140 | token: 141 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjQ3MDc5OTIzMDIsImlzcyI6Imh0dHBzOi8vMWI4ODA1OTAtNjMwMS00YmI1LWIzNGYtNDVkYjFjNWY1NjQ0LnB1c2hub3RpZmljYXRpb25zLnB1c2hlci5jb20iLCJzdWIiOiJjdWNhcyJ9.CTtrDXh7vae3rSSKBKf5X0y4RQpFg7YvIlirmBQqJn4', 142 | }), 143 | }; 144 | 145 | const beamsClient = new PusherPushNotifications.Client({ 146 | instanceId: '1b880590-6301-4bb5-b34f-45db1c5f5644', 147 | }); 148 | beamsClient 149 | .setUserId('cucas', tokenProvider) 150 | .then(() => beamsClient.setUserId('ronaldinho', tokenProvider)) 151 | .then(() => asyncScriptReturnCallback('')) 152 | .catch(e => asyncScriptReturnCallback(e.message)); 153 | }); 154 | 155 | expect(setUserIdError).toBe('Changing the `userId` is not allowed.'); 156 | }); 157 | 158 | test('SDK should return an error if .start has not been called', async () => { 159 | await chromeDriver.get('http://localhost:3000'); 160 | await chromeDriver.wait(() => { 161 | return chromeDriver.getTitle().then(title => title.includes('Test Page')); 162 | }, 2000); 163 | 164 | const setUserIdError = await chromeDriver.executeAsyncScript(() => { 165 | const asyncScriptReturnCallback = arguments[arguments.length - 1]; 166 | 167 | let tokenProvider = { 168 | fetchToken: () => ({ 169 | token: 170 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjQ3MDc5OTIzMDIsImlzcyI6Imh0dHBzOi8vMWI4ODA1OTAtNjMwMS00YmI1LWIzNGYtNDVkYjFjNWY1NjQ0LnB1c2hub3RpZmljYXRpb25zLnB1c2hlci5jb20iLCJzdWIiOiJjdWNhcyJ9.CTtrDXh7vae3rSSKBKf5X0y4RQpFg7YvIlirmBQqJn4', 171 | }), 172 | }; 173 | 174 | const beamsClient = new PusherPushNotifications.Client({ 175 | instanceId: '1b880590-6301-4bb5-b34f-45db1c5f5644', 176 | }); 177 | beamsClient 178 | .setUserId('cucas', tokenProvider) 179 | .then(() => asyncScriptReturnCallback('')) 180 | .catch(e => asyncScriptReturnCallback(e.message)); 181 | }); 182 | 183 | expect(setUserIdError).toBe('.start must be called before .setUserId'); 184 | }); 185 | 186 | test('SDK should return an error if user ID is empty string', async () => { 187 | await chromeDriver.get('http://localhost:3000'); 188 | await chromeDriver.wait(() => { 189 | return chromeDriver.getTitle().then(title => title.includes('Test Page')); 190 | }, 2000); 191 | 192 | const deviceId = await chromeDriver.executeAsyncScript(() => { 193 | const asyncScriptReturnCallback = arguments[arguments.length - 1]; 194 | 195 | const beamsClient = new PusherPushNotifications.Client({ 196 | instanceId: '1b880590-6301-4bb5-b34f-45db1c5f5644', 197 | }); 198 | beamsClient 199 | .start() 200 | .then(() => beamsClient.getDeviceId()) 201 | .then(deviceId => asyncScriptReturnCallback(deviceId)) 202 | .catch(e => asyncScriptReturnCallback(e.message)); 203 | }); 204 | 205 | expect(deviceId).toContain('web-'); 206 | 207 | const setUserIdError = await chromeDriver.executeAsyncScript(() => { 208 | const asyncScriptReturnCallback = arguments[arguments.length - 1]; 209 | 210 | let tokenProvider = { 211 | fetchToken: () => ({ 212 | token: 213 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjQ3MDc5OTIzMDIsImlzcyI6Imh0dHBzOi8vMWI4ODA1OTAtNjMwMS00YmI1LWIzNGYtNDVkYjFjNWY1NjQ0LnB1c2hub3RpZmljYXRpb25zLnB1c2hlci5jb20iLCJzdWIiOiJjdWNhcyJ9.CTtrDXh7vae3rSSKBKf5X0y4RQpFg7YvIlirmBQqJn4', 214 | }), 215 | }; 216 | 217 | const beamsClient = new PusherPushNotifications.Client({ 218 | instanceId: '1b880590-6301-4bb5-b34f-45db1c5f5644', 219 | }); 220 | beamsClient 221 | .setUserId('', tokenProvider) 222 | .then(() => asyncScriptReturnCallback('')) 223 | .catch(e => asyncScriptReturnCallback(e.message)); 224 | }); 225 | 226 | expect(setUserIdError).toBe('User ID cannot be the empty string'); 227 | }); 228 | 229 | test('SDK should return an error if user ID is not a string', async () => { 230 | await chromeDriver.get('http://localhost:3000'); 231 | await chromeDriver.wait(() => { 232 | return chromeDriver.getTitle().then(title => title.includes('Test Page')); 233 | }, 2000); 234 | 235 | const deviceId = await chromeDriver.executeAsyncScript(() => { 236 | const asyncScriptReturnCallback = arguments[arguments.length - 1]; 237 | 238 | const beamsClient = new PusherPushNotifications.Client({ 239 | instanceId: '1b880590-6301-4bb5-b34f-45db1c5f5644', 240 | }); 241 | beamsClient 242 | .start() 243 | .then(() => beamsClient.getDeviceId()) 244 | .then(deviceId => asyncScriptReturnCallback(deviceId)) 245 | .catch(e => asyncScriptReturnCallback(e.message)); 246 | }); 247 | 248 | expect(deviceId).toContain('web-'); 249 | 250 | const setUserIdError = await chromeDriver.executeAsyncScript(() => { 251 | const asyncScriptReturnCallback = arguments[arguments.length - 1]; 252 | 253 | let tokenProvider = { 254 | fetchToken: () => ({ 255 | token: 256 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjQ3MDc5OTIzMDIsImlzcyI6Imh0dHBzOi8vMWI4ODA1OTAtNjMwMS00YmI1LWIzNGYtNDVkYjFjNWY1NjQ0LnB1c2hub3RpZmljYXRpb25zLnB1c2hlci5jb20iLCJzdWIiOiJjdWNhcyJ9.CTtrDXh7vae3rSSKBKf5X0y4RQpFg7YvIlirmBQqJn4', 257 | }), 258 | }; 259 | 260 | const beamsClient = new PusherPushNotifications.Client({ 261 | instanceId: '1b880590-6301-4bb5-b34f-45db1c5f5644', 262 | }); 263 | beamsClient 264 | .setUserId(undefined, tokenProvider) 265 | .then(() => asyncScriptReturnCallback('')) 266 | .catch(e => asyncScriptReturnCallback(e.message)); 267 | }); 268 | 269 | expect(setUserIdError).toBe('User ID must be a string (was undefined)'); 270 | }); 271 | 272 | afterAll(async () => { 273 | if (killServer) killServer(); 274 | if (chromeDriver) await chromeDriver.quit(); 275 | }); 276 | -------------------------------------------------------------------------------- /end-to-end-tests/sdk-start.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | launchServer, 3 | createChromeWebDriver, 4 | NOTIFICATIONS_GRANTED, 5 | unregisterServiceWorker, 6 | SCRIPT_TIMEOUT_MS 7 | } from './test-utils'; 8 | 9 | let killServer = null; 10 | let chromeDriver = null; 11 | 12 | beforeAll(() => { 13 | return launchServer() 14 | .then(killFunc => { 15 | killServer = killFunc; 16 | }) 17 | .then(() => createChromeWebDriver(NOTIFICATIONS_GRANTED)) 18 | .then(driver => { 19 | chromeDriver = driver; 20 | }); 21 | }); 22 | 23 | afterEach(() => unregisterServiceWorker(chromeDriver)); 24 | 25 | test('SDK should register a device with errol', async () => { 26 | await chromeDriver.get('http://localhost:3000'); 27 | await chromeDriver.wait(() => { 28 | return chromeDriver.getTitle().then(title => { 29 | return title.includes('Test Page'); 30 | }); 31 | }, 2000); 32 | 33 | const initialDeviceId = await chromeDriver.executeAsyncScript(() => { 34 | const asyncScriptReturnCallback = arguments[arguments.length - 1]; 35 | const instanceId = 'deadc0de-2ce6-46e3-ad9a-5c02d0ab119b'; 36 | const beamsClient = new PusherPushNotifications.Client({ instanceId }); 37 | beamsClient 38 | .start() 39 | .then(() => beamsClient.getDeviceId()) 40 | .then(deviceId => { 41 | asyncScriptReturnCallback(deviceId); 42 | }) 43 | .catch(e => { 44 | asyncScriptReturnCallback(e.message); 45 | }); 46 | }); 47 | 48 | expect(initialDeviceId).toContain('web-'); 49 | }, SCRIPT_TIMEOUT_MS); 50 | 51 | test('SDK should remember the device ID', async () => { 52 | await chromeDriver.get('http://localhost:3000'); 53 | await chromeDriver.wait(() => { 54 | return chromeDriver.getTitle().then(title => title.includes('Test Page')); 55 | }, 2000); 56 | 57 | const initialDeviceId = await chromeDriver.executeAsyncScript(() => { 58 | const asyncScriptReturnCallback = arguments[arguments.length - 1]; 59 | 60 | const instanceId = 'deadc0de-2ce6-46e3-ad9a-5c02d0ab119b'; 61 | const beamsClient = new PusherPushNotifications.Client({ instanceId }); 62 | beamsClient 63 | .start() 64 | .then(() => beamsClient.getDeviceId()) 65 | .then(deviceId => asyncScriptReturnCallback(deviceId)) 66 | .catch(e => asyncScriptReturnCallback(e.message)); 67 | }); 68 | 69 | await chromeDriver.get('http://localhost:3000'); 70 | await chromeDriver.wait(() => { 71 | return chromeDriver.getTitle().then(title => title.includes('Test Page')); 72 | }, 2000); 73 | 74 | const reloadedDeviceId = await chromeDriver.executeAsyncScript(() => { 75 | const asyncScriptReturnCallback = arguments[arguments.length - 1]; 76 | 77 | const instanceId = 'deadc0de-2ce6-46e3-ad9a-5c02d0ab119b'; 78 | const beamsClient = new PusherPushNotifications.Client({ instanceId }); 79 | beamsClient 80 | .start() 81 | .then(() => beamsClient.getDeviceId()) 82 | .then(deviceId => asyncScriptReturnCallback(deviceId)) 83 | .catch(e => asyncScriptReturnCallback(e.message)); 84 | }); 85 | 86 | expect(reloadedDeviceId).toBe(initialDeviceId); 87 | }); 88 | 89 | describe('When service worker is missing', () => { 90 | // Need a new test server that is configured to return 404 when asked 91 | // for the service worker 92 | let killTestServer; 93 | beforeAll(() => { 94 | return launchServer({ 95 | port: 3210, 96 | serviceWorkerPresent: false, 97 | }).then(killFunc => { 98 | killTestServer = killFunc; 99 | }); 100 | }); 101 | 102 | test('SDK should return the proper exception if service worker cannot be found', async () => { 103 | await chromeDriver.get('http://localhost:3210'); 104 | await chromeDriver.wait(() => { 105 | return chromeDriver.getTitle().then(title => title.includes('Test Page')); 106 | }, 2000); 107 | 108 | // make sure device isn't there 109 | await chromeDriver.executeAsyncScript(() => { 110 | const asyncScriptReturnCallback = arguments[arguments.length - 1]; 111 | 112 | let deleteDbRequest = window.indexedDB.deleteDatabase( 113 | 'beams-deadc0de-2ce6-46e3-ad9a-5c02d0ab119b' 114 | ); 115 | deleteDbRequest.onsuccess = asyncScriptReturnCallback; 116 | deleteDbRequest.onerror = asyncScriptReturnCallback; 117 | }); 118 | 119 | const startResult = await chromeDriver.executeAsyncScript(async () => { 120 | const asyncScriptReturnCallback = arguments[arguments.length - 1]; 121 | 122 | const instanceId = 'deadc0de-2ce6-46e3-ad9a-5c02d0ab119b'; 123 | const beamsClient = new PusherPushNotifications.Client({ 124 | instanceId, 125 | }); 126 | beamsClient 127 | .start() 128 | .then(() => asyncScriptReturnCallback('start succeeded')) 129 | .catch(e => asyncScriptReturnCallback(e.message)); 130 | }); 131 | 132 | expect(startResult).not.toContain('succeeded'); 133 | expect(startResult).toContain('service worker missing'); 134 | }); 135 | 136 | afterAll(() => { 137 | killTestServer(); 138 | }); 139 | }); 140 | 141 | test('SDK should register a device with errol without registering the service worker itself', async () => { 142 | // this is the case where customers want to manage the service worker themselves 143 | await chromeDriver.get('http://localhost:3000'); 144 | await chromeDriver.wait(() => { 145 | return chromeDriver.getTitle().then(title => title.includes('Test Page')); 146 | }, 2000); 147 | 148 | // make sure device isn't there 149 | await chromeDriver.executeAsyncScript(() => { 150 | const asyncScriptReturnCallback = arguments[arguments.length - 1]; 151 | 152 | let deleteDbRequest = window.indexedDB.deleteDatabase( 153 | 'beams-deadc0de-2ce6-46e3-ad9a-5c02d0ab119b' 154 | ); 155 | deleteDbRequest.onsuccess = asyncScriptReturnCallback; 156 | deleteDbRequest.onerror = asyncScriptReturnCallback; 157 | }); 158 | const initialDeviceId = await chromeDriver.executeAsyncScript(async () => { 159 | const asyncScriptReturnCallback = arguments[arguments.length - 1]; 160 | 161 | const instanceId = 'deadc0de-2ce6-46e3-ad9a-5c02d0ab119b'; 162 | const beamsClient = new PusherPushNotifications.Client({ 163 | serviceWorkerRegistration: await window.navigator.serviceWorker.register( 164 | '/service-worker.js' 165 | ), 166 | instanceId, 167 | }); 168 | beamsClient 169 | .start() 170 | .then(() => beamsClient.getDeviceId()) 171 | .then(deviceId => asyncScriptReturnCallback(deviceId)) 172 | .catch(e => asyncScriptReturnCallback(e.message)); 173 | }); 174 | 175 | expect(initialDeviceId).toContain('web-'); 176 | }); 177 | 178 | test('SDK should fail if provided service worker is in wrong scope', async () => { 179 | await chromeDriver.get('http://localhost:3000'); 180 | await chromeDriver.wait(() => { 181 | return chromeDriver.getTitle().then(title => title.includes('Test Page')); 182 | }, 2000); 183 | 184 | const errorMessage = await chromeDriver.executeAsyncScript(() => { 185 | const asyncScriptReturnCallback = arguments[arguments.length - 1]; 186 | 187 | const instanceId = 'deadc0de-2ce6-46e3-ad9a-5c02d0ab119b'; 188 | return window.navigator.serviceWorker 189 | .register('/not-the-root/service-worker.js') 190 | .then(registration => { 191 | const beamsClient = new PusherPushNotifications.Client({ 192 | instanceId, 193 | serviceWorkerRegistration: registration, 194 | }); 195 | return beamsClient.start(); 196 | }) 197 | .catch(e => asyncScriptReturnCallback(e.message)); 198 | }); 199 | 200 | expect(errorMessage).toContain( 201 | 'current page not in serviceWorkerRegistration scope' 202 | ); 203 | }); 204 | 205 | afterAll(() => { 206 | if (killServer) { 207 | killServer(); 208 | } 209 | if (chromeDriver) { 210 | chromeDriver.quit(); 211 | } 212 | }); 213 | -------------------------------------------------------------------------------- /end-to-end-tests/sdk-stop.test.js: -------------------------------------------------------------------------------- 1 | import { ErrolTestClient } from './errol-client'; 2 | import { 3 | launchServer, 4 | createChromeWebDriver, 5 | NOTIFICATIONS_GRANTED, 6 | unregisterServiceWorker, 7 | SCRIPT_TIMEOUT_MS 8 | } from './test-utils'; 9 | 10 | let killServer = null; 11 | let chromeDriver = null; 12 | 13 | beforeAll(() => { 14 | return launchServer() 15 | .then(killFunc => { 16 | killServer = killFunc; 17 | }) 18 | .then(() => createChromeWebDriver(NOTIFICATIONS_GRANTED)) 19 | .then(driver => { 20 | chromeDriver = driver; 21 | }); 22 | }); 23 | 24 | beforeEach(() => { 25 | return (async () => { 26 | await chromeDriver.get('http://localhost:3000'); 27 | await chromeDriver.wait(() => { 28 | return chromeDriver.getTitle().then(title => title.includes('Test Page')); 29 | }, 2000); 30 | 31 | return chromeDriver.executeAsyncScript(() => { 32 | const asyncScriptReturnCallback = arguments[arguments.length - 1]; 33 | 34 | let deleteDbRequest = window.indexedDB.deleteDatabase( 35 | 'beams-1b880590-6301-4bb5-b34f-45db1c5f5644' 36 | ); 37 | deleteDbRequest.onsuccess = asyncScriptReturnCallback; 38 | deleteDbRequest.onerror = asyncScriptReturnCallback; 39 | }); 40 | })(); 41 | }); 42 | 43 | afterEach(() => unregisterServiceWorker(chromeDriver)); 44 | 45 | test('Calling .stop should clear SDK state', async () => { 46 | const errolClient = new ErrolTestClient( 47 | '1b880590-6301-4bb5-b34f-45db1c5f5644' 48 | ); 49 | 50 | // Load test application 51 | await chromeDriver.get('http://localhost:3000'); 52 | await chromeDriver.wait(() => { 53 | return chromeDriver.getTitle().then(title => title.includes('Test Page')); 54 | }, 2000); 55 | 56 | // Register a new device 57 | const deviceIdBeforeStop = await chromeDriver.executeAsyncScript(() => { 58 | const asyncScriptReturnCallback = arguments[arguments.length - 1]; 59 | 60 | const instanceId = '1b880590-6301-4bb5-b34f-45db1c5f5644'; 61 | 62 | window.beamsClient = new PusherPushNotifications.Client({ instanceId }); 63 | window.beamsClient 64 | .start() 65 | .then(() => window.beamsClient.getDeviceId()) 66 | .then(deviceId => asyncScriptReturnCallback(deviceId)) 67 | .catch(e => asyncScriptReturnCallback(e.message)); 68 | }); 69 | 70 | // Assert that a device has been registered 71 | expect(deviceIdBeforeStop).toContain('web-'); 72 | 73 | // Call .stop 74 | const stopError = await chromeDriver.executeAsyncScript(() => { 75 | const asyncScriptReturnCallback = arguments[arguments.length - 1]; 76 | 77 | return window.beamsClient 78 | .stop() 79 | .then(() => asyncScriptReturnCallback('')) 80 | .catch(e => asyncScriptReturnCallback(e.message)); 81 | }); 82 | expect(stopError).toBe(''); 83 | 84 | // Reload the page 85 | await chromeDriver.get('http://localhost:3000'); 86 | await chromeDriver.wait(() => { 87 | return chromeDriver.getTitle().then(title => title.includes('Test Page')); 88 | }, 2000); 89 | 90 | const deviceIdAfterStop = await chromeDriver.executeAsyncScript(() => { 91 | const asyncScriptReturnCallback = arguments[arguments.length - 1]; 92 | 93 | const instanceId = '1b880590-6301-4bb5-b34f-45db1c5f5644'; 94 | 95 | const beamsClient = new PusherPushNotifications.Client({ instanceId }); 96 | beamsClient 97 | .getDeviceId() 98 | .then(deviceId => asyncScriptReturnCallback(deviceId)) 99 | .catch(e => asyncScriptReturnCallback(e.message)); 100 | }); 101 | 102 | // Assert that the SDK no longer has a device ID 103 | expect(deviceIdAfterStop).toBe(null); 104 | 105 | // Assert that the device no longer exists on the server 106 | const response = await errolClient.getWebDevice(deviceIdBeforeStop); 107 | expect(response.statusCode).toBe(404); 108 | }, SCRIPT_TIMEOUT_MS); 109 | 110 | test('Calling .stop before .start should do nothing', async () => { 111 | const errolClient = new ErrolTestClient( 112 | '1b880590-6301-4bb5-b34f-45db1c5f5644' 113 | ); 114 | 115 | // Load test application 116 | await chromeDriver.get('http://localhost:3000'); 117 | await chromeDriver.wait(() => { 118 | return chromeDriver.getTitle().then(title => title.includes('Test Page')); 119 | }, 2000); 120 | 121 | // Call .stop 122 | const stopError = await chromeDriver.executeAsyncScript(() => { 123 | const asyncScriptReturnCallback = arguments[arguments.length - 1]; 124 | 125 | const instanceId = '1b880590-6301-4bb5-b34f-45db1c5f5644'; 126 | 127 | const beamsClient = new PusherPushNotifications.Client({ instanceId }); 128 | beamsClient 129 | .stop() 130 | .then(() => asyncScriptReturnCallback('')) 131 | .catch(e => asyncScriptReturnCallback(e.message)); 132 | }); 133 | expect(stopError).toBe(''); 134 | }); 135 | 136 | test('Calling .clearAllState should clear SDK state and create a new device', async () => { 137 | const errolClient = new ErrolTestClient( 138 | '1b880590-6301-4bb5-b34f-45db1c5f5644' 139 | ); 140 | 141 | // Load test application 142 | await chromeDriver.get('http://localhost:3000'); 143 | await chromeDriver.wait(() => { 144 | return chromeDriver.getTitle().then(title => title.includes('Test Page')); 145 | }, 2000); 146 | 147 | // Register a new device 148 | const deviceIdBeforeClear = await chromeDriver.executeAsyncScript(() => { 149 | const asyncScriptReturnCallback = arguments[arguments.length - 1]; 150 | 151 | const instanceId = '1b880590-6301-4bb5-b34f-45db1c5f5644'; 152 | 153 | const beamsClient = new PusherPushNotifications.Client({ instanceId }); 154 | beamsClient 155 | .start() 156 | .then(() => beamsClient.getDeviceId()) 157 | .then(deviceId => asyncScriptReturnCallback(deviceId)) 158 | .catch(e => asyncScriptReturnCallback(e.message)); 159 | }); 160 | 161 | // Assert that a device has been registered 162 | expect(deviceIdBeforeClear).toContain('web-'); 163 | 164 | // Call .clearAllState 165 | const deviceIdAfterClear = await chromeDriver.executeAsyncScript(() => { 166 | const asyncScriptReturnCallback = arguments[arguments.length - 1]; 167 | 168 | const instanceId = '1b880590-6301-4bb5-b34f-45db1c5f5644'; 169 | 170 | const beamsClient = new PusherPushNotifications.Client({ instanceId }); 171 | beamsClient 172 | .clearAllState() 173 | .then(() => beamsClient.getDeviceId()) 174 | .then(deviceId => asyncScriptReturnCallback(deviceId)) 175 | .catch(e => asyncScriptReturnCallback(e.message)); 176 | }); 177 | 178 | // Assert that the SDK has a device ID 179 | expect(deviceIdAfterClear).toContain('web-'); 180 | // Assert that the device ID has changed 181 | expect(deviceIdAfterClear).not.toBe(deviceIdBeforeClear); 182 | }); 183 | 184 | afterAll(() => { 185 | if (killServer) { 186 | killServer(); 187 | } 188 | if (chromeDriver) { 189 | chromeDriver.quit(); 190 | } 191 | }); 192 | -------------------------------------------------------------------------------- /end-to-end-tests/test-app/server.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const path = require('path'); 3 | const app = express(); 4 | 5 | const PORT = process.env.PORT || 3000; 6 | const SERVICE_WORKER_PRESENT = process.env.SERVICE_WORKER_PRESENT || 'true'; 7 | 8 | app.get('/', (req, res) => { 9 | res.send(` 10 | 11 | 12 | 13 | 14 | Push Notifications Web - Test Page 15 | 16 | 17 | 18 | 19 | 20 | `); 21 | }); 22 | 23 | app.get('/push-notifications-cdn.js', (req, res) => { 24 | res.sendFile(path.resolve('./dist/push-notifications-cdn.js')); 25 | }); 26 | 27 | app.get('/service-worker.js', (req, res) => { 28 | if (SERVICE_WORKER_PRESENT === 'true') { 29 | res.set('Content-Type', 'text/javascript'); 30 | res.send(''); 31 | } else { 32 | res.status(404).send('Not found'); 33 | } 34 | }); 35 | 36 | // Service worker in unusual location 37 | app.get('/not-the-root/service-worker.js', (req, res) => { 38 | res.set('Content-Type', 'text/javascript'); 39 | res.send(''); 40 | }); 41 | 42 | app.listen(PORT, () => console.log(`Listening on port ${PORT}...`)); 43 | -------------------------------------------------------------------------------- /end-to-end-tests/test-utils.js: -------------------------------------------------------------------------------- 1 | const { spawn } = require('child_process'); 2 | const fs = require('fs'); 3 | const http = require('http'); 4 | const path = require('path'); 5 | const chrome = require('selenium-webdriver/chrome'); 6 | const { Builder } = require('selenium-webdriver'); 7 | 8 | export const SCRIPT_TIMEOUT_MS = 60000; 9 | 10 | const MAX_TEST_SERVER_CHECKS = 10; 11 | const TEST_SERVER_CHECK_SLEEP_MS = 200; 12 | 13 | const TEST_SERVER_ENTRYPOINT = './end-to-end-tests/test-app/server.js'; 14 | const TEST_SERVER_URL = 'http://localhost'; 15 | 16 | const CHROME_SCREEN_SIZE = { 17 | width: 640, 18 | height: 480, 19 | }; 20 | const CHROME_CONFIG_TEMP_DIR = `${__dirname}/temp`; 21 | 22 | export const NOTIFICATIONS_DEFAULT = 0 23 | export const NOTIFICATIONS_GRANTED = 1 24 | export const NOTIFICATIONS_DENIED = 2 25 | 26 | /** 27 | * Helper for launching a test application server 28 | * @async 29 | * @return {Promise} - Call this function to shutdown the launched 30 | * server 31 | */ 32 | export async function launchServer(config = {}) { 33 | const { port = 3000, serviceWorkerPresent = 'true' } = config; 34 | 35 | const testServer = spawn('node', [TEST_SERVER_ENTRYPOINT], { 36 | env: { 37 | ...process.env, 38 | PORT: port, 39 | SERVICE_WORKER_PRESENT: serviceWorkerPresent, 40 | }, 41 | }); 42 | const killFunc = () => testServer.kill('SIGHUP'); 43 | 44 | return ( 45 | retryLoop( 46 | () => httpPing(`${TEST_SERVER_URL}:${port}`), 47 | MAX_TEST_SERVER_CHECKS, 48 | TEST_SERVER_CHECK_SLEEP_MS 49 | ) 50 | // If we successfully launched the test server, 51 | // return a function to kill it 52 | .then(() => killFunc) 53 | // Otherwise, kill the server and raise an exception 54 | .catch(e => { 55 | killFunc(); 56 | throw e; 57 | }) 58 | ); 59 | } 60 | 61 | async function retryLoop(func, maxChecks, sleepInterval) { 62 | for (let i = 0; i < maxChecks; i++) { 63 | const result = await func(); 64 | if (result) { 65 | return; 66 | } 67 | 68 | await sleep(sleepInterval); 69 | } 70 | 71 | throw new Error('Max retries exceeded'); 72 | } 73 | 74 | function httpPing(url) { 75 | return new Promise((resolve, reject) => { 76 | const req = http.get(url, res => { 77 | if (res.statusCode <= 299) { 78 | resolve(true); 79 | } else { 80 | resolve(false); 81 | } 82 | }); 83 | 84 | req.on('error', () => resolve(false)); 85 | }); 86 | } 87 | 88 | /** 89 | * Helper for instantiating a new Selenium webdriver instance (Chrome only) 90 | * @async 91 | * @param {number} notificationPermission - The notification permission for the test server 92 | * @return {ChromeDriver} - Selenium webdriver instance (Chrome) 93 | */ 94 | export async function createChromeWebDriver(notificationPermission = NOTIFICATIONS_DEFAULT) { 95 | // This is tricky for a few reasons: 96 | // 1. Selenium cannot accept the Web Push permission, so we have to 97 | // create a custom config file to do this in advance. 98 | // 2. Chrome requires service workers / Web Push applications to be served 99 | // over HTTPS. This has to be disabled. 100 | // 3. Chrome caches pages by default, which can be a pain when updating 101 | // tests. This also has to be disabled. 102 | 103 | /** 104 | * Create a config file that has already accepted web push for localhost 105 | */ 106 | 107 | // Delete temp dir if it exists 108 | if (fs.existsSync(CHROME_CONFIG_TEMP_DIR)) { 109 | rimraf(CHROME_CONFIG_TEMP_DIR); 110 | } 111 | 112 | // (Re)create temp directory 113 | fs.mkdirSync(CHROME_CONFIG_TEMP_DIR); 114 | 115 | // Create preferences directory 116 | const prefsDir = `${CHROME_CONFIG_TEMP_DIR}/Default`; 117 | if (!fs.existsSync(prefsDir)) { 118 | fs.mkdirSync(prefsDir); 119 | } 120 | 121 | // Create preferences file 122 | const prefsFileName = `${prefsDir}/Preferences`; 123 | const configFileObject = createTempBrowserPreferences(TEST_SERVER_URL, notificationPermission); 124 | const configFileString = JSON.stringify(configFileObject); 125 | 126 | fs.writeFileSync(prefsFileName, configFileString); 127 | 128 | /** 129 | * Construct a new Chrome instance 130 | */ 131 | const chromeOptions = new chrome.Options() 132 | .windowSize(CHROME_SCREEN_SIZE) 133 | // .headless() -- Cannot run in headless mode, this breaks web push 134 | .addArguments('--ignore-certificate-errors') // Allow web push over http 135 | .addArguments('--profile-directory=Default') // Override config 136 | .addArguments(`--user-data-dir=${CHROME_CONFIG_TEMP_DIR}`) 137 | .addArguments('--disk-cache-dir=/dev/null'); // Disable cache 138 | 139 | const driver = await new Builder() 140 | .forBrowser('chrome') 141 | .setChromeOptions(chromeOptions) 142 | .build(); 143 | 144 | await driver.manage().setTimeouts({ script: SCRIPT_TIMEOUT_MS }); 145 | 146 | return driver; 147 | } 148 | 149 | export async function unregisterServiceWorker(chromeDriver) { 150 | return chromeDriver.executeAsyncScript(async () => { 151 | const asyncScriptReturnCallback = arguments[arguments.length - 1]; 152 | let serviceWorkerRegistration = await window.navigator.serviceWorker.getRegistration() 153 | if (serviceWorkerRegistration) { 154 | await serviceWorkerRegistration.unregister() 155 | } 156 | asyncScriptReturnCallback() 157 | }) 158 | } 159 | 160 | function createTempBrowserPreferences(testSiteURL, notificationPermission) { 161 | let notifications = {} 162 | if (notificationPermission !== NOTIFICATIONS_DEFAULT) { 163 | const testSiteKey = `${testSiteURL},*`; 164 | notifications = { 165 | [testSiteKey]: { 166 | setting: notificationPermission, 167 | } 168 | } 169 | } 170 | return { 171 | profile: { 172 | content_settings: { 173 | exceptions: { 174 | notifications 175 | }, 176 | }, 177 | }, 178 | }; 179 | } 180 | 181 | function sleep(ms) { 182 | return new Promise((resolve, reject) => { 183 | setTimeout(resolve, ms); 184 | }); 185 | } 186 | 187 | /** 188 | * Remove directory recursively 189 | * @param {string} dir_path 190 | * @see https://stackoverflow.com/a/42505874/3027390 191 | */ 192 | function rimraf(dir_path) { 193 | if (fs.existsSync(dir_path)) { 194 | fs.readdirSync(dir_path).forEach(function(entry) { 195 | var entry_path = path.join(dir_path, entry); 196 | if (fs.lstatSync(entry_path).isDirectory()) { 197 | rimraf(entry_path); 198 | } else { 199 | fs.unlinkSync(entry_path); 200 | } 201 | }); 202 | fs.rmdirSync(dir_path); 203 | } 204 | } 205 | 206 | -------------------------------------------------------------------------------- /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.browser, 19 | ...globals.jest, 20 | ...globals.node, 21 | ...globals.serviceworker, 22 | }, 23 | 24 | ecmaVersion: 2018, 25 | sourceType: "module", 26 | }, 27 | 28 | rules: { 29 | "no-unused-vars": ["error", { 30 | argsIgnorePattern: "^_", 31 | varsIgnorePattern: "^_", 32 | caughtErrorsIgnorePattern: "^_", 33 | }], 34 | 35 | "no-console": 0, 36 | indent: ["error", 2], 37 | quotes: [2, "single"], 38 | "linebreak-style": [2, "unix"], 39 | semi: [2, "always"], 40 | }, 41 | }]; -------------------------------------------------------------------------------- /example.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Example 9 | 10 | 11 |

Example

12 | 13 | 14 | 30 | 31 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | export interface TokenProviderResponse { 2 | token: string; 3 | } 4 | export interface ITokenProvider { 5 | fetchToken(userId: string): Promise; 6 | } 7 | 8 | interface TokenProviderOptions { 9 | url: string; 10 | queryParams?: { [key: string]: any }; 11 | headers?: { [key: string]: string }; 12 | credentials?: string; 13 | } 14 | 15 | export class TokenProvider implements ITokenProvider { 16 | constructor(options: TokenProviderOptions); 17 | fetchToken(userId: string): Promise; 18 | } 19 | 20 | export enum RegistrationState { 21 | PERMISSION_GRANTED_REGISTERED_WITH_BEAMS = 'PERMISSION_GRANTED_REGISTERED_WITH_BEAMS', 22 | PERMISSION_GRANTED_NOT_REGISTERED_WITH_BEAMS = 'PERMISSION_GRANTED_NOT_REGISTERED_WITH_BEAMS', 23 | PERMISSION_PROMPT_REQUIRED = 'PERMISSION_PROMPT_REQUIRED', 24 | PERMISSION_DENIED = 'PERMISSION_DENIED', 25 | } 26 | 27 | export class Client { 28 | instanceId: string; 29 | deviceId: string; 30 | userId: string; 31 | 32 | constructor(options: ClientOptions); 33 | 34 | start(): Promise; 35 | getDeviceId(): Promise; 36 | addDeviceInterest(interest: string): Promise; 37 | removeDeviceInterest(interest: string): Promise; 38 | getDeviceInterests(): Promise>; 39 | setDeviceInterests(interests: Array): Promise; 40 | clearDeviceInterests(): Promise; 41 | getUserId(): Promise; 42 | setUserId(userId: string, tokenProvider: ITokenProvider): Promise; 43 | stop(): Promise; 44 | clearAllState(): Promise; 45 | getRegistrationState(): Promise; 46 | } 47 | 48 | interface ClientOptions { 49 | instanceId: string; 50 | serviceWorkerRegistration?: ServiceWorkerRegistration; 51 | endpointOverride?: string; 52 | } 53 | -------------------------------------------------------------------------------- /index.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectType } from 'tsd'; 2 | 3 | import * as PusherPushNotifications from '.'; 4 | 5 | // Create a client 6 | const beamsClient = new PusherPushNotifications.Client({ 7 | instanceId: 'YOUR_INSTANCE_ID', 8 | }); 9 | 10 | // Lifecycle management 11 | expectType>(beamsClient.start()); 12 | expectType>(beamsClient.stop()); 13 | expectType>(beamsClient.clearAllState()); 14 | expectType>(beamsClient.getDeviceId()); 15 | 16 | // Interest management 17 | expectType>(beamsClient.addDeviceInterest('hello')); 18 | expectType>(beamsClient.removeDeviceInterest('hello')); 19 | expectType>>(beamsClient.getDeviceInterests()); 20 | expectType>(beamsClient.setDeviceInterests(['a', 'b', 'c'])); 21 | expectType>(beamsClient.clearDeviceInterests()); 22 | 23 | // Authenticated Users 24 | const tokenProvider = new PusherPushNotifications.TokenProvider({ 25 | url: 'YOUR_BEAMS_AUTH_URL_HERE', 26 | queryParams: { someQueryParam: 'parameter-content' }, 27 | headers: { someHeader: 'header-content' }, 28 | }); 29 | 30 | expectType>(beamsClient.getUserId()); 31 | expectType>(beamsClient.setUserId('alice', tokenProvider)); 32 | 33 | // Registration state 34 | expectType>( 35 | beamsClient.getRegistrationState() 36 | ); 37 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | automock: false, 3 | setupFiles: ['./test-setup.js'], 4 | testEnvironment: 'jsdom' 5 | }; 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@pusher/push-notifications-web", 3 | "version": "2.1.0", 4 | "description": "", 5 | "main": "dist/push-notifications-esm.js", 6 | "types": "index.d.ts", 7 | "scripts": { 8 | "build:esm": "rollup -c ./rollup/esm.js", 9 | "build:cdn": "rollup -c ./rollup/cdn.js", 10 | "build:sw": "rollup -c ./rollup/service-worker.js && node ./scripts/add-version-to-service-worker.js", 11 | "format": "prettier ./src/**/*.js --write", 12 | "lint": "eslint ./src/**/*.js && prettier ./src/**/*.js -l", 13 | "test": "npm run test:unit", 14 | "test:unit": "jest ./src/*", 15 | "test:ts": "tsd .", 16 | "test:e2e": "jest ./end-to-end-tests/* --runInBand", 17 | "prepublishchecks": "npm run lint && npm run test:unit && npm run build:cdn && npm run build:esm && npm run test:e2e", 18 | "prepublishOnly": "publish-please guard", 19 | "publish-please": "publish-please" 20 | }, 21 | "keywords": [], 22 | "author": "Pusher", 23 | "license": "MIT", 24 | "devDependencies": { 25 | "@babel/core": "^7.26.0", 26 | "@babel/plugin-transform-runtime": "^7.25.9", 27 | "@babel/preset-env": "^7.26.0", 28 | "@babel/runtime": "^7.26.0", 29 | "@eslint/eslintrc": "^3.2.0", 30 | "@eslint/js": "^9.17.0", 31 | "@rollup/plugin-babel": "^6.0.4", 32 | "@rollup/plugin-commonjs": "^28.0.2", 33 | "@rollup/plugin-json": "^6.1.0", 34 | "@rollup/plugin-node-resolve": "^16.0.0", 35 | "babel-jest": "^29.7.0", 36 | "eslint": "^9.17.0", 37 | "eslint-config-prettier": "^9.1.0", 38 | "express": "^4.21.2", 39 | "globals": "^15.14.0", 40 | "jest": "^29.7.0", 41 | "jest-environment-jsdom": "^29.7.0", 42 | "jest-fetch-mock": "^3.0.3", 43 | "prettier": "3.4.2", 44 | "publish-please": "^5.5.2", 45 | "request-promise-native": "^1.0.9", 46 | "rollup": "^4.29.1", 47 | "selenium-webdriver": "4.7.1", 48 | "tsd": "^0.31.2" 49 | }, 50 | "overrides": { 51 | "braces": ">=3.0.3", 52 | "lodash": "^4.17.21", 53 | "semver": "^7.5.0" 54 | }, 55 | "browserify": { 56 | "transform": [ 57 | [ 58 | "babelify", 59 | { 60 | "presets": [ 61 | "es2015" 62 | ] 63 | } 64 | ] 65 | ] 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /rollup/cdn.js: -------------------------------------------------------------------------------- 1 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 2 | import commonjs from '@rollup/plugin-commonjs'; 3 | import { babel } from '@rollup/plugin-babel'; 4 | import json from '@rollup/plugin-json'; 5 | 6 | export default [ 7 | { 8 | input: 'src/push-notifications.js', 9 | output: { 10 | name: 'PusherPushNotifications', 11 | file: './dist/push-notifications-cdn.js', 12 | format: 'iife', 13 | }, 14 | plugins: [ 15 | json(), 16 | nodeResolve(), 17 | commonjs(), 18 | babel({ 19 | babelHelpers: 'runtime', 20 | presets: ['@babel/preset-env'], 21 | plugins: ['@babel/plugin-transform-runtime'], 22 | exclude: ['node_modules/**'], 23 | }), 24 | ], 25 | }, 26 | ]; 27 | -------------------------------------------------------------------------------- /rollup/esm.js: -------------------------------------------------------------------------------- 1 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 2 | import commonjs from '@rollup/plugin-commonjs'; 3 | import { babel } from '@rollup/plugin-babel'; 4 | import json from '@rollup/plugin-json'; 5 | 6 | export default [ 7 | { 8 | input: 'src/push-notifications.js', 9 | output: { 10 | file: './dist/push-notifications-esm.js', 11 | format: 'esm', 12 | }, 13 | plugins: [ 14 | json(), 15 | nodeResolve(), 16 | commonjs(), 17 | babel({ 18 | babelHelpers: 'runtime', 19 | presets: ['@babel/preset-env'], 20 | plugins: ['@babel/plugin-transform-runtime'], 21 | exclude: ['node_modules/**'], 22 | }), 23 | ], 24 | }, 25 | ]; 26 | -------------------------------------------------------------------------------- /rollup/service-worker.js: -------------------------------------------------------------------------------- 1 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 2 | import commonjs from '@rollup/plugin-commonjs'; 3 | import { babel } from '@rollup/plugin-babel'; 4 | import json from '@rollup/plugin-json'; 5 | 6 | export default [ 7 | { 8 | input: 'src/service-worker.js', 9 | output: { 10 | name: 'serviceWorker', 11 | file: './dist/service-worker.js', 12 | format: 'cjs', 13 | }, 14 | plugins: [ 15 | json(), 16 | nodeResolve(), 17 | commonjs(), 18 | babel({ 19 | babelHelpers: 'runtime', 20 | presets: ['@babel/preset-env'], 21 | plugins: ['@babel/plugin-transform-runtime'], 22 | exclude: ['node_modules/**'], 23 | }), 24 | ], 25 | }, 26 | ]; 27 | -------------------------------------------------------------------------------- /running-end-to-end-tests.md: -------------------------------------------------------------------------------- 1 | # Running end-to-end tests 2 | 3 | Then end-to-end tests depend on ChromeDriver, the web driver for Chrome. [You can install it here](http://chromedriver.chromium.org/downloads). Install a version that matches the version of Chrome on your machine. 4 | 5 | After that, you can run the end-to-end tests with 6 | 7 | ```bash 8 | npm run test:e2e 9 | ``` 10 | -------------------------------------------------------------------------------- /scripts/add-version-to-service-worker.js: -------------------------------------------------------------------------------- 1 | const childProcess = require('child_process'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | 5 | const PACKAGE_JSON_PATH = '../package.json'; 6 | const SW_FILE_PATH = '../dist/service-worker.js'; 7 | 8 | // Get SDK version 9 | const packageJSONFullPath = path.join(__dirname, PACKAGE_JSON_PATH); 10 | const packageJSON = require(packageJSONFullPath); 11 | 12 | const version = packageJSON.version; 13 | if (!version) { 14 | console.error('Version not set in package.json'); 15 | process.exit(1); 16 | } 17 | 18 | // Get current git commit 19 | const commitHash = childProcess 20 | .execSync('git rev-parse HEAD') 21 | .toString() 22 | .trim(); 23 | 24 | // Add version comment to generated Service Worker file 25 | const versionComment = `// SDK version: v${version}\n// Git commit: ${commitHash}`; 26 | 27 | const swFileFullPath = path.join(__dirname, SW_FILE_PATH); 28 | let swContents = ''; 29 | try { 30 | swContents = fs.readFileSync(swFileFullPath, 'utf8'); 31 | } catch (e) { 32 | console.error('Could not read ../dist/service-worker.js:', e.message); 33 | process.exit(1); 34 | } 35 | 36 | const versionedSw = versionComment + '\n\n' + swContents; 37 | try { 38 | fs.writeFileSync(swFileFullPath, versionedSw); 39 | } catch (e) { 40 | console.error('Could not overwrite service worker:', e.message); 41 | process.exit(1); 42 | } 43 | -------------------------------------------------------------------------------- /service-worker.js: -------------------------------------------------------------------------------- 1 | importScripts('/dist/service-worker.js'); 2 | -------------------------------------------------------------------------------- /src/device-state-store.js: -------------------------------------------------------------------------------- 1 | export default class DeviceStateStore { 2 | constructor(instanceId) { 3 | this._instanceId = instanceId; 4 | this._dbConn = null; 5 | } 6 | 7 | get _dbName() { 8 | return `beams-${this._instanceId}`; 9 | } 10 | 11 | get isConnected() { 12 | return this._dbConn !== null; 13 | } 14 | 15 | connect() { 16 | return new Promise((resolve, reject) => { 17 | const request = indexedDB.open(this._dbName); 18 | 19 | request.onsuccess = (event) => { 20 | const db = event.target.result; 21 | this._dbConn = db; 22 | 23 | this._readState() 24 | .then((state) => (state === null ? this.clear() : Promise.resolve())) 25 | .then(resolve); 26 | }; 27 | 28 | request.onupgradeneeded = (event) => { 29 | const db = event.target.result; 30 | db.createObjectStore('beams', { 31 | keyPath: 'instance_id', 32 | }); 33 | }; 34 | 35 | request.onerror = (event) => { 36 | const error = new Error(`Database error: ${event.target.error}`); 37 | reject(error); 38 | }; 39 | }); 40 | } 41 | 42 | clear() { 43 | return this._writeState({ 44 | instance_id: this._instanceId, 45 | device_id: null, 46 | token: null, 47 | user_id: null, 48 | }); 49 | } 50 | 51 | _readState() { 52 | if (!this.isConnected) { 53 | throw new Error( 54 | 'Cannot read value: DeviceStateStore not connected to IndexedDB' 55 | ); 56 | } 57 | 58 | return new Promise((resolve, reject) => { 59 | const request = this._dbConn 60 | .transaction('beams') 61 | .objectStore('beams') 62 | .get(this._instanceId); 63 | 64 | request.onsuccess = (event) => { 65 | const state = event.target.result; 66 | if (!state) { 67 | resolve(null); 68 | } 69 | resolve(state); 70 | }; 71 | 72 | request.onerror = (event) => { 73 | reject(event.target.error); 74 | }; 75 | }); 76 | } 77 | 78 | async _readProperty(name) { 79 | const state = await this._readState(); 80 | if (state === null) { 81 | return null; 82 | } 83 | return state[name] || null; 84 | } 85 | 86 | _writeState(state) { 87 | if (!this.isConnected) { 88 | throw new Error( 89 | 'Cannot write value: DeviceStateStore not connected to IndexedDB' 90 | ); 91 | } 92 | 93 | return new Promise((resolve, reject) => { 94 | const request = this._dbConn 95 | .transaction('beams', 'readwrite') 96 | .objectStore('beams') 97 | .put(state); 98 | 99 | request.onsuccess = (_) => { 100 | resolve(); 101 | }; 102 | 103 | request.onerror = (event) => { 104 | reject(event.target.error); 105 | }; 106 | }); 107 | } 108 | 109 | async _writeProperty(name, value) { 110 | const state = await this._readState(); 111 | state[name] = value; 112 | await this._writeState(state); 113 | } 114 | 115 | getToken() { 116 | return this._readProperty('token'); 117 | } 118 | 119 | setToken(token) { 120 | return this._writeProperty('token', token); 121 | } 122 | 123 | getDeviceId() { 124 | return this._readProperty('device_id'); 125 | } 126 | 127 | setDeviceId(deviceId) { 128 | return this._writeProperty('device_id', deviceId); 129 | } 130 | 131 | getUserId() { 132 | return this._readProperty('user_id'); 133 | } 134 | 135 | setUserId(userId) { 136 | return this._writeProperty('user_id', userId); 137 | } 138 | 139 | getLastSeenSdkVersion() { 140 | return this._readProperty('last_seen_sdk_version'); 141 | } 142 | 143 | setLastSeenSdkVersion(sdkVersion) { 144 | return this._writeProperty('last_seen_sdk_version', sdkVersion); 145 | } 146 | 147 | getLastSeenUserAgent() { 148 | return this._readProperty('last_seen_user_agent'); 149 | } 150 | 151 | setLastSeenUserAgent(userAgent) { 152 | return this._writeProperty('last_seen_user_agent', userAgent); 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/do-request.js: -------------------------------------------------------------------------------- 1 | export default function doRequest({ 2 | method, 3 | path, 4 | params = {}, 5 | body = null, 6 | headers = {}, 7 | credentials = 'same-origin', 8 | }) { 9 | const options = { 10 | method, 11 | headers, 12 | credentials, 13 | }; 14 | 15 | if (!emptyParams(params)) { 16 | // check for empty params obj 17 | path += '?'; 18 | path += Object.entries(params) 19 | .filter((x) => x[1]) 20 | .map((pair) => pair.map((x) => encodeURIComponent(x)).join('=')) 21 | .join('&'); 22 | } 23 | 24 | if (body !== null) { 25 | options.body = JSON.stringify(body); 26 | options.headers = { 'Content-Type': 'application/json', ...headers }; 27 | } 28 | 29 | return fetch(path, options).then(async (response) => { 30 | if (!response.ok) { 31 | await handleError(response); 32 | } 33 | 34 | try { 35 | return await response.json(); 36 | } catch (_) { 37 | return null; 38 | } 39 | }); 40 | } 41 | 42 | function emptyParams(params) { 43 | for (let i in params) return false; 44 | return true; 45 | } 46 | 47 | async function handleError(response) { 48 | let errorMessage; 49 | try { 50 | const { error = 'Unknown error', description = 'No description' } = 51 | await response.json(); 52 | errorMessage = `Unexpected status code ${ 53 | response.status 54 | }: ${error}, ${description}`; 55 | } catch (_) { 56 | errorMessage = `Unexpected status code ${ 57 | response.status 58 | }: Cannot parse error response`; 59 | } 60 | 61 | throw new Error(errorMessage); 62 | } 63 | -------------------------------------------------------------------------------- /src/do-request.test.js: -------------------------------------------------------------------------------- 1 | import doRequest from './do-request'; 2 | 3 | test('doRequest', () => { 4 | expect(typeof doRequest).toBe('function'); 5 | }); 6 | 7 | test('Handles URL with params', () => { 8 | const options = { 9 | method: 'GET', 10 | path: 'http://fake-url.com', 11 | params: { search: 'test string' }, 12 | }; 13 | return doRequest(options).then(() => 14 | expect(fetch.mock.calls[0][0]).toEqual( 15 | 'http://fake-url.com?search=test%20string' 16 | ) 17 | ); 18 | }); 19 | 20 | test('Handles URL with multiple params', () => { 21 | const options = { 22 | method: 'GET', 23 | path: 'http://fake-url.com', 24 | params: { search: 'test string', other: 123 }, 25 | }; 26 | return doRequest(options).then(() => 27 | expect(fetch.mock.calls[1][0]).toEqual( 28 | 'http://fake-url.com?search=test%20string&other=123' 29 | ) 30 | ); 31 | }); 32 | 33 | test('Handles responses with no body', () => { 34 | const options = { method: 'POST', path: 'http://fake-url.com' }; 35 | return doRequest(options).then((res) => expect(res).toBeNull()); 36 | }); 37 | 38 | test('Handles HTML response body', () => { 39 | fetch.mockResponseOnce(` 40 | 41 | 42 | 43 | 44 | 45 | Document 46 | 47 | 48 | `); 49 | const options = { method: 'GET', path: 'http://fake-url.com' }; 50 | return doRequest(options).then((res) => { 51 | expect(res).toBeNull(); 52 | }); 53 | }); 54 | 55 | test('Handles bad JSON', () => { 56 | fetch.mockResponseOnce('{"badjson": "very incorrect"'); 57 | const options = { method: 'GET', path: 'http://fake-url.com' }; 58 | return doRequest(options).then((res) => { 59 | expect(res).toBeNull(); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /src/push-notifications.js: -------------------------------------------------------------------------------- 1 | import doRequest from './do-request'; 2 | import TokenProvider from './token-provider'; 3 | import DeviceStateStore from './device-state-store'; 4 | import { version as sdkVersion } from '../package.json'; 5 | 6 | const INTERESTS_REGEX = new RegExp('^(_|\\-|=|@|,|\\.|;|[A-Z]|[a-z]|[0-9])*$'); 7 | const MAX_INTEREST_LENGTH = 164; 8 | const MAX_INTERESTS_NUM = 5000; 9 | 10 | const SERVICE_WORKER_URL = `/service-worker.js?pusherBeamsWebSDKVersion=${sdkVersion}`; 11 | 12 | export const RegistrationState = Object.freeze({ 13 | PERMISSION_PROMPT_REQUIRED: 'PERMISSION_PROMPT_REQUIRED', 14 | PERMISSION_GRANTED_NOT_REGISTERED_WITH_BEAMS: 15 | 'PERMISSION_GRANTED_NOT_REGISTERED_WITH_BEAMS', 16 | PERMISSION_GRANTED_REGISTERED_WITH_BEAMS: 17 | 'PERMISSION_GRANTED_REGISTERED_WITH_BEAMS', 18 | PERMISSION_DENIED: 'PERMISSION_DENIED', 19 | }); 20 | 21 | export class Client { 22 | constructor(config) { 23 | if (!config) { 24 | throw new Error('Config object required'); 25 | } 26 | const { 27 | instanceId, 28 | endpointOverride = null, 29 | serviceWorkerRegistration = null, 30 | } = config; 31 | 32 | if (instanceId === undefined) { 33 | throw new Error('Instance ID is required'); 34 | } 35 | if (typeof instanceId !== 'string') { 36 | throw new Error('Instance ID must be a string'); 37 | } 38 | if (instanceId.length === 0) { 39 | throw new Error('Instance ID cannot be empty'); 40 | } 41 | 42 | if (!('indexedDB' in window)) { 43 | throw new Error( 44 | 'Pusher Beams does not support this browser version (IndexedDB not supported)' 45 | ); 46 | } 47 | 48 | if (!window.isSecureContext) { 49 | throw new Error( 50 | 'Pusher Beams relies on Service Workers, which only work in secure contexts. Check that your page is being served from localhost/over HTTPS' 51 | ); 52 | } 53 | 54 | if (!('serviceWorker' in navigator)) { 55 | throw new Error( 56 | 'Pusher Beams does not support this browser version (Service Workers not supported)' 57 | ); 58 | } 59 | 60 | if (!('PushManager' in window)) { 61 | throw new Error( 62 | 'Pusher Beams does not support this browser version (Web Push not supported)' 63 | ); 64 | } 65 | 66 | if (serviceWorkerRegistration) { 67 | const serviceWorkerScope = serviceWorkerRegistration.scope; 68 | const currentURL = window.location.href; 69 | const scopeMatchesCurrentPage = currentURL.startsWith(serviceWorkerScope); 70 | if (!scopeMatchesCurrentPage) { 71 | throw new Error( 72 | `Could not initialize Pusher web push: current page not in serviceWorkerRegistration scope (${serviceWorkerScope})` 73 | ); 74 | } 75 | } 76 | 77 | this.instanceId = instanceId; 78 | this._deviceId = null; 79 | this._token = null; 80 | this._userId = null; 81 | this._serviceWorkerRegistration = serviceWorkerRegistration; 82 | this._deviceStateStore = new DeviceStateStore(instanceId); 83 | this._endpoint = endpointOverride; // Internal only 84 | 85 | this._ready = this._init(); 86 | } 87 | 88 | async _init() { 89 | if (this._deviceId !== null) { 90 | return; 91 | } 92 | 93 | await this._deviceStateStore.connect(); 94 | 95 | if (this._serviceWorkerRegistration) { 96 | // If we have been given a service worker, wait for it to be ready 97 | await window.navigator.serviceWorker.ready; 98 | } else { 99 | // Otherwise register our own one 100 | this._serviceWorkerRegistration = await getServiceWorkerRegistration(); 101 | } 102 | 103 | await this._detectSubscriptionChange(); 104 | 105 | this._deviceId = await this._deviceStateStore.getDeviceId(); 106 | this._token = await this._deviceStateStore.getToken(); 107 | this._userId = await this._deviceStateStore.getUserId(); 108 | } 109 | 110 | // Ensure SDK is loaded and is consistent 111 | async _resolveSDKState() { 112 | await this._ready; 113 | await this._detectSubscriptionChange(); 114 | } 115 | 116 | async _detectSubscriptionChange() { 117 | const storedToken = await this._deviceStateStore.getToken(); 118 | const actualToken = await getWebPushToken(this._serviceWorkerRegistration); 119 | 120 | const pushTokenHasChanged = storedToken !== actualToken; 121 | 122 | if (pushTokenHasChanged) { 123 | // The web push subscription has changed out from underneath us. 124 | // This can happen when the user disables the web push permission 125 | // (potentially also renabling it, thereby changing the token) 126 | // 127 | // This means the SDK has effectively been stopped, so we should update 128 | // the SDK state to reflect that. 129 | await this._deviceStateStore.clear(); 130 | this._deviceId = null; 131 | this._token = null; 132 | this._userId = null; 133 | } 134 | } 135 | 136 | async getDeviceId() { 137 | await this._resolveSDKState(); 138 | return this._ready.then(() => this._deviceId); 139 | } 140 | 141 | async getToken() { 142 | await this._resolveSDKState(); 143 | return this._ready.then(() => this._token); 144 | } 145 | 146 | async getUserId() { 147 | await this._resolveSDKState(); 148 | return this._ready.then(() => this._userId); 149 | } 150 | 151 | get _baseURL() { 152 | if (this._endpoint !== null) { 153 | return this._endpoint; 154 | } 155 | return `https://${this.instanceId}.pushnotifications.pusher.com`; 156 | } 157 | 158 | _throwIfNotStarted(message) { 159 | if (!this._deviceId) { 160 | throw new Error( 161 | `${message}. SDK not registered with Beams. Did you call .start?` 162 | ); 163 | } 164 | } 165 | 166 | async start() { 167 | await this._resolveSDKState(); 168 | 169 | if (!isWebPushSupported()) { 170 | return this; 171 | } 172 | 173 | if (this._deviceId !== null) { 174 | return this; 175 | } 176 | 177 | const { vapidPublicKey: publicKey } = await this._getPublicKey(); 178 | 179 | // register with pushManager, get endpoint etc 180 | const token = await this._getPushToken(publicKey); 181 | 182 | // get device id from errol 183 | const deviceId = await this._registerDevice(token); 184 | 185 | await this._deviceStateStore.setToken(token); 186 | await this._deviceStateStore.setDeviceId(deviceId); 187 | await this._deviceStateStore.setLastSeenSdkVersion(sdkVersion); 188 | await this._deviceStateStore.setLastSeenUserAgent( 189 | window.navigator.userAgent 190 | ); 191 | 192 | this._token = token; 193 | this._deviceId = deviceId; 194 | return this; 195 | } 196 | 197 | async getRegistrationState() { 198 | await this._resolveSDKState(); 199 | 200 | if (Notification.permission === 'denied') { 201 | return RegistrationState.PERMISSION_DENIED; 202 | } 203 | 204 | if (Notification.permission === 'granted' && this._deviceId !== null) { 205 | return RegistrationState.PERMISSION_GRANTED_REGISTERED_WITH_BEAMS; 206 | } 207 | 208 | if (Notification.permission === 'granted' && this._deviceId === null) { 209 | return RegistrationState.PERMISSION_GRANTED_NOT_REGISTERED_WITH_BEAMS; 210 | } 211 | 212 | return RegistrationState.PERMISSION_PROMPT_REQUIRED; 213 | } 214 | 215 | async addDeviceInterest(interest) { 216 | await this._resolveSDKState(); 217 | this._throwIfNotStarted('Could not add Device Interest'); 218 | 219 | validateInterestName(interest); 220 | 221 | const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( 222 | this.instanceId 223 | )}/devices/web/${this._deviceId}/interests/${encodeURIComponent(interest)}`; 224 | const options = { 225 | method: 'POST', 226 | path, 227 | }; 228 | await doRequest(options); 229 | } 230 | 231 | async removeDeviceInterest(interest) { 232 | await this._resolveSDKState(); 233 | this._throwIfNotStarted('Could not remove Device Interest'); 234 | 235 | validateInterestName(interest); 236 | 237 | const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( 238 | this.instanceId 239 | )}/devices/web/${this._deviceId}/interests/${encodeURIComponent(interest)}`; 240 | const options = { 241 | method: 'DELETE', 242 | path, 243 | }; 244 | await doRequest(options); 245 | } 246 | 247 | async getDeviceInterests(limit = 100, cursor = null) { 248 | await this._resolveSDKState(); 249 | this._throwIfNotStarted('Could not get Device Interests'); 250 | 251 | const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( 252 | this.instanceId 253 | )}/devices/web/${this._deviceId}/interests`; 254 | const options = { 255 | method: 'GET', 256 | path, 257 | params: { limit, cursor }, 258 | }; 259 | let res = await doRequest(options); 260 | res = { 261 | interests: (res && res['interests']) || [], 262 | ...((res && res.responseMetadata) || {}), 263 | }; 264 | return res; 265 | } 266 | 267 | async setDeviceInterests(interests) { 268 | await this._resolveSDKState(); 269 | this._throwIfNotStarted('Could not set Device Interests'); 270 | 271 | if (interests === undefined || interests === null) { 272 | throw new Error('interests argument is required'); 273 | } 274 | if (!Array.isArray(interests)) { 275 | throw new Error('interests argument must be an array'); 276 | } 277 | if (interests.length > MAX_INTERESTS_NUM) { 278 | throw new Error( 279 | `Number of interests (${ 280 | interests.length 281 | }) exceeds maximum of ${MAX_INTERESTS_NUM}` 282 | ); 283 | } 284 | for (let interest of interests) { 285 | validateInterestName(interest); 286 | } 287 | 288 | const uniqueInterests = Array.from(new Set(interests)); 289 | const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( 290 | this.instanceId 291 | )}/devices/web/${this._deviceId}/interests`; 292 | const options = { 293 | method: 'PUT', 294 | path, 295 | body: { 296 | interests: uniqueInterests, 297 | }, 298 | }; 299 | await doRequest(options); 300 | } 301 | 302 | async clearDeviceInterests() { 303 | await this._resolveSDKState(); 304 | this._throwIfNotStarted('Could not clear Device Interests'); 305 | 306 | await this.setDeviceInterests([]); 307 | } 308 | 309 | async setUserId(userId, tokenProvider) { 310 | await this._resolveSDKState(); 311 | 312 | if (!isWebPushSupported()) { 313 | return; 314 | } 315 | 316 | if (this._deviceId === null) { 317 | const error = new Error('.start must be called before .setUserId'); 318 | return Promise.reject(error); 319 | } 320 | if (typeof userId !== 'string') { 321 | throw new Error(`User ID must be a string (was ${userId})`); 322 | } 323 | if (userId === '') { 324 | throw new Error('User ID cannot be the empty string'); 325 | } 326 | if (this._userId !== null && this._userId !== userId) { 327 | throw new Error('Changing the `userId` is not allowed.'); 328 | } 329 | 330 | const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( 331 | this.instanceId 332 | )}/devices/web/${this._deviceId}/user`; 333 | 334 | const { token: beamsAuthToken } = await tokenProvider.fetchToken(userId); 335 | const options = { 336 | method: 'PUT', 337 | path, 338 | headers: { 339 | Authorization: `Bearer ${beamsAuthToken}`, 340 | }, 341 | }; 342 | await doRequest(options); 343 | 344 | this._userId = userId; 345 | return this._deviceStateStore.setUserId(userId); 346 | } 347 | 348 | async stop() { 349 | await this._resolveSDKState(); 350 | 351 | if (!isWebPushSupported()) { 352 | return; 353 | } 354 | 355 | if (this._deviceId === null) { 356 | return; 357 | } 358 | 359 | await this._deleteDevice(); 360 | await this._deviceStateStore.clear(); 361 | this._clearPushToken().catch(() => {}); // Not awaiting this, best effort. 362 | 363 | this._deviceId = null; 364 | this._token = null; 365 | this._userId = null; 366 | } 367 | 368 | async clearAllState() { 369 | if (!isWebPushSupported()) { 370 | return; 371 | } 372 | 373 | await this.stop(); 374 | await this.start(); 375 | } 376 | 377 | async _getPublicKey() { 378 | const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( 379 | this.instanceId 380 | )}/web-vapid-public-key`; 381 | 382 | const options = { method: 'GET', path }; 383 | return doRequest(options); 384 | } 385 | 386 | async _getPushToken(publicKey) { 387 | try { 388 | // The browser might already have a push subscription to different key. 389 | // Lets clear it out first. 390 | await this._clearPushToken(); 391 | const sub = await this._serviceWorkerRegistration.pushManager.subscribe({ 392 | userVisibleOnly: true, 393 | applicationServerKey: urlBase64ToUInt8Array(publicKey), 394 | }); 395 | return btoa(JSON.stringify(sub)); 396 | } catch (e) { 397 | return Promise.reject(e); 398 | } 399 | } 400 | 401 | async _clearPushToken() { 402 | return navigator.serviceWorker.ready 403 | .then((reg) => reg.pushManager.getSubscription()) 404 | .then((sub) => { 405 | if (sub) sub.unsubscribe(); 406 | }); 407 | } 408 | 409 | async _registerDevice(token) { 410 | const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( 411 | this.instanceId 412 | )}/devices/web`; 413 | 414 | const device = { 415 | token, 416 | metadata: { 417 | sdkVersion, 418 | }, 419 | }; 420 | 421 | const options = { method: 'POST', path, body: device }; 422 | const response = await doRequest(options); 423 | return response.id; 424 | } 425 | 426 | async _deleteDevice() { 427 | const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( 428 | this.instanceId 429 | )}/devices/web/${encodeURIComponent(this._deviceId)}`; 430 | 431 | const options = { method: 'DELETE', path }; 432 | await doRequest(options); 433 | } 434 | 435 | /** 436 | * Submit SDK version and browser details (via the user agent) to Pusher Beams. 437 | */ 438 | async _updateDeviceMetadata() { 439 | const userAgent = window.navigator.userAgent; 440 | const storedUserAgent = await this._deviceStateStore.getLastSeenUserAgent(); 441 | const storedSdkVersion = 442 | await this._deviceStateStore.getLastSeenSdkVersion(); 443 | 444 | if (userAgent === storedUserAgent && sdkVersion === storedSdkVersion) { 445 | // Nothing to do 446 | return; 447 | } 448 | 449 | const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( 450 | this.instanceId 451 | )}/devices/web/${this._deviceId}/metadata`; 452 | 453 | const metadata = { 454 | sdkVersion, 455 | }; 456 | 457 | const options = { method: 'PUT', path, body: metadata }; 458 | await doRequest(options); 459 | 460 | await this._deviceStateStore.setLastSeenSdkVersion(sdkVersion); 461 | await this._deviceStateStore.setLastSeenUserAgent(userAgent); 462 | } 463 | } 464 | 465 | const validateInterestName = (interest) => { 466 | if (interest === undefined || interest === null) { 467 | throw new Error('Interest name is required'); 468 | } 469 | if (typeof interest !== 'string') { 470 | throw new Error(`Interest ${interest} is not a string`); 471 | } 472 | if (!INTERESTS_REGEX.test(interest)) { 473 | throw new Error( 474 | `interest "${interest}" contains a forbidden character. ` + 475 | 'Allowed characters are: ASCII upper/lower-case letters, ' + 476 | 'numbers or one of _-=@,.;' 477 | ); 478 | } 479 | if (interest.length > MAX_INTEREST_LENGTH) { 480 | throw new Error( 481 | `Interest is longer than the maximum of ${MAX_INTEREST_LENGTH} chars` 482 | ); 483 | } 484 | }; 485 | 486 | async function getServiceWorkerRegistration() { 487 | // Check that service worker file exists 488 | const { status: swStatusCode } = await fetch(SERVICE_WORKER_URL); 489 | if (swStatusCode !== 200) { 490 | throw new Error( 491 | 'Cannot start SDK, service worker missing: No file found at /service-worker.js' 492 | ); 493 | } 494 | 495 | window.navigator.serviceWorker.register(SERVICE_WORKER_URL, { 496 | // explicitly opting out of `importScripts` caching just in case our 497 | // customers decides to host and serve the imported scripts and 498 | // accidentally set `Cache-Control` to something other than `max-age=0` 499 | updateViaCache: 'none', 500 | }); 501 | return window.navigator.serviceWorker.ready; 502 | } 503 | 504 | function getWebPushToken(swReg) { 505 | return swReg.pushManager 506 | .getSubscription() 507 | .then((sub) => (!sub ? null : encodeSubscription(sub))); 508 | } 509 | 510 | function encodeSubscription(sub) { 511 | return btoa(JSON.stringify(sub)); 512 | } 513 | 514 | function urlBase64ToUInt8Array(base64String) { 515 | const padding = '='.repeat((4 - (base64String.length % 4)) % 4); 516 | const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); 517 | const rawData = window.atob(base64); 518 | return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0))); 519 | } 520 | 521 | function isWebPushSupported() { 522 | const hasNotification = 'Notification' in window; 523 | const hasPushManager = 'PushManager' in window; 524 | const hasServiceWorker = 'serviceWorker' in navigator; 525 | 526 | if (!hasNotification || !hasPushManager || !hasServiceWorker) { 527 | console.warn('Missing required Web Push APIs. Please upgrade your browser'); 528 | return false; 529 | } 530 | 531 | return true; 532 | } 533 | 534 | export { TokenProvider }; 535 | -------------------------------------------------------------------------------- /src/push-notifications.test.js: -------------------------------------------------------------------------------- 1 | import * as PusherPushNotifications from './push-notifications'; 2 | import { makeDeviceStateStore } from '../test-utils/fake-device-state-store'; 3 | 4 | const DUMMY_PUSH_SUBSCRIPTION = { foo: 'bar' }; 5 | const ENCODED_DUMMY_PUSH_SUBSCRIPTION = 'eyJmb28iOiJiYXIifQ=='; 6 | 7 | describe('Constructor', () => { 8 | afterEach(() => { 9 | jest.resetModules(); 10 | tearDownGlobals(); 11 | }); 12 | 13 | test('will throw if there is no config object given', () => { 14 | return expect(() => new PusherPushNotifications.Client()).toThrow( 15 | 'Config object required' 16 | ); 17 | }); 18 | 19 | test('will throw if there is no instance ID specified', () => { 20 | return expect(() => new PusherPushNotifications.Client({})).toThrow( 21 | 'Instance ID is required' 22 | ); 23 | }); 24 | 25 | test('will throw if instance ID is not a string', () => { 26 | const instanceId = null; 27 | return expect( 28 | () => new PusherPushNotifications.Client({ instanceId }) 29 | ).toThrow('Instance ID must be a string'); 30 | }); 31 | 32 | test('will throw if the instance id is the empty string', () => { 33 | const instanceId = ''; 34 | return expect( 35 | () => new PusherPushNotifications.Client({ instanceId }) 36 | ).toThrow('Instance ID cannot be empty'); 37 | }); 38 | 39 | test('will throw if indexedDB is not available', () => { 40 | setUpGlobals({ indexedDBSupport: false }); 41 | const instanceId = 'df3c1965-e870-4bd6-8d75-fea56b26335f'; 42 | return expect( 43 | () => new PusherPushNotifications.Client({ instanceId }) 44 | ).toThrow('IndexedDB not supported'); 45 | }); 46 | 47 | test('will throw if the SDK is loaded from a context that is not secure', () => { 48 | setUpGlobals({ isSecureContext: false }); 49 | const instanceId = 'df3c1965-e870-4bd6-8d75-fea56b26335f'; 50 | return expect( 51 | () => new PusherPushNotifications.Client({ instanceId }) 52 | ).toThrow( 53 | 'Pusher Beams relies on Service Workers, which only work in secure contexts' 54 | ); 55 | }); 56 | 57 | test('will throw if ServiceWorkerRegistration not supported', () => { 58 | setUpGlobals({ serviceWorkerSupport: false }); 59 | const instanceId = 'df3c1965-e870-4bd6-8d75-fea56b26335f'; 60 | return expect( 61 | () => new PusherPushNotifications.Client({ instanceId }) 62 | ).toThrow('Service Workers not supported'); 63 | }); 64 | 65 | test('will throw if Web Push not supported', () => { 66 | setUpGlobals({ webPushSupport: false }); 67 | const instanceId = 'df3c1965-e870-4bd6-8d75-fea56b26335f'; 68 | return expect( 69 | () => new PusherPushNotifications.Client({ instanceId }) 70 | ).toThrow('Web Push not supported'); 71 | }); 72 | 73 | test('will return properly configured instance otherwise', () => { 74 | const PusherPushNotifications = require('./push-notifications'); 75 | const devicestatestore = require('./device-state-store'); 76 | 77 | setUpGlobals({}); 78 | 79 | devicestatestore.default = makeDeviceStateStore({ 80 | deviceId: 'web-1db66b8a-f51f-49de-b225-72591535c855', 81 | token: ENCODED_DUMMY_PUSH_SUBSCRIPTION, 82 | userId: 'alice', 83 | }); 84 | 85 | const instanceId = 'df3c1965-e870-4bd6-8d75-fea56b26335f'; 86 | 87 | const beamsClient = new PusherPushNotifications.Client({ instanceId }); 88 | return Promise.all([ 89 | beamsClient.getDeviceId(), 90 | beamsClient.getToken(), 91 | beamsClient.getUserId(), 92 | ]).then(([deviceId, token, userId]) => { 93 | expect(deviceId).toEqual('web-1db66b8a-f51f-49de-b225-72591535c855'); 94 | expect(token).toEqual(ENCODED_DUMMY_PUSH_SUBSCRIPTION); 95 | expect(userId).toEqual('alice'); 96 | }); 97 | }); 98 | }); 99 | 100 | describe('interest methods', () => { 101 | let PusherPushNotifications = require('./push-notifications'); 102 | let devicestatestore = require('./device-state-store'); 103 | let dorequest = require('./do-request'); 104 | 105 | beforeEach(() => { 106 | devicestatestore.default = makeDeviceStateStore({ 107 | deviceId: 'web-1db66b8a-f51f-49de-b225-72591535c855', 108 | token: ENCODED_DUMMY_PUSH_SUBSCRIPTION, 109 | userId: 'alice', 110 | }); 111 | setUpGlobals({}); 112 | }); 113 | 114 | afterEach(() => { 115 | jest.resetModules(); 116 | PusherPushNotifications = require('./push-notifications'); 117 | devicestatestore = require('./device-state-store'); 118 | dorequest = require('./do-request'); 119 | }); 120 | 121 | describe('.addDeviceInterest', () => { 122 | test('should make correct request given valid arguments', () => { 123 | const instanceId = 'df3c1965-e870-4bd6-8d75-fea56b26335f'; 124 | const interest = 'donuts'; 125 | 126 | const mockDoRequest = jest.fn(); 127 | mockDoRequest.mockReturnValueOnce(Promise.resolve('ok')); 128 | 129 | dorequest.default = mockDoRequest; 130 | 131 | const beamsClient = new PusherPushNotifications.Client({ 132 | instanceId, 133 | }); 134 | return beamsClient.addDeviceInterest(interest).then(() => { 135 | expect(mockDoRequest.mock.calls.length).toBe(1); 136 | expect(mockDoRequest.mock.calls[0].length).toBe(1); 137 | expect(mockDoRequest.mock.calls[0][0]).toEqual({ 138 | method: 'POST', 139 | path: [ 140 | 'https://df3c1965-e870-4bd6-8d75-fea56b26335f.pushnotifications.pusher.com', 141 | '/device_api/v1/instances/df3c1965-e870-4bd6-8d75-fea56b26335f', 142 | '/devices/web/web-1db66b8a-f51f-49de-b225-72591535c855', 143 | '/interests/donuts', 144 | ].join(''), 145 | }); 146 | }); 147 | }); 148 | 149 | test('should fail if interest name is not passed', () => { 150 | const instanceId = 'df3c1965-e870-4bd6-8d75-fea56b26335f'; 151 | return expect( 152 | new PusherPushNotifications.Client({ 153 | instanceId, 154 | }).addDeviceInterest() 155 | ).rejects.toThrow('Interest name is required'); 156 | }); 157 | 158 | test('should fail if a interest name is not a string', () => { 159 | const instanceId = 'df3c1965-e870-4bd6-8d75-fea56b26335f'; 160 | const interest = false; 161 | return expect( 162 | new PusherPushNotifications.Client({ 163 | instanceId, 164 | }).addDeviceInterest(interest) 165 | ).rejects.toThrow('Interest false is not a string'); 166 | }); 167 | 168 | test('should fail if a interest name is too long', () => { 169 | const instanceId = 'df3c1965-e870-4bd6-8d75-fea56b26335f'; 170 | let interest = ''; 171 | for (let i = 0; i < 165; i++) { 172 | interest += 'A'; 173 | } 174 | return expect( 175 | new PusherPushNotifications.Client({ 176 | instanceId, 177 | }).addDeviceInterest(interest) 178 | ).rejects.toThrow('Interest is longer than the maximum of 164 chars'); 179 | }); 180 | 181 | test('should fail if interest name contains invalid characters', () => { 182 | const instanceId = 'df3c1965-e870-4bd6-8d75-fea56b26335f'; 183 | const interest = 'bad|interest'; 184 | return expect( 185 | new PusherPushNotifications.Client({ 186 | instanceId, 187 | }).addDeviceInterest(interest) 188 | ).rejects.toThrow('contains a forbidden character'); 189 | }); 190 | 191 | test('should fail if SDK is not started', () => { 192 | // Emulate a fresh SDK, where start has not been called 193 | devicestatestore.default = makeDeviceStateStore({ 194 | deviceId: null, 195 | token: null, 196 | userId: null, 197 | }); 198 | 199 | const instanceId = 'df3c1965-e870-4bd6-8d75-fea56b26335f'; 200 | const interest = 'some-interest'; 201 | return expect( 202 | new PusherPushNotifications.Client({ 203 | instanceId, 204 | }).addDeviceInterest(interest) 205 | ).rejects.toThrow('SDK not registered with Beams. Did you call .start?'); 206 | }); 207 | }); 208 | 209 | describe('.removeDeviceInterest', () => { 210 | test('should make correct DELETE request', () => { 211 | const instanceId = 'df3c1965-e870-4bd6-8d75-fea56b26335f'; 212 | const interest = 'donuts'; 213 | 214 | const mockDoRequest = jest.fn(); 215 | mockDoRequest.mockReturnValueOnce(Promise.resolve('ok')); 216 | 217 | dorequest.default = mockDoRequest; 218 | 219 | const beamsClient = new PusherPushNotifications.Client({ instanceId }); 220 | return beamsClient.removeDeviceInterest(interest).then(() => { 221 | expect(mockDoRequest.mock.calls.length).toBe(1); 222 | expect(mockDoRequest.mock.calls[0].length).toBe(1); 223 | expect(mockDoRequest.mock.calls[0][0]).toEqual({ 224 | method: 'DELETE', 225 | path: [ 226 | 'https://df3c1965-e870-4bd6-8d75-fea56b26335f.pushnotifications.pusher.com', 227 | '/device_api/v1/instances/df3c1965-e870-4bd6-8d75-fea56b26335f', 228 | '/devices/web/web-1db66b8a-f51f-49de-b225-72591535c855', 229 | '/interests/donuts', 230 | ].join(''), 231 | }); 232 | }); 233 | }); 234 | 235 | test('should fail if interest name is not passed', () => { 236 | const instanceId = 'df3c1965-e870-4bd6-8d75-fea56b26335f'; 237 | return expect( 238 | new PusherPushNotifications.Client({ 239 | instanceId, 240 | }).removeDeviceInterest() 241 | ).rejects.toThrow('Interest name is required'); 242 | }); 243 | 244 | test('should fail if a interest name is not a string', () => { 245 | const instanceId = 'df3c1965-e870-4bd6-8d75-fea56b26335f'; 246 | const interest = false; 247 | return expect( 248 | new PusherPushNotifications.Client({ 249 | instanceId, 250 | }).removeDeviceInterest(interest) 251 | ).rejects.toThrow('Interest false is not a string'); 252 | }); 253 | 254 | test('should fail if a interest name is too long', () => { 255 | const instanceId = 'df3c1965-e870-4bd6-8d75-fea56b26335f'; 256 | let interest = ''; 257 | for (let i = 0; i < 165; i++) { 258 | interest += 'A'; 259 | } 260 | return expect( 261 | new PusherPushNotifications.Client({ 262 | instanceId, 263 | }).removeDeviceInterest(interest) 264 | ).rejects.toThrow('Interest is longer than the maximum of 164 chars'); 265 | }); 266 | 267 | test('should fail if interest name contains invalid characters', () => { 268 | const instanceId = 'df3c1965-e870-4bd6-8d75-fea56b26335f'; 269 | const interest = 'bad|interest'; 270 | return expect( 271 | new PusherPushNotifications.Client({ 272 | instanceId, 273 | }).removeDeviceInterest(interest) 274 | ).rejects.toThrow('contains a forbidden character'); 275 | }); 276 | 277 | test('should fail if SDK is not started', () => { 278 | // Emulate a fresh SDK, where start has not been called 279 | devicestatestore.default = makeDeviceStateStore({ 280 | deviceId: null, 281 | token: null, 282 | userId: null, 283 | }); 284 | 285 | const instanceId = 'df3c1965-e870-4bd6-8d75-fea56b26335f'; 286 | const interest = 'some-interest'; 287 | return expect( 288 | new PusherPushNotifications.Client({ 289 | instanceId, 290 | }).removeDeviceInterest(interest) 291 | ).rejects.toThrow('SDK not registered with Beams. Did you call .start?'); 292 | }); 293 | }); 294 | 295 | describe('.getDeviceInterests', () => { 296 | test('should make correct request and return the interests', () => { 297 | const instanceId = 'df3c1965-e870-4bd6-8d75-fea56b26335f'; 298 | 299 | const mockDoRequest = jest.fn(); 300 | mockDoRequest.mockReturnValueOnce( 301 | Promise.resolve({ 302 | interests: ['donuts'], 303 | responseMetadata: {}, 304 | }) 305 | ); 306 | 307 | dorequest.default = mockDoRequest; 308 | 309 | const beamsClient = new PusherPushNotifications.Client({ 310 | instanceId, 311 | }); 312 | return beamsClient.getDeviceInterests().then((interests) => { 313 | expect(mockDoRequest.mock.calls.length).toBe(1); 314 | expect(mockDoRequest.mock.calls[0].length).toBe(1); 315 | expect(mockDoRequest.mock.calls[0][0]).toEqual({ 316 | method: 'GET', 317 | params: { 318 | cursor: null, 319 | limit: 100, 320 | }, 321 | path: [ 322 | 'https://df3c1965-e870-4bd6-8d75-fea56b26335f.pushnotifications.pusher.com', 323 | '/device_api/v1/instances/df3c1965-e870-4bd6-8d75-fea56b26335f', 324 | '/devices/web/web-1db66b8a-f51f-49de-b225-72591535c855', 325 | '/interests', 326 | ].join(''), 327 | }); 328 | expect(interests).toEqual({ 329 | interests: ['donuts'], 330 | }); 331 | }); 332 | }); 333 | test('should make correct request with cursor and return the interests', () => { 334 | const instanceId = 'df3c1965-e870-4bd6-8d75-fea56b26335f'; 335 | 336 | const mockDoRequest = jest.fn(); 337 | mockDoRequest.mockReturnValueOnce( 338 | Promise.resolve({ 339 | interests: ['donuts'], 340 | responseMetadata: {}, 341 | }) 342 | ); 343 | 344 | dorequest.default = mockDoRequest; 345 | 346 | const beamsClient = new PusherPushNotifications.Client({ 347 | instanceId, 348 | }); 349 | return beamsClient.getDeviceInterests(150, 2).then((interests) => { 350 | expect(mockDoRequest.mock.calls.length).toBe(1); 351 | expect(mockDoRequest.mock.calls[0].length).toBe(1); 352 | expect(mockDoRequest.mock.calls[0][0]).toEqual({ 353 | method: 'GET', 354 | params: { 355 | cursor: 2, 356 | limit: 150, 357 | }, 358 | path: [ 359 | 'https://df3c1965-e870-4bd6-8d75-fea56b26335f.pushnotifications.pusher.com', 360 | '/device_api/v1/instances/df3c1965-e870-4bd6-8d75-fea56b26335f', 361 | '/devices/web/web-1db66b8a-f51f-49de-b225-72591535c855', 362 | '/interests', 363 | ].join(''), 364 | }); 365 | expect(interests).toEqual({ 366 | interests: ['donuts'], 367 | }); 368 | }); 369 | }); 370 | 371 | test('should fail if SDK is not started', () => { 372 | // Emulate a fresh SDK, where start has not been called 373 | devicestatestore.default = makeDeviceStateStore({ 374 | deviceId: null, 375 | token: null, 376 | userId: null, 377 | }); 378 | 379 | const instanceId = 'df3c1965-e870-4bd6-8d75-fea56b26335f'; 380 | return expect( 381 | new PusherPushNotifications.Client({ 382 | instanceId, 383 | }).getDeviceInterests() 384 | ).rejects.toThrow('SDK not registered with Beams. Did you call .start?'); 385 | }); 386 | }); 387 | 388 | describe('.setDeviceInterests', () => { 389 | test('should make correct PUT request', () => { 390 | const instanceId = 'df3c1965-e870-4bd6-8d75-fea56b26335f'; 391 | const interests = ['apples', 'bananas', 'cabbages', 'donuts']; 392 | 393 | const mockDoRequest = jest.fn(); 394 | mockDoRequest.mockReturnValueOnce(Promise.resolve('ok')); 395 | 396 | dorequest.default = mockDoRequest; 397 | 398 | const beamsClient = new PusherPushNotifications.Client({ 399 | instanceId, 400 | }); 401 | return beamsClient.setDeviceInterests(interests).then(() => { 402 | expect(mockDoRequest.mock.calls.length).toBe(1); 403 | expect(mockDoRequest.mock.calls[0].length).toBe(1); 404 | expect(mockDoRequest.mock.calls[0][0].method).toEqual('PUT'); 405 | expect(mockDoRequest.mock.calls[0][0].path).toEqual( 406 | [ 407 | 'https://df3c1965-e870-4bd6-8d75-fea56b26335f.pushnotifications.pusher.com', 408 | '/device_api/v1/instances/df3c1965-e870-4bd6-8d75-fea56b26335f', 409 | '/devices/web/web-1db66b8a-f51f-49de-b225-72591535c855', 410 | '/interests', 411 | ].join('') 412 | ); 413 | expect(mockDoRequest.mock.calls[0][0].body.interests.sort()).toEqual( 414 | [...interests].sort() 415 | ); 416 | }); 417 | }); 418 | 419 | test('should make correct PUT request with duplicate interests', () => { 420 | const instanceId = 'df3c1965-e870-4bd6-8d75-fea56b26335f'; 421 | const interests = ['apples', 'apples', 'apples', 'bananas']; 422 | 423 | const mockDoRequest = jest.fn(); 424 | mockDoRequest.mockReturnValueOnce(Promise.resolve('ok')); 425 | 426 | dorequest.default = mockDoRequest; 427 | 428 | const expectedInterests = ['apples', 'bananas']; 429 | 430 | const beamsClient = new PusherPushNotifications.Client({ 431 | instanceId, 432 | }); 433 | return beamsClient.setDeviceInterests(interests).then(() => { 434 | expect(mockDoRequest.mock.calls.length).toBe(1); 435 | expect(mockDoRequest.mock.calls[0].length).toBe(1); 436 | expect(mockDoRequest.mock.calls[0][0].method).toEqual('PUT'); 437 | expect(mockDoRequest.mock.calls[0][0].path).toEqual( 438 | [ 439 | 'https://df3c1965-e870-4bd6-8d75-fea56b26335f.pushnotifications.pusher.com', 440 | '/device_api/v1/instances/df3c1965-e870-4bd6-8d75-fea56b26335f', 441 | '/devices/web/web-1db66b8a-f51f-49de-b225-72591535c855', 442 | '/interests', 443 | ].join('') 444 | ); 445 | expect(mockDoRequest.mock.calls[0][0].body.interests.sort()).toEqual( 446 | expectedInterests.sort() 447 | ); 448 | }); 449 | }); 450 | 451 | test('should fail if interest array is not passed', () => { 452 | const instanceId = 'df3c1965-e870-4bd6-8d75-fea56b26335f'; 453 | return expect( 454 | new PusherPushNotifications.Client({ 455 | instanceId, 456 | }).setDeviceInterests() 457 | ).rejects.toThrow('interests argument is required'); 458 | }); 459 | 460 | test('should fail if interest arg is not an array', () => { 461 | const instanceId = 'df3c1965-e870-4bd6-8d75-fea56b26335f'; 462 | const interests = false; 463 | return expect( 464 | new PusherPushNotifications.Client({ 465 | instanceId, 466 | }).setDeviceInterests(interests) 467 | ).rejects.toThrow('interests argument must be an array'); 468 | }); 469 | 470 | test('should fail if too many interests are passed', () => { 471 | const maxInterests = 5000; 472 | const instanceId = 'df3c1965-e870-4bd6-8d75-fea56b26335f'; 473 | const interests = []; 474 | for (let i = 0; i < maxInterests + 1; i++) { 475 | interests.push('' + i); 476 | } 477 | 478 | return expect( 479 | new PusherPushNotifications.Client({ 480 | instanceId, 481 | }).setDeviceInterests(interests) 482 | ).rejects.toThrow( 483 | `Number of interests (${ 484 | maxInterests + 1 485 | }) exceeds maximum of ${maxInterests}` 486 | ); 487 | }); 488 | 489 | test('should fail if a given interest is not a string', () => { 490 | const instanceId = 'df3c1965-e870-4bd6-8d75-fea56b26335f'; 491 | const interests = ['good-interest', false]; 492 | 493 | return expect( 494 | new PusherPushNotifications.Client({ 495 | instanceId, 496 | }).setDeviceInterests(interests) 497 | ).rejects.toThrow('Interest false is not a string'); 498 | }); 499 | 500 | test('should fail if a given interest is too long', () => { 501 | const instanceId = 'df3c1965-e870-4bd6-8d75-fea56b26335f'; 502 | const interests = ['right-length', '']; 503 | for (let i = 0; i < 165; i++) { 504 | interests[1] += 'A'; 505 | } 506 | 507 | return expect( 508 | new PusherPushNotifications.Client({ 509 | instanceId, 510 | }).setDeviceInterests(interests) 511 | ).rejects.toThrow('longer than the maximum of 164 chars'); 512 | }); 513 | 514 | test('should fail if a given interest contains a forbidden character', () => { 515 | const instanceId = 'df3c1965-e870-4bd6-8d75-fea56b26335f'; 516 | const interests = ['good-interest', 'bad|interest']; 517 | 518 | return expect( 519 | new PusherPushNotifications.Client({ 520 | instanceId, 521 | }).setDeviceInterests(interests) 522 | ).rejects.toThrow( 523 | 'interest "bad|interest" contains a forbidden character' 524 | ); 525 | }); 526 | 527 | test('should fail if SDK is not started', () => { 528 | // Emulate a fresh SDK, where start has not been called 529 | devicestatestore.default = makeDeviceStateStore({ 530 | deviceId: null, 531 | token: null, 532 | userId: null, 533 | }); 534 | 535 | const instanceId = 'df3c1965-e870-4bd6-8d75-fea56b26335f'; 536 | return expect( 537 | new PusherPushNotifications.Client({ 538 | instanceId, 539 | }).setDeviceInterests([]) 540 | ).rejects.toThrow('SDK not registered with Beams. Did you call .start?'); 541 | }); 542 | }); 543 | 544 | describe('.clearDeviceInterests', () => { 545 | test('should make correct PUT request', () => { 546 | const instanceId = 'df3c1965-e870-4bd6-8d75-fea56b26335f'; 547 | 548 | const mockDoRequest = jest.fn(); 549 | mockDoRequest.mockReturnValueOnce(Promise.resolve('ok')); 550 | 551 | dorequest.default = mockDoRequest; 552 | 553 | const beamsClient = new PusherPushNotifications.Client({ 554 | instanceId, 555 | }); 556 | return beamsClient.clearDeviceInterests().then(() => { 557 | expect(mockDoRequest.mock.calls.length).toBe(1); 558 | expect(mockDoRequest.mock.calls[0].length).toBe(1); 559 | expect(mockDoRequest.mock.calls[0][0]).toEqual({ 560 | method: 'PUT', 561 | path: [ 562 | 'https://df3c1965-e870-4bd6-8d75-fea56b26335f.pushnotifications.pusher.com', 563 | '/device_api/v1/instances/df3c1965-e870-4bd6-8d75-fea56b26335f', 564 | '/devices/web/web-1db66b8a-f51f-49de-b225-72591535c855', 565 | '/interests', 566 | ].join(''), 567 | body: { interests: [] }, 568 | }); 569 | }); 570 | }); 571 | 572 | test('should fail if SDK is not started', () => { 573 | // Emulate a fresh SDK, where start has not been called 574 | devicestatestore.default = makeDeviceStateStore({ 575 | deviceId: null, 576 | token: null, 577 | userId: null, 578 | }); 579 | 580 | const instanceId = 'df3c1965-e870-4bd6-8d75-fea56b26335f'; 581 | return expect( 582 | new PusherPushNotifications.Client({ 583 | instanceId, 584 | }).clearDeviceInterests() 585 | ).rejects.toThrow('SDK not registered with Beams. Did you call .start?'); 586 | }); 587 | }); 588 | }); 589 | 590 | describe('.getRegistrationState', () => { 591 | const instanceId = 'df3c1965-e870-4bd6-8d75-fea56b26335f'; 592 | 593 | describe('if SDK is started', () => { 594 | let devicestatestore = require('./device-state-store'); 595 | beforeEach(() => { 596 | devicestatestore.default = makeDeviceStateStore({ 597 | deviceId: 'web-1db66b8a-f51f-49de-b225-72591535c855', 598 | token: ENCODED_DUMMY_PUSH_SUBSCRIPTION, 599 | userId: null, 600 | }); 601 | }); 602 | 603 | afterEach(() => { 604 | jest.resetModules(); 605 | devicestatestore = require('./device-state-store'); 606 | }); 607 | 608 | test('should return PERMISSION_GRANTED_REGISTERED_WITH_BEAMS if browser permission is granted', () => { 609 | setUpGlobals({ notificationPermission: 'granted' }); 610 | 611 | let beamsClient = new PusherPushNotifications.Client({ 612 | instanceId, 613 | }); 614 | return beamsClient.getRegistrationState().then((state) => { 615 | expect(state).toEqual( 616 | PusherPushNotifications.RegistrationState 617 | .PERMISSION_GRANTED_REGISTERED_WITH_BEAMS 618 | ); 619 | }); 620 | }); 621 | }); 622 | 623 | describe('if SDK is not started', () => { 624 | let devicestatestore = require('./device-state-store'); 625 | beforeEach(() => { 626 | devicestatestore.default = makeDeviceStateStore({ 627 | deviceId: null, 628 | token: null, 629 | userId: null, 630 | }); 631 | }); 632 | 633 | afterEach(() => { 634 | jest.resetModules(); 635 | devicestatestore = require('./device-state-store'); 636 | tearDownGlobals(); 637 | }); 638 | 639 | test('should return PERMISSION_DENIED if browser permission is denied', () => { 640 | setUpGlobals({ notificationPermission: 'denied' }); 641 | 642 | let beamsClient = new PusherPushNotifications.Client({ 643 | instanceId, 644 | }); 645 | return beamsClient.getRegistrationState().then((state) => { 646 | expect(state).toEqual( 647 | PusherPushNotifications.RegistrationState.PERMISSION_DENIED 648 | ); 649 | }); 650 | }); 651 | 652 | test('should return PERMISSION_PROMPT_REQUIRED if browser permission is default', () => { 653 | setUpGlobals({ notificationPermission: 'default' }); 654 | 655 | let beamsClient = new PusherPushNotifications.Client({ 656 | instanceId, 657 | }); 658 | return beamsClient.getRegistrationState().then((state) => { 659 | expect(state).toEqual( 660 | PusherPushNotifications.RegistrationState.PERMISSION_PROMPT_REQUIRED 661 | ); 662 | }); 663 | }); 664 | 665 | test('should return PERMISSION_GRANTED_NOT_REGISTERED_WITH_BEAMS if browser permission is granted', () => { 666 | setUpGlobals({ notificationPermission: 'granted' }); 667 | 668 | let beamsClient = new PusherPushNotifications.Client({ 669 | instanceId, 670 | }); 671 | return beamsClient.getRegistrationState().then((state) => { 672 | expect(state).toEqual( 673 | PusherPushNotifications.RegistrationState 674 | .PERMISSION_GRANTED_NOT_REGISTERED_WITH_BEAMS 675 | ); 676 | }); 677 | }); 678 | }); 679 | }); 680 | 681 | describe('SDK state', () => { 682 | afterEach(() => { 683 | jest.resetModules(); 684 | tearDownGlobals(); 685 | }); 686 | 687 | test('should be reset if subscription changes', () => { 688 | const PusherPushNotifications = require('./push-notifications'); 689 | const devicestatestore = require('./device-state-store'); 690 | 691 | let subscription = DUMMY_PUSH_SUBSCRIPTION; 692 | setUpGlobals({ 693 | getSWSubscription: () => { 694 | return Promise.resolve(subscription); 695 | }, 696 | }); 697 | 698 | const instanceId = 'df3c1965-e870-4bd6-8d75-fea56b26335f'; 699 | let deviceId = 'web-1db66b8a-f51f-49de-b225-72591535c855'; 700 | let newSubscription = { another: 'subscription' }; 701 | expect(newSubscription).not.toEqual(DUMMY_PUSH_SUBSCRIPTION); 702 | 703 | devicestatestore.default = makeDeviceStateStore({ 704 | deviceId, 705 | token: ENCODED_DUMMY_PUSH_SUBSCRIPTION, 706 | userId: 'alice', 707 | }); 708 | 709 | let beamsClient = new PusherPushNotifications.Client({ 710 | instanceId, 711 | }); 712 | 713 | return beamsClient 714 | .getDeviceId() 715 | .then((returnedDeviceId) => { 716 | // Device ID should have been set 717 | return expect(returnedDeviceId).toEqual(deviceId); 718 | }) 719 | .then(() => { 720 | // Change subscription 721 | subscription = newSubscription; 722 | }) 723 | .then(() => beamsClient.getDeviceId()) 724 | .then((deviceId) => { 725 | // Device ID should have been cleared 726 | return expect(deviceId).toBeNull(); 727 | }); 728 | }); 729 | }); 730 | 731 | const setUpGlobals = ({ 732 | indexedDBSupport = true, 733 | serviceWorkerSupport = true, 734 | webPushSupport = true, 735 | isSecureContext = true, 736 | notificationPermission = 'default', 737 | getSWSubscription = () => Promise.resolve(DUMMY_PUSH_SUBSCRIPTION), 738 | }) => { 739 | if (indexedDBSupport) { 740 | global.window.indexedDB = {}; 741 | } 742 | if (serviceWorkerSupport) { 743 | global.navigator.serviceWorker = {}; 744 | global.navigator.serviceWorker.register = () => {}; 745 | global.navigator.serviceWorker.ready = Promise.resolve({ 746 | pushManager: { 747 | getSubscription: getSWSubscription, 748 | }, 749 | }); 750 | } 751 | if (webPushSupport) { 752 | global.window.PushManager = {}; 753 | } 754 | global.window.isSecureContext = isSecureContext; 755 | 756 | global.Notification = {}; 757 | global.Notification.permission = notificationPermission; 758 | }; 759 | 760 | const tearDownGlobals = () => { 761 | delete global.window.indexedDB; 762 | delete global.window.PushManager; 763 | delete global.navigator.serviceWorker; 764 | delete global.window.isSecureContext; 765 | delete global.Notification; 766 | }; 767 | -------------------------------------------------------------------------------- /src/service-worker.js: -------------------------------------------------------------------------------- 1 | /* eslint-env serviceworker */ 2 | import doRequest from './do-request'; 3 | import DeviceStateStore from './device-state-store'; 4 | 5 | self.PusherPushNotifications = { 6 | endpointOverride: null, 7 | onNotificationReceived: null, 8 | 9 | _endpoint: (instanceId) => 10 | self.PusherPushNotifications.endpointOverride 11 | ? self.PusherPushNotifications.endpointOverride 12 | : `https://${instanceId}.pushnotifications.pusher.com`, 13 | 14 | _getVisibleClient: () => 15 | self.clients 16 | .matchAll({ 17 | type: 'window', 18 | includeUncontrolled: true, 19 | }) 20 | .then((clients) => clients.find((c) => c.visibilityState === 'visible')), 21 | 22 | _hasVisibleClient: () => 23 | self.PusherPushNotifications._getVisibleClient().then( 24 | (client) => client !== undefined 25 | ), 26 | 27 | _getFocusedClient: () => 28 | self.clients 29 | .matchAll({ 30 | type: 'window', 31 | includeUncontrolled: true, 32 | }) 33 | .then((clients) => clients.find((c) => c.focused === true)), 34 | 35 | _hasFocusedClient: () => 36 | self.PusherPushNotifications._getFocusedClient().then( 37 | (client) => client !== undefined 38 | ), 39 | 40 | _getState: async (pusherMetadata) => { 41 | const { instanceId, publishId, hasDisplayableContent, hasData } = 42 | pusherMetadata; 43 | if (!instanceId || !publishId) { 44 | // Can't report this notification, fail silently. 45 | return; 46 | } 47 | 48 | const deviceStateStore = new DeviceStateStore(instanceId); 49 | await deviceStateStore.connect(); 50 | 51 | const deviceId = await deviceStateStore.getDeviceId(); 52 | const userId = (await deviceStateStore.getUserId()) || null; 53 | 54 | const appInBackground = 55 | !(await self.PusherPushNotifications._hasVisibleClient()); 56 | 57 | return { 58 | instanceId, 59 | publishId, 60 | deviceId, 61 | userId, 62 | appInBackground, 63 | hasDisplayableContent, 64 | hasData, 65 | }; 66 | }, 67 | 68 | reportEvent: async ({ eventType, state }) => { 69 | const path = `${self.PusherPushNotifications._endpoint( 70 | state.instanceId 71 | )}/reporting_api/v2/instances/${state.instanceId}/events`; 72 | 73 | const options = { 74 | method: 'POST', 75 | path, 76 | body: { 77 | publishId: state.publishId, 78 | event: eventType, 79 | deviceId: state.deviceId, 80 | userId: state.userId, 81 | timestampSecs: Math.floor(Date.now() / 1000), 82 | appInBackground: state.appInBackground, 83 | hasDisplayableContent: state.hasDisplayableContent, 84 | hasData: state.hasData, 85 | }, 86 | }; 87 | 88 | try { 89 | await doRequest(options); 90 | } catch (_) { 91 | // Reporting is best effort, so we do nothing. 92 | } 93 | }, 94 | }; 95 | 96 | self.addEventListener('push', (e) => { 97 | let payload; 98 | try { 99 | payload = e.data.json(); 100 | } catch (_) { 101 | return; // Not a pusher notification 102 | } 103 | 104 | if (!payload.data || !payload.data.pusher) { 105 | return; // Not a pusher notification 106 | } 107 | 108 | const statePromise = self.PusherPushNotifications._getState( 109 | payload.data.pusher 110 | ); 111 | 112 | statePromise.then((state) => { 113 | // Report analytics event, best effort 114 | self.PusherPushNotifications.reportEvent({ 115 | eventType: 'delivery', 116 | state, 117 | }); 118 | }); 119 | 120 | const customerPayload = { ...payload }; 121 | const customerData = {}; 122 | Object.keys(customerPayload.data || {}).forEach((key) => { 123 | if (key !== 'pusher') { 124 | customerData[key] = customerPayload.data[key]; 125 | } 126 | }); 127 | customerPayload.data = customerData; 128 | 129 | const pusherMetadata = payload.data.pusher; 130 | 131 | const handleNotification = async (payloadFromCallback) => { 132 | const hideNotificationIfSiteHasFocus = 133 | payloadFromCallback.notification.hide_notification_if_site_has_focus === 134 | true; 135 | if ( 136 | hideNotificationIfSiteHasFocus && 137 | (await self.PusherPushNotifications._hasFocusedClient()) 138 | ) { 139 | return; 140 | } 141 | 142 | const title = payloadFromCallback.notification.title || ''; 143 | const body = payloadFromCallback.notification.body || ''; 144 | const icon = payloadFromCallback.notification.icon; 145 | 146 | const options = { 147 | body, 148 | icon, 149 | data: { 150 | pusher: { 151 | customerPayload: payloadFromCallback, 152 | pusherMetadata, 153 | }, 154 | }, 155 | }; 156 | 157 | return self.registration.showNotification(title, options); 158 | }; 159 | 160 | if (self.PusherPushNotifications.onNotificationReceived) { 161 | self.PusherPushNotifications.onNotificationReceived({ 162 | payload: customerPayload, 163 | pushEvent: e, 164 | handleNotification, 165 | statePromise, 166 | }); 167 | } else { 168 | e.waitUntil(handleNotification(customerPayload)); 169 | } 170 | }); 171 | 172 | self.addEventListener('notificationclick', (e) => { 173 | const { pusher } = e.notification.data; 174 | 175 | const isPusherNotification = pusher !== undefined; 176 | if (isPusherNotification) { 177 | const statePromise = self.PusherPushNotifications._getState( 178 | pusher.pusherMetadata 179 | ); 180 | 181 | // Report analytics event, best effort 182 | statePromise.then((state) => { 183 | self.PusherPushNotifications.reportEvent({ 184 | eventType: 'open', 185 | state, 186 | }); 187 | }); 188 | 189 | const deepLink = pusher.customerPayload.notification.deep_link; 190 | if (deepLink) { 191 | // if the deep link is already opened, focus the existing window, else open a new window 192 | const promise = clients 193 | .matchAll({ includeUncontrolled: true }) 194 | .then((windowClients) => { 195 | const existingWindow = windowClients.find( 196 | (windowClient) => windowClient.url === deepLink 197 | ); 198 | if (existingWindow) { 199 | return existingWindow.focus(); 200 | } else { 201 | return clients.openWindow(deepLink); 202 | } 203 | }); 204 | e.waitUntil(promise); 205 | } 206 | e.notification.close(); 207 | } 208 | }); 209 | -------------------------------------------------------------------------------- /src/service-worker.test.js: -------------------------------------------------------------------------------- 1 | import { makeDeviceStateStore } from '../test-utils/fake-device-state-store'; 2 | 3 | const ASYNC_TEST_WAIT_MS = 100; 4 | 5 | const TEST_INSTANCE_ID = 'some-instance-id'; 6 | const TEST_PUBLISH_ID = 'some-publish-id'; 7 | const TEST_NOTIFICATION_TITLE = 'Hi!'; 8 | const TEST_NOTIFICATION_BODY = 'This is a test notification!'; 9 | const TEST_NOTIFICATION_ICON = 'an-icon.png'; 10 | const TEST_DEVICE_ID = 'web-1db66b8a-f51f-49de-b225-72591535c855'; 11 | const TEST_USER_ID = 'alice'; 12 | 13 | let listeners = {}; 14 | let shownNotifications = []; 15 | let clients = []; 16 | let now; 17 | 18 | beforeEach(() => { 19 | listeners = {}; 20 | shownNotifications = []; 21 | clients = []; 22 | now = new Date('2000-01-01T00:00:00Z'); 23 | 24 | global.addEventListener = (name, func) => { 25 | listeners[name] = func; 26 | }; 27 | global.registration = { 28 | showNotification: (title, options) => 29 | shownNotifications.push({ title, options }), 30 | }; 31 | global.clients = { 32 | openWindow: (url) => { 33 | const client = new FakeWindowClient({ url }); 34 | clients.push(client); 35 | return Promise.resolve(client); 36 | }, 37 | matchAll: () => Promise.resolve(clients), 38 | }; 39 | global.Date.now = () => now.getTime(); 40 | 41 | jest.resetModules(); 42 | 43 | // Mock out IO modules 44 | const devicestatestore = require('./device-state-store'); 45 | devicestatestore.default = makeDeviceStateStore({ 46 | deviceId: TEST_DEVICE_ID, 47 | token: 'some-token', 48 | userId: TEST_USER_ID, 49 | }); 50 | const dorequest = require('./do-request'); 51 | dorequest.default = () => Promise.resolve('ok'); 52 | }); 53 | 54 | afterEach(() => { 55 | // Wait for any async operations to complete 56 | // This is horrible, but we we want to do open/delivery tracking without 57 | // blocking the callbacks this will have to do. 58 | return new Promise((resolve) => setTimeout(resolve, ASYNC_TEST_WAIT_MS)); 59 | }); 60 | 61 | describe('SW should ignore notification when', () => { 62 | test.each([ 63 | ['payload is not a json object', '£)$*£()*A)(£*$£('], 64 | ['payload has no data field', '{"key": "value"}'], 65 | ['payload has no pusher field', '{"data": {}}'], 66 | ])('%s', (_, payload) => { 67 | require('./service-worker.js'); 68 | const PusherPushNotifications = global.PusherPushNotifications; 69 | 70 | // Given an onNotificationReceived had been set 71 | let onNotificationReceivedCalled = false; 72 | PusherPushNotifications.onNotificationReceived = () => { 73 | onNotificationReceivedCalled = true; 74 | }; 75 | 76 | // When the push listener is called 77 | const pushListener = listeners['push']; 78 | if (!pushListener) { 79 | throw new Error('No push listener has been set'); 80 | } 81 | pushListener(makePushEvent(payload)); 82 | 83 | // Then a notification should NOT be shown 84 | expect(shownNotifications).toHaveLength(0); 85 | 86 | // And the onNotificationReceived handler should NOT be called 87 | expect(onNotificationReceivedCalled).toBe(false); 88 | }); 89 | }); 90 | 91 | test('SW should show notification when it comes from Pusher', () => { 92 | require('./service-worker.js'); 93 | 94 | // Given a push event that comes from Pusher 95 | const pushEvent = makeBeamsPushEvent({}); 96 | 97 | // When the push listener is called 98 | const pushListener = listeners['push']; 99 | if (!pushListener) { 100 | throw new Error('No push listener has been set'); 101 | } 102 | pushListener(pushEvent); 103 | 104 | // Then a notification should be shown 105 | expect(shownNotifications).toHaveLength(1); 106 | expect(shownNotifications[0]).toEqual({ 107 | title: TEST_NOTIFICATION_TITLE, 108 | options: { 109 | icon: TEST_NOTIFICATION_ICON, 110 | body: TEST_NOTIFICATION_BODY, 111 | data: { 112 | pusher: { 113 | customerPayload: { 114 | notification: { 115 | title: TEST_NOTIFICATION_TITLE, 116 | body: TEST_NOTIFICATION_BODY, 117 | icon: TEST_NOTIFICATION_ICON, 118 | }, 119 | data: {}, 120 | }, 121 | pusherMetadata: { 122 | instanceId: TEST_INSTANCE_ID, 123 | publishId: TEST_PUBLISH_ID, 124 | hasDisplayableContent: true, 125 | hasData: false, 126 | }, 127 | }, 128 | }, 129 | }, 130 | }); 131 | }); 132 | 133 | test('SW should NOT show notification if onNotificationReceived handler is set', () => { 134 | require('./service-worker.js'); 135 | const PusherPushNotifications = global.PusherPushNotifications; 136 | 137 | // Given a push event that comes from Pusher 138 | const pushEvent = makeBeamsPushEvent({}); 139 | 140 | // And an onNotificationReceived had been set 141 | let onNotificationReceivedCalled = false; 142 | PusherPushNotifications.onNotificationReceived = () => { 143 | onNotificationReceivedCalled = true; 144 | }; 145 | 146 | // When the push listener is called 147 | const pushListener = listeners['push']; 148 | if (!pushListener) { 149 | throw new Error('No push listener has been set'); 150 | } 151 | pushListener(pushEvent); 152 | 153 | // Then a notification should NOT be shown 154 | expect(shownNotifications).toHaveLength(0); 155 | 156 | // And the onNotificationReceived handler should be called 157 | expect(onNotificationReceivedCalled).toBe(true); 158 | }); 159 | 160 | test('SW should pass correct params to onNotificationReceived', () => { 161 | require('./service-worker.js'); 162 | const PusherPushNotifications = global.PusherPushNotifications; 163 | 164 | // Given a push event that comes from Pusher 165 | const pushEvent = makeBeamsPushEvent({}); 166 | 167 | // And an onNotificationReceived had been set 168 | let onNotificationReceivedParams; 169 | PusherPushNotifications.onNotificationReceived = (params) => { 170 | onNotificationReceivedParams = params; 171 | }; 172 | 173 | // When the push listener is called 174 | const pushListener = listeners['push']; 175 | if (!pushListener) { 176 | throw new Error('No push listener has been set'); 177 | } 178 | pushListener(pushEvent); 179 | 180 | // Then onNotificationReceivedCalled should get the expected params 181 | expect(onNotificationReceivedParams.payload).toEqual({ 182 | notification: { 183 | title: TEST_NOTIFICATION_TITLE, 184 | body: TEST_NOTIFICATION_BODY, 185 | icon: TEST_NOTIFICATION_ICON, 186 | }, 187 | data: {}, // Pusher namespace should be stripped 188 | }); 189 | expect(onNotificationReceivedParams.pushEvent).toBe(pushEvent); 190 | expect(typeof onNotificationReceivedParams.handleNotification).toEqual( 191 | 'function' 192 | ); 193 | onNotificationReceivedParams.statePromise.then((state) => { 194 | expect(state).toEqual({ 195 | instanceId: TEST_INSTANCE_ID, 196 | publishId: TEST_PUBLISH_ID, 197 | deviceId: TEST_DEVICE_ID, 198 | userId: TEST_USER_ID, 199 | appInBackground: true, 200 | hasDisplayableContent: true, 201 | hasData: false, 202 | }); 203 | }); 204 | }); 205 | 206 | test('SW should show correct notification if handleNotification is called', () => { 207 | require('./service-worker.js'); 208 | const PusherPushNotifications = global.PusherPushNotifications; 209 | 210 | // Given a push event that comes from Pusher 211 | const pushEvent = makeBeamsPushEvent({}); 212 | 213 | // And an onNotificationReceived had been set 214 | PusherPushNotifications.onNotificationReceived = ({ 215 | payload, 216 | handleNotification, 217 | }) => { 218 | payload.notification.body = 'Body has been changed'; 219 | handleNotification(payload); 220 | }; 221 | 222 | // When the push listener is called 223 | const pushListener = listeners['push']; 224 | if (!pushListener) { 225 | throw new Error('No push listener has been set'); 226 | } 227 | pushListener(pushEvent); 228 | 229 | // Then a notification should be shown 230 | expect(shownNotifications).toHaveLength(1); 231 | 232 | // And should have the correct payload 233 | const notification = shownNotifications[0]; 234 | expect(notification).toEqual({ 235 | title: TEST_NOTIFICATION_TITLE, 236 | options: { 237 | icon: TEST_NOTIFICATION_ICON, 238 | body: 'Body has been changed', // Notification body should have changed 239 | data: { 240 | pusher: { 241 | customerPayload: { 242 | notification: { 243 | title: TEST_NOTIFICATION_TITLE, 244 | body: 'Body has been changed', // Here too 245 | icon: TEST_NOTIFICATION_ICON, 246 | }, 247 | data: {}, // Pusher metadata has been stripped 248 | }, 249 | pusherMetadata: { 250 | // But still embedded in the notification, out of band 251 | instanceId: TEST_INSTANCE_ID, 252 | publishId: TEST_PUBLISH_ID, 253 | hasDisplayableContent: true, 254 | hasData: false, 255 | }, 256 | }, 257 | }, 258 | }, 259 | }); 260 | }); 261 | 262 | test('SW should open deep link in click handler if one is provided', () => { 263 | require('./service-worker.js'); 264 | 265 | // Given a notification click event with a deep link 266 | const clickEvent = makeClickEvent({ 267 | data: { 268 | pusher: { 269 | customerPayload: { 270 | notification: { 271 | title: 'Hi!', 272 | body: 'This is a notification!', 273 | deep_link: 'https://pusher.com', 274 | }, 275 | data: {}, 276 | }, 277 | pusherMetadata: { 278 | instanceId: TEST_INSTANCE_ID, 279 | publishId: TEST_PUBLISH_ID, 280 | hasDisplayableContent: true, 281 | hasData: false, 282 | }, 283 | }, 284 | }, 285 | }); 286 | 287 | // When the notificationclick listener is called 288 | const clickListener = listeners['notificationclick']; 289 | if (!clickListener) { 290 | throw new Error('No click listener has been set'); 291 | } 292 | clickListener(clickEvent); 293 | 294 | return clickEvent.getWaitUntilPromise().then(() => { 295 | // Then the deep link should be opened in a new tab 296 | expect(clients).toContainEqual({ url: 'https://pusher.com' }); 297 | 298 | // And the notification should be closed 299 | expect(clickEvent._isOpen()).toEqual(false); 300 | }); 301 | }); 302 | 303 | test('SW should focus existing window if the deep link in click handler is already open', () => { 304 | require('./service-worker.js'); 305 | 306 | // Given a notification click event with a deep link 307 | const clickEvent = makeClickEvent({ 308 | data: { 309 | pusher: { 310 | customerPayload: { 311 | notification: { 312 | title: 'Hi!', 313 | body: 'This is a notification!', 314 | deep_link: 'https://pusher.com', 315 | }, 316 | data: {}, 317 | }, 318 | pusherMetadata: { 319 | instanceId: TEST_INSTANCE_ID, 320 | publishId: TEST_PUBLISH_ID, 321 | hasDisplayableContent: true, 322 | hasData: false, 323 | }, 324 | }, 325 | }, 326 | }); 327 | 328 | // And an existing window of the deep link is already opened 329 | clients.push(new FakeWindowClient({ url: 'https://pusher.com' })); 330 | 331 | // When the notificationclick listener is called 332 | const clickListener = listeners['notificationclick']; 333 | if (!clickListener) { 334 | throw new Error('No click listener has been set'); 335 | } 336 | clickListener(clickEvent); 337 | 338 | return clickEvent.getWaitUntilPromise().then(() => { 339 | // Then a new window should not be opened 340 | expect( 341 | clients.filter((client) => client.url === 'https://pusher.com') 342 | ).toHaveLength(1); 343 | 344 | // And the existing window should be focused 345 | const window = clients.find( 346 | (client) => client.url === 'https://pusher.com' 347 | ); 348 | expect(window.focused).toEqual(true); 349 | 350 | // And the notification should be closed 351 | expect(clickEvent._isOpen()).toEqual(false); 352 | }); 353 | }); 354 | 355 | test('SW should do nothing on click if notification is not from Pusher', () => { 356 | require('./service-worker.js'); 357 | 358 | // Given a notification click event with a deep link 359 | const clickEvent = makeClickEvent({ 360 | data: {}, 361 | }); 362 | 363 | // When the notificationclick listener is called 364 | const clickListener = listeners['notificationclick']; 365 | if (!clickListener) { 366 | throw new Error('No click listener has been set'); 367 | } 368 | clickListener(clickEvent); 369 | 370 | // Then no new tabs should be opened 371 | expect(clients).toHaveLength(0); 372 | 373 | // And the notification should NOT be closed 374 | expect(clickEvent._isOpen()).toEqual(true); 375 | }); 376 | 377 | test('SW should send delivery event when notification arrives', () => { 378 | jest.resetModules(); 379 | 380 | const devicestatestore = require('./device-state-store'); 381 | devicestatestore.default = makeDeviceStateStore({ 382 | deviceId: 'web-1db66b8a-f51f-49de-b225-72591535c855', 383 | token: 'some-token', 384 | userId: 'alice', 385 | }); 386 | 387 | const dorequest = require('./do-request'); 388 | const mockDoRequest = jest.fn(); 389 | mockDoRequest.mockReturnValueOnce(Promise.resolve('ok')); 390 | dorequest.default = mockDoRequest; 391 | 392 | require('./service-worker.js'); 393 | 394 | // Given a push event that comes from Pusher 395 | const pushEvent = makeBeamsPushEvent({}); 396 | 397 | // When the push listener is called 398 | const pushListener = listeners['push']; 399 | if (!pushListener) { 400 | throw new Error('No push listener has been set'); 401 | } 402 | pushListener(pushEvent); 403 | 404 | // Then the correct delivery event should be reported 405 | return new Promise((resolve) => setTimeout(resolve, 200)).then(() => { 406 | expect(mockDoRequest.mock.calls.length).toBe(1); 407 | expect(mockDoRequest.mock.calls[0].length).toBe(1); 408 | const requestOptions = mockDoRequest.mock.calls[0][0]; 409 | 410 | expect(requestOptions.method).toBe('POST'); 411 | expect(requestOptions.path).toBe( 412 | [ 413 | `https://${TEST_INSTANCE_ID}.pushnotifications.pusher.com`, 414 | `/reporting_api/v2/instances/${TEST_INSTANCE_ID}/events`, 415 | ].join('') 416 | ); 417 | 418 | expect(requestOptions.body.publishId).toBe(TEST_PUBLISH_ID); 419 | expect(requestOptions.body.event).toBe('delivery'); 420 | expect(requestOptions.body.userId).toBe('alice'); 421 | expect(requestOptions.body.timestampSecs).toBe(946684800); 422 | expect(requestOptions.body.appInBackground).toBe(true); 423 | expect(requestOptions.body.hasDisplayableContent).toBe(true); 424 | expect(requestOptions.body.hasData).toBe(false); 425 | }); 426 | }); 427 | 428 | test('SW should send integer timestamp when time has fractional millis', () => { 429 | jest.resetModules(); 430 | 431 | const devicestatestore = require('./device-state-store'); 432 | devicestatestore.default = makeDeviceStateStore({ 433 | deviceId: 'web-1db66b8a-f51f-49de-b225-72591535c855', 434 | token: 'some-token', 435 | userId: 'alice', 436 | }); 437 | 438 | const dorequest = require('./do-request'); 439 | const mockDoRequest = jest.fn(); 440 | mockDoRequest.mockReturnValueOnce(Promise.resolve('ok')); 441 | dorequest.default = mockDoRequest; 442 | 443 | require('./service-worker.js'); 444 | 445 | // Given a push event that comes from Pusher 446 | const pushEvent = makeBeamsPushEvent({}); 447 | 448 | // And that the current time as a fractional millis part 449 | now = new Date('2000-01-01T00:00:00.999Z'); 450 | 451 | // When the push listener is called 452 | const pushListener = listeners['push']; 453 | if (!pushListener) { 454 | throw new Error('No push listener has been set'); 455 | } 456 | pushListener(pushEvent); 457 | 458 | return new Promise((resolve) => setTimeout(resolve, 200)).then(() => { 459 | expect(mockDoRequest.mock.calls.length).toBe(1); 460 | expect(mockDoRequest.mock.calls[0].length).toBe(1); 461 | const requestOptions = mockDoRequest.mock.calls[0][0]; 462 | 463 | expect(requestOptions.method).toBe('POST'); 464 | expect(requestOptions.path).toBe( 465 | [ 466 | `https://${TEST_INSTANCE_ID}.pushnotifications.pusher.com`, 467 | `/reporting_api/v2/instances/${TEST_INSTANCE_ID}/events`, 468 | ].join('') 469 | ); 470 | 471 | // Then the timetamp should be rounded down to the nearest second 472 | expect(requestOptions.body.timestampSecs).toBe(946684800); 473 | }); 474 | }); 475 | 476 | test('SW should send open event when notification clicked', () => { 477 | jest.resetModules(); 478 | 479 | const devicestatestore = require('./device-state-store'); 480 | devicestatestore.default = makeDeviceStateStore({ 481 | deviceId: 'web-1db66b8a-f51f-49de-b225-72591535c855', 482 | token: 'some-token', 483 | userId: 'alice', 484 | }); 485 | 486 | const dorequest = require('./do-request'); 487 | const mockDoRequest = jest.fn(); 488 | mockDoRequest.mockReturnValueOnce(Promise.resolve('ok')); 489 | dorequest.default = mockDoRequest; 490 | 491 | require('./service-worker.js'); 492 | 493 | // Given a notification click event with a deep link 494 | const clickEvent = makeClickEvent({ 495 | data: { 496 | pusher: { 497 | customerPayload: { 498 | notification: { 499 | title: 'Hi!', 500 | body: 'This is a notification!', 501 | deep_link: 'https://pusher.com', 502 | }, 503 | data: {}, 504 | }, 505 | pusherMetadata: { 506 | instanceId: TEST_INSTANCE_ID, 507 | publishId: TEST_PUBLISH_ID, 508 | hasDisplayableContent: true, 509 | hasData: false, 510 | }, 511 | }, 512 | }, 513 | }); 514 | 515 | // When the notificationclick listener is called 516 | const clickListener = listeners['notificationclick']; 517 | if (!clickListener) { 518 | throw new Error('No click listener has been set'); 519 | } 520 | clickListener(clickEvent); 521 | 522 | // Then an open event should be reported 523 | return new Promise((resolve) => setTimeout(resolve, 200)).then(() => { 524 | expect(mockDoRequest.mock.calls.length).toBe(1); 525 | expect(mockDoRequest.mock.calls[0].length).toBe(1); 526 | const requestOptions = mockDoRequest.mock.calls[0][0]; 527 | 528 | expect(requestOptions.method).toBe('POST'); 529 | expect(requestOptions.path).toBe( 530 | [ 531 | `https://${TEST_INSTANCE_ID}.pushnotifications.pusher.com`, 532 | `/reporting_api/v2/instances/${TEST_INSTANCE_ID}/events`, 533 | ].join('') 534 | ); 535 | 536 | expect(requestOptions.body.publishId).toBe(TEST_PUBLISH_ID); 537 | expect(requestOptions.body.event).toBe('open'); 538 | expect(requestOptions.body.userId).toBe('alice'); 539 | expect(requestOptions.body.timestampSecs).toBe(946684800); 540 | expect(requestOptions.body.appInBackground).toBe(true); 541 | expect(requestOptions.body.hasDisplayableContent).toBe(true); 542 | expect(requestOptions.body.hasData).toBe(false); 543 | }); 544 | }); 545 | 546 | test('SW should send event with appInBackground false given a visible client', () => { 547 | jest.resetModules(); 548 | 549 | const devicestatestore = require('./device-state-store'); 550 | devicestatestore.default = makeDeviceStateStore({ 551 | deviceId: 'web-1db66b8a-f51f-49de-b225-72591535c855', 552 | token: 'some-token', 553 | userId: 'alice', 554 | }); 555 | 556 | const dorequest = require('./do-request'); 557 | const mockDoRequest = jest.fn(); 558 | mockDoRequest.mockReturnValueOnce(Promise.resolve('ok')); 559 | dorequest.default = mockDoRequest; 560 | 561 | require('./service-worker.js'); 562 | 563 | // Given a push event that comes from Pusher 564 | const pushEvent = makeBeamsPushEvent({}); 565 | 566 | // and at least once visible client 567 | registerVisibleClient(); 568 | 569 | // When the push listener is called 570 | const pushListener = listeners['push']; 571 | if (!pushListener) { 572 | throw new Error('No push listener has been set'); 573 | } 574 | pushListener(pushEvent); 575 | 576 | // Then the correct delivery event should be reported 577 | return new Promise((resolve) => setTimeout(resolve, 200)).then(() => { 578 | expect(mockDoRequest.mock.calls.length).toBe(1); 579 | expect(mockDoRequest.mock.calls[0].length).toBe(1); 580 | const requestOptions = mockDoRequest.mock.calls[0][0]; 581 | 582 | expect(requestOptions.body.appInBackground).toBe(false); 583 | }); 584 | }); 585 | 586 | test('SW should show notification if site has focus but hide flag is not set', () => { 587 | require('./service-worker.js'); 588 | 589 | // Given a push event that comes from Pusher without the flag set 590 | const pushEvent = makeBeamsPushEvent({ 591 | hide_notification_if_site_has_focus: undefined, 592 | }); 593 | 594 | // and at least once focused client 595 | registerFocusedClient(); 596 | 597 | // When the push listener is called 598 | const pushListener = listeners['push']; 599 | if (!pushListener) { 600 | throw new Error('No push listener has been set'); 601 | } 602 | pushListener(pushEvent); 603 | 604 | return pushEvent.getWaitUntilPromise().then(() => { 605 | // Then a notification should be shown 606 | expect(shownNotifications).toHaveLength(1); 607 | }); 608 | }); 609 | 610 | test('SW should show notification if site has focus but hide flag is false', () => { 611 | require('./service-worker.js'); 612 | 613 | // Given a push event that comes from Pusher with the flag set to false 614 | const pushEvent = makeBeamsPushEvent({ 615 | hide_notification_if_site_has_focus: false, 616 | }); 617 | 618 | // and at least once focused client 619 | registerFocusedClient(); 620 | 621 | // When the push listener is called 622 | const pushListener = listeners['push']; 623 | if (!pushListener) { 624 | throw new Error('No push listener has been set'); 625 | } 626 | pushListener(pushEvent); 627 | 628 | return pushEvent.getWaitUntilPromise().then(() => { 629 | // Then a notification should be shown 630 | expect(shownNotifications).toHaveLength(1); 631 | }); 632 | }); 633 | 634 | test('SW should not show notification if site has focus and hide flag is true', () => { 635 | require('./service-worker.js'); 636 | 637 | // Given a push event that comes from Pusher with the flag set 638 | const pushEvent = makeBeamsPushEvent({ 639 | hide_notification_if_site_has_focus: true, 640 | }); 641 | 642 | // and at least once focused client 643 | registerFocusedClient(); 644 | 645 | // When the push listener is called 646 | const pushListener = listeners['push']; 647 | if (!pushListener) { 648 | throw new Error('No push listener has been set'); 649 | } 650 | pushListener(pushEvent); 651 | 652 | return pushEvent.getWaitUntilPromise().then(() => { 653 | // Then a notification should not be shown 654 | expect(shownNotifications).toHaveLength(0); 655 | }); 656 | }); 657 | 658 | test('SW should show notification if site does not have focus and hide flag is true', () => { 659 | require('./service-worker.js'); 660 | 661 | // Given a push event that comes from Pusher with the flag set 662 | 663 | const pushEvent = makeBeamsPushEvent({ 664 | hide_notification_if_site_has_focus: true, 665 | }); 666 | 667 | // and no focused clients 668 | expect(clients).toHaveLength(0); 669 | 670 | // When the push listener is called 671 | const pushListener = listeners['push']; 672 | if (!pushListener) { 673 | throw new Error('No push listener has been set'); 674 | } 675 | pushListener(pushEvent); 676 | 677 | return pushEvent.getWaitUntilPromise().then(() => { 678 | // Then a notification should be shown 679 | expect(shownNotifications).toHaveLength(1); 680 | }); 681 | }); 682 | 683 | class FakePushEvent { 684 | constructor(payload) { 685 | this.data = { 686 | json: () => JSON.parse(payload), 687 | }; 688 | this.waitUntil = (promise) => { 689 | this.waitUntilPromise = promise; 690 | }; 691 | } 692 | 693 | getWaitUntilPromise() { 694 | expect(this.waitUntilPromise).not.toBeUndefined(); 695 | return this.waitUntilPromise; 696 | } 697 | } 698 | 699 | const makePushEvent = (payload) => new FakePushEvent(payload); 700 | 701 | const makeBeamsPushEvent = ({ 702 | instanceId = TEST_INSTANCE_ID, 703 | publishId = TEST_PUBLISH_ID, 704 | title = TEST_NOTIFICATION_TITLE, 705 | body = TEST_NOTIFICATION_BODY, 706 | icon = TEST_NOTIFICATION_ICON, 707 | hide_notification_if_site_has_focus = undefined, 708 | }) => 709 | makePushEvent( 710 | JSON.stringify({ 711 | notification: { title, body, icon, hide_notification_if_site_has_focus }, 712 | data: { 713 | pusher: { 714 | instanceId, 715 | publishId, 716 | hasDisplayableContent: true, 717 | hasData: false, 718 | }, 719 | }, 720 | }) 721 | ); 722 | 723 | const makeClickEvent = ({ data }) => { 724 | let isOpen = true; 725 | 726 | return { 727 | _isOpen: () => isOpen, 728 | 729 | waitUntil(promise) { 730 | this.waitUntilPromise = promise; 731 | }, 732 | getWaitUntilPromise() { 733 | expect(this.waitUntilPromise).not.toBeUndefined(); 734 | return this.waitUntilPromise; 735 | }, 736 | 737 | notification: { 738 | data, 739 | close: () => { 740 | isOpen = false; 741 | }, 742 | }, 743 | }; 744 | }; 745 | 746 | class FakeWindowClient { 747 | constructor({ url, focused, visibilityState }) { 748 | this.url = url; 749 | this.focused = focused; 750 | this.visibilityState = visibilityState; 751 | } 752 | 753 | focus() { 754 | this.focused = true; 755 | return Promise.resolve(this); 756 | } 757 | } 758 | 759 | const registerVisibleClient = () => 760 | clients.push(new FakeWindowClient({ visibilityState: 'visible' })); 761 | 762 | const registerFocusedClient = () => 763 | clients.push(new FakeWindowClient({ focused: true })); 764 | -------------------------------------------------------------------------------- /src/token-provider.js: -------------------------------------------------------------------------------- 1 | import doRequest from './do-request'; 2 | 3 | export default class TokenProvider { 4 | constructor({ url, queryParams, headers, credentials } = {}) { 5 | this.url = url; 6 | this.queryParams = queryParams; 7 | this.headers = headers; 8 | this.credentials = credentials; 9 | } 10 | 11 | async fetchToken(userId) { 12 | let queryParams = { user_id: userId, ...this.queryParams }; 13 | const encodedParams = Object.entries(queryParams) 14 | .map((kv) => kv.map(encodeURIComponent).join('=')) 15 | .join('&'); 16 | const options = { 17 | method: 'GET', 18 | path: `${this.url}?${encodedParams}`, 19 | headers: this.headers, 20 | credentials: this.credentials, 21 | }; 22 | let response = await doRequest(options); 23 | return response; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /test-setup.js: -------------------------------------------------------------------------------- 1 | global.fetch = require('jest-fetch-mock'); 2 | -------------------------------------------------------------------------------- /test-utils/fake-device-state-store.js: -------------------------------------------------------------------------------- 1 | export const makeDeviceStateStore = ({ deviceId, token, userId }) => { 2 | class FakeDeviceStateStore { 3 | constructor(instanceId) { 4 | this.instanceId = instanceId; 5 | this._deviceId = null; 6 | this._token = null; 7 | this._userId = null; 8 | } 9 | 10 | async connect() { 11 | this._deviceId = deviceId || null; 12 | this._token = token || null; 13 | this._userId = userId || null; 14 | } 15 | 16 | async clear() { 17 | this._deviceId = null; 18 | this._token = null; 19 | this._userId = null; 20 | } 21 | 22 | async getDeviceId() { 23 | return this._deviceId; 24 | } 25 | 26 | async setDeviceId(deviceId) { 27 | this._deviceId = deviceId; 28 | } 29 | 30 | async getToken() { 31 | return this._token; 32 | } 33 | 34 | async setToken(token) { 35 | this._token = token; 36 | } 37 | 38 | async getUserId() { 39 | return this._userId; 40 | } 41 | 42 | async setUserId(userId) { 43 | this._userId = userId; 44 | } 45 | } 46 | 47 | return FakeDeviceStateStore; 48 | }; 49 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["es6", "dom"] 4 | } 5 | } 6 | --------------------------------------------------------------------------------