├── .github ├── ISSUE_TEMPLATE.md ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .npmignore ├── .prettierrc ├── .publishrc ├── .travis.yml ├── AUTHORS.md ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── example └── chatkit.html ├── karma.conf.js ├── package.json ├── react-native.js ├── src ├── base-client.ts ├── declarations │ └── global │ │ └── global.d.ts ├── host-base.ts ├── index.ts ├── instance.ts ├── logger.ts ├── network.ts ├── request.ts ├── resuming-subscription.ts ├── retry-strategy.ts ├── retrying-subscription.ts ├── subscribe-strategy.ts ├── subscription.ts ├── token-provider.ts ├── token-providing-subscription.ts ├── transport │ ├── http.ts │ └── websocket.ts └── transports.ts ├── test ├── .DS_Store ├── integration │ ├── config.js │ ├── request_failed.test.js │ ├── request_successful.test.js │ ├── subscribe-failed.test.js │ └── subscribe.test.js └── unit │ ├── app.test.js │ ├── base-client.test.js │ ├── resumable-subscription.test.js │ └── subscription.test.js ├── tsconfig.json ├── tslint.json ├── webpack ├── config.react-native.js ├── config.shared.js ├── config.web.js └── config.worker.js ├── worker.js └── yarn.lock /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### What? 2 | 3 | 4 | 5 | ### Why? 6 | 7 | 8 | 9 | ### How? 10 | 11 | 12 | 13 | ---- 14 | 15 | CC @pusher/sigsdk 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | 13 | 14 | **Describe the bug** 15 | A clear and concise description of what the bug is. 16 | 17 | **To Reproduce** 18 | Steps to reproduce the behavior: 19 | 1. Go to '...' 20 | 2. Click on '....' 21 | 3. Scroll down to '....' 22 | 4. See error 23 | 24 | **Expected behavior** 25 | A clear and concise description of what you expected to happen. 26 | 27 | **Screenshots** 28 | If applicable, add screenshots to help explain your problem. 29 | 30 | **Additional context** 31 | SDK version: 32 | Platform/ OS/ Browser: 33 | 34 | Add any other context about the problem here. 35 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: feature request 6 | assignees: '' 7 | 8 | --- 9 | 10 | 13 | 14 | **Is your feature request related to a problem? Please describe.** 15 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 16 | 17 | **Describe the solution you'd like** 18 | A clear and concise description of what you want to happen. 19 | 20 | **Describe alternatives you've considered** 21 | A clear and concise description of any alternative solutions or features you've considered. 22 | 23 | **Additional context** 24 | Add any other context or screenshots about the feature request here. 25 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### What? 2 | 3 | 4 | 5 | ### Why? 6 | 7 | 8 | 9 | ### How? 10 | 11 | 12 | 13 | ---- 14 | 15 | CC @pusher/sigsdk 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | npm-debug.log 4 | yarn-error.log 5 | .DS_Store 6 | .vscode/ 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # This file is written to be a whitelist instead of a blacklist. Start by 2 | # ignoring everything, then add back the files we want to be included in the 3 | # final NPM package. 4 | * 5 | 6 | # And these are the files that are allowed. 7 | !/LICENSE.md 8 | !/package.json 9 | !/react-native.js 10 | !/worker.js 11 | !/dist/**/* 12 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | singleQuote: true 2 | trailingComma: all 3 | -------------------------------------------------------------------------------- /.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": "yarn lint:build:test", 14 | "postPublishScript": false 15 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "node" 4 | 5 | addons: 6 | apt: 7 | packages: 8 | - xvfb 9 | 10 | install: 11 | - export DISPLAY=':99.0' 12 | - Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & 13 | 14 | before_script: 15 | - yarn 16 | - sed -i -e "s|localhost:10443|$SDK_TESTER_HOST|g" test/integration/config.js 17 | 18 | script: 19 | - yarn test 20 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | # Authors 2 | 3 | In addition to authors shown in the git log, special thanks to these authors: 4 | 5 | * Jamie Patel 6 | * James Fisher 7 | * Hamilton Chapman 8 | * Haukur Páll Hallvarðsson 9 | * Paweł Ledwoń 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | This project adheres to [Semantic Versioning Scheme](http://semver.org) 4 | 5 | --- 6 | 7 | ## [Unreleased](https://github.com/pusher/pusher-platform-js/compare/0.18.0...HEAD) 8 | 9 | ## [0.18.0](https://github.com/pusher/pusher-platform-js/compare/0.17.0...0.18.0) - 2020-03-06 10 | 11 | ### Changes 12 | 13 | - Update dependencies 14 | 15 | ## [0.17.0](https://github.com/pusher/pusher-platform-js/compare/0.16.2...0.17.0) - 2019-09-18 16 | 17 | ### Additions 18 | 19 | - `BaseClientOptions` now takes an `sdkLanguage` parameter to allow the 20 | `X-SDK-Language` header to be overwritten. 21 | 22 | ## [0.16.2](https://github.com/pusher/pusher-platform-js/compare/0.16.1...0.16.2) - 2019-02-14 23 | 24 | ### Additions 25 | 26 | - `RawRequestOptions` now includes a `withCredentials` option which gets 27 | forwarded to `XMLHttpRequest` internally. [See 28 | here.](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/withCredentials) 29 | 30 | ## [0.16.1](https://github.com/pusher/pusher-platform-js/compare/0.16.0...0.16.1) - 2018-01-17 31 | 32 | ### Fixes 33 | 34 | - Remove some rogue logging that shouldn't have been there 35 | - Make receiving an event for a subscription that the client doesn't know about not be considered an error that is worth re-establishing the websocket connection for 36 | 37 | ## [0.16.0](https://github.com/pusher/pusher-platform-js/compare/0.15.6...0.16.0) - 2018-12-18 38 | 39 | ### Fixes 40 | 41 | - Makes WebSockets and subscriptions reconnect more reliably 42 | 43 | ### Changes 44 | 45 | - Subscriptions will now default to retrying, rather than not, if the error that caused the close isn't of an expected form 46 | 47 | ## [0.15.6](https://github.com/pusher/pusher-platform-js/compare/0.15.5...0.15.6) - 2018-12-05 48 | 49 | ### Fixes 50 | 51 | - Reconnections and subscription retries now occur as expected when the websocket transport receives a close message 52 | 53 | ## [0.15.5](https://github.com/pusher/pusher-platform-js/compare/0.15.3...0.15.5) - 2018-11-19 54 | 55 | - Adopt package in to the pusher org as `@pusher/platform` 56 | 57 | ## [0.15.3](https://github.com/pusher/pusher-platform-js/compare/0.15.2...0.15.3) - 2018-11-16 58 | 59 | ### Fixes 60 | 61 | - Work around Chrome taking 60 seconds to close a broken websocket by disposing of the registered callbacks and proceeding immediately with a new one 62 | 63 | ## [0.15.2](https://github.com/pusher/pusher-platform-js/compare/0.15.1...0.15.2) - 2018-10-12 64 | 65 | ### Changes 66 | 67 | - Don't close websocket connection if received pong ID doesn't match last send ping ID 68 | 69 | ## [0.15.1](https://github.com/pusher/pusher-platform-js/compare/0.15.0...0.15.1) - 2018-04-10 70 | 71 | ### Additions 72 | 73 | - `BaseClient` takes `sdkProduct` and `sdkVersion` options. Sends them as headers with every request (along with language and platform). 74 | 75 | ## [0.15.0](https://github.com/pusher/pusher-platform-js/compare/0.14.0...0.15.0) - 2018-03-07 76 | 77 | ### Changes 78 | 79 | - `RequestOptions` and `RawRequestOptions` no longer have `logger` as an optional property 80 | 81 | ### Fixes 82 | 83 | - Rejected promises arising from failed network requests, where a token provider was being used, now propogate as you'd expect 84 | 85 | ## [0.14.0](https://github.com/pusher/pusher-platform-js/compare/0.13.2...0.14.0) - 2017-12-07 86 | 87 | ### Changes 88 | 89 | - Added support for web workers with a build for web workers 90 | 91 | ## [0.13.2](https://github.com/pusher/pusher-platform-js/compare/0.13.1...0.13.2) - 2017-12-05 92 | 93 | ### Changes 94 | 95 | - `tokenProvider` property on `Instance` is now public 96 | 97 | ## [0.13.1](https://github.com/pusher/pusher-platform-js/compare/0.13.0...0.13.1) - 2017-12-05 98 | 99 | ### Changes 100 | 101 | - `HOST_BASE` is now exported 102 | 103 | ## [0.13.0](https://github.com/pusher/pusher-platform-js/compare/0.12.4...0.13.0) - 2017-11-28 104 | 105 | ### Changes 106 | 107 | - Using `request(...)` on an `Instance` will no longer default to `JSON.stringify`-ing and adding `application/json` as the `Content-Type` when you provide a `body` as part of your `RequestOptions`. If you want to send JSON in your request you can now use the `json` key in your `RequestOptions` instead of `body`. `body` should be used for all body types that aren't JSON. Note that you'll need to set your own `Content-Type` header though (in most cases). 108 | - `dist/` directory removed from repo (but it's still part of the NPM releases - see `.npmignore` vs `.gitignore`) 109 | - The default `ConsoleLogger` will now log out `error_uri`s and `error_description`s in a helpful way, if they're present as part of an `ErrorResponse` object passed to a logger call. 110 | 111 | 112 | ## [0.12.4](https://github.com/pusher/pusher-platform-js/compare/0.12.3...0.12.4) - 2017-11-24 113 | 114 | ### Fixed 115 | 116 | - `fromXHR` in `ErrorResponse` now tries to `JSON.parse` the `responseText` from the `xhr` request to make errors easier to read 117 | 118 | 119 | ## [0.12.3](https://github.com/pusher/pusher-platform-js/compare/0.12.2...0.12.3) - 2017-11-22 120 | 121 | ### Fixed 122 | 123 | - Call appropriate `onError` listener if `fetchToken` fails as part of a subscription 124 | 125 | 126 | ## [0.12.2](https://github.com/pusher/pusher-platform-js/compare/0.12.1...0.12.2) - 2017-11-22 127 | 128 | ### Changes 129 | 130 | - Removed custom `XMLHttpRequest` and `WebSocket` and `Window` declarations and added `"webworker"` to `"lib"` in `tsconfig.json` 131 | 132 | 133 | ## [0.12.1](https://github.com/pusher/pusher-platform-js/compare/0.12.0...0.12.1) - 2017-11-21 134 | 135 | ### Changes 136 | 137 | - Added `sendRawRequest` to allow service-specific SDKs to make requests without having to worry about networking setups themselves. `sendRawRequest` takes an `options` parameter of type `RawRequestOptions` 138 | 139 | 140 | ## [0.12.0](https://github.com/pusher/pusher-platform-js/compare/0.11.1...0.12.0) - 2017-11-17 141 | 142 | ### Changes 143 | 144 | - `request` no longer takes `tokenProvider` as a second parameter, it is now a part of the `options` parameter, of type `RequestOptions` 145 | - `TokenProvider`'s `fetchToken` now returns a normal a Promise instead of a PCancelable 146 | - `request` in `Instance` and `BaseClient` now return a normal `Promise` instead of a `PCancelable` 147 | - React Native support (use `@pusher/platform/react-native`) 148 | - Code formatted using prettier 149 | - Code linted using tslint 150 | - strict mode enabled in `tsconfig.json` 151 | - Refactored TokenProvidingSubscription 152 | - Removed jwt-simple as dependency 153 | - Removed p-cancelable as dependency 154 | - Build artifacts now live in dist/ instead of target/ 155 | - Single index.d.ts declarations file no longer generated (removed dts-bundle as a dependency) 156 | 157 | 158 | ## [0.11.1](https://github.com/pusher/pusher-platform-js/compare/0.11.0...0.11.1) - 2017-11-03 159 | 160 | ### Fixes 161 | 162 | - Treat all 2XX status codes as successful 163 | 164 | ## [0.11.0](https://github.com/pusher/pusher-platform-js/compare/0.10.0...0.11.0) - 2017-11-03 165 | 166 | ### Changes 167 | 168 | This is quite big change since we added `websockets` as basic transport for all subscriptions. On the other hand there are no changes on public interfaces (the SDK is using a different transport internally). 169 | 170 | - Added `websockets` as basic transport for all subscriptions 171 | - Separate `HTTP` related code into HTTP transport object 172 | 173 | ## [0.10.0](https://github.com/pusher/pusher-platform-js/compare/0.9.2...0.10.0) - 2017-10-27 174 | 175 | ### Changes 176 | 177 | - `instanceId` renamed to `locator` in Instance class 178 | - Added export of `SubscriptionEvent` 179 | 180 | ### Fixes 181 | 182 | - Default `logger` now set in `BaseClient` if `baseClient` without a `logger` is provided to the `Instance` constructor 183 | 184 | ## [0.9.2](https://github.com/pusher/pusher-platform-js/compare/0.9.0...0.9.2) - 2017-10-06 185 | 186 | ### Fixes 187 | 188 | - Making authorized requests 189 | - Making authorized subscriptions 190 | 191 | ## [0.9.0](https://github.com/pusher/pusher-platform-js/compare/0.7.0...0.9.0) - 2017-09-14 192 | 193 | Refactored a lot of stuff internally, some API changes. This is a big release. There will be an additional document describing how subscriptions are constructed, and how they work "under the hood". 194 | 195 | ### Changes 196 | 197 | - We stopped using the term 'resumable' in favour of 'resuming'. Resumable as a passive form is used to describe a resource, on a remote server, whereas Resuming is an active form and better describes the client's behaviour. 198 | - renamed `Instance.subscribe` to `Instance.subscribeNonResuming` 199 | - renamed `Instance.subscribeResumable` to `Instance.subscribeResuming` 200 | - all subscriptions now return an object of type `Subscription`, that can be unsubscribed via `subscription.unsubscribe()`. 201 | - Removed the `RetryStrategy` in favour of `RetryStrategyOptions`. Any custom config can be passed in there, including limit for maximum number of retries, and the function to increment the wait time. 202 | - Listeners are now passed to subscriptions as a single interface, containing optional callbacks: 203 | - `onOpen(headers)`, 204 | - `onSubscribe()`, 205 | - `onError(error)`, 206 | - `onEvent(event)`, 207 | - `onEnd(error)` 208 | - `onError` now returns `any` type, making for easier casting. 209 | - `TokenProvider` is now an interface. Implementers are expected to create their own. 210 | - `TokenProvider` returns a cancelable promise - from the `p-cancelable` library. 211 | - `Logger` now can't be passed to each request anymore. It is now only created as part of `Instance` creation. 212 | - `Instance.request` now returns a `CancelablePromise` from `p-cancelable` library. It does not retry as of now. 213 | 214 | ## [0.7.0](https://github.com/pusher/pusher-platform-js/compare/0.6.1...0.7.0) - 2017-07-19 215 | 216 | ### Changes 217 | 218 | - Renamed the `instance` to `instanceId` when instantiating an `Instance`. `Instance` class now has a parameter `id` that used to be `instance`. 219 | - Won the Internet for the most confusing changelog entries. 220 | 221 | ## [0.6.1](https://github.com/pusher/pusher-platform-js/compare/0.6.0...0.6.1) - 2017-07-10 222 | 223 | ### Fixes 224 | 225 | - `ErrorResponse` `instanceof` now works correctly 226 | - `Retry-After` header is now handled correctly. 227 | 228 | ## [0.6.0](https://github.com/pusher/pusher-platform-js/compare/0.5.2...0.6.0) - 2017-07-05 229 | 230 | ### Changes 231 | 232 | - Changed the artifact name to `pusher-platform` 233 | - Renamed `App` to `Instance`, `appId` to `instanceId`. 234 | - Updated the tenancy to the upcoming standard: https://cluster.and.host/services/serviceName/serviceVersion/instanceId/... 235 | 236 | --- 237 | 238 | Older releases are not covered by this changelog. 239 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT license 2 | 3 | Copyright (c) 2017 Pusher Ltd. 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pusher-platform.js 2 | 3 | This is the official Pusher Platform client library for web browsers. Use it to build SDKs for services running on Pusher Platform / Elements infrastructure. 4 | 5 | ## Issues, Bugs, and Feature Requests 6 | 7 | Feel free to create an issue on GitHub if you find anything wrong. Please use the existing template. 8 | If you wish to contribute, please make a pull request. 9 | To summon help you can also ping @pusher/sigsdk or @zmarkan. 10 | 11 | ## Installation 12 | 13 | We assume you use yarn/npm in your development workflow. You can grab it from the yarn/npm repository: 14 | 15 | ```bash 16 | yarn add '@pusher/platform' 17 | ``` 18 | 19 | The latest working version will always be published there. 20 | 21 | If you like to live dangerously, you can check in the Releases tab on Github for the latest release, or clone a local version and refer to it using a relative path. 22 | 23 | ## Importing 24 | 25 | We assume you use Webpack or something similar: 26 | 27 | Currently there are two ways to import it - either import the whole thing: 28 | 29 | #### Browser 30 | 31 | ```javascript 32 | import PusherPlatform from '@pusher/platform'; 33 | 34 | let instance = new PusherPlatform.Instance(...); 35 | ``` 36 | 37 | #### React Native 38 | 39 | ```javascript 40 | import PusherPlatform from '@pusher/platform/react-native'; 41 | 42 | let instance = new PusherPlatform.Instance(...); 43 | ``` 44 | 45 | 46 | Or import individual components. Currently you can access: 47 | 48 | - Instance 49 | - BaseClient 50 | - Subscription 51 | 52 | 53 | #### Browser 54 | 55 | ```javascript 56 | import { Instance, ... } from '@pusher/platform'; 57 | 58 | let instance = new Instance(...); 59 | ``` 60 | 61 | #### React Native 62 | 63 | ```javascript 64 | import { Instance, ... } from '@pusher/platform/react-native'; 65 | 66 | let instance = new Instance(...); 67 | ``` 68 | 69 | ## Usage and Features 70 | 71 | ### Instance 72 | 73 | This is the main entry point - represents a single instance of a service running on the Elements infrastructure. 74 | Initialise with an `InstanceOptions` object that MUST contain at least the `locator`, `serviceName`, and `serviceVersion`. 75 | 76 | InstanceOptions: 77 | ```typescript 78 | serviceName: string; //Mandatory 79 | locator: string; // Mandatory 80 | serviceVersion: string //Mandatory 81 | 82 | host?: string; // Use in debugging, overrides the cluster setting that is the part of `locator` 83 | encrypted?: boolean; // Defaults to true 84 | 85 | client?: BaseClient; // You can provide custom implementation - this will probably be deprecated in the future 86 | logger?: Logger; // You can provide custom implementation. Defaults to ConsoleLogger(2) - logging anything non-verbose (level debug and above) 87 | ``` 88 | 89 | It has 3 methods of interest: 90 | 91 | - `request(options: RequestOptions): Promise` 92 | 93 | For regular HTTP requests. Relays to BaseClient. 94 | 95 | RequestOptions: 96 | 97 | ```typescript 98 | export interface RequestOptions { 99 | method: string; 100 | path: string; 101 | jwt?: string; 102 | headers?: ElementsHeaders; 103 | body?: any; 104 | logger?: Logger; 105 | tokenProvider?: TokenProvider; 106 | } 107 | 108 | request(options: RequestOptions, tokenParams?: any): Promise 109 | ``` 110 | 111 | - `subscribeNonResuming(options: SubscribeOptions)` 112 | 113 | A subscription to events. Creates a SUBSCRIBE call using the base client. Returns `Subscription` 114 | 115 | - `subscribeResuming(options: ResumableSubscribeOptions)` 116 | 117 | Like a subscription, but allows you to specify a `initialEventId` that will return you all items from this ID. Example - Feeds. Returns `Subscription`. 118 | 119 | ### BaseClient 120 | 121 | This makes all the requests and executes them. They are [standard XHR objects](https://developer.mozilla.org/en/docs/Web/API/XMLHttpRequest). 122 | 123 | It also creates XHRs that are used to create instances of `Subscription`. 124 | 125 | ### Subscription 126 | 127 | SubscribeOptions: 128 | ```typescript 129 | export interface SubscribeOptions { 130 | path: string; 131 | headers?: ElementsHeaders; 132 | listeners: SubscriptionListeners; 133 | retryStrategyOptions?: RetryStrategyOptions; 134 | tokenProvider?: TokenProvider; 135 | } 136 | 137 | export interface SubscriptionListeners { 138 | onOpen?: (headers: ElementsHeaders) => void; 139 | onSubscribe?: () => void; 140 | onRetrying?:() => void; 141 | onEvent?: (event: SubscriptionEvent) => void; 142 | onError?: (error: any) => void; 143 | onEnd?: (error: any) => void; 144 | } 145 | 146 | ``` 147 | 148 | There are standard callbacks for different subscription events `onOpen`, `onEvent`, `onEnd`, and `onError`. There are also helper callbacks `onRetrying` and `onSubscribe` that can be used to inform developers when a subscription has been lost or re-established. 149 | 150 | Use `unsubscribe()` to close this subscription. 151 | 152 | 153 | ### Subscription and Resumable Subscription 154 | 155 | Options: 156 | 157 | ```typescript 158 | export interface SubscribeOptions { 159 | path: string; 160 | headers?: ElementsHeaders; 161 | listeners: SubscriptionListeners; 162 | retryStrategyOptions?: RetryStrategyOptions; 163 | tokenProvider?: TokenProvider; 164 | } 165 | 166 | export interface ResumableSubscribeOptions extends SubscribeOptions { 167 | initialEventId?: string; 168 | } 169 | ``` 170 | 171 | Listeners: 172 | 173 | ```typescript 174 | export interface SubscriptionListeners { 175 | onOpen?: (headers: ElementsHeaders) => void; // Triggered once per subscription 176 | onSubscribe?: () => void; // Triggered each time a subscription is established 177 | onRetrying?:() => void; // Triggered each time we are retrying to connect 178 | onEvent?: (event: SubscriptionEvent) => void; // Triggered for each event 179 | onError?: (error: any) => void; // Triggered once. Ends session 180 | onEnd?: (error: any) => void; // Triggered once. 181 | } 182 | ``` 183 | 184 | Token Provider: 185 | 186 | ```typescript 187 | export interface TokenProvider { 188 | fetchToken(tokenParams?: any): Promise; 189 | clearToken(token?: string): void; 190 | } 191 | ``` 192 | 193 | - `tokenParams` can be anything, and is optional. Some services might require it. 194 | - `clearToken` allows you to "forget" an expired token in the current token provider 195 | 196 | Retry Strategy: 197 | 198 | ```typescript 199 | export interface RetryStrategyOptions { 200 | initialTimeoutMillis?: number, //Defaults to 1000 201 | maxTimeoutMillis?: number, //Defaults to 5000 202 | limit?: number, //Defaults to -1 (unlimited). Set to 0 to disable retrying. 203 | increaseTimeout?: (currentTimeout: number) => number; //Defaults to currentTimeout*2 or maxTimeoutMillis 204 | } 205 | ``` 206 | 207 | ### Logger 208 | 209 | It logs things. 210 | Interface: 211 | 212 | ```typescript 213 | export interface Logger { 214 | verbose(message: string, error?: Error): void; 215 | debug(message: string, error?: Error): void; 216 | info(message: string, error?: Error): void; 217 | warn(message: string, error?: Error): void; 218 | error(message: string, error?: Error): void; 219 | } 220 | ``` 221 | 222 | You can pass it to the `Instance` object at startup. The default implementation is the `ConsoleLogger`. You initiate it with a threshold for the log level (defaults to `LogLevel.DEBUG`). It will log everything at or above this log level. 223 | 224 | The default log levels are: 225 | 226 | ```typescript 227 | export enum LogLevel { 228 | VERBOSE = 1, 229 | DEBUG = 2, 230 | INFO = 3, 231 | WARNING = 4, 232 | ERROR = 5 233 | } 234 | ``` 235 | 236 | ### TokenProvider 237 | 238 | This is up to the service implementer to implement. 239 | 240 | ```typescript 241 | export interface TokenProvider { 242 | fetchToken(tokenParams?: any): Promise; 243 | clearToken(token?: string): void; 244 | } 245 | ``` 246 | 247 | ## Building 248 | 249 | ```bash 250 | yarn install 251 | yarn build # builds both web and react-native versions in dist/ 252 | ``` 253 | 254 | This will create `dist/web/pusher-platform.js` as the main library. 255 | 256 | ## Testing 257 | 258 | We have 2 types of tests - unit and integration. 259 | 260 | `yarn test-jest` for unit tests 261 | 262 | Integration testing is trickier as it requires a testing service to be running. Currently it runs locally on your machine but this will eventually be deployed behind the Elements bridge. 263 | 264 | The repository for it is [here](https://github.com/pusher/platform-sdk-tester), and it requires a working Go installation. 265 | Then hopefully just run from the main directory of that test library: 266 | 267 | ```bash 268 | dep ensure 269 | ./script/build 270 | ./target/server 271 | ``` 272 | 273 | Then run tests (from this dir): `yarn test-integration`. 274 | 275 | This will run tests and watch the `src/` and `test/` dirs to rebuild every time a file is changed. Good for development. 276 | 277 | Once we have some sort of CI setup we'll be able to run just `yarn test` to get everything going and run just once. 278 | 279 | ## Formatting and linting 280 | 281 | Prettier and TSLint are used to keep the code formatted and linted nicely. 282 | 283 | To format your code using Prettier, run: 284 | 285 | ``` 286 | yarn format 287 | ``` 288 | 289 | To lint your code using TSLint, run: 290 | 291 | ``` 292 | yarn lint 293 | ``` 294 | 295 | Please ensure that your code is formatted and linted before merging it into master. 296 | 297 | ## License 298 | 299 | pusher-platform-js is released under the MIT license. See LICENSE for details. 300 | -------------------------------------------------------------------------------- /example/chatkit.html: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 152 | 153 | 154 | 155 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration 2 | // Generated on Wed Jun 21 2017 14:56:06 GMT+0100 (BST) 3 | 4 | module.exports = function(config) { 5 | config.set({ 6 | 7 | // base path that will be used to resolve all patterns (eg. files, exclude) 8 | basePath: '', 9 | 10 | 11 | // frameworks to use 12 | // available frameworks: https://npmjs.org/browse/keyword/karma-adapter 13 | frameworks: ['jasmine'], 14 | 15 | 16 | // list of files / patterns to load in the browser 17 | files: [ 18 | "test/integration/*.test.js" 19 | ], 20 | 21 | 22 | // list of files to exclude 23 | exclude: [ 24 | ], 25 | 26 | 27 | // preprocess matching files before serving them to the browser 28 | // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor 29 | preprocessors: { 30 | 'test/integration/*.test.js': ['webpack'], 31 | }, 32 | 33 | 34 | webpack: { 35 | // karma watches the test entry points 36 | // (you don't need to specify the entry option) 37 | // webpack watches dependencies 38 | 39 | // webpack configuration 40 | }, 41 | 42 | webpackMiddleware: { 43 | // webpack-dev-middleware configuration 44 | // i. e. 45 | stats: 'errors-only' 46 | }, 47 | 48 | // test results reporter to use 49 | // possible values: 'dots', 'progress' 50 | // available reporters: https://npmjs.org/browse/keyword/karma-reporter 51 | reporters: ['progress'], 52 | 53 | 54 | // web server port 55 | port: 9876, 56 | 57 | 58 | // enable / disable colors in the output (reporters and logs) 59 | colors: true, 60 | 61 | 62 | // level of logging 63 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG 64 | logLevel: config.LOG_WARN, 65 | 66 | 67 | // enable / disable watching file and executing tests whenever any file changes 68 | autoWatch: true, 69 | 70 | autoWatchBatchDelay: 1000, 71 | // start these browsers 72 | // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher 73 | browsers: ['Chrome_without_security'], 74 | 75 | // you can define custom flags 76 | customLaunchers: { 77 | Chrome_without_security: { 78 | base: 'Chrome', 79 | flags: ['--disable-web-security', '-ignore-certificate-errors'] 80 | } 81 | }, 82 | 83 | // Continuous Integration mode 84 | // if true, Karma captures browsers, runs the tests and exits 85 | singleRun: false, 86 | 87 | // Concurrency level 88 | // how many browser should be started simultaneous 89 | concurrency: 1, 90 | 91 | // Makes things work on Travis where the timeouts are otherwise too low 92 | captureTimeout: 210000, 93 | browserDisconnectTolerance: 3, 94 | browserDisconnectTimeout : 210000, 95 | browserNoActivityTimeout : 210000 96 | }) 97 | } 98 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@pusher/platform", 3 | "description": "Pusher Platform client library for browsers and react native", 4 | "main": "dist/web/pusher-platform.js", 5 | "types": "dist/web/declarations/index.d.ts", 6 | "version": "0.18.0", 7 | "author": "Pusher", 8 | "license": "MIT", 9 | "homepage": "https://github.com/pusher/pusher-platform-js", 10 | "bugs": { 11 | "url": "https://github.com/pusher/pusher-platform-js/issues" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/pusher/pusher-platform-js.git" 16 | }, 17 | "scripts": { 18 | "build": "yarn build:all", 19 | "build:all": "yarn clean && yarn build:web && yarn build:react-native && yarn build:worker", 20 | "build:web": "webpack --config=webpack/config.web.js", 21 | "build:react-native": "webpack --config=webpack/config.react-native.js", 22 | "build:worker": "webpack --config=webpack/config.worker.js", 23 | "clean": "rm -rf dist", 24 | "format": "yarn prettier --write", 25 | "lint": "tslint -c tslint.json 'src/**/*.ts'", 26 | "lint:build:test": "yarn lint && yarn test", 27 | "prettier": "prettier 'src/**/*.ts' 'test/**/*.js'", 28 | "test-jest": "jest test/unit/*.test.js", 29 | "test-integration": "karma start --single-run", 30 | "test": "yarn build && yarn test-jest && yarn test-integration", 31 | "publish-please": "publish-please", 32 | "prepublishOnly": "publish-please guard" 33 | }, 34 | "devDependencies": { 35 | "babel-core": "^6.25.0", 36 | "babel-loader": "^7.0.0", 37 | "babel-preset-es2015": "^6.24.1", 38 | "jasmine-core": "^2.6.4", 39 | "jest": "^20.0.4", 40 | "karma": "^1.7.0", 41 | "karma-chrome-launcher": "^2.1.1", 42 | "karma-jasmine": "^1.1.0", 43 | "karma-webpack": "^2.0.3", 44 | "lodash": "^4.17.4", 45 | "prettier": "^1.8.1", 46 | "publish-please": "^5.4.3", 47 | "ts-loader": "^2.1.0", 48 | "tslint": "^5.8.0", 49 | "typescript": "^2.6.1", 50 | "webpack": "^3.8.1" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /react-native.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./dist/react-native/pusher-platform'); 2 | -------------------------------------------------------------------------------- /src/base-client.ts: -------------------------------------------------------------------------------- 1 | import { ConsoleLogger, Logger } from './logger'; 2 | import { ElementsHeaders, responseToHeadersObject } from './network'; 3 | import { executeNetworkRequest, RequestOptions } from './request'; 4 | import { createResumingStrategy } from './resuming-subscription'; 5 | import { RetryStrategyOptions } from './retry-strategy'; 6 | import { createRetryingStrategy } from './retrying-subscription'; 7 | import { subscribeStrategyListenersFromSubscriptionListeners } from './subscribe-strategy'; 8 | import { 9 | replaceMissingListenersWithNoOps, 10 | Subscription, 11 | SubscriptionConstructor, 12 | SubscriptionListeners, 13 | } from './subscription'; 14 | import { TokenProvider } from './token-provider'; 15 | import { createTokenProvidingStrategy } from './token-providing-subscription'; 16 | import HttpTransport from './transport/http'; 17 | import WebSocketTransport from './transport/websocket'; 18 | import { createTransportStrategy } from './transports'; 19 | 20 | export interface BaseClientOptions { 21 | host: string; 22 | encrypted?: boolean; 23 | logger?: Logger; 24 | sdkLanguage?: string; 25 | sdkProduct?: string; 26 | sdkVersion?: string; 27 | } 28 | 29 | export class BaseClient { 30 | private host: string; 31 | private XMLHttpRequest: any; 32 | private logger: Logger; 33 | private websocketTransport: WebSocketTransport; 34 | private httpTransport: HttpTransport; 35 | private sdkProduct: string; 36 | private sdkVersion: string; 37 | private sdkLanguage: string; 38 | private sdkPlatform: string; 39 | 40 | constructor(private options: BaseClientOptions) { 41 | this.host = options.host.replace(/(\/)+$/, ''); 42 | const logger = options.logger || new ConsoleLogger(); 43 | this.logger = logger; 44 | this.websocketTransport = new WebSocketTransport(this.host, logger); 45 | this.httpTransport = new HttpTransport(this.host, options.encrypted); 46 | this.sdkProduct = options.sdkProduct || 'unknown'; 47 | this.sdkVersion = options.sdkVersion || 'unknown'; 48 | this.sdkLanguage = options.sdkLanguage || 'javascript'; 49 | this.sdkPlatform = navigator 50 | ? navigator.product === 'ReactNative' 51 | ? 'react-native' 52 | : 'web' 53 | : 'node'; 54 | } 55 | 56 | request(options: RequestOptions, tokenParams?: any): Promise { 57 | if (options.tokenProvider) { 58 | return options.tokenProvider 59 | .fetchToken(tokenParams) 60 | .then(token => { 61 | if (options.headers !== undefined) { 62 | // tslint:disable-next-line:no-string-literal 63 | options.headers['Authorization'] = `Bearer ${token}`; 64 | } else { 65 | options.headers = { 66 | ['Authorization']: `Bearer ${token}`, 67 | }; 68 | } 69 | return options; 70 | }) 71 | .then(optionsWithToken => this.makeRequest(optionsWithToken)); 72 | } 73 | 74 | return this.makeRequest(options); 75 | } 76 | 77 | subscribeResuming( 78 | path: string, 79 | headers: ElementsHeaders, 80 | listeners: SubscriptionListeners, 81 | retryStrategyOptions: RetryStrategyOptions, 82 | initialEventId?: string, 83 | tokenProvider?: TokenProvider, 84 | ): Subscription { 85 | const completeListeners = replaceMissingListenersWithNoOps(listeners); 86 | const subscribeStrategyListeners = subscribeStrategyListenersFromSubscriptionListeners( 87 | completeListeners, 88 | ); 89 | const subscriptionStrategy = createResumingStrategy( 90 | retryStrategyOptions, 91 | createTokenProvidingStrategy( 92 | createTransportStrategy(path, this.websocketTransport, this.logger), 93 | this.logger, 94 | tokenProvider, 95 | ), 96 | this.logger, 97 | initialEventId, 98 | ); 99 | 100 | let opened = false; 101 | return subscriptionStrategy( 102 | { 103 | onEnd: subscribeStrategyListeners.onEnd, 104 | onError: subscribeStrategyListeners.onError, 105 | onEvent: subscribeStrategyListeners.onEvent, 106 | onOpen: subHeaders => { 107 | if (!opened) { 108 | opened = true; 109 | subscribeStrategyListeners.onOpen(subHeaders); 110 | } 111 | completeListeners.onSubscribe(); 112 | }, 113 | onRetrying: subscribeStrategyListeners.onRetrying, 114 | }, 115 | { 116 | ...headers, 117 | ...this.infoHeaders(), 118 | }, 119 | ); 120 | } 121 | 122 | subscribeNonResuming( 123 | path: string, 124 | headers: ElementsHeaders, 125 | listeners: SubscriptionListeners, 126 | retryStrategyOptions: RetryStrategyOptions, 127 | tokenProvider?: TokenProvider, 128 | ) { 129 | const completeListeners = replaceMissingListenersWithNoOps(listeners); 130 | const subscribeStrategyListeners = subscribeStrategyListenersFromSubscriptionListeners( 131 | completeListeners, 132 | ); 133 | 134 | const subscriptionStrategy = createRetryingStrategy( 135 | retryStrategyOptions, 136 | createTokenProvidingStrategy( 137 | createTransportStrategy(path, this.websocketTransport, this.logger), 138 | this.logger, 139 | tokenProvider, 140 | ), 141 | this.logger, 142 | ); 143 | 144 | let opened = false; 145 | return subscriptionStrategy( 146 | { 147 | onEnd: subscribeStrategyListeners.onEnd, 148 | onError: subscribeStrategyListeners.onError, 149 | onEvent: subscribeStrategyListeners.onEvent, 150 | onOpen: subHeaders => { 151 | if (!opened) { 152 | opened = true; 153 | subscribeStrategyListeners.onOpen(subHeaders); 154 | } 155 | completeListeners.onSubscribe(); 156 | }, 157 | onRetrying: subscribeStrategyListeners.onRetrying, 158 | }, 159 | { 160 | ...headers, 161 | ...this.infoHeaders(), 162 | }, 163 | ); 164 | } 165 | 166 | private infoHeaders(): { [key: string]: string } { 167 | return { 168 | 'X-SDK-Language': this.sdkLanguage, 169 | 'X-SDK-Platform': this.sdkPlatform, 170 | 'X-SDK-Product': this.sdkProduct, 171 | 'X-SDK-Version': this.sdkVersion, 172 | }; 173 | } 174 | 175 | private makeRequest(options: RequestOptions): Promise { 176 | return executeNetworkRequest( 177 | () => 178 | this.httpTransport.request({ 179 | ...options, 180 | headers: { 181 | ...options.headers, 182 | ...this.infoHeaders(), 183 | }, 184 | }), 185 | options, 186 | ).catch(error => { 187 | this.logger.error(error); 188 | throw error; 189 | }); 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /src/declarations/global/global.d.ts: -------------------------------------------------------------------------------- 1 | declare const global: any; 2 | -------------------------------------------------------------------------------- /src/host-base.ts: -------------------------------------------------------------------------------- 1 | export const HOST_BASE = 'pusherplatform.io'; 2 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { BaseClient, BaseClientOptions } from './base-client'; 2 | import { HOST_BASE } from './host-base'; 3 | import { 4 | default as Instance, 5 | ResumableSubscribeOptions, 6 | SubscribeOptions, 7 | } from './instance'; 8 | import { ConsoleLogger, EmptyLogger, Logger } from './logger'; 9 | import { 10 | ElementsHeaders, 11 | ErrorResponse, 12 | NetworkError, 13 | responseToHeadersObject, 14 | XhrReadyState, 15 | } from './network'; 16 | import { 17 | executeNetworkRequest, 18 | RawRequestOptions, 19 | RequestOptions, 20 | sendRawRequest, 21 | } from './request'; 22 | import { createResumingStrategy } from './resuming-subscription'; 23 | import { 24 | createRetryStrategyOptionsOrDefault, 25 | DoNotRetry, 26 | Retry, 27 | RetryResolution, 28 | RetryStrategyOptions, 29 | RetryStrategyResult, 30 | } from './retry-strategy'; 31 | import { createRetryingStrategy } from './retrying-subscription'; 32 | import { 33 | Subscription, 34 | SubscriptionConstructor, 35 | SubscriptionEvent, 36 | SubscriptionListeners, 37 | } from './subscription'; 38 | import { TokenProvider } from './token-provider'; 39 | import { createTokenProvidingStrategy } from './token-providing-subscription'; 40 | import { createTransportStrategy } from './transports'; 41 | 42 | export { 43 | BaseClient, 44 | BaseClientOptions, 45 | ConsoleLogger, 46 | createResumingStrategy, 47 | createRetryingStrategy, 48 | createRetryStrategyOptionsOrDefault, 49 | createTokenProvidingStrategy, 50 | createTransportStrategy, 51 | DoNotRetry, 52 | ElementsHeaders, 53 | EmptyLogger, 54 | ErrorResponse, 55 | executeNetworkRequest, 56 | HOST_BASE, 57 | Instance, 58 | Logger, 59 | NetworkError, 60 | RawRequestOptions, 61 | RequestOptions, 62 | responseToHeadersObject, 63 | ResumableSubscribeOptions, 64 | RetryStrategyResult, 65 | Retry, 66 | RetryStrategyOptions, 67 | RetryResolution, 68 | sendRawRequest, 69 | SubscribeOptions, 70 | Subscription, 71 | SubscriptionEvent, 72 | SubscriptionListeners, 73 | TokenProvider, 74 | XhrReadyState, 75 | }; 76 | 77 | export default { 78 | BaseClient, 79 | ConsoleLogger, 80 | EmptyLogger, 81 | Instance, 82 | }; 83 | -------------------------------------------------------------------------------- /src/instance.ts: -------------------------------------------------------------------------------- 1 | import { BaseClient } from './base-client'; 2 | import { HOST_BASE } from './host-base'; 3 | import { ConsoleLogger, Logger } from './logger'; 4 | import { ElementsHeaders } from './network'; 5 | import { RequestOptions } from './request'; 6 | import { RetryStrategyOptions } from './retry-strategy'; 7 | import { Subscription, SubscriptionListeners } from './subscription'; 8 | import { TokenProvider } from './token-provider'; 9 | 10 | export interface InstanceOptions { 11 | locator: string; 12 | serviceName: string; 13 | serviceVersion: string; 14 | host?: string; // Allows injection of the hostname explicitly 15 | logger?: Logger; 16 | client?: BaseClient; 17 | encrypted?: boolean; 18 | tokenProvider?: TokenProvider; 19 | } 20 | 21 | export interface SubscribeOptions { 22 | path: string; 23 | headers?: ElementsHeaders; 24 | listeners: SubscriptionListeners; 25 | retryStrategyOptions?: RetryStrategyOptions; 26 | tokenProvider?: TokenProvider; 27 | } 28 | 29 | export interface ResumableSubscribeOptions extends SubscribeOptions { 30 | initialEventId?: string; 31 | } 32 | 33 | export default class Instance { 34 | logger: Logger; 35 | tokenProvider?: TokenProvider; 36 | private client: BaseClient; 37 | private host: string; 38 | private id: string; 39 | private cluster: string; 40 | private platformVersion: string; 41 | private serviceVersion: string; 42 | private serviceName: string; 43 | 44 | constructor(options: InstanceOptions) { 45 | if (!options.locator) { 46 | throw new Error('Expected `locator` property in Instance options!'); 47 | } 48 | 49 | const splitInstanceLocator = options.locator.split(':'); 50 | if (splitInstanceLocator.length !== 3) { 51 | throw new Error( 52 | 'The instance locator supplied is invalid. Did you copy it correctly from the Pusher dashboard?', 53 | ); 54 | } 55 | 56 | if (!options.serviceName) { 57 | throw new Error('Expected `serviceName` property in Instance options!'); 58 | } 59 | 60 | if (!options.serviceVersion) { 61 | throw new Error( 62 | 'Expected `serviceVersion` property in Instance options!', 63 | ); 64 | } 65 | 66 | this.platformVersion = splitInstanceLocator[0]; 67 | this.cluster = splitInstanceLocator[1]; 68 | this.id = splitInstanceLocator[2]; 69 | 70 | this.serviceName = options.serviceName; 71 | this.serviceVersion = options.serviceVersion; 72 | 73 | this.host = options.host || `${this.cluster}.${HOST_BASE}`; 74 | this.logger = options.logger || new ConsoleLogger(); 75 | 76 | this.client = 77 | options.client || 78 | new BaseClient({ 79 | encrypted: options.encrypted, 80 | host: this.host, 81 | logger: this.logger, 82 | }); 83 | 84 | this.tokenProvider = options.tokenProvider; 85 | } 86 | 87 | request(options: RequestOptions, tokenParams?: any): Promise { 88 | options.path = this.absPath(options.path); 89 | if (options.headers == null || options.headers === undefined) { 90 | options.headers = {}; 91 | } 92 | options.tokenProvider = options.tokenProvider || this.tokenProvider; 93 | return this.client.request(options, tokenParams); 94 | } 95 | 96 | subscribeNonResuming(options: SubscribeOptions): Subscription { 97 | const headers: ElementsHeaders = options.headers || {}; 98 | const retryStrategyOptions = options.retryStrategyOptions || {}; 99 | const tokenProvider = options.tokenProvider || this.tokenProvider; 100 | 101 | return this.client.subscribeNonResuming( 102 | this.absPath(options.path), 103 | headers, 104 | options.listeners, 105 | retryStrategyOptions, 106 | tokenProvider, 107 | ); 108 | } 109 | 110 | subscribeResuming(options: ResumableSubscribeOptions): Subscription { 111 | const headers: ElementsHeaders = options.headers || {}; 112 | const retryStrategyOptions = options.retryStrategyOptions || {}; 113 | const tokenProvider = options.tokenProvider || this.tokenProvider; 114 | 115 | return this.client.subscribeResuming( 116 | this.absPath(options.path), 117 | headers, 118 | options.listeners, 119 | retryStrategyOptions, 120 | options.initialEventId, 121 | tokenProvider, 122 | ); 123 | } 124 | 125 | private absPath(relativePath: string): string { 126 | return `/services/${this.serviceName}/${this.serviceVersion}/${this.id}/${ 127 | relativePath 128 | }` 129 | .replace(/\/+/g, '/') 130 | .replace(/\/+$/, ''); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | export enum LogLevel { 2 | VERBOSE = 1, 3 | DEBUG = 2, 4 | INFO = 3, 5 | WARNING = 4, 6 | ERROR = 5, 7 | } 8 | 9 | export interface Logger { 10 | verbose(...items: any[]): void; 11 | debug(...items: any[]): void; 12 | info(...items: any[]): void; 13 | warn(...items: any[]): void; 14 | error(...items: any[]): void; 15 | } 16 | 17 | /** 18 | * Default implementation of the Logger. Wraps standards console calls. 19 | * Logs only calls that are at or above the threshold (verbose/debug/info/warn/error) 20 | * If error is passed, it will append the message to the error object. 21 | */ 22 | export class ConsoleLogger implements Logger { 23 | private threshold: LogLevel; 24 | 25 | constructor(threshold: LogLevel = 2) { 26 | this.threshold = threshold; 27 | 28 | const groups = Array(); 29 | const hr = 30 | '--------------------------------------------------------------------------------'; 31 | 32 | if (!global.console.group) { 33 | global.console.group = (label: string) => { 34 | groups.push(label); 35 | global.console.log('%c \nBEGIN GROUP: %c', hr, label); 36 | }; 37 | } 38 | if (!global.console.groupEnd) { 39 | global.console.groupEnd = () => { 40 | global.console.log('END GROUP: %c\n%c', groups.pop(), hr); 41 | }; 42 | } 43 | } 44 | 45 | verbose(...items: any[]) { 46 | this.log(global.console.log, LogLevel.VERBOSE, items); 47 | } 48 | 49 | debug(...items: any[]) { 50 | this.log(global.console.log, LogLevel.DEBUG, items); 51 | } 52 | 53 | info(...items: any[]) { 54 | this.log(global.console.info, LogLevel.INFO, items); 55 | } 56 | 57 | warn(...items: any[]) { 58 | this.log(global.console.warn, LogLevel.WARNING, items); 59 | } 60 | 61 | error(...items: any[]) { 62 | this.log(global.console.error, LogLevel.ERROR, items); 63 | } 64 | 65 | private log( 66 | logFunction: (...items: any[]) => void, 67 | level: LogLevel, 68 | items: any[], 69 | ): void { 70 | if (level >= this.threshold) { 71 | const loggerSignature = `Logger.${LogLevel[level]}`; 72 | 73 | if (items.length > 1) { 74 | global.console.group(); 75 | items.forEach((item: any) => { 76 | this.errorAwareLog(logFunction, item, loggerSignature); 77 | }); 78 | global.console.groupEnd(); 79 | } else { 80 | this.errorAwareLog(logFunction, items[0], loggerSignature); 81 | } 82 | } 83 | } 84 | 85 | private errorAwareLog( 86 | logFunction: (...items: any[]) => void, 87 | item: any, 88 | loggerSignature: string, 89 | ): void { 90 | if (item != null && item.info && item.info.error_uri) { 91 | const errorDesc = item.info.error_description; 92 | const errorIntro = errorDesc ? errorDesc : 'An error has occurred'; 93 | logFunction( 94 | `${errorIntro}. More information can be found at ${ 95 | item.info.error_uri 96 | }. Error object: `, 97 | item, 98 | ); 99 | } else { 100 | logFunction(`${loggerSignature}: `, item); 101 | } 102 | } 103 | } 104 | 105 | export class EmptyLogger implements Logger { 106 | /* tslint:disable:no-empty */ 107 | verbose(...items: any[]) {} 108 | debug(...items: any[]) {} 109 | info(...items: any[]) {} 110 | warn(...items: any[]) {} 111 | error(...items: any[]) {} 112 | /* tslint:enable:no-empty */ 113 | } 114 | -------------------------------------------------------------------------------- /src/network.ts: -------------------------------------------------------------------------------- 1 | export type ElementsHeaders = { 2 | [key: string]: string; 3 | }; 4 | 5 | export function responseToHeadersObject(headerStr: string): ElementsHeaders { 6 | const headers: ElementsHeaders = {}; 7 | if (!headerStr) { 8 | return headers; 9 | } 10 | 11 | const headerPairs = headerStr.split('\u000d\u000a'); 12 | for (const headerPair of headerPairs) { 13 | const index = headerPair.indexOf('\u003a\u0020'); 14 | if (index > 0) { 15 | const key = headerPair.substring(0, index); 16 | const val = headerPair.substring(index + 2); 17 | headers[key] = val; 18 | } 19 | } 20 | return headers; 21 | } 22 | 23 | export class ErrorResponse { 24 | statusCode: number; 25 | headers: ElementsHeaders; 26 | info: any; 27 | 28 | constructor(statusCode: number, headers: ElementsHeaders, info: any) { 29 | this.statusCode = statusCode; 30 | this.headers = headers; 31 | this.info = info; 32 | } 33 | 34 | static fromXHR(xhr: XMLHttpRequest): ErrorResponse { 35 | let errorInfo = xhr.responseText; 36 | try { 37 | errorInfo = JSON.parse(xhr.responseText); 38 | } catch (e) { 39 | // Error info is formatted badly so we just return the raw text 40 | } 41 | return new ErrorResponse( 42 | xhr.status, 43 | responseToHeadersObject(xhr.getAllResponseHeaders()), 44 | errorInfo, 45 | ); 46 | } 47 | } 48 | 49 | export class NetworkError { 50 | constructor(public error: string) {} 51 | } 52 | 53 | export class ProtocolError { 54 | constructor(public error: string) {} 55 | } 56 | 57 | // Follows https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/readyState 58 | export enum XhrReadyState { 59 | UNSENT = 0, 60 | OPENED = 1, 61 | HEADERS_RECEIVED = 2, 62 | LOADING = 3, 63 | DONE = 4, 64 | } 65 | -------------------------------------------------------------------------------- /src/request.ts: -------------------------------------------------------------------------------- 1 | import { ConsoleLogger, Logger } from './logger'; 2 | import { ElementsHeaders, ErrorResponse, NetworkError } from './network'; 3 | import { TokenProvider } from './token-provider'; 4 | 5 | export interface BasicRequestOptions { 6 | method: string; 7 | path: string; 8 | jwt?: string; 9 | headers?: ElementsHeaders; 10 | tokenProvider?: TokenProvider; 11 | } 12 | 13 | export interface SimpleRequestOptions extends BasicRequestOptions { 14 | body?: any; 15 | } 16 | 17 | export interface JSONRequestOptions extends BasicRequestOptions { 18 | json?: any; 19 | } 20 | 21 | export type RequestOptions = SimpleRequestOptions | JSONRequestOptions; 22 | 23 | export interface RawRequestOptions { 24 | method: string; 25 | url: string; 26 | headers?: ElementsHeaders; 27 | body?: any; 28 | withCredentials?: boolean; 29 | } 30 | 31 | // TODO: Could we make this generic and remove the `any`s? 32 | export function executeNetworkRequest( 33 | createXhr: () => XMLHttpRequest, 34 | options: RequestOptions, 35 | ): Promise { 36 | return new Promise((resolve, reject) => { 37 | const xhr = attachOnReadyStateChangeHandler(createXhr(), resolve, reject); 38 | sendBody(xhr, options); 39 | }); 40 | } 41 | 42 | function sendBody(xhr: XMLHttpRequest, options: any) { 43 | if (options.json) { 44 | xhr.send(JSON.stringify(options.json)); 45 | } else { 46 | xhr.send(options.body); 47 | } 48 | } 49 | 50 | export function sendRawRequest(options: RawRequestOptions): Promise { 51 | return new Promise((resolve, reject) => { 52 | const xhr = attachOnReadyStateChangeHandler( 53 | new global.XMLHttpRequest(), 54 | resolve, 55 | reject, 56 | ); 57 | xhr.withCredentials = Boolean(options.withCredentials); 58 | 59 | xhr.open(options.method.toUpperCase(), options.url, true); 60 | 61 | if (options.headers) { 62 | for (const key in options.headers) { 63 | if (options.headers.hasOwnProperty(key)) { 64 | xhr.setRequestHeader(key, options.headers[key]); 65 | } 66 | } 67 | } 68 | 69 | xhr.send(options.body); 70 | }); 71 | } 72 | 73 | function attachOnReadyStateChangeHandler( 74 | xhr: XMLHttpRequest, 75 | resolve: any, 76 | reject: any, 77 | ): XMLHttpRequest { 78 | xhr.onreadystatechange = () => { 79 | if (xhr.readyState === 4) { 80 | if (xhr.status >= 200 && xhr.status < 300) { 81 | resolve(xhr.response); 82 | } else if (xhr.status !== 0) { 83 | reject(ErrorResponse.fromXHR(xhr)); 84 | } else { 85 | reject(new NetworkError('No Connection')); 86 | } 87 | } 88 | }; 89 | return xhr; 90 | } 91 | -------------------------------------------------------------------------------- /src/resuming-subscription.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from './logger'; 2 | import { ElementsHeaders, ErrorResponse } from './network'; 3 | import { 4 | createRetryStrategyOptionsOrDefault, 5 | Retry, 6 | RetryResolution, 7 | RetryStrategyOptions, 8 | RetryStrategyResult, 9 | } from './retry-strategy'; 10 | import { 11 | SubscribeStrategy, 12 | SubscribeStrategyListeners, 13 | } from './subscribe-strategy'; 14 | import { 15 | Subscription, 16 | SubscriptionConstructor, 17 | SubscriptionEvent, 18 | SubscriptionState, 19 | } from './subscription'; 20 | 21 | export let createResumingStrategy: ( 22 | retryingOptions: RetryStrategyOptions, 23 | nextSubscribeStrategy: SubscribeStrategy, 24 | logger: Logger, 25 | initialEventId?: string, 26 | ) => SubscribeStrategy = ( 27 | retryOptions, 28 | nextSubscribeStrategy, 29 | logger, 30 | initialEventId, 31 | ) => { 32 | const completeRetryOptions = createRetryStrategyOptionsOrDefault( 33 | retryOptions, 34 | ); 35 | const retryResolution = new RetryResolution(completeRetryOptions, logger); 36 | 37 | // All the magic in the world. 38 | return (listeners, headers) => 39 | new ResumingSubscription( 40 | logger, 41 | headers, 42 | nextSubscribeStrategy, 43 | listeners, 44 | retryResolution, 45 | ); 46 | }; 47 | 48 | class ResumingSubscription implements Subscription { 49 | private state: SubscriptionState; 50 | 51 | constructor( 52 | logger: Logger, 53 | headers: ElementsHeaders, 54 | nextSubscribeStrategy: SubscribeStrategy, 55 | listeners: SubscribeStrategyListeners, 56 | retryResolution: RetryResolution, 57 | ) { 58 | // Here we init the state transition shenaningans 59 | this.state = new OpeningSubscriptionState( 60 | this.onTransition, 61 | logger, 62 | headers, 63 | nextSubscribeStrategy, 64 | listeners, 65 | retryResolution, 66 | ); 67 | } 68 | 69 | unsubscribe = () => { 70 | this.state.unsubscribe(); 71 | }; 72 | 73 | private onTransition = (newState: SubscriptionState) => { 74 | this.state = newState; 75 | }; 76 | } 77 | 78 | class OpeningSubscriptionState implements SubscriptionState { 79 | private underlyingSubscription!: Subscription; 80 | 81 | constructor( 82 | private onTransition: (newState: SubscriptionState) => void, 83 | private logger: Logger, 84 | private headers: ElementsHeaders, 85 | private nextSubscribeStrategy: SubscribeStrategy, 86 | private listeners: SubscribeStrategyListeners, 87 | private retryResolution: RetryResolution, 88 | private initialEventId?: string, 89 | ) { 90 | let lastEventId = initialEventId; 91 | logger.verbose( 92 | `ResumingSubscription: transitioning to OpeningSubscriptionState`, 93 | ); 94 | 95 | if (lastEventId) { 96 | headers['Last-Event-Id'] = lastEventId; 97 | logger.verbose(`ResumingSubscription: initialEventId is ${lastEventId}`); 98 | } 99 | 100 | this.underlyingSubscription = nextSubscribeStrategy( 101 | { 102 | onEnd: error => { 103 | onTransition(new EndedSubscriptionState(logger, listeners, error)); 104 | }, 105 | onError: error => { 106 | onTransition( 107 | new ResumingSubscriptionState( 108 | error, 109 | onTransition, 110 | logger, 111 | headers, 112 | listeners, 113 | nextSubscribeStrategy, 114 | retryResolution, 115 | lastEventId, 116 | ), 117 | ); 118 | }, 119 | onEvent: event => { 120 | lastEventId = event.eventId; 121 | listeners.onEvent(event); 122 | }, 123 | onOpen: subHeaders => { 124 | onTransition( 125 | new OpenSubscriptionState( 126 | logger, 127 | subHeaders, 128 | listeners, 129 | this.underlyingSubscription, 130 | onTransition, 131 | ), 132 | ); 133 | }, 134 | onRetrying: listeners.onRetrying, 135 | }, 136 | headers, 137 | ); 138 | } 139 | 140 | unsubscribe() { 141 | this.onTransition(new EndingSubscriptionState(this.logger)); 142 | if (this.underlyingSubscription != null) { 143 | this.underlyingSubscription.unsubscribe(); 144 | } 145 | } 146 | } 147 | 148 | class OpenSubscriptionState implements SubscriptionState { 149 | constructor( 150 | private logger: Logger, 151 | private headers: ElementsHeaders, 152 | private listeners: SubscribeStrategyListeners, 153 | private underlyingSubscription: Subscription, 154 | private onTransition: (state: SubscriptionState) => void, 155 | ) { 156 | logger.verbose( 157 | `ResumingSubscription: transitioning to OpenSubscriptionState`, 158 | ); 159 | listeners.onOpen(headers); 160 | } 161 | 162 | unsubscribe() { 163 | this.onTransition(new EndingSubscriptionState(this.logger)); 164 | this.underlyingSubscription.unsubscribe(); 165 | } 166 | } 167 | 168 | class ResumingSubscriptionState implements SubscriptionState { 169 | private timeout: number = -1; 170 | private underlyingSubscription!: Subscription; 171 | 172 | constructor( 173 | error: any, 174 | private onTransition: (newState: SubscriptionState) => void, 175 | private logger: Logger, 176 | private headers: ElementsHeaders, 177 | private listeners: SubscribeStrategyListeners, 178 | private nextSubscribeStrategy: SubscribeStrategy, 179 | private retryResolution: RetryResolution, 180 | lastEventId?: string, 181 | ) { 182 | logger.verbose( 183 | `ResumingSubscription: transitioning to ResumingSubscriptionState`, 184 | ); 185 | 186 | const executeSubscriptionOnce = ( 187 | subError: any, 188 | subLastEventId?: string, 189 | ) => { 190 | listeners.onRetrying(); 191 | const resolveError: ( 192 | errToResolve: any, 193 | ) => RetryStrategyResult = errToResolve => { 194 | if (errToResolve instanceof ErrorResponse) { 195 | errToResolve.headers['Request-Method'] = 'SUBSCRIBE'; 196 | } 197 | return retryResolution.attemptRetry(errToResolve); 198 | }; 199 | 200 | const errorResolution = resolveError(subError); 201 | if (errorResolution instanceof Retry) { 202 | this.timeout = global.setTimeout(() => { 203 | executeNextSubscribeStrategy(subLastEventId); 204 | }, errorResolution.waitTimeMillis); 205 | } else { 206 | onTransition(new FailedSubscriptionState(logger, listeners, subError)); 207 | } 208 | }; 209 | 210 | const executeNextSubscribeStrategy = (subLastEventId?: string) => { 211 | logger.verbose( 212 | `ResumingSubscription: trying to re-establish the subscription`, 213 | ); 214 | if (subLastEventId) { 215 | logger.verbose(`ResumingSubscription: lastEventId: ${subLastEventId}`); 216 | headers['Last-Event-Id'] = subLastEventId; 217 | } 218 | 219 | this.underlyingSubscription = nextSubscribeStrategy( 220 | { 221 | onEnd: endError => { 222 | onTransition( 223 | new EndedSubscriptionState(logger, listeners, endError), 224 | ); 225 | }, 226 | onError: subError => { 227 | executeSubscriptionOnce(subError, lastEventId); 228 | }, 229 | onEvent: event => { 230 | lastEventId = event.eventId; 231 | listeners.onEvent(event); 232 | }, 233 | onOpen: openHeaders => { 234 | onTransition( 235 | new OpenSubscriptionState( 236 | logger, 237 | openHeaders, 238 | listeners, 239 | this.underlyingSubscription, 240 | onTransition, 241 | ), 242 | ); 243 | }, 244 | onRetrying: listeners.onRetrying, 245 | }, 246 | headers, 247 | ); 248 | }; 249 | executeSubscriptionOnce(error, lastEventId); 250 | } 251 | 252 | unsubscribe() { 253 | this.onTransition(new EndingSubscriptionState(this.logger)); 254 | global.clearTimeout(this.timeout); 255 | if (this.underlyingSubscription != null) { 256 | this.underlyingSubscription.unsubscribe(); 257 | } 258 | } 259 | } 260 | 261 | class EndingSubscriptionState implements SubscriptionState { 262 | constructor(private logger: Logger, error?: any) { 263 | logger.verbose( 264 | `ResumingSubscription: transitioning to EndingSubscriptionState`, 265 | ); 266 | } 267 | unsubscribe() { 268 | throw new Error('Subscription is already ending'); 269 | } 270 | } 271 | 272 | class EndedSubscriptionState implements SubscriptionState { 273 | constructor( 274 | private logger: Logger, 275 | private listeners: SubscribeStrategyListeners, 276 | error?: any, 277 | ) { 278 | logger.verbose( 279 | `ResumingSubscription: transitioning to EndedSubscriptionState`, 280 | ); 281 | listeners.onEnd(error); 282 | } 283 | unsubscribe() { 284 | throw new Error('Subscription has already ended'); 285 | } 286 | } 287 | 288 | class FailedSubscriptionState implements SubscriptionState { 289 | constructor( 290 | private logger: Logger, 291 | private listeners: SubscribeStrategyListeners, 292 | error?: any, 293 | ) { 294 | logger.verbose( 295 | `ResumingSubscription: transitioning to FailedSubscriptionState`, 296 | error, 297 | ); 298 | listeners.onError(error); 299 | } 300 | unsubscribe() { 301 | throw new Error('Subscription has already ended'); 302 | } 303 | } 304 | -------------------------------------------------------------------------------- /src/retry-strategy.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from './logger'; 2 | import { ErrorResponse, NetworkError, ProtocolError } from './network'; 3 | 4 | export interface RetryStrategyOptions { 5 | increaseTimeout?: (currentTimeout: number) => number; 6 | initialTimeoutMillis?: number; 7 | limit?: number; 8 | maxTimeoutMillis?: number; 9 | } 10 | 11 | export interface CompleteRetryStrategyOptions { 12 | increaseTimeout: (currentTimeout: number) => number; 13 | initialTimeoutMillis: number; 14 | limit: number; 15 | maxTimeoutMillis: number; 16 | } 17 | 18 | export let createRetryStrategyOptionsOrDefault: ( 19 | options: RetryStrategyOptions, 20 | ) => CompleteRetryStrategyOptions = (options: RetryStrategyOptions) => { 21 | const initialTimeoutMillis = options.initialTimeoutMillis || 1000; 22 | const maxTimeoutMillis = options.maxTimeoutMillis || 5000; 23 | 24 | let limit = -1; 25 | if (options.limit !== undefined && options.limit != null) { 26 | limit = options.limit; 27 | } 28 | 29 | let increaseTimeout: (currentTimeout: number) => number; 30 | 31 | if (options.increaseTimeout !== undefined) { 32 | increaseTimeout = options.increaseTimeout; 33 | } else { 34 | increaseTimeout = currentTimeout => { 35 | if (currentTimeout * 2 > maxTimeoutMillis) { 36 | return maxTimeoutMillis; 37 | } else { 38 | return currentTimeout * 2; 39 | } 40 | }; 41 | } 42 | 43 | return { 44 | increaseTimeout, 45 | initialTimeoutMillis, 46 | limit, 47 | maxTimeoutMillis, 48 | }; 49 | }; 50 | 51 | /* tslint:disable-next-line:no-empty-interface */ 52 | export interface RetryStrategyResult {} 53 | 54 | export class Retry implements RetryStrategyResult { 55 | waitTimeMillis: number; 56 | 57 | constructor(waitTimeMillis: number) { 58 | this.waitTimeMillis = waitTimeMillis; 59 | } 60 | } 61 | 62 | export class DoNotRetry implements RetryStrategyResult { 63 | error: Error; 64 | constructor(error: Error) { 65 | this.error = error; 66 | } 67 | } 68 | 69 | const requestMethodIsSafe: (method: string) => boolean = method => { 70 | method = method.toUpperCase(); 71 | return ( 72 | method === 'GET' || 73 | method === 'HEAD' || 74 | method === 'OPTIONS' || 75 | method === 'SUBSCRIBE' 76 | ); 77 | }; 78 | 79 | export class RetryResolution { 80 | private initialTimeoutMillis: number; 81 | private maxTimeoutMillis: number; 82 | private limit: number; 83 | private increaseTimeoutFunction: (currentTimeout: number) => number; 84 | 85 | private currentRetryCount = 0; 86 | private currentBackoffMillis: number; 87 | 88 | constructor( 89 | private options: CompleteRetryStrategyOptions, 90 | private logger: Logger, 91 | private retryUnsafeRequests?: boolean, 92 | ) { 93 | this.initialTimeoutMillis = options.initialTimeoutMillis; 94 | this.maxTimeoutMillis = options.maxTimeoutMillis; 95 | this.limit = options.limit; 96 | this.increaseTimeoutFunction = options.increaseTimeout; 97 | this.currentBackoffMillis = this.initialTimeoutMillis; 98 | } 99 | 100 | attemptRetry(error: any): RetryStrategyResult { 101 | this.logger.verbose(`${this.constructor.name}: Error received`, error); 102 | 103 | if (this.currentRetryCount >= this.limit && this.limit >= 0) { 104 | this.logger.verbose( 105 | `${this.constructor.name}: Retry count is over the maximum limit: ${ 106 | this.limit 107 | }`, 108 | ); 109 | return new DoNotRetry(error); 110 | } 111 | 112 | if (error == null) { 113 | return new Retry(this.calculateMillisToRetry()); 114 | } 115 | 116 | if (error instanceof ErrorResponse && error.headers['Retry-After']) { 117 | this.logger.verbose( 118 | `${this.constructor.name}: Retry-After header is present, retrying in ${ 119 | error.headers['Retry-After'] 120 | }`, 121 | ); 122 | return new Retry(parseInt(error.headers['Retry-After'], 10) * 1000); 123 | } 124 | 125 | if (this.retryUnsafeRequests) { 126 | return new Retry(this.calculateMillisToRetry()); 127 | } 128 | 129 | switch (error.constructor) { 130 | case ErrorResponse: 131 | const { statusCode, headers } = error; 132 | const requestMethod = headers['Request-Method']; 133 | 134 | if ( 135 | statusCode >= 500 && 136 | statusCode < 600 && 137 | requestMethodIsSafe(requestMethod) 138 | ) { 139 | this.logger.verbose( 140 | `${this.constructor.name}: Encountered an error with status code ${ 141 | statusCode 142 | } and request method ${requestMethod}, will retry`, 143 | ); 144 | return new Retry(this.calculateMillisToRetry()); 145 | } else { 146 | this.logger.verbose( 147 | `${this.constructor.name}: Encountered an error with status code ${ 148 | statusCode 149 | } and request method ${requestMethod}, will not retry`, 150 | error, 151 | ); 152 | return new DoNotRetry(error as any); 153 | } 154 | case NetworkError: 155 | this.logger.verbose( 156 | `${this.constructor.name}: Encountered a network error, will retry`, 157 | error, 158 | ); 159 | return new Retry(this.calculateMillisToRetry()); 160 | case ProtocolError: 161 | this.logger.verbose( 162 | `${this.constructor.name}: Encountered a protocol error, will retry`, 163 | error, 164 | ); 165 | return new Retry(this.calculateMillisToRetry()); 166 | default: 167 | this.logger.verbose( 168 | `${this.constructor.name}: Encountered an error, will retry`, 169 | error, 170 | ); 171 | 172 | return new Retry(this.calculateMillisToRetry()); 173 | } 174 | } 175 | 176 | private calculateMillisToRetry(): number { 177 | this.currentBackoffMillis = this.increaseTimeoutFunction( 178 | this.currentBackoffMillis, 179 | ); 180 | 181 | this.logger.verbose( 182 | `${this.constructor.name}: Retrying in ${this.currentBackoffMillis}ms`, 183 | ); 184 | return this.currentBackoffMillis; 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /src/retrying-subscription.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from './logger'; 2 | import { ElementsHeaders, ErrorResponse } from './network'; 3 | import { 4 | createRetryStrategyOptionsOrDefault, 5 | Retry, 6 | RetryResolution, 7 | RetryStrategyOptions, 8 | RetryStrategyResult, 9 | } from './retry-strategy'; 10 | import { 11 | SubscribeStrategy, 12 | SubscribeStrategyListeners, 13 | } from './subscribe-strategy'; 14 | import { 15 | Subscription, 16 | SubscriptionEvent, 17 | SubscriptionState, 18 | } from './subscription'; 19 | 20 | export let createRetryingStrategy: ( 21 | retryingOptions: RetryStrategyOptions, 22 | nextSubscribeStrategy: SubscribeStrategy, 23 | logger: Logger, 24 | ) => SubscribeStrategy = ( 25 | retryOptions, 26 | nextSubscribeStrategy, 27 | logger, 28 | ): SubscribeStrategy => { 29 | const enrichedRetryOptions = createRetryStrategyOptionsOrDefault( 30 | retryOptions, 31 | ); 32 | const retryResolution = new RetryResolution(enrichedRetryOptions, logger); 33 | return (listeners, headers) => 34 | new RetryingSubscription( 35 | logger, 36 | headers, 37 | listeners, 38 | nextSubscribeStrategy, 39 | retryResolution, 40 | ); 41 | }; 42 | 43 | class RetryingSubscription implements Subscription { 44 | private state: SubscriptionState; 45 | 46 | constructor( 47 | logger: Logger, 48 | headers: ElementsHeaders, 49 | listeners: SubscribeStrategyListeners, 50 | nextSubscribeStrategy: SubscribeStrategy, 51 | retryResolution: RetryResolution, 52 | ) { 53 | this.state = new OpeningSubscriptionState( 54 | this.onTransition, 55 | logger, 56 | headers, 57 | listeners, 58 | nextSubscribeStrategy, 59 | retryResolution, 60 | ); 61 | } 62 | 63 | unsubscribe = () => { 64 | this.state.unsubscribe(); 65 | }; 66 | 67 | private onTransition = (newState: SubscriptionState) => { 68 | this.state = newState; 69 | }; 70 | } 71 | 72 | class OpeningSubscriptionState implements SubscriptionState { 73 | private underlyingSubscription: Subscription; 74 | 75 | constructor( 76 | onTransition: (newState: SubscriptionState) => void, 77 | private logger: Logger, 78 | private headers: ElementsHeaders, 79 | private listeners: SubscribeStrategyListeners, 80 | private nextSubscribeStrategy: SubscribeStrategy, 81 | private retryResolution: RetryResolution, 82 | ) { 83 | logger.verbose( 84 | `RetryingSubscription: transitioning to OpeningSubscriptionState`, 85 | ); 86 | 87 | this.underlyingSubscription = nextSubscribeStrategy( 88 | { 89 | onEnd: error => 90 | onTransition(new EndedSubscriptionState(logger, listeners, error)), 91 | onError: error => 92 | onTransition( 93 | new RetryingSubscriptionState( 94 | error, 95 | onTransition, 96 | logger, 97 | headers, 98 | listeners, 99 | nextSubscribeStrategy, 100 | retryResolution, 101 | ), 102 | ), 103 | onEvent: listeners.onEvent, 104 | onOpen: subHeaders => 105 | onTransition( 106 | new OpenSubscriptionState( 107 | logger, 108 | listeners, 109 | subHeaders, 110 | this.underlyingSubscription, 111 | onTransition, 112 | ), 113 | ), 114 | onRetrying: listeners.onRetrying, 115 | }, 116 | headers, 117 | ); 118 | } 119 | 120 | unsubscribe() { 121 | this.underlyingSubscription.unsubscribe(); 122 | throw new Error('Method not implemented.'); 123 | } 124 | } 125 | 126 | class RetryingSubscriptionState implements SubscriptionState { 127 | private timeout: number = -1; 128 | 129 | constructor( 130 | error: any, 131 | private onTransition: (newState: SubscriptionState) => void, 132 | private logger: Logger, 133 | private headers: ElementsHeaders, 134 | private listeners: SubscribeStrategyListeners, 135 | private nextSubscribeStrategy: SubscribeStrategy, 136 | private retryResolution: RetryResolution, 137 | ) { 138 | logger.verbose( 139 | `RetryingSubscription: transitioning to RetryingSubscriptionState`, 140 | ); 141 | 142 | const executeSubscriptionOnce = (subError: any) => { 143 | listeners.onRetrying(); 144 | 145 | const resolveError: ( 146 | errToResolve: any, 147 | ) => RetryStrategyResult = errToResolve => { 148 | if (errToResolve instanceof ErrorResponse) { 149 | errToResolve.headers['Request-Method'] = 'SUBSCRIBE'; 150 | } 151 | return retryResolution.attemptRetry(errToResolve); 152 | }; 153 | 154 | const errorResolution = resolveError(subError); 155 | if (errorResolution instanceof Retry) { 156 | this.timeout = global.setTimeout(() => { 157 | executeNextSubscribeStrategy(); 158 | }, errorResolution.waitTimeMillis); 159 | } else { 160 | onTransition(new FailedSubscriptionState(logger, listeners, subError)); 161 | } 162 | }; 163 | 164 | const executeNextSubscribeStrategy = () => { 165 | logger.verbose( 166 | `RetryingSubscription: trying to re-establish the subscription`, 167 | ); 168 | 169 | const underlyingSubscription = nextSubscribeStrategy( 170 | { 171 | onEnd: endError => 172 | onTransition( 173 | new EndedSubscriptionState(logger, listeners, endError), 174 | ), 175 | onError: subError => executeSubscriptionOnce(subError), 176 | onEvent: listeners.onEvent, 177 | onOpen: openHeaders => { 178 | onTransition( 179 | new OpenSubscriptionState( 180 | logger, 181 | listeners, 182 | openHeaders, 183 | underlyingSubscription, 184 | onTransition, 185 | ), 186 | ); 187 | }, 188 | onRetrying: listeners.onRetrying, 189 | }, 190 | headers, 191 | ); 192 | }; 193 | 194 | executeSubscriptionOnce(error); 195 | } 196 | 197 | unsubscribe() { 198 | global.clearTimeout(this.timeout); 199 | this.onTransition(new EndedSubscriptionState(this.logger, this.listeners)); 200 | } 201 | } 202 | 203 | class OpenSubscriptionState implements SubscriptionState { 204 | constructor( 205 | private logger: Logger, 206 | private listeners: SubscribeStrategyListeners, 207 | private headers: ElementsHeaders, 208 | private underlyingSubscription: Subscription, 209 | private onTransition: (newState: SubscriptionState) => void, 210 | ) { 211 | logger.verbose( 212 | `RetryingSubscription: transitioning to OpenSubscriptionState`, 213 | ); 214 | listeners.onOpen(headers); 215 | } 216 | unsubscribe() { 217 | this.underlyingSubscription.unsubscribe(); 218 | this.onTransition(new EndedSubscriptionState(this.logger, this.listeners)); 219 | } 220 | } 221 | 222 | class EndedSubscriptionState implements SubscriptionState { 223 | constructor( 224 | private logger: Logger, 225 | private listeners: SubscribeStrategyListeners, 226 | error?: any, 227 | ) { 228 | logger.verbose( 229 | `RetryingSubscription: transitioning to EndedSubscriptionState`, 230 | ); 231 | listeners.onEnd(error); 232 | } 233 | unsubscribe() { 234 | throw new Error('Subscription has already ended'); 235 | } 236 | } 237 | 238 | class FailedSubscriptionState implements SubscriptionState { 239 | constructor( 240 | private logger: Logger, 241 | private listeners: SubscribeStrategyListeners, 242 | error?: any, 243 | ) { 244 | logger.verbose( 245 | `RetryingSubscription: transitioning to FailedSubscriptionState`, 246 | error, 247 | ); 248 | listeners.onError(error); 249 | } 250 | unsubscribe() { 251 | throw new Error('Subscription has already ended'); 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /src/subscribe-strategy.ts: -------------------------------------------------------------------------------- 1 | import { ElementsHeaders } from './network'; 2 | import { 3 | CompleteSubscriptionListeners, 4 | Subscription, 5 | SubscriptionConstructor, 6 | SubscriptionEvent, 7 | SubscriptionListeners, 8 | } from './subscription'; 9 | 10 | // Just like the top-level SubscriptionListeners, but all mandatory and without the onSubscribe callback. 11 | export interface SubscribeStrategyListeners { 12 | onEnd: (error: any) => void; 13 | onError: (error: any) => void; 14 | onEvent: (event: SubscriptionEvent) => void; 15 | onOpen: (headers: ElementsHeaders) => void; 16 | onRetrying: () => void; 17 | } 18 | 19 | export type SubscribeStrategy = ( 20 | listeners: SubscribeStrategyListeners, 21 | headers: ElementsHeaders, 22 | ) => Subscription; 23 | 24 | export let subscribeStrategyListenersFromSubscriptionListeners = ( 25 | subListeners: CompleteSubscriptionListeners, 26 | ): SubscribeStrategyListeners => { 27 | return { 28 | onEnd: subListeners.onEnd, 29 | onError: subListeners.onError, 30 | onEvent: subListeners.onEvent, 31 | onOpen: subListeners.onOpen, 32 | onRetrying: subListeners.onRetrying, 33 | }; 34 | }; 35 | -------------------------------------------------------------------------------- /src/subscription.ts: -------------------------------------------------------------------------------- 1 | import { ElementsHeaders } from './network'; 2 | 3 | export interface Subscription { 4 | unsubscribe(): void; 5 | } 6 | 7 | export interface SubscriptionListeners { 8 | onOpen?: (headers: ElementsHeaders) => void; 9 | onSubscribe?: () => void; 10 | onRetrying?: () => void; 11 | onEvent?: (event: SubscriptionEvent) => void; 12 | onError?: (error: any) => void; 13 | onEnd?: (error: any) => void; 14 | } 15 | 16 | export interface CompleteSubscriptionListeners { 17 | onOpen: (headers: ElementsHeaders) => void; 18 | onSubscribe: () => void; 19 | onRetrying: () => void; 20 | onEvent: (event: SubscriptionEvent) => void; 21 | onError: (error: any) => void; 22 | onEnd: (error: any) => void; 23 | } 24 | 25 | export interface SubscriptionState { 26 | unsubscribe(): void; 27 | } 28 | 29 | export interface SubscriptionEvent { 30 | eventId: string; 31 | headers: ElementsHeaders; 32 | body: any; 33 | } 34 | 35 | export interface SubscriptionTransport { 36 | subscribe( 37 | path: string, 38 | listeners: SubscriptionListeners, 39 | headers: ElementsHeaders, 40 | ): Subscription; 41 | } 42 | 43 | export type SubscriptionConstructor = ( 44 | onOpen: (headers: ElementsHeaders) => void, 45 | onError: (error: any) => void, 46 | onEvent: (event: SubscriptionEvent) => void, 47 | onEnd: (error: any) => void, 48 | headers: ElementsHeaders, 49 | ) => Subscription; 50 | 51 | export let replaceMissingListenersWithNoOps: ( 52 | listeners: SubscriptionListeners, 53 | ) => CompleteSubscriptionListeners = listeners => { 54 | /* tslint:disable:no-empty */ 55 | const onEndNoOp = (error: any) => {}; 56 | const onEnd = listeners.onEnd || onEndNoOp; 57 | 58 | const onErrorNoOp = (error: any) => {}; 59 | const onError = listeners.onError || onErrorNoOp; 60 | 61 | const onEventNoOp = (event: SubscriptionEvent) => {}; 62 | const onEvent = listeners.onEvent || onEventNoOp; 63 | 64 | const onOpenNoOp = (headers: ElementsHeaders) => {}; 65 | const onOpen = listeners.onOpen || onOpenNoOp; 66 | 67 | const onRetryingNoOp = () => {}; 68 | const onRetrying = listeners.onRetrying || onRetryingNoOp; 69 | 70 | const onSubscribeNoOp = () => {}; 71 | const onSubscribe = listeners.onSubscribe || onSubscribeNoOp; 72 | /* tslint:enable:no-empty */ 73 | 74 | return { 75 | onEnd, 76 | onError, 77 | onEvent, 78 | onOpen, 79 | onRetrying, 80 | onSubscribe, 81 | }; 82 | }; 83 | -------------------------------------------------------------------------------- /src/token-provider.ts: -------------------------------------------------------------------------------- 1 | export interface TokenProvider { 2 | fetchToken(tokenParams?: any): Promise; 3 | clearToken(token?: string): void; 4 | } 5 | -------------------------------------------------------------------------------- /src/token-providing-subscription.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from './logger'; 2 | import { ElementsHeaders, ErrorResponse } from './network'; 3 | import { 4 | SubscribeStrategy, 5 | SubscribeStrategyListeners, 6 | } from './subscribe-strategy'; 7 | import { 8 | Subscription, 9 | SubscriptionConstructor, 10 | SubscriptionEvent, 11 | SubscriptionListeners, 12 | } from './subscription'; 13 | import { TokenProvider } from './token-provider'; 14 | 15 | export let createTokenProvidingStrategy: ( 16 | nextSubscribeStrategy: SubscribeStrategy, 17 | logger: Logger, 18 | tokenProvider?: TokenProvider, 19 | ) => SubscribeStrategy = (nextSubscribeStrategy, logger, tokenProvider) => { 20 | // Token provider might not be provided. If missing, go straight to underlying subscribe strategy. 21 | if (tokenProvider) { 22 | return (listeners, headers) => 23 | new TokenProvidingSubscription( 24 | logger, 25 | listeners, 26 | headers, 27 | tokenProvider, 28 | nextSubscribeStrategy, 29 | ); 30 | } 31 | return nextSubscribeStrategy; 32 | }; 33 | 34 | interface TokenProvidingSubscriptionState { 35 | subscribe(token: string, listeners: SubscriptionListeners): void; 36 | unsubscribe(): void; 37 | } 38 | 39 | class TokenProvidingSubscription implements Subscription { 40 | private state: TokenProvidingSubscriptionState; 41 | 42 | constructor( 43 | private logger: Logger, 44 | private listeners: SubscribeStrategyListeners, 45 | private headers: ElementsHeaders, 46 | private tokenProvider: TokenProvider, 47 | private nextSubscribeStrategy: SubscribeStrategy, 48 | ) { 49 | this.state = new ActiveState(logger, headers, nextSubscribeStrategy); 50 | this.subscribe(); 51 | } 52 | 53 | unsubscribe = () => { 54 | this.state.unsubscribe(); 55 | this.state = new InactiveState(this.logger); 56 | }; 57 | 58 | private subscribe(): void { 59 | this.tokenProvider 60 | .fetchToken() 61 | .then(token => { 62 | const existingListeners = Object.assign({}, this.listeners); 63 | this.state.subscribe(token, { 64 | onEnd: (error: any) => { 65 | this.state = new InactiveState(this.logger); 66 | existingListeners.onEnd(error); 67 | }, 68 | onError: (error: any) => { 69 | if (this.isTokenExpiredError(error)) { 70 | this.tokenProvider.clearToken(token); 71 | this.subscribe(); 72 | } else { 73 | this.state = new InactiveState(this.logger); 74 | existingListeners.onError(error); 75 | } 76 | }, 77 | onEvent: this.listeners.onEvent, 78 | onOpen: this.listeners.onOpen, 79 | }); 80 | }) 81 | .catch((error: any) => { 82 | this.logger.debug( 83 | 'TokenProvidingSubscription: error when fetching token:', 84 | error, 85 | ); 86 | this.state = new InactiveState(this.logger); 87 | this.listeners.onError(error); 88 | }); 89 | } 90 | 91 | private isTokenExpiredError(error: any): boolean { 92 | return ( 93 | error instanceof ErrorResponse && 94 | error.statusCode === 401 && 95 | error.info === 'authentication/expired' 96 | ); 97 | } 98 | } 99 | 100 | class ActiveState implements TokenProvidingSubscriptionState { 101 | private underlyingSubscription!: Subscription; 102 | 103 | constructor( 104 | private logger: Logger, 105 | private headers: ElementsHeaders, 106 | private nextSubscribeStrategy: SubscribeStrategy, 107 | ) { 108 | logger.verbose('TokenProvidingSubscription: transitioning to ActiveState'); 109 | } 110 | 111 | subscribe(token: string, listeners: SubscribeStrategyListeners): void { 112 | this.putTokenIntoHeader(token); 113 | this.underlyingSubscription = this.nextSubscribeStrategy( 114 | { 115 | onEnd: error => { 116 | this.logger.verbose('TokenProvidingSubscription: subscription ended'); 117 | listeners.onEnd(error); 118 | }, 119 | onError: error => { 120 | this.logger.verbose( 121 | 'TokenProvidingSubscription: subscription errored:', 122 | error, 123 | ); 124 | listeners.onError(error); 125 | }, 126 | onEvent: listeners.onEvent, 127 | onOpen: headers => { 128 | this.logger.verbose( 129 | `TokenProvidingSubscription: subscription opened`, 130 | ); 131 | listeners.onOpen(headers); 132 | }, 133 | onRetrying: listeners.onRetrying, 134 | }, 135 | this.headers, 136 | ); 137 | } 138 | 139 | unsubscribe() { 140 | if (this.underlyingSubscription != null) { 141 | this.underlyingSubscription.unsubscribe(); 142 | } 143 | } 144 | 145 | private putTokenIntoHeader(token: string) { 146 | // tslint:disable-next-line:no-string-literal 147 | this.headers['Authorization'] = `Bearer ${token}`; 148 | this.logger.verbose(`TokenProvidingSubscription: token fetched: ${token}`); 149 | } 150 | } 151 | 152 | class InactiveState implements TokenProvidingSubscriptionState { 153 | constructor(private logger: Logger) { 154 | logger.verbose( 155 | 'TokenProvidingSubscription: transitioning to InactiveState', 156 | ); 157 | } 158 | 159 | subscribe(token: string, listeners: SubscribeStrategyListeners): void { 160 | this.logger.verbose( 161 | 'TokenProvidingSubscription: subscribe called in Inactive state; doing nothing', 162 | ); 163 | } 164 | 165 | unsubscribe(): void { 166 | this.logger.verbose( 167 | 'TokenProvidingSubscription: unsubscribe called in Inactive state; doing nothing', 168 | ); 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/transport/http.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '../logger'; 2 | import { 3 | ElementsHeaders, 4 | ErrorResponse, 5 | NetworkError, 6 | responseToHeadersObject, 7 | XhrReadyState, 8 | } from '../network'; 9 | import { RequestOptions } from '../request'; 10 | import { 11 | Subscription, 12 | SubscriptionEvent, 13 | SubscriptionListeners, 14 | SubscriptionTransport, 15 | } from '../subscription'; 16 | 17 | export enum HttpTransportState { 18 | UNOPENED = 0, // haven't called xhr.send() 19 | OPENING, // called xhr.send(); not yet received response headers 20 | OPEN, // received response headers; called onOpen(); expecting message 21 | ENDING, // received EOS message; response not yet finished 22 | ENDED, // called onEnd() or onError(err) 23 | } 24 | 25 | class HttpSubscription implements Subscription { 26 | private gotEOS: boolean = false; 27 | private lastNewlineIndex: number = 0; 28 | private listeners: SubscriptionListeners; 29 | private state: HttpTransportState = HttpTransportState.UNOPENED; 30 | private xhr: XMLHttpRequest; 31 | 32 | constructor(xhr: XMLHttpRequest, listeners: SubscriptionListeners) { 33 | this.xhr = xhr; 34 | this.listeners = listeners; 35 | 36 | this.xhr.onreadystatechange = () => { 37 | switch (this.xhr.readyState) { 38 | case XhrReadyState.UNSENT: 39 | case XhrReadyState.OPENED: 40 | case XhrReadyState.HEADERS_RECEIVED: 41 | this.assertStateIsIn(HttpTransportState.OPENING); 42 | break; 43 | 44 | case XhrReadyState.LOADING: 45 | this.onLoading(); 46 | break; 47 | 48 | case XhrReadyState.DONE: 49 | this.onDone(); 50 | break; 51 | } 52 | }; 53 | this.state = HttpTransportState.OPENING; 54 | this.xhr.send(); 55 | 56 | return this; 57 | } 58 | 59 | unsubscribe(): void { 60 | this.state = HttpTransportState.ENDED; 61 | this.xhr.abort(); 62 | if (this.listeners.onEnd) { 63 | this.listeners.onEnd(null); 64 | } 65 | } 66 | 67 | private onLoading(): void { 68 | this.assertStateIsIn( 69 | HttpTransportState.OPENING, 70 | HttpTransportState.OPEN, 71 | HttpTransportState.ENDING, 72 | ); 73 | 74 | if (this.xhr.status === 200) { 75 | // Check if we just transitioned to the open state 76 | if (this.state === HttpTransportState.OPENING) { 77 | this.state = HttpTransportState.OPEN; 78 | if (this.listeners.onOpen) { 79 | this.listeners.onOpen( 80 | responseToHeadersObject(this.xhr.getAllResponseHeaders()), 81 | ); 82 | } 83 | } 84 | 85 | this.assertStateIsIn(HttpTransportState.OPEN); 86 | const err = this.onChunk(); // might transition our state from OPEN -> ENDING 87 | this.assertStateIsIn(HttpTransportState.OPEN, HttpTransportState.ENDING); 88 | 89 | if (err) { 90 | this.state = HttpTransportState.ENDED; 91 | if (err instanceof ErrorResponse && err.statusCode !== 204) { 92 | if (this.listeners.onError) { 93 | this.listeners.onError(err); 94 | } 95 | } 96 | // Because we abort()ed, we will get no more calls to our onreadystatechange handler, 97 | // and so we will not call the event handler again. 98 | // Finish with options.onError instead of the options.onEnd. 99 | } else { 100 | // We consumed some response text, and all's fine. We expect more text. 101 | } 102 | } 103 | } 104 | 105 | private onDone(): void { 106 | if (this.xhr.status === 200) { 107 | if (this.state === HttpTransportState.OPENING) { 108 | this.state = HttpTransportState.OPEN; 109 | if (this.listeners.onOpen) { 110 | this.listeners.onOpen( 111 | responseToHeadersObject(this.xhr.getAllResponseHeaders()), 112 | ); 113 | } 114 | } 115 | this.assertStateIsIn(HttpTransportState.OPEN, HttpTransportState.ENDING); 116 | const err = this.onChunk(); 117 | if (err) { 118 | this.state = HttpTransportState.ENDED; 119 | if ((err as any).statusCode === 204) { 120 | // TODO: That cast is horrific 121 | if (this.listeners.onEnd) { 122 | this.listeners.onEnd(null); 123 | } 124 | } else { 125 | if (this.listeners.onError) { 126 | this.listeners.onError(err); 127 | } 128 | } 129 | } else if (this.state <= HttpTransportState.ENDING) { 130 | if (this.listeners.onError) { 131 | this.listeners.onError( 132 | new Error('HTTP response ended without receiving EOS message'), 133 | ); 134 | } 135 | } else { 136 | // Stream ended normally. 137 | if (this.listeners.onEnd) { 138 | this.listeners.onEnd(null); 139 | } 140 | } 141 | } else { 142 | this.assertStateIsIn( 143 | HttpTransportState.OPENING, 144 | HttpTransportState.OPEN, 145 | HttpTransportState.ENDED, 146 | ); 147 | 148 | if (this.state === HttpTransportState.ENDED) { 149 | // We aborted the request deliberately, and called onError/onEnd elsewhere. 150 | return; 151 | } else if (this.xhr.status === 0) { 152 | // Something terrible has happened. Most likely a network error. XHR is useless at that point. 153 | if (this.listeners.onError) { 154 | this.listeners.onError(new NetworkError('Connection lost.')); 155 | } 156 | } else { 157 | if (this.listeners.onError) { 158 | this.listeners.onError(ErrorResponse.fromXHR(this.xhr)); 159 | } 160 | } 161 | } 162 | } 163 | 164 | private onChunk(): Error | undefined { 165 | this.assertStateIsIn(HttpTransportState.OPEN); 166 | const response = this.xhr.responseText; 167 | const newlineIndex = response.lastIndexOf('\n'); 168 | if (newlineIndex > this.lastNewlineIndex) { 169 | const rawEvents = response 170 | .slice(this.lastNewlineIndex, newlineIndex) 171 | .split('\n'); 172 | this.lastNewlineIndex = newlineIndex; 173 | 174 | for (const rawEvent of rawEvents) { 175 | if (rawEvent.length === 0) { 176 | continue; // FIXME why? This should be a protocol error 177 | } 178 | const data = JSON.parse(rawEvent); 179 | const err = this.onMessage(data); 180 | if (err != null) { 181 | return err; 182 | } 183 | } 184 | } 185 | } 186 | 187 | private assertStateIsIn(...validStates: HttpTransportState[]) { 188 | const stateIsValid = validStates.some( 189 | validState => validState === this.state, 190 | ); 191 | if (!stateIsValid) { 192 | const expectedStates = validStates 193 | .map(state => HttpTransportState[state]) 194 | .join(', '); 195 | const actualState = HttpTransportState[this.state]; 196 | global.console.warn( 197 | `Expected this.state to be one of [${expectedStates}] but it is ${ 198 | actualState 199 | }`, 200 | ); 201 | } 202 | } 203 | 204 | /** 205 | * Calls options.onEvent 0+ times, then returns an Error or null 206 | * Also asserts the message is formatted correctly and we're in an allowed state (not terminated). 207 | */ 208 | private onMessage(message: any[]): Error | null { 209 | this.assertStateIsIn(HttpTransportState.OPEN); 210 | this.verifyMessage(message); 211 | 212 | switch (message[0]) { 213 | case 0: 214 | return null; 215 | case 1: 216 | return this.onEventMessage(message); 217 | case 255: 218 | return this.onEOSMessage(message); 219 | default: 220 | return new Error('Unknown Message: ' + JSON.stringify(message)); 221 | } 222 | } 223 | 224 | // EITHER calls options.onEvent, OR returns an error 225 | private onEventMessage(eventMessage: any[]): Error | null { 226 | this.assertStateIsIn(HttpTransportState.OPEN); 227 | 228 | if (eventMessage.length !== 4) { 229 | return new Error( 230 | 'Event message has ' + eventMessage.length + ' elements (expected 4)', 231 | ); 232 | } 233 | const [_, id, headers, body] = eventMessage; 234 | if (typeof id !== 'string') { 235 | return new Error( 236 | 'Invalid event ID in message: ' + JSON.stringify(eventMessage), 237 | ); 238 | } 239 | if (typeof headers !== 'object' || Array.isArray(headers)) { 240 | return new Error( 241 | 'Invalid event headers in message: ' + JSON.stringify(eventMessage), 242 | ); 243 | } 244 | 245 | if (this.listeners.onEvent) { 246 | this.listeners.onEvent({ body, headers, eventId: id }); 247 | } 248 | return null; 249 | } 250 | 251 | /** 252 | * EOS message received. Sets subscription state to Ending and returns an error with given status code 253 | * @param eosMessage final message of the subscription 254 | */ 255 | 256 | private onEOSMessage(eosMessage: any[]): any { 257 | this.assertStateIsIn(HttpTransportState.OPEN); 258 | 259 | if (eosMessage.length !== 4) { 260 | return new Error( 261 | 'EOS message has ' + eosMessage.length + ' elements (expected 4)', 262 | ); 263 | } 264 | const [_, statusCode, headers, info] = eosMessage; 265 | if (typeof statusCode !== 'number') { 266 | return new Error('Invalid EOS Status Code'); 267 | } 268 | if (typeof headers !== 'object' || Array.isArray(headers)) { 269 | return new Error('Invalid EOS ElementsHeaders'); 270 | } 271 | 272 | this.state = HttpTransportState.ENDING; 273 | return new ErrorResponse(statusCode, headers, info); 274 | } 275 | 276 | /** 277 | * Check if a single subscription message is in the right format. 278 | * @param message The message to check. 279 | * @returns null or error if the message is wrong. 280 | */ 281 | private verifyMessage(message: any[]) { 282 | if (this.gotEOS) { 283 | return new Error('Got another message after EOS message'); 284 | } 285 | if (!Array.isArray(message)) { 286 | return new Error('Message is not an array'); 287 | } 288 | if (message.length < 1) { 289 | return new Error('Message is empty array'); 290 | } 291 | } 292 | } 293 | 294 | export default class HttpTransport implements SubscriptionTransport { 295 | private baseURL: string; 296 | 297 | constructor(host: string, encrypted = true) { 298 | this.baseURL = `${encrypted ? 'https' : 'http'}://${host}`; 299 | } 300 | 301 | request(requestOptions: RequestOptions): XMLHttpRequest { 302 | return this.createXHR(this.baseURL, requestOptions); 303 | } 304 | 305 | subscribe( 306 | path: string, 307 | listeners: SubscriptionListeners, 308 | headers: ElementsHeaders, 309 | ): Subscription { 310 | const requestOptions: RequestOptions = { 311 | headers, 312 | method: 'SUBSCRIBE', 313 | path, 314 | }; 315 | 316 | return new HttpSubscription( 317 | this.createXHR(this.baseURL, requestOptions), 318 | listeners, 319 | ); 320 | } 321 | 322 | private createXHR(baseURL: string, options: RequestOptions): XMLHttpRequest { 323 | let xhr = new global.XMLHttpRequest(); 324 | const path = options.path.replace(/^\/+/, ''); 325 | const endpoint = `${baseURL}/${path}`; 326 | xhr.open(options.method.toUpperCase(), endpoint, true); 327 | xhr = this.setJSONHeaderIfAppropriate(xhr, options); 328 | if (options.jwt) { 329 | xhr.setRequestHeader('authorization', `Bearer ${options.jwt}`); 330 | } 331 | 332 | if (options.headers) { 333 | for (const key in options.headers) { 334 | if (options.headers.hasOwnProperty(key)) { 335 | xhr.setRequestHeader(key, options.headers[key]); 336 | } 337 | } 338 | } 339 | return xhr; 340 | } 341 | 342 | private setJSONHeaderIfAppropriate( 343 | xhr: XMLHttpRequest, 344 | options: any, 345 | ): XMLHttpRequest { 346 | if (options.json) { 347 | xhr.setRequestHeader('content-type', 'application/json'); 348 | } 349 | return xhr; 350 | } 351 | } 352 | -------------------------------------------------------------------------------- /src/transport/websocket.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '../logger'; 2 | import { 3 | ElementsHeaders, 4 | ErrorResponse, 5 | NetworkError, 6 | ProtocolError, 7 | responseToHeadersObject, 8 | } from '../network'; 9 | import { 10 | Subscription, 11 | SubscriptionEvent, 12 | SubscriptionListeners, 13 | SubscriptionTransport, 14 | } from '../subscription'; 15 | 16 | type WsMessageType = number; 17 | type Message = any[]; 18 | 19 | const SubscribeMessageType: WsMessageType = 100; 20 | const OpenMessageType: WsMessageType = 101; 21 | const EventMessageType: WsMessageType = 102; 22 | const UnsubscribeMessageType: WsMessageType = 198; 23 | const EosMessageType: WsMessageType = 199; 24 | const PingMessageType: WsMessageType = 16; 25 | const PongMessageType: WsMessageType = 17; 26 | const CloseMessageType: WsMessageType = 99; 27 | 28 | export enum WSReadyState { 29 | Connecting = 0, 30 | Open, 31 | Closing, 32 | Closed, 33 | } 34 | 35 | type SubscriptionData = { 36 | path: string; 37 | listeners: SubscriptionListeners; 38 | headers: ElementsHeaders; 39 | subID?: number; 40 | }; 41 | 42 | type WsSubscriptionsType = { 43 | [key: number]: SubscriptionData; 44 | }; 45 | 46 | class WsSubscriptions { 47 | private subscriptions: WsSubscriptionsType; 48 | 49 | constructor() { 50 | this.subscriptions = {}; 51 | } 52 | 53 | add( 54 | subID: number, 55 | path: string, 56 | listeners: SubscriptionListeners, 57 | headers: ElementsHeaders, 58 | ): number { 59 | this.subscriptions[subID] = { 60 | headers, 61 | listeners, 62 | path, 63 | }; 64 | 65 | return subID; 66 | } 67 | 68 | has(subID: number): boolean { 69 | return this.subscriptions[subID] !== undefined; 70 | } 71 | 72 | isEmpty(): boolean { 73 | return Object.keys(this.subscriptions).length === 0; 74 | } 75 | 76 | remove(subID: number): boolean { 77 | return delete this.subscriptions[subID]; 78 | } 79 | 80 | get(subID: number): SubscriptionData { 81 | return this.subscriptions[subID]; 82 | } 83 | 84 | getAll(): WsSubscriptionsType { 85 | return this.subscriptions; 86 | } 87 | 88 | getAllAsArray(): SubscriptionData[] { 89 | return Object.keys(this.subscriptions).map(subID => ({ 90 | subID: parseInt(subID, 10), 91 | ...this.subscriptions[parseInt(subID, 10)], 92 | })); 93 | } 94 | 95 | removeAll() { 96 | this.subscriptions = {}; 97 | } 98 | } 99 | 100 | class WsSubscription implements Subscription { 101 | constructor(private wsTransport: WebSocketTransport, private subID: number) {} 102 | 103 | unsubscribe() { 104 | this.wsTransport.unsubscribe(this.subID); 105 | } 106 | } 107 | 108 | const pingIntervalMs: number = 30000; 109 | const pingTimeoutMs: number = 10000; 110 | 111 | export default class WebSocketTransport implements SubscriptionTransport { 112 | private baseURL: string; 113 | private webSocketPath: string = '/ws'; 114 | private socket!: WebSocket; 115 | private forcedClose: boolean = false; 116 | private closedError: any = null; 117 | private lastSubscriptionID: number; 118 | private subscriptions: WsSubscriptions; 119 | private pendingSubscriptions: WsSubscriptions; 120 | private lastMessageReceivedTimestamp!: number; 121 | private pingInterval: any; 122 | private pongTimeout: any; 123 | private lastSentPingID: number | null = null; 124 | private logger: Logger; 125 | 126 | constructor(host: string, logger: Logger) { 127 | this.baseURL = `wss://${host}${this.webSocketPath}`; 128 | this.lastSubscriptionID = 0; 129 | this.logger = logger; 130 | this.subscriptions = new WsSubscriptions(); 131 | this.pendingSubscriptions = new WsSubscriptions(); 132 | 133 | this.connect(); 134 | } 135 | 136 | subscribe( 137 | path: string, 138 | listeners: SubscriptionListeners, 139 | headers: ElementsHeaders, 140 | ): Subscription { 141 | // If connection was closed, try to reconnect 142 | this.tryReconnectIfNeeded(); 143 | 144 | const subID = this.lastSubscriptionID++; 145 | 146 | // Add subscription to pending if socket is not open 147 | if (this.socket.readyState !== WSReadyState.Open) { 148 | this.pendingSubscriptions.add(subID, path, listeners, headers); 149 | return new WsSubscription(this, subID); 150 | } 151 | 152 | // Add or select subscription 153 | this.subscriptions.add(subID, path, listeners, headers); 154 | 155 | this.sendMessage( 156 | this.getMessage(SubscribeMessageType, subID, path, headers), 157 | ); 158 | 159 | return new WsSubscription(this, subID); 160 | } 161 | 162 | unsubscribe(subID: number): void { 163 | this.sendMessage(this.getMessage(UnsubscribeMessageType, subID)); 164 | 165 | const subscription = this.subscriptions.get(subID); 166 | if (subscription.listeners.onEnd) { 167 | subscription.listeners.onEnd(null); 168 | } 169 | this.subscriptions.remove(subID); 170 | } 171 | 172 | private connect() { 173 | this.forcedClose = false; 174 | this.closedError = null; 175 | 176 | this.socket = new global.WebSocket(this.baseURL); 177 | 178 | this.socket.onopen = (event: any) => { 179 | const allPendingSubscriptions = this.pendingSubscriptions.getAllAsArray(); 180 | 181 | // Re-subscribe old subscriptions for new connection 182 | allPendingSubscriptions.forEach(subscription => { 183 | const { subID, path, listeners, headers } = subscription; 184 | this.subscribePending(path, listeners, headers, subID); 185 | }); 186 | 187 | this.pendingSubscriptions.removeAll(); 188 | 189 | this.pingInterval = global.setInterval(() => { 190 | if (this.pongTimeout) { 191 | return; 192 | } 193 | 194 | const now = new Date().getTime(); 195 | 196 | if (pingTimeoutMs > now - this.lastMessageReceivedTimestamp) { 197 | return; 198 | } 199 | 200 | this.sendMessage(this.getMessage(PingMessageType, now)); 201 | 202 | this.lastSentPingID = now; 203 | 204 | this.pongTimeout = global.setTimeout(() => { 205 | const pongNow = new Date().getTime(); 206 | 207 | if (pingTimeoutMs > pongNow - this.lastMessageReceivedTimestamp) { 208 | this.pongTimeout = null; 209 | return; 210 | } 211 | 212 | this.close( 213 | new NetworkError(`Pong response wasn't received within timeout`), 214 | ); 215 | }, pingTimeoutMs); 216 | }, pingIntervalMs); 217 | }; 218 | 219 | this.socket.onmessage = (event: any) => this.receiveMessage(event); 220 | this.socket.onerror = (event: any) => { 221 | this.logger.verbose('WebSocket encountered an error event', event); 222 | }; 223 | this.socket.onclose = (event: any) => { 224 | this.logger.verbose('WebSocket encountered a close event', event); 225 | 226 | const allSubscriptions = this.subscriptions 227 | .getAllAsArray() 228 | .concat(this.pendingSubscriptions.getAllAsArray()); 229 | 230 | this.subscriptions.removeAll(); 231 | this.pendingSubscriptions.removeAll(); 232 | 233 | allSubscriptions.forEach(sub => { 234 | if (sub.listeners.onError) { 235 | sub.listeners.onError(this.closedError); 236 | } 237 | }); 238 | 239 | this.tryReconnectIfNeeded(); 240 | }; 241 | } 242 | 243 | private close(error?: any) { 244 | if (!(this.socket instanceof global.WebSocket)) { 245 | return; 246 | } 247 | 248 | /* tslint:disable-next-line:no-empty */ 249 | let onClose = (event?: any) => {}; 250 | // In Chrome there is a substantial delay between calling close on a broken 251 | // websocket and the onclose method firing. When we're force closing the 252 | // connection we can expedite the reconnect process by manually calling 253 | // onclose. We then need to delete the socket's handlers so that we don't 254 | // get extra calls from the dying socket. Calling bind here means we get 255 | // a copy of the onclose callback, rather than a reference to it. 256 | if (this.socket.onclose != null) { 257 | onClose = this.socket.onclose.bind(this); 258 | } 259 | 260 | // Set all callbacks to be noops because we don't care about them anymore. 261 | // We need to set the callbacks to new values because just calling delete 262 | // doesn't seem to actually remove the property on the socket, and so 263 | // the onclose callback would end up being called twice, leading to sad 264 | // times. 265 | /* tslint:disable:no-empty */ 266 | this.socket.onclose = () => {}; 267 | this.socket.onerror = () => {}; 268 | this.socket.onmessage = () => {}; 269 | this.socket.onopen = () => {}; 270 | /* tslint:enable:no-empty */ 271 | 272 | this.forcedClose = true; 273 | this.closedError = error; 274 | this.socket.close(); 275 | 276 | global.clearTimeout(this.pingInterval); 277 | global.clearTimeout(this.pongTimeout); 278 | this.pongTimeout = null; 279 | this.pingInterval = null; 280 | this.lastSentPingID = null; 281 | 282 | onClose(); 283 | } 284 | 285 | private tryReconnectIfNeeded() { 286 | // If we've force closed, the socket might not actually be in the Closed 287 | // state yet but we should create a new one anyway. 288 | if (this.forcedClose || this.socket.readyState === WSReadyState.Closed) { 289 | this.connect(); 290 | } 291 | } 292 | 293 | private subscribePending( 294 | path: string, 295 | listeners: SubscriptionListeners, 296 | headers: ElementsHeaders, 297 | subID?: number, 298 | ) { 299 | if (subID === undefined) { 300 | this.logger.debug(`Subscription to path ${path} has an undefined ID`); 301 | return; 302 | } 303 | 304 | // Add or select subscription 305 | this.subscriptions.add(subID, path, listeners, headers); 306 | 307 | this.sendMessage( 308 | this.getMessage(SubscribeMessageType, subID, path, headers), 309 | ); 310 | } 311 | 312 | private getMessage( 313 | messageType: WsMessageType, 314 | id: number, 315 | path?: string, 316 | headers?: ElementsHeaders, 317 | ): Message { 318 | return [messageType, id, path, headers]; 319 | } 320 | 321 | private sendMessage(message: Message) { 322 | if (this.socket.readyState !== WSReadyState.Open) { 323 | this.logger.warn( 324 | `Can't send on socket in "${ 325 | WSReadyState[this.socket.readyState] 326 | }" state`, 327 | ); 328 | return; 329 | } 330 | 331 | this.socket.send(JSON.stringify(message)); 332 | } 333 | 334 | private subscription(subID: number) { 335 | return this.subscriptions.get(subID); 336 | } 337 | 338 | private receiveMessage(event: any) { 339 | this.lastMessageReceivedTimestamp = new Date().getTime(); 340 | 341 | // First try to parse event to JSON message. 342 | let message: Message; 343 | try { 344 | message = JSON.parse(event.data); 345 | } catch (err) { 346 | this.close( 347 | new ProtocolError( 348 | `Message is not valid JSON format. Getting ${event.data}`, 349 | ), 350 | ); 351 | return; 352 | } 353 | 354 | // Validate structure of message. 355 | // Close connection if not valid. 356 | const nonValidMessageError = this.validateMessage(message); 357 | if (nonValidMessageError) { 358 | this.close(nonValidMessageError); 359 | return; 360 | } 361 | 362 | const messageType = message.shift(); 363 | 364 | // Try to handle connection level messages first 365 | switch (messageType) { 366 | case PongMessageType: 367 | this.onPongMessage(message); 368 | return; 369 | case PingMessageType: 370 | this.onPingMessage(message); 371 | return; 372 | case CloseMessageType: 373 | this.onCloseMessage(message); 374 | return; 375 | } 376 | 377 | const subID = message.shift(); 378 | const subscription = this.subscription(subID); 379 | 380 | if (!subscription) { 381 | this.logger.debug(`Received message for unknown subscription ID: ${subID}`); 382 | return; 383 | } 384 | 385 | const { listeners } = subscription; 386 | 387 | // Handle subscription level messages. 388 | switch (messageType) { 389 | case OpenMessageType: 390 | this.onOpenMessage(message, subID, listeners); 391 | break; 392 | case EventMessageType: 393 | this.onEventMessage(message, listeners); 394 | break; 395 | case EosMessageType: 396 | this.onEOSMessage(message, subID, listeners); 397 | break; 398 | default: 399 | this.close(new ProtocolError('Received unknown type of message.')); 400 | } 401 | } 402 | 403 | /** 404 | * Check if a single subscription message is in the right format. 405 | * @param message The message to check. 406 | * @returns null or error if the message is wrong. 407 | */ 408 | private validateMessage(message: Message): ProtocolError | null { 409 | if (!Array.isArray(message)) { 410 | return new ProtocolError( 411 | `Message is expected to be an array. Getting: ${JSON.stringify( 412 | message, 413 | )}`, 414 | ); 415 | } 416 | 417 | if (message.length < 1) { 418 | return new ProtocolError( 419 | `Message is empty array: ${JSON.stringify(message)}`, 420 | ); 421 | } 422 | 423 | return null; 424 | } 425 | 426 | private onOpenMessage( 427 | message: Message, 428 | subID: number, 429 | subscriptionListeners: SubscriptionListeners, 430 | ) { 431 | if (subscriptionListeners.onOpen) { 432 | subscriptionListeners.onOpen(message[1]); 433 | } 434 | } 435 | 436 | private onEventMessage( 437 | eventMessage: Message, 438 | subscriptionListeners: SubscriptionListeners, 439 | ) { 440 | if (eventMessage.length !== 3) { 441 | if (subscriptionListeners.onError) { 442 | subscriptionListeners.onError( 443 | new ProtocolError( 444 | 'Event message has ' + 445 | eventMessage.length + 446 | ' elements (expected 4)', 447 | ), 448 | ); 449 | } 450 | return; 451 | } 452 | 453 | const [eventId, headers, body] = eventMessage; 454 | if (typeof eventId !== 'string') { 455 | if (subscriptionListeners.onError) { 456 | subscriptionListeners.onError( 457 | new ProtocolError( 458 | `Invalid event ID in message: ${JSON.stringify(eventMessage)}`, 459 | ), 460 | ); 461 | } 462 | return; 463 | } 464 | 465 | if (typeof headers !== 'object' || Array.isArray(headers)) { 466 | if (subscriptionListeners.onError) { 467 | subscriptionListeners.onError( 468 | new ProtocolError( 469 | `Invalid event headers in message: ${JSON.stringify(eventMessage)}`, 470 | ), 471 | ); 472 | } 473 | return; 474 | } 475 | 476 | if (subscriptionListeners.onEvent) { 477 | subscriptionListeners.onEvent({ eventId, headers, body }); 478 | } 479 | } 480 | 481 | private onEOSMessage( 482 | eosMessage: Message, 483 | subID: number, 484 | subscriptionListeners: SubscriptionListeners, 485 | ) { 486 | this.subscriptions.remove(subID); 487 | 488 | if (eosMessage.length !== 3) { 489 | if (subscriptionListeners.onError) { 490 | subscriptionListeners.onError( 491 | new ProtocolError( 492 | `EOS message has ${eosMessage.length} elements (expected 4)`, 493 | ), 494 | ); 495 | } 496 | return; 497 | } 498 | 499 | const [statusCode, headers, body] = eosMessage; 500 | if (typeof statusCode !== 'number') { 501 | if (subscriptionListeners.onError) { 502 | subscriptionListeners.onError( 503 | new ProtocolError('Invalid EOS Status Code'), 504 | ); 505 | } 506 | return; 507 | } 508 | 509 | if (typeof headers !== 'object' || Array.isArray(headers)) { 510 | if (subscriptionListeners.onError) { 511 | subscriptionListeners.onError( 512 | new ProtocolError('Invalid EOS ElementsHeaders'), 513 | ); 514 | } 515 | return; 516 | } 517 | 518 | if (statusCode === 204) { 519 | if (subscriptionListeners.onEnd) { 520 | subscriptionListeners.onEnd(null); 521 | } 522 | return; 523 | } 524 | 525 | if (subscriptionListeners.onError) { 526 | subscriptionListeners.onError( 527 | new ErrorResponse(statusCode, headers, body), 528 | ); 529 | } 530 | 531 | return; 532 | } 533 | 534 | private onCloseMessage(closeMessage: Message) { 535 | const [statusCode, headers, body] = closeMessage; 536 | if (typeof statusCode !== 'number') { 537 | this.close(new ProtocolError('Close message: Invalid EOS Status Code')); 538 | return; 539 | } 540 | 541 | if (typeof headers !== 'object' || Array.isArray(headers)) { 542 | this.close( 543 | new ProtocolError('Close message: Invalid EOS ElementsHeaders'), 544 | ); 545 | return; 546 | } 547 | 548 | const errorInfo = { 549 | error: body.error || 'network_error', 550 | error_description: body.error_description || 'Network error', 551 | }; 552 | 553 | this.close(new ErrorResponse(statusCode, headers, errorInfo)); 554 | } 555 | 556 | private onPongMessage(message: Message) { 557 | const [receviedPongID] = message; 558 | 559 | if (this.lastSentPingID !== receviedPongID) { 560 | this.close( 561 | new ProtocolError( 562 | `Received pong with ID ${receviedPongID} but last sent ping ID was ${ 563 | this.lastSentPingID 564 | }`, 565 | ), 566 | ); 567 | } 568 | 569 | global.clearTimeout(this.pongTimeout); 570 | delete this.pongTimeout; 571 | this.lastSentPingID = null; 572 | } 573 | 574 | private onPingMessage(message: Message) { 575 | const [receviedPingID] = message; 576 | 577 | this.sendMessage(this.getMessage(PongMessageType, receviedPingID)); 578 | } 579 | } 580 | -------------------------------------------------------------------------------- /src/transports.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from './logger'; 2 | import { ElementsHeaders } from './network'; 3 | import { SubscribeStrategy } from './subscribe-strategy'; 4 | import { SubscriptionTransport } from './subscription'; 5 | 6 | export let createTransportStrategy: ( 7 | path: string, 8 | transport: SubscriptionTransport, 9 | logger: Logger, 10 | ) => SubscribeStrategy = (path, transport, logger) => { 11 | return (listeners, headers) => transport.subscribe(path, listeners, headers); 12 | }; 13 | -------------------------------------------------------------------------------- /test/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pusher/pusher-platform-js/21f2544ed40d4833b0617414bdc5d8edfea7a2a9/test/.DS_Store -------------------------------------------------------------------------------- /test/integration/config.js: -------------------------------------------------------------------------------- 1 | export const INSTANCE_HOST = 'localhost:10443'; 2 | -------------------------------------------------------------------------------- /test/integration/request_failed.test.js: -------------------------------------------------------------------------------- 1 | const { 2 | default: PusherPlatform, 3 | } = require('../../dist/web/pusher-platform.js'); 4 | 5 | const { INSTANCE_HOST } = require('./config'); 6 | 7 | // these just test GET - everything else should just work. 8 | describe('Instance requests - failing', () => { 9 | let instance; 10 | let instanceWithTokenProvider; 11 | 12 | beforeAll(() => { 13 | instance = new PusherPlatform.Instance({ 14 | locator: 'v1:api-ceres:1', 15 | serviceName: 'platform_sdk_tester', 16 | serviceVersion: 'v1', 17 | host: INSTANCE_HOST, 18 | }); 19 | 20 | class DummyTokenProvider { 21 | fetchToken(tokenParams) { 22 | return Promise.resolve('blahblaharandomtoken'); 23 | } 24 | 25 | clearToken(token) {} 26 | } 27 | 28 | instanceWithTokenProvider = new PusherPlatform.Instance({ 29 | locator: 'v1:api-ceres:1', 30 | serviceName: 'platform_sdk_tester', 31 | serviceVersion: 'v1', 32 | host: INSTANCE_HOST, 33 | tokenProvider: new DummyTokenProvider(), 34 | }); 35 | }); 36 | 37 | describe('with no token provider', () => { 38 | it('fails on 400 error', done => { 39 | instance 40 | .request({ 41 | method: 'GET', 42 | path: 'get_400', 43 | }) 44 | .then(res => { 45 | fail('Expecting error'); 46 | }) 47 | .catch(error => { 48 | expect(error.statusCode).toBe(400); 49 | done(); 50 | }); 51 | }); 52 | 53 | it('fails on 403 error', done => { 54 | instance 55 | .request({ 56 | method: 'GET', 57 | path: 'get_403', 58 | }) 59 | .then(res => { 60 | fail('Expecting error'); 61 | }) 62 | .catch(error => { 63 | expect(error.statusCode).toBe(403); 64 | done(); 65 | }); 66 | }); 67 | 68 | it('fails on 404 error', done => { 69 | instance 70 | .request({ 71 | method: 'GET', 72 | path: 'get_404', 73 | }) 74 | .then(res => { 75 | fail('Expecting error'); 76 | }) 77 | .catch(error => { 78 | expect(error.statusCode).toBe(404); 79 | done(); 80 | }); 81 | }); 82 | 83 | it('fails on 500 error', done => { 84 | instance 85 | .request({ 86 | method: 'GET', 87 | path: 'get_500', 88 | }) 89 | .then(res => { 90 | fail('Expecting error'); 91 | }) 92 | .catch(error => { 93 | expect(error.statusCode).toBe(500); 94 | done(); 95 | }); 96 | }); 97 | 98 | it('fails on 503 error', done => { 99 | instance 100 | .request({ 101 | method: 'GET', 102 | path: 'get_503', 103 | }) 104 | .then(res => { 105 | fail('Expecting error'); 106 | }) 107 | .catch(error => { 108 | expect(error.statusCode).toBe(503); 109 | done(); 110 | }); 111 | }); 112 | }); 113 | 114 | describe('with a token provider', () => { 115 | it('fails on 400 error', done => { 116 | instanceWithTokenProvider 117 | .request({ 118 | method: 'GET', 119 | path: 'get_400', 120 | }) 121 | .then(res => { 122 | fail('Expecting error'); 123 | }) 124 | .catch(error => { 125 | expect(error.statusCode).toBe(400); 126 | done(); 127 | }); 128 | }); 129 | 130 | it('fails on 403 error', done => { 131 | instanceWithTokenProvider 132 | .request({ 133 | method: 'GET', 134 | path: 'get_403', 135 | }) 136 | .then(res => { 137 | fail('Expecting error'); 138 | }) 139 | .catch(error => { 140 | expect(error.statusCode).toBe(403); 141 | done(); 142 | }); 143 | }); 144 | 145 | it('fails on 404 error', done => { 146 | instanceWithTokenProvider 147 | .request({ 148 | method: 'GET', 149 | path: 'get_404', 150 | }) 151 | .then(res => { 152 | fail('Expecting error'); 153 | }) 154 | .catch(error => { 155 | expect(error.statusCode).toBe(404); 156 | done(); 157 | }); 158 | }); 159 | 160 | it('fails on 500 error', done => { 161 | instanceWithTokenProvider 162 | .request({ 163 | method: 'GET', 164 | path: 'get_500', 165 | }) 166 | .then(res => { 167 | fail('Expecting error'); 168 | }) 169 | .catch(error => { 170 | expect(error.statusCode).toBe(500); 171 | done(); 172 | }); 173 | }); 174 | 175 | it('fails on 503 error', done => { 176 | instanceWithTokenProvider 177 | .request({ 178 | method: 'GET', 179 | path: 'get_503', 180 | }) 181 | .then(res => { 182 | fail('Expecting error'); 183 | }) 184 | .catch(error => { 185 | expect(error.statusCode).toBe(503); 186 | done(); 187 | }); 188 | }); 189 | }); 190 | }); 191 | -------------------------------------------------------------------------------- /test/integration/request_successful.test.js: -------------------------------------------------------------------------------- 1 | const { 2 | default: PusherPlatform, 3 | } = require('../../dist/web/pusher-platform.js'); 4 | 5 | const { INSTANCE_HOST } = require('./config'); 6 | 7 | describe('Instance Requests - Successful', () => { 8 | let instance; 9 | 10 | beforeAll(() => { 11 | instance = new PusherPlatform.Instance({ 12 | locator: 'v1:api-ceres:1', 13 | serviceName: 'platform_sdk_tester', 14 | serviceVersion: 'v1', 15 | host: INSTANCE_HOST, 16 | }); 17 | }); 18 | 19 | it('makes a successful GET request', done => { 20 | instance 21 | .request({ 22 | method: 'GET', 23 | path: 'get_ok', 24 | }) 25 | .then(res => { 26 | done(); 27 | }); 28 | }); 29 | 30 | it('makes a successful POST request', done => { 31 | instance 32 | .request({ 33 | method: 'POST', 34 | path: 'post_ok', 35 | }) 36 | .then(res => { 37 | done(); 38 | }); 39 | }); 40 | 41 | it('makes a successful POST request with JSON body', done => { 42 | instance 43 | .request({ 44 | method: 'post', 45 | path: 'post_ok_echo', 46 | json: { 47 | test: '123', 48 | }, 49 | }) 50 | .then(res => { 51 | expect(JSON.parse(res).test).toEqual('123'); 52 | done(); 53 | }); 54 | }); 55 | 56 | it('makes a successful PUT request', done => { 57 | instance 58 | .request({ 59 | method: 'PUT', 60 | path: 'put_ok', 61 | }) 62 | .then(res => { 63 | done(); 64 | }); 65 | }); 66 | 67 | it('makes a successful DELETE request', done => { 68 | instance 69 | .request({ 70 | method: 'DELETE', 71 | path: 'delete_ok', 72 | }) 73 | .then(res => { 74 | done(); 75 | }); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /test/integration/subscribe-failed.test.js: -------------------------------------------------------------------------------- 1 | const { 2 | default: PusherPlatform, 3 | } = require('../../dist/web/pusher-platform.js'); 4 | 5 | const { INSTANCE_HOST } = require('./config'); 6 | 7 | const PATH_NOT_EXISTING = 'subscribe_missing'; 8 | const PATH_FORBIDDEN = 'subscribe_forbidden'; 9 | 10 | let logger = new PusherPlatform.ConsoleLogger(1); 11 | 12 | describe('Instance Subscribe errors nicely', () => { 13 | beforeAll(() => { 14 | instance = new PusherPlatform.Instance({ 15 | locator: 'v1:api-ceres:1', 16 | serviceName: 'platform_sdk_tester', 17 | serviceVersion: 'v1', 18 | host: INSTANCE_HOST, 19 | logger: logger, 20 | }); 21 | 22 | neverRetryOptions = { 23 | limit: 0, 24 | }; 25 | }); 26 | 27 | it('handles 404', done => { 28 | instance.subscribeNonResuming({ 29 | path: PATH_NOT_EXISTING, 30 | retryStrategyOptions: neverRetryOptions, 31 | listeners: { 32 | onOpen: headers => {}, 33 | onEvent: event => { 34 | fail('Expecting onError'); 35 | }, 36 | onEnd: () => { 37 | fail('Expecting onError'); 38 | }, 39 | onError: err => { 40 | expect(err.statusCode).toBe(404); 41 | done(); 42 | }, 43 | }, 44 | }); 45 | }); 46 | 47 | it('handles 403', done => { 48 | instance.subscribeNonResuming({ 49 | path: PATH_FORBIDDEN, 50 | retryStrategyOptions: neverRetryOptions, 51 | listeners: { 52 | onOpen: headers => {}, 53 | onEvent: event => { 54 | fail('Expecting onError'); 55 | }, 56 | onEnd: () => { 57 | fail('Expecting onError'); 58 | }, 59 | onError: err => { 60 | expect(err.statusCode).toBe(403); 61 | done(); 62 | }, 63 | }, 64 | }); 65 | }); 66 | 67 | it('handles 500', done => { 68 | instance.subscribeNonResuming({ 69 | path: 'subscribe_internal_server_error', 70 | retryStrategyOptions: neverRetryOptions, 71 | listeners: { 72 | onOpen: headers => {}, 73 | onRetrying: () => console.log('retrying'), 74 | onEvent: event => { 75 | console.log(event); 76 | fail('Expecting onError'); 77 | }, 78 | onEnd: () => { 79 | console.log('end'); 80 | fail('Expecting onError'); 81 | }, 82 | onError: err => { 83 | console.log(err); 84 | expect(err.statusCode).toBe(500); 85 | done(); 86 | }, 87 | }, 88 | }); 89 | }); 90 | 91 | //Not going to work unless the service supports it 92 | // it('tries to resumable subscribe to a subscription that doesnt support resuming', (done) => { 93 | // instance.resumableSubscribe({ 94 | // path: "services/platform_lib_tester/v1/subscribe_try_resuming", 95 | // lastEventId: "1234", 96 | // onEvent: (event) => { 97 | 98 | // fail("Expecting onError"); 99 | // }, 100 | // onEnd: () => { 101 | // fail("Expecting onError"); 102 | // }, 103 | // onError: (err) => { 104 | 105 | // expect(err.statusCode).toBe(403); 106 | // done(); 107 | // } 108 | // }) 109 | // }); 110 | }); 111 | -------------------------------------------------------------------------------- /test/integration/subscribe.test.js: -------------------------------------------------------------------------------- 1 | const { 2 | default: PusherPlatform, 3 | } = require('../../dist/web/pusher-platform.js'); 4 | 5 | const { INSTANCE_HOST } = require('./config'); 6 | 7 | const PATH_10_AND_EOS = 'subscribe10'; 8 | const PATH_3_AND_OPEN = 'subscribe_3_continuous'; 9 | const PATH_0_EOS = 'subscribe_0_eos'; 10 | 11 | describe('Instance Subscribe', () => { 12 | var originalTimeout; 13 | 14 | beforeEach(() => { 15 | let logger = new PusherPlatform.ConsoleLogger(1); 16 | 17 | instance = new PusherPlatform.Instance({ 18 | locator: 'v1:api-ceres:test', 19 | serviceName: 'platform_sdk_tester', 20 | serviceVersion: 'v1', 21 | host: INSTANCE_HOST, 22 | logger: logger, 23 | }); 24 | 25 | neverRetryOptions = { 26 | limit: 0, 27 | }; 28 | 29 | eventCount = 0; 30 | endCount = 0; 31 | errorCount = 0; 32 | errorThrown = false; 33 | 34 | originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; 35 | jasmine.DEFAULT_TIMEOUT_INTERVAL = 30000; 36 | }); 37 | 38 | afterEach(function() { 39 | jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout; 40 | }); 41 | 42 | it('subscribes and terminates on EOS after receiving all events', done => { 43 | instance.subscribeNonResuming({ 44 | path: PATH_10_AND_EOS, 45 | retryStrategyOptions: neverRetryOptions, 46 | listeners: { 47 | onOpen: headers => {}, 48 | onEvent: event => { 49 | eventCount += 1; 50 | }, 51 | onRetrying: () => console.log('onRetrying'), 52 | onEnd: () => { 53 | expect(eventCount).toBe(10); 54 | done(); 55 | }, 56 | onError: err => { 57 | fail(); 58 | }, 59 | }, 60 | }); 61 | }); 62 | 63 | it('subscribes, terminates on EOS, and triggers onEnd callback exactly once', done => { 64 | instance.subscribeNonResuming({ 65 | path: PATH_10_AND_EOS, 66 | retryStrategyOptions: neverRetryOptions, 67 | listeners: { 68 | onOpen: headers => {}, 69 | onEvent: event => {}, 70 | onEnd: () => { 71 | endCount += 1; 72 | expect(endCount).toBe(1); 73 | done(); 74 | }, 75 | onError: err => { 76 | fail(); 77 | }, 78 | }, 79 | }); 80 | }); 81 | 82 | it('subscribes with resuming subscription but retry strategy that says 0 retries, terminates on EOS, and triggers onEnd callback exactly once', done => { 83 | instance.subscribeResuming({ 84 | path: PATH_10_AND_EOS, 85 | retryStrategyOptions: neverRetryOptions, 86 | listeners: { 87 | onOpen: headers => {}, 88 | onEvent: event => {}, 89 | onEnd: () => { 90 | endCount += 1; 91 | expect(endCount).toBe(1); 92 | done(); 93 | }, 94 | onError: err => { 95 | fail(); 96 | }, 97 | }, 98 | }); 99 | }); 100 | 101 | it('subscribes with resuming subscription and terminates on EOS after receiving all events', done => { 102 | instance.subscribeResuming({ 103 | path: PATH_10_AND_EOS, 104 | listeners: { 105 | onOpen: headers => {}, 106 | onEvent: event => { 107 | eventCount += 1; 108 | }, 109 | onRetrying: () => console.log('onRetrying'), 110 | onEnd: () => { 111 | expect(eventCount).toBe(10); 112 | done(); 113 | }, 114 | onError: err => { 115 | fail(); 116 | }, 117 | }, 118 | }); 119 | }); 120 | 121 | it('subscribes to a subscription that is kept open', done => { 122 | let sub = instance.subscribeNonResuming({ 123 | path: PATH_3_AND_OPEN, 124 | retryStrategyOptions: neverRetryOptions, 125 | listeners: { 126 | onOpen: headers => {}, 127 | onEvent: event => { 128 | eventCount += 1; 129 | if (eventCount > 3) { 130 | fail(`Too many events received: ${eventCount}`); 131 | } 132 | if (eventCount === 3) { 133 | sub.unsubscribe(); 134 | done(); 135 | } 136 | }, 137 | onEnd: () => { 138 | if (eventCount !== 3) { 139 | fail("onEnd triggered. This shouldn't be!"); 140 | } 141 | }, 142 | onError: err => { 143 | fail("onError triggered - this shouldn't be!"); 144 | }, 145 | }, 146 | }); 147 | }); 148 | 149 | it('subscribes and then unsubscribes - expecting onEnd', done => { 150 | let sub = instance.subscribeNonResuming({ 151 | path: PATH_3_AND_OPEN, 152 | retryStrategyOptions: neverRetryOptions, 153 | listeners: { 154 | onOpen: headers => {}, 155 | onEvent: event => { 156 | eventCount += 1; 157 | if (eventCount > 3) { 158 | fail(`Too many events received: ${eventCount}`); 159 | } 160 | if (eventCount === 3) { 161 | sub.unsubscribe(); 162 | } 163 | }, 164 | onEnd: () => { 165 | done(); 166 | }, 167 | onError: err => { 168 | fail("onError triggered - this shouldn't be!"); 169 | }, 170 | }, 171 | }); 172 | }); 173 | 174 | it('subscribes and receives EOS immediately - expecting onEnd with no events', done => { 175 | let sub = instance.subscribeNonResuming({ 176 | path: PATH_0_EOS, 177 | retryStrategyOptions: neverRetryOptions, 178 | listeners: { 179 | onOpen: headers => {}, 180 | onEvent: event => { 181 | fail('No events should have been received'); 182 | }, 183 | onEnd: () => { 184 | done(); 185 | }, 186 | onError: err => { 187 | fail('We should not error'); 188 | }, 189 | }, 190 | }); 191 | }); 192 | 193 | it('subscribes and receives EOS with retry-after headers', done => { 194 | let sub = instance.subscribeNonResuming({ 195 | path: 'subscribe_retry_after', 196 | retryStrategyOptions: neverRetryOptions, 197 | listeners: { 198 | onOpen: headers => {}, 199 | onEvent: event => { 200 | fail('No events should have been received'); 201 | }, 202 | onEnd: () => { 203 | fail('We should get an error'); 204 | }, 205 | onError: err => { 206 | expect(err.headers['Retry-After']).toBe('10'); 207 | done(); 208 | }, 209 | }, 210 | }); 211 | }); 212 | 213 | it('subscribes and receives a close message with retry-after headers', done => { 214 | let sub = instance.subscribeResuming({ 215 | path: 'subscribe_connection_close', 216 | listeners: { 217 | onOpen: headers => {}, 218 | onEvent: event => { 219 | eventCount += 1; 220 | 221 | switch (eventCount) { 222 | case 1: 223 | expect(event.body.test_id).toBe(99); 224 | // Send a message to request the websocket connection be closed by 225 | // the server 226 | const shuttingDownErr = { 227 | error: 'shutting_down', 228 | error_description: 'Shutting down', 229 | }; 230 | instance.client.websocketTransport.sendMessage([ 231 | 99, 232 | 503, 233 | { 'x-pusher-echo': 'true' }, 234 | shuttingDownErr, 235 | ]); 236 | break; 237 | case 2: 238 | expect(event.body.test_id).toBe(100); 239 | done(); 240 | break; 241 | default: 242 | fail('Received too many events from the server'); 243 | break; 244 | } 245 | }, 246 | onEnd: () => { 247 | fail( 248 | 'We should get an error that is handled internally and then close ourselves', 249 | ); 250 | }, 251 | onError: err => { 252 | fail( 253 | 'We should not see an error - it should be handled internally because the subscription is resuming', 254 | ); 255 | }, 256 | }, 257 | }); 258 | }); 259 | }); 260 | -------------------------------------------------------------------------------- /test/unit/app.test.js: -------------------------------------------------------------------------------- 1 | const PusherPlatform = require('../../dist/web/pusher-platform.js').default; 2 | 3 | describe('Instance', () => { 4 | test('empty', () => { 5 | //No tests yet :( 6 | }); 7 | 8 | it('Throws an error if `locator` is missing', () => { 9 | expect(() => new PusherPlatform.Instance({})).toThrow( 10 | new Error('Expected `locator` property in Instance options!'), 11 | ); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /test/unit/base-client.test.js: -------------------------------------------------------------------------------- 1 | const PusherPlatform = require('../../dist/web/pusher-platform.js').default; 2 | 3 | describe('BaseClient', () => { 4 | test('empty', () => { 5 | //No tests yet :( 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /test/unit/resumable-subscription.test.js: -------------------------------------------------------------------------------- 1 | const PusherPlatform = require('../../dist/web/pusher-platform.js').default; 2 | 3 | describe('Resumable Subscription', () => { 4 | test('empty', () => { 5 | //No tests yet :( 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /test/unit/subscription.test.js: -------------------------------------------------------------------------------- 1 | const PusherPlatform = require('../../dist/web/pusher-platform.js').default; 2 | 3 | describe('Subscription', () => { 4 | test('empty', () => { 5 | //No tests yet :( 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "declarationDir": "declarations", 5 | "lib": ["ES6", "webworker"], 6 | "module": "commonjs", 7 | "removeComments": true, 8 | "sourceMap": true, 9 | "strict": true, 10 | "target": "ES5" 11 | }, 12 | "files": [ 13 | "src/declarations/global/global.d.ts" 14 | ], 15 | "exclude": [ 16 | "node_modules", 17 | "target", 18 | "test" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint:recommended" 5 | ], 6 | "jsRules": {}, 7 | "linterOptions": { 8 | "exclude": [ 9 | "src/declarations/**/*.d.ts" 10 | ] 11 | }, 12 | "rules": { 13 | "arrow-parens": false, 14 | "interface-name": [true, "never-prefix"], 15 | "interface-over-type-literal": false, 16 | "max-classes-per-file": [false], 17 | "member-access": [true, "no-public"], 18 | "member-ordering": [true, {"order": "instance-sandwich"}], 19 | "quotemark": [ 20 | true, 21 | "single", 22 | "avoid-escape" 23 | ], 24 | "semicolon": [true, "always", "ignore-bound-class-methods"] 25 | }, 26 | "rulesDirectory": [] 27 | } 28 | -------------------------------------------------------------------------------- /webpack/config.react-native.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | var path = require('path'); 3 | 4 | var sharedConfig = require('./config.shared'); 5 | 6 | module.exports = _.merge(sharedConfig, { 7 | entry: { 8 | 'pusher-platform': './src/index.ts' 9 | }, 10 | output: { 11 | library: "PusherPlatform", 12 | libraryTarget:"commonjs2", 13 | path: path.join(__dirname, "../dist/react-native"), 14 | filename: "pusher-platform.js" 15 | }, 16 | }) 17 | -------------------------------------------------------------------------------- /webpack/config.shared.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | 3 | module.exports = { 4 | resolve: { 5 | extensions: ['.ts', '.js'], 6 | }, 7 | module: { 8 | rules: [ 9 | { 10 | test: /\.ts$/, 11 | loader: `ts-loader?${ JSON.stringify({ logInfoToStdOut: true }) }`, 12 | exclude: [/node_modules/, /dist/, /example/] 13 | } 14 | ], 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /webpack/config.web.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | var path = require('path'); 3 | var webpack = require('webpack'); 4 | 5 | var sharedConfig = require('./config.shared'); 6 | 7 | module.exports = _.merge(sharedConfig, { 8 | entry: { 9 | 'pusher-platform': './src/index.ts' 10 | }, 11 | output: { 12 | library: "PusherPlatform", 13 | path: path.join(__dirname, "../dist/web"), 14 | filename: "pusher-platform.js", 15 | libraryTarget: "umd" 16 | }, 17 | plugins: [ 18 | new webpack.DefinePlugin({ 19 | global: "window" 20 | }), 21 | ], 22 | }); 23 | -------------------------------------------------------------------------------- /webpack/config.worker.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | var path = require('path'); 3 | var webpack = require('webpack'); 4 | 5 | var sharedConfig = require('./config.shared'); 6 | 7 | module.exports = _.merge(sharedConfig, { 8 | entry: { 9 | 'pusher-platform.worker': './src/index.ts' 10 | }, 11 | output: { 12 | library: "PusherPlatform", 13 | path: path.join(__dirname, "../dist/worker"), 14 | filename: "pusher-platform.worker.js" 15 | }, 16 | plugins: [ 17 | new webpack.DefinePlugin({ 18 | global: "self" 19 | }), 20 | ] 21 | }); 22 | -------------------------------------------------------------------------------- /worker.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./dist/worker/pusher-platform.worker'); 2 | --------------------------------------------------------------------------------