├── .github └── workflows │ ├── build.yml │ ├── coverage.yml │ ├── documentation.yml │ ├── lint.yml │ ├── package-npm.yml │ └── test.yml ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── eslint.config.mjs ├── package.json ├── src ├── backoff │ ├── backoff.ts │ ├── constantbackoff.ts │ ├── exponentialbackoff.ts │ └── linearbackoff.ts ├── index.ts ├── queue │ ├── array_queue.ts │ ├── queue.ts │ └── ring_queue.ts ├── websocket.ts ├── websocket_buffer.ts ├── websocket_builder.ts ├── websocket_event.ts ├── websocket_options.ts └── websocket_retry_options.ts ├── tests ├── backoff │ ├── constantbackoff.test.ts │ ├── exponentialbackoff.test.ts │ └── linearbackoff.test.ts ├── queue │ ├── array_queue.test.ts │ └── ring_queue.test.ts ├── websocket.test.ts └── websocket_builder.test.ts ├── tsconfig.cjs.json ├── tsconfig.esm.json └── vitest.config.ts /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: [ push, pull_request ] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: actions/setup-node@v4.2.0 11 | with: 12 | node-version: '22.14.0' 13 | - name: Install dev-dependencies 14 | run: npm install --only=dev 15 | - name: Run build 16 | run: npm run build 17 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: Coverage 2 | 3 | on: [ push, pull_request ] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: actions/setup-node@v4.2.0 11 | with: 12 | node-version: '22.14.0' 13 | - name: Install dev-dependencies 14 | run: npm install --only=dev 15 | - name: Run test with coverage 16 | run: npm run test:coverage 17 | - name: Generate coveralls report 18 | env: 19 | COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} 20 | run: npm run coveralls 21 | -------------------------------------------------------------------------------- /.github/workflows/documentation.yml: -------------------------------------------------------------------------------- 1 | name: Generate and Deploy Documentation 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | documentation: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v4.2.0 14 | with: 15 | node-version: '22.14.0' 16 | - name: Install dependencies and generate documentation 17 | run: | 18 | npm install 19 | npm install typedoc@0.27.7 20 | npm install typedoc-theme-hierarchy@5.0.4 21 | npx typedoc --tsconfig tsconfig.esm.json --exclude "src/**/index.ts" --entryPoints ./src --entryPointStrategy expand --out ./docs --plugin typedoc-theme-hierarchy --theme hierarchy --name websocket-ts 22 | - name: Deploy 23 | uses: JamesIves/github-pages-deploy-action@v4.7.2 24 | with: 25 | branch: gh-pages 26 | folder: docs 27 | target-folder: docs 28 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: [ push, pull_request ] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: actions/setup-node@v4.2.0 11 | with: 12 | node-version: '22.14.0' 13 | - name: Install dev-dependencies 14 | run: npm install --only=dev 15 | - name: Run lint 16 | run: npm run lint -------------------------------------------------------------------------------- /.github/workflows/package-npm.yml: -------------------------------------------------------------------------------- 1 | name: Create and publish npm package 2 | 3 | on: 4 | release: 5 | types: [ published ] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: actions/setup-node@v4.2.0 13 | with: 14 | node-version: '22.14.0' 15 | registry-url: 'https://registry.npmjs.org' 16 | 17 | # Install, build & publish to npm 18 | - name: Install dependencies 19 | run: npm install 20 | - name: Build package 21 | run: npm run build 22 | - run: npm publish 23 | env: 24 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 25 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [ push, pull_request ] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: actions/setup-node@v4.2.0 11 | with: 12 | node-version: '22.14.0' 13 | - name: Install dev-dependencies 14 | run: npm install --only=dev 15 | - name: Run test 16 | run: npm run test 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | # tool directories 4 | .idea/ 5 | .github/ 6 | 7 | # development directories 8 | tests/ 9 | coverage/ 10 | dist/cjs/tests/ 11 | dist/esm/tests/ 12 | 13 | # config files 14 | .eslintrc.json 15 | .gitignore 16 | .travis.yml 17 | vitest.config.ts 18 | typedoc.json -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Joscha Behrmann 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 |
2 |
3 | websocket-ts 4 |
5 |

6 | Build Status 7 | Build Status 8 | 9 | Coverage Status 10 | 11 | 12 | Release 13 | 14 | 15 | License 16 | 17 |

18 |
19 | 20 |
21 | A WebSocket for browsers with auto-reconnect and message buffering written in TypeScript. 22 |
23 | 24 | ## Features 25 | 26 | - **Lightweight & Standalone**: No dependencies, 2.1 kB minified & gzipped. 27 | - **Browser-native**: Utilizes WebSocket API, offers direct access. 28 | - **Smart Reconnect**: Optional auto-reconnect and message buffering. 29 | - **Easy Setup**: Optional builder class for quick initialization. 30 | - **Well-Tested**: High test coverage, well-documented for extensibility. 31 | - **Module Support**: Supports CommonJS and ES6 modules. 32 | 33 | ## Installation 34 | 35 | Install `websocket-ts` with npm: 36 | 37 | ```bash 38 | $ npm install websocket-ts 39 | ``` 40 | 41 | ## Quickstart 42 | This example shows how to use the package, complete with message buffering and automatic reconnection. 43 | The created websocket will echo back any received messages. It will buffer messages when disconnected 44 | and attempt to reconnect every 1 second. 45 | 46 | ```typescript 47 | import { 48 | ArrayQueue, 49 | ConstantBackoff, 50 | Websocket, 51 | WebsocketBuilder, 52 | WebsocketEvent, 53 | } from "websocket-ts"; 54 | 55 | // Initialize WebSocket with buffering and 1s reconnection delay 56 | const ws = new WebsocketBuilder("ws://localhost:8080") 57 | .withBuffer(new ArrayQueue()) // buffer messages when disconnected 58 | .withBackoff(new ConstantBackoff(1000)) // retry every 1s 59 | .build(); 60 | 61 | // Function to output & echo received messages 62 | const echoOnMessage = (i: Websocket, ev: MessageEvent) => { 63 | console.log(`received message: ${ev.data}`); 64 | i.send(`echo: ${ev.data}`); 65 | }; 66 | 67 | // Add event listeners 68 | ws.addEventListener(WebsocketEvent.open, () => console.log("opened!")); 69 | ws.addEventListener(WebsocketEvent.close, () => console.log("closed!")); 70 | ws.addEventListener(WebsocketEvent.message, echoOnMessage); 71 | ``` 72 | 73 | ## Usage 74 | This will demonstrate how to use `websocket-ts` in your project using the provided `WebsocketBuild`-class. 75 | 76 | For a more detailed description of the API, please refer to the [API Documentation](https://jjxxs.github.io/websocket-ts/). 77 | 78 | #### Initialization 79 | 80 | Create a new instance with the `WebsocketBuilder`: 81 | 82 | ```typescript 83 | const ws = new WebsocketBuilder("ws://localhost:42421").build(); 84 | ``` 85 | 86 | #### Events 87 | 88 | There are six events which can be subscribed to through with event listeners: 89 | 90 | ```typescript 91 | export enum WebsocketEvent { 92 | open = "open", // Connection opened 93 | close = "close", // Connection closed 94 | error = "error", // Error-induced closure 95 | message = "message", // Message received 96 | retry = "retry", // Reconnect attempt 97 | reconnect = "reconnect" // Successful reconnect 98 | } 99 | ``` 100 | 101 | #### Add Event Listeners 102 | Event listeners receive the websocket instance (`i`) and the triggering event (`ev`) as arguments. 103 | 104 | ```typescript 105 | const ws = new WebsocketBuilder("ws://localhost:42421") 106 | .onOpen((i, ev) => console.log("opened")) 107 | .onClose((i, ev) => console.log("closed")) 108 | .onError((i, ev) => console.log("error")) 109 | .onMessage((i, ev) => console.log("message")) 110 | .onRetry((i, ev) => console.log("retry")) 111 | .onReconnect((i, ev) => console.log("reconnect")) 112 | .build(); 113 | ``` 114 | 115 | #### Remove Event Listeners 116 | 117 | To unregister a specific event listener, use `removeEventListener`: 118 | 119 | ```typescript 120 | let ws: Websocket 121 | /* ... */ 122 | ws.removeEventListener(WebsocketEvent.open, openEventListener); 123 | ``` 124 | 125 | #### Send Message 126 | 127 | Use the `send` method to send a message to the server: 128 | 129 | ```typescript 130 | let ws: Websocket; 131 | /* ... */ 132 | ws.send("Hello World!"); 133 | ``` 134 | 135 | #### Reconnect & Backoff (Optional) 136 | 137 | If you'd like the websocket to automatically reconnect upon disconnection, you can optionally provide a `Backoff` strategy. 138 | This sets the delay between reconnection attempts. There are three built-in `Backoff` implementations, or you can create 139 | your own by implementing the `Backoff` interface. If no Backoff is provided, the websocket will not attempt to reconnect. 140 | 141 | ##### ConstantBackoff 142 | 143 | The `ConstantBackoff` strategy enforces a fixed delay between each reconnection attempt. 144 | To set a constant 1-second wait time, use: 145 | 146 | ```typescript 147 | const ws = new WebsocketBuilder("ws://localhost:42421") 148 | .withBackoff(new ConstantBackoff(1000)) // 1000ms = 1s 149 | .build(); 150 | ``` 151 | 152 | ##### LinearBackoff 153 | 154 | The `LinearBackoff` strategy increases the delay between reconnection attempts linearly, 155 | up to an optional maximum. For example, to start with a 0-second delay and increase by 156 | 10 second for each retry, capping at 60 seconds, use: 157 | 158 | ```typescript 159 | const ws = new WebsocketBuilder("ws://localhost:42421") 160 | .withBackoff(new LinearBackoff(0, 10000, 60000)) // 0ms, 10s, 20s, 30s, 40s, 50s, 60s 161 | .build(); 162 | ``` 163 | 164 | ##### ExponentialBackoff 165 | 166 | The `ExponentialBackoff` strategy doubles the delay between each reconnection attempt, up 167 | to a specified maximum. This approach is inspired by the binary exponential backoff algorithm 168 | commonly used in networking. For example, to generate a backoff series like `[1s, 2s, 4s, 8s]`, use: 169 | 170 | ```typescript 171 | const ws = new WebsocketBuilder("ws://localhost:42421") 172 | .withBackoff(new ExponentialBackoff(1000, 6)) // 1s, 2s, 4s, 8s, 16s, 32s, 64s 173 | .build(); 174 | ``` 175 | 176 | #### Buffer (Optional) 177 | 178 | To buffer outgoing messages when the websocket is disconnected, you can optionally specify 179 | a `Queue`. This queue will temporarily store your messages and send them in sequence when 180 | the websocket (re)connects. Two built-in `Queue` implementations are available, or you can 181 | create your own by implementing the `Queue` interface. If no queue is provided, messages 182 | won't be buffered. 183 | 184 | ##### RingQueue 185 | 186 | The `RingQueue` is a fixed-capacity, first-in-first-out (FIFO) queue. When it reaches capacity, 187 | the oldest element is removed to accommodate new ones. Reading from the queue returns and 188 | removes the oldest element. For instance, to set up a `RingQueue` with a 100-element capacity, 189 | use: 190 | 191 | ```typescript 192 | const ws = new WebsocketBuilder("ws://localhost:42421") 193 | .withBuffer(new RingQueue(100)) 194 | .build(); 195 | ``` 196 | 197 | ##### ArrayQueue 198 | 199 | The ArrayQueue offers an unbounded capacity, functioning as a first-in-first-out (FIFO) queue. 200 | Reading from this queue returns and removes the oldest element. To use an `ArrayQueue`, use: 201 | 202 | ```typescript 203 | const ws = new WebsocketBuilder("ws://localhost:42421") 204 | .withBuffer(new ArrayQueue()) 205 | .build(); 206 | ``` 207 | 208 | ## Build & Tests 209 | 210 | To compile the project, execute `npm run build`. 211 | 212 | To run tests, use `npm run test`. -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import typescriptEslint from "@typescript-eslint/eslint-plugin"; 2 | import prettier from "eslint-plugin-prettier"; 3 | import globals from "globals"; 4 | import tsParser from "@typescript-eslint/parser"; 5 | import path from "node:path"; 6 | import { fileURLToPath } from "node:url"; 7 | import js from "@eslint/js"; 8 | import { FlatCompat } from "@eslint/eslintrc"; 9 | 10 | const __filename = fileURLToPath(import.meta.url); 11 | const __dirname = path.dirname(__filename); 12 | const compat = new FlatCompat({ 13 | baseDirectory: __dirname, 14 | recommendedConfig: js.configs.recommended, 15 | allConfig: js.configs.all, 16 | }); 17 | 18 | export default [ 19 | ...compat.extends( 20 | "eslint:recommended", 21 | "plugin:@typescript-eslint/recommended", 22 | "plugin:prettier/recommended", 23 | ), 24 | { 25 | plugins: { 26 | "@typescript-eslint": typescriptEslint, 27 | prettier, 28 | }, 29 | 30 | languageOptions: { 31 | globals: { 32 | ...globals.browser, 33 | ...globals.node, 34 | }, 35 | 36 | parser: tsParser, 37 | ecmaVersion: 2021, 38 | sourceType: "module", 39 | }, 40 | 41 | rules: {}, 42 | }, 43 | ]; 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "websocket-ts", 3 | "description": "A WebSocket client library with optional reconnecting and buffering capabilities.", 4 | "version": "2.2.1", 5 | "main": "dist/cjs/src/index.js", 6 | "types": "dist/cjs/src/index.d.ts", 7 | "module": "dist/esm/src/index.js", 8 | "license": "MIT", 9 | "keywords": [ 10 | "websocket", 11 | "browser", 12 | "client", 13 | "typescript", 14 | "reconnecting", 15 | "buffered" 16 | ], 17 | "sideEffects": false, 18 | "repository": { 19 | "url": "github:jjxxs/websocket-ts" 20 | }, 21 | "scripts": { 22 | "build": "npm run build:cjs && npm run build:esm", 23 | "build:cjs": "tsc -p tsconfig.cjs.json", 24 | "build:esm": "tsc -p tsconfig.esm.json", 25 | "coveralls": "cat ./coverage/lcov.info | coveralls", 26 | "lint": "cross-env-shell eslint './src/{**/*,*}.{js,ts}' './tests/{**/*,*}.{js,ts}'", 27 | "test": "vitest", 28 | "test:coverage": "vitest --coverage" 29 | }, 30 | "devDependencies": { 31 | "@eslint/eslintrc": "^3.2.0", 32 | "@eslint/js": "^9.20.0", 33 | "@types/ws": "^8.5.14", 34 | "@typescript-eslint/eslint-plugin": "^8.24.0", 35 | "@typescript-eslint/parser": "^8.24.0", 36 | "@vitest/coverage-v8": "^3.0.5", 37 | "coveralls": "^3.1.1", 38 | "cross-env": "^7.0.3", 39 | "eslint": "^9.20.1", 40 | "eslint-config-prettier": "^10.0.1", 41 | "eslint-plugin-prettier": "^5.2.3", 42 | "globals": "^15.15.0", 43 | "jsdom": "^26.0.0", 44 | "ts-node": "^10.9.2", 45 | "typescript": "^5.7.3", 46 | "vitest": "^3.0.5", 47 | "ws": "^8.18.0" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/backoff/backoff.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A Backoff produces a series of numbers that are used to determine 3 | * the delay between connection-retries. Values are expected to be in milliseconds. 4 | */ 5 | export interface Backoff { 6 | /** 7 | * The number of retries. Starts at 0, increases by 1 for each call to next(). Resets to 0 when reset() is called. 8 | */ 9 | readonly retries: number; 10 | 11 | /** 12 | * Current number in the series. 13 | */ 14 | readonly current: number; 15 | 16 | /** 17 | * Advances the series to the next number and returns it. 18 | * @return the next number in the series 19 | */ 20 | next(): number; 21 | 22 | /** 23 | * Resets the series to its initial state. 24 | */ 25 | reset(): void; 26 | } 27 | -------------------------------------------------------------------------------- /src/backoff/constantbackoff.ts: -------------------------------------------------------------------------------- 1 | import { Backoff } from "./backoff"; 2 | 3 | /** 4 | * ConstantBackoff always returns the same backoff-time. 5 | */ 6 | export class ConstantBackoff implements Backoff { 7 | private readonly backoff: number; 8 | private _retries: number = 0; 9 | 10 | /** 11 | * Creates a new ConstantBackoff. 12 | * @param backoff the backoff-time to return 13 | */ 14 | constructor(backoff: number) { 15 | if (!Number.isInteger(backoff) || backoff < 0) { 16 | throw new Error("Backoff must be a positive integer"); 17 | } 18 | 19 | this.backoff = backoff; 20 | } 21 | 22 | get retries(): number { 23 | return this._retries; 24 | } 25 | 26 | get current(): number { 27 | return this.backoff; 28 | } 29 | 30 | next(): number { 31 | this._retries++; 32 | return this.backoff; 33 | } 34 | 35 | reset(): void { 36 | this._retries = 0; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/backoff/exponentialbackoff.ts: -------------------------------------------------------------------------------- 1 | import { Backoff } from "./backoff"; 2 | 3 | /** 4 | * ExponentialBackoff increases the backoff-time exponentially. 5 | * An optional maximum can be provided as an upper bound to the 6 | * exponent and thus to the returned backoff. 7 | * 8 | * The series can be described as ('i' is the current step/retry): 9 | * backoff = base * 2^i | without bound 10 | * backoff = base * 2^min(i, expMax) | with bound 11 | * 12 | * Example: 13 | * 14 | * 1) Without bound: 15 | * base = 1000, expMax = undefined 16 | * backoff = 1000 * 2^0 = 1000 // first retry 17 | * backoff = 1000 * 2^1 = 2000 // second retry 18 | * backoff = 1000 * 2^2 = 4000 // ...doubles with every retry 19 | * backoff = 1000 * 2^3 = 8000 20 | * backoff = 1000 * 2^4 = 16000 21 | * ... // and so on 22 | * 23 | * 2) With bound: 24 | * base = 1000, expMax = 3 25 | * backoff = 1000 * 2^0 = 1000 // first retry 26 | * backoff = 1000 * 2^1 = 2000 // second retry 27 | * backoff = 1000 * 2^2 = 4000 // third retry 28 | * backoff = 1000 * 2^3 = 8000 // maximum reached, don't increase further 29 | * backoff = 1000 * 2^3 = 8000 30 | * backoff = 1000 * 2^3 = 8000 31 | * ... // and so on 32 | */ 33 | export class ExponentialBackoff implements Backoff { 34 | private readonly base: number; 35 | private readonly expMax?: number; 36 | private i: number; 37 | private _retries: number = 0; 38 | 39 | /** 40 | * Creates a new ExponentialBackoff. 41 | * @param base the base of the exponentiation 42 | * @param expMax the maximum exponent, no bound if undefined 43 | */ 44 | constructor(base: number, expMax?: number) { 45 | if (!Number.isInteger(base) || base < 0) { 46 | throw new Error("Base must be a positive integer or zero"); 47 | } 48 | if (expMax !== undefined && (!Number.isInteger(expMax) || expMax < 0)) { 49 | throw new Error("ExpMax must be a undefined, a positive integer or zero"); 50 | } 51 | 52 | this.base = base; 53 | this.expMax = expMax; 54 | this.i = 0; 55 | } 56 | 57 | get retries() { 58 | return this._retries; 59 | } 60 | 61 | get current(): number { 62 | return this.base * Math.pow(2, this.i); 63 | } 64 | 65 | next(): number { 66 | this._retries++; 67 | this.i = 68 | this.expMax === undefined 69 | ? this.i + 1 70 | : Math.min(this.i + 1, this.expMax); 71 | return this.current; 72 | } 73 | 74 | reset(): void { 75 | this._retries = 0; 76 | this.i = 0; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/backoff/linearbackoff.ts: -------------------------------------------------------------------------------- 1 | import { Backoff } from "./backoff"; 2 | 3 | /** 4 | * LinearBackoff returns a backoff-time that is incremented by a fixed amount 5 | * with every step/retry. An optional maximum can be provided as an upper bound 6 | * to the returned backoff. 7 | * 8 | * The series can be described as ('i' is the current step/retry): 9 | * backoff = initial + increment * i | without bound 10 | * backoff = initial + increment * min(i, max) | with bound 11 | * 12 | * Example: 13 | * 14 | * 1) Without bound: 15 | * initial = 1000, increment = 1000 16 | * backoff = 1000 + 1000 * 0 = 1000 // first retry 17 | * backoff = 1000 + 1000 * 1 = 2000 // second retry 18 | * backoff = 1000 + 1000 * 2 = 3000 // ...increases by 'increment' with every retry 19 | * backoff = 1000 + 1000 * 3 = 4000 20 | * backoff = 1000 + 1000 * 4 = 5000 21 | * ... // and so on 22 | * 23 | * 2) With bound: 24 | * initial = 1000, increment = 1000, max = 5000 25 | * backoff = 1000 + 1000 * 0 = 1000 // first retry 26 | * backoff = 1000 + 1000 * 1 = 2000 // second retry 27 | * backoff = 1000 + 1000 * 2 = 3000 // third retry 28 | * backoff = 1000 + 1000 * 3 = 4000 // fourth retry 29 | * backoff = 1000 + 1000 * 4 = 5000 // maximum reached, don't increase further 30 | * backoff = 1000 + 1000 * 4 = 5000 31 | * backoff = 1000 + 1000 * 4 = 5000 32 | * ... // and so on 33 | */ 34 | export class LinearBackoff implements Backoff { 35 | private readonly initial: number; 36 | private readonly increment: number; 37 | private readonly max?: number; 38 | private i: number = 0; 39 | private _retries: number = 0; 40 | 41 | /** 42 | * Creates a new LinearBackoff. 43 | * @param initial the initial backoff-time in milliseconds 44 | * @param increment the amount to increment the backoff-time with every step (in milliseconds) 45 | * @param max the maximum backoff-time (in milliseconds), no bound if undefined 46 | */ 47 | constructor(initial: number, increment: number, max?: number) { 48 | if (initial < 0) { 49 | throw new Error("Initial must be a positive number or zero"); 50 | } 51 | if (increment < 0) { 52 | throw new Error("Increment must be a positive number or zero"); 53 | } 54 | if (max !== undefined && max < 0) { 55 | throw new Error("Max must be undefined, a positive number or zero"); 56 | } 57 | if (max !== undefined && max < initial) { 58 | throw new Error( 59 | "Max must be undefined or greater than or equal to initial", 60 | ); 61 | } 62 | 63 | this.initial = initial; 64 | this.increment = increment; 65 | this.max = max; 66 | } 67 | 68 | get retries() { 69 | return this._retries; 70 | } 71 | 72 | get current(): number { 73 | return this.max === undefined 74 | ? this.initial + this.increment * this.i 75 | : Math.min(this.initial + this.increment * this.i, this.max); 76 | } 77 | 78 | next(): number { 79 | this._retries++; 80 | this.i++; 81 | return this.current; 82 | } 83 | 84 | reset(): void { 85 | this._retries = 0; 86 | this.i = 0; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { Backoff } from "./backoff/backoff"; 2 | export { ConstantBackoff } from "./backoff/constantbackoff"; 3 | export { ExponentialBackoff } from "./backoff/exponentialbackoff"; 4 | export { LinearBackoff } from "./backoff/linearbackoff"; 5 | export { Queue } from "./queue/queue"; 6 | export { ArrayQueue } from "./queue/array_queue"; 7 | export { RingQueue } from "./queue/ring_queue"; 8 | export { Websocket } from "./websocket"; 9 | export { WebsocketBuffer } from "./websocket_buffer"; 10 | export { WebsocketBuilder } from "./websocket_builder"; 11 | export { 12 | WebsocketEvent, 13 | RetryEventDetail, 14 | ReconnectEventDetail, 15 | WebsocketEventMap, 16 | WebsocketEventListener, 17 | WebsocketEventListenerParams, 18 | WebsocketEventListenerOptions, 19 | WebsocketEventListenerWithOptions, 20 | WebsocketEventListeners, 21 | } from "./websocket_event"; 22 | export { WebsocketOptions } from "./websocket_options"; 23 | export { WebsocketConnectionRetryOptions } from "./websocket_retry_options"; 24 | -------------------------------------------------------------------------------- /src/queue/array_queue.ts: -------------------------------------------------------------------------------- 1 | import { Queue } from "./queue"; 2 | 3 | /** 4 | * An array queue is a queue that has an unbounded capacity. Reading from an array queue 5 | * will return the oldest element and effectively remove it from the queue. 6 | */ 7 | export class ArrayQueue implements Queue { 8 | private readonly elements: E[]; 9 | 10 | constructor() { 11 | this.elements = []; 12 | } 13 | 14 | add(element: E): void { 15 | this.elements.push(element); 16 | } 17 | 18 | clear() { 19 | this.elements.length = 0; 20 | } 21 | 22 | forEach(fn: (element: E) => unknown) { 23 | this.elements.forEach(fn); 24 | } 25 | 26 | length(): number { 27 | return this.elements.length; 28 | } 29 | 30 | isEmpty(): boolean { 31 | return this.elements.length === 0; 32 | } 33 | 34 | peek(): E | undefined { 35 | return this.elements[0]; 36 | } 37 | 38 | read(): E | undefined { 39 | return this.elements.shift(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/queue/queue.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A queue holds elements until they are read. The order in which elements are 3 | * read is determined by the implementation of the queue. 4 | */ 5 | export interface Queue { 6 | /** 7 | * Adds an element to the queue. 8 | * @param element the element to add 9 | */ 10 | add(element: E): void; 11 | 12 | /** 13 | * Clears the queue, removing all elements. 14 | */ 15 | clear(): void; 16 | 17 | /** 18 | * Calls the given function for each element in the queue. 19 | * @param fn the function to call 20 | */ 21 | forEach(fn: (element: E) => unknown): void; 22 | 23 | /** 24 | * Number of elements in the queue. 25 | * @return the number of elements in the queue 26 | */ 27 | length(): number; 28 | 29 | /** 30 | * Whether the queue is empty. 31 | * @return true if the queue is empty, false otherwise 32 | */ 33 | isEmpty(): boolean; 34 | 35 | /** 36 | * Returns the next element in the queue without removing it. 37 | * @return the next element in the queue, or undefined if the queue is empty 38 | */ 39 | peek(): E | undefined; 40 | 41 | /** 42 | * Removes and returns the next element in the queue. 43 | * @return the next element in the queue, or undefined if the queue is empty 44 | */ 45 | read(): E | undefined; 46 | } 47 | -------------------------------------------------------------------------------- /src/queue/ring_queue.ts: -------------------------------------------------------------------------------- 1 | import { Queue } from "./queue"; 2 | 3 | /** 4 | * A ring queue is a queue that has a fixed capacity. When the queue is full, the oldest element is 5 | * removed to make room for the new element. Reading from a ring queue will return the oldest 6 | * element and effectively remove it from the queue. 7 | */ 8 | export class RingQueue implements Queue { 9 | private readonly elements: E[]; 10 | private head: number; // index of the next position to write to 11 | private tail: number; // index of the next position to read from 12 | 13 | constructor(capacity: number) { 14 | if (!Number.isInteger(capacity) || capacity <= 0) { 15 | throw new Error("Capacity must be a positive integer"); 16 | } 17 | 18 | this.elements = new Array(capacity + 1); // +1 to distinguish between full and empty 19 | this.head = 0; 20 | this.tail = 0; 21 | } 22 | 23 | add(element: E): void { 24 | this.elements[this.head] = element; 25 | this.head = (this.head + 1) % this.elements.length; 26 | if (this.head === this.tail) { 27 | this.tail = (this.tail + 1) % this.elements.length; 28 | } 29 | } 30 | 31 | clear() { 32 | this.head = 0; 33 | this.tail = 0; 34 | } 35 | 36 | forEach(fn: (element: E) => unknown) { 37 | for ( 38 | let i = this.tail; 39 | i !== this.head; 40 | i = (i + 1) % this.elements.length 41 | ) { 42 | fn(this.elements[i]); 43 | } 44 | } 45 | 46 | length(): number { 47 | return this.tail === this.head 48 | ? 0 49 | : this.tail < this.head 50 | ? this.head - this.tail 51 | : this.elements.length - this.tail + this.head; 52 | } 53 | 54 | isEmpty(): boolean { 55 | return this.head === this.tail; 56 | } 57 | 58 | peek(): E | undefined { 59 | return this.isEmpty() ? undefined : this.elements[this.tail]; 60 | } 61 | 62 | read(): E | undefined { 63 | const e = this.peek(); 64 | if (e !== undefined) { 65 | this.tail = (this.tail + 1) % this.elements.length; 66 | } 67 | return e; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/websocket.ts: -------------------------------------------------------------------------------- 1 | import { Backoff } from "./backoff/backoff"; 2 | import { WebsocketBuffer } from "./websocket_buffer"; 3 | import { 4 | ReconnectEventDetail, 5 | RetryEventDetail, 6 | WebsocketEvent, 7 | WebsocketEventListener, 8 | WebsocketEventListenerOptions, 9 | WebsocketEventListeners, 10 | WebsocketEventListenerWithOptions, 11 | WebsocketEventMap, 12 | } from "./websocket_event"; 13 | import { WebsocketOptions } from "./websocket_options"; 14 | 15 | /** 16 | * A websocket wrapper that can be configured to reconnect automatically and buffer messages when the websocket is not connected. 17 | */ 18 | export class Websocket { 19 | private readonly _url: string; // the url to connect to 20 | private readonly _protocols?: string | string[]; // the protocols to use 21 | 22 | private _closedByUser: boolean = false; // whether the websocket was closed by the user 23 | private _lastConnection?: Date; // timestamp of the last connection 24 | private _underlyingWebsocket: WebSocket; // the underlying websocket, e.g. native browser websocket 25 | private retryTimeout?: ReturnType; // timeout for the next retry, if any 26 | 27 | private _options: WebsocketOptions & 28 | Required>; // options/config for the websocket 29 | 30 | /** 31 | * Creates a new websocket. 32 | * 33 | * @param url to connect to. 34 | * @param protocols optional protocols to use. 35 | * @param options optional options to use. 36 | */ 37 | constructor( 38 | url: string, 39 | protocols?: string | string[], 40 | options?: WebsocketOptions, 41 | ) { 42 | this._url = url; 43 | this._protocols = protocols; 44 | 45 | // make a copy of the options to prevent the user from changing them 46 | this._options = { 47 | buffer: options?.buffer, 48 | retry: { 49 | maxRetries: options?.retry?.maxRetries, 50 | instantReconnect: options?.retry?.instantReconnect, 51 | backoff: options?.retry?.backoff, 52 | }, 53 | listeners: { 54 | open: [...(options?.listeners?.open ?? [])], 55 | close: [...(options?.listeners?.close ?? [])], 56 | error: [...(options?.listeners?.error ?? [])], 57 | message: [...(options?.listeners?.message ?? [])], 58 | retry: [...(options?.listeners?.retry ?? [])], 59 | reconnect: [...(options?.listeners?.reconnect ?? [])], 60 | }, 61 | }; 62 | 63 | this._underlyingWebsocket = this.tryConnect(); 64 | } 65 | 66 | /** 67 | * Getter for the url. 68 | * 69 | * @return the url. 70 | */ 71 | get url(): string { 72 | return this._url; 73 | } 74 | 75 | /** 76 | * Getter for the protocols. 77 | * 78 | * @return the protocols, or undefined if none were provided. 79 | */ 80 | get protocols(): string | string[] | undefined { 81 | return this._protocols; 82 | } 83 | 84 | /** 85 | * Getter for the buffer. 86 | * 87 | * @return the buffer, or undefined if none was provided. 88 | */ 89 | get buffer(): WebsocketBuffer | undefined { 90 | return this._options.buffer; 91 | } 92 | 93 | /** 94 | * Getter for the maxRetries. 95 | * 96 | * @return the maxRetries, or undefined if none was provided (no limit). 97 | */ 98 | get maxRetries(): number | undefined { 99 | return this._options.retry.maxRetries; 100 | } 101 | 102 | /** 103 | * Getter for the instantReconnect. 104 | * 105 | * @return the instantReconnect, or undefined if none was provided. 106 | */ 107 | get instantReconnect(): boolean | undefined { 108 | return this._options.retry.instantReconnect; 109 | } 110 | 111 | /** 112 | * Getter for the backoff. 113 | * 114 | * @return the backoff, or undefined if none was provided. 115 | */ 116 | get backoff(): Backoff | undefined { 117 | return this._options.retry.backoff; 118 | } 119 | 120 | /** 121 | * Whether the websocket was closed by the user. A websocket is closed by the user by calling close(). 122 | * 123 | * @return true if the websocket was closed by the user, false otherwise. 124 | */ 125 | get closedByUser(): boolean { 126 | return this._closedByUser; 127 | } 128 | 129 | /** 130 | * Getter for the last 'open' event, e.g. the last time the websocket was connected. 131 | * 132 | * @return the last 'open' event, or undefined if the websocket was never connected. 133 | */ 134 | get lastConnection(): Date | undefined { 135 | return this._lastConnection; 136 | } 137 | 138 | /** 139 | * Getter for the underlying websocket. This can be used to access the browser's native websocket directly. 140 | * 141 | * @see https://developer.mozilla.org/en-US/docs/Web/API/WebSocket 142 | * @return the underlying websocket. 143 | */ 144 | get underlyingWebsocket(): WebSocket { 145 | return this._underlyingWebsocket; 146 | } 147 | 148 | /** 149 | * Getter for the readyState of the underlying websocket. 150 | * 151 | * @see https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/readyState 152 | * @return the readyState of the underlying websocket. 153 | */ 154 | get readyState(): number { 155 | return this._underlyingWebsocket.readyState; 156 | } 157 | 158 | /** 159 | * Getter for the bufferedAmount of the underlying websocket. 160 | * 161 | * @see https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/bufferedAmount 162 | * @return the bufferedAmount of the underlying websocket. 163 | */ 164 | get bufferedAmount(): number { 165 | return this._underlyingWebsocket.bufferedAmount; 166 | } 167 | 168 | /** 169 | * Getter for the extensions of the underlying websocket. 170 | * 171 | * @see https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/extensions 172 | * @return the extensions of the underlying websocket. 173 | */ 174 | get extensions(): string { 175 | return this._underlyingWebsocket.extensions; 176 | } 177 | 178 | /** 179 | * Getter for the binaryType of the underlying websocket. 180 | * 181 | * @see https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/binaryType 182 | * @return the binaryType of the underlying websocket. 183 | */ 184 | get binaryType(): BinaryType { 185 | return this._underlyingWebsocket.binaryType; 186 | } 187 | 188 | /** 189 | * Setter for the binaryType of the underlying websocket. 190 | * 191 | * @param value to set, 'blob' or 'arraybuffer'. 192 | */ 193 | set binaryType(value: BinaryType) { 194 | this._underlyingWebsocket.binaryType = value; 195 | } 196 | 197 | /** 198 | * Sends data over the websocket. 199 | * 200 | * If the websocket is not connected and a buffer was provided on creation, the data will be added to the buffer. 201 | * If no buffer was provided or the websocket was closed by the user, the data will be dropped. 202 | * 203 | * @see https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/send 204 | * @param data to send. 205 | */ 206 | public send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void { 207 | if (this.closedByUser) return; // no-op if closed by user 208 | 209 | if ( 210 | this._underlyingWebsocket.readyState === this._underlyingWebsocket.OPEN 211 | ) { 212 | this._underlyingWebsocket.send(data); // websocket is connected, send data 213 | } else if (this.buffer !== undefined) { 214 | this.buffer.add(data); // websocket is not connected, add data to buffer 215 | } 216 | } 217 | 218 | /** 219 | * Close the websocket. No connection-retry will be attempted after this. 220 | * 221 | * @see https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close 222 | * @param code optional close code. 223 | * @param reason optional close reason. 224 | */ 225 | public close(code?: number, reason?: string): void { 226 | this.cancelScheduledConnectionRetry(); // cancel any scheduled retries 227 | this._closedByUser = true; // mark websocket as closed by user 228 | this._underlyingWebsocket.close(code, reason); // close underlying websocket with provided code and reason 229 | } 230 | 231 | /** 232 | * Adds an event listener for the given event-type. 233 | * 234 | * @see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener 235 | * @param type of the event to add the listener for. 236 | * @param listener to add. 237 | * @param options to use when adding the listener. 238 | */ 239 | public addEventListener( 240 | type: K, 241 | listener: WebsocketEventListener, 242 | options?: WebsocketEventListenerOptions, 243 | ): void { 244 | this._options.listeners[type].push({ listener, options }); // add listener to list of listeners 245 | } 246 | 247 | /** 248 | * Removes one or more event listener for the given event-type that match the given listener and options. 249 | * 250 | * @param type of the event to remove the listener for. 251 | * @param listener to remove. 252 | * @param options that were used when the listener was added. 253 | */ 254 | public removeEventListener( 255 | type: K, 256 | listener: WebsocketEventListener, 257 | options?: WebsocketEventListenerOptions, 258 | ): void { 259 | const isListenerNotToBeRemoved = ( 260 | l: WebsocketEventListenerWithOptions, 261 | ) => l.listener !== listener || l.options !== options; 262 | 263 | (this._options.listeners[type] as WebsocketEventListenerWithOptions[]) = 264 | this._options.listeners[type].filter(isListenerNotToBeRemoved); // only keep listeners that are not to be removed 265 | } 266 | 267 | /** 268 | * Creates a new browser-native websocket and connects it to the given URL with the given protocols 269 | * and adds all event listeners to the browser-native websocket. 270 | * 271 | * @return the created browser-native websocket which is also stored in the '_underlyingWebsocket' property. 272 | */ 273 | private tryConnect(): WebSocket { 274 | this._underlyingWebsocket = new WebSocket(this.url, this.protocols); // create new browser-native websocket and add all event listeners 275 | this._underlyingWebsocket.addEventListener( 276 | WebsocketEvent.open, 277 | this.handleOpenEvent, 278 | ); 279 | this._underlyingWebsocket.addEventListener( 280 | WebsocketEvent.close, 281 | this.handleCloseEvent, 282 | ); 283 | this._underlyingWebsocket.addEventListener( 284 | WebsocketEvent.error, 285 | this.handleErrorEvent, 286 | ); 287 | this._underlyingWebsocket.addEventListener( 288 | WebsocketEvent.message, 289 | this.handleMessageEvent, 290 | ); 291 | 292 | return this._underlyingWebsocket; 293 | } 294 | 295 | /** 296 | * Removes all event listeners from the browser-native websocket and closes it. 297 | */ 298 | private clearWebsocket() { 299 | this._underlyingWebsocket.removeEventListener( 300 | WebsocketEvent.open, 301 | this.handleOpenEvent, 302 | ); 303 | this._underlyingWebsocket.removeEventListener( 304 | WebsocketEvent.close, 305 | this.handleCloseEvent, 306 | ); 307 | this._underlyingWebsocket.removeEventListener( 308 | WebsocketEvent.error, 309 | this.handleErrorEvent, 310 | ); 311 | this._underlyingWebsocket.removeEventListener( 312 | WebsocketEvent.message, 313 | this.handleMessageEvent, 314 | ); 315 | this._underlyingWebsocket.close(); 316 | } 317 | 318 | /** 319 | * Handles the 'open' event of the browser-native websocket. 320 | * @param event to handle. 321 | */ 322 | private handleOpenEvent = (event: Event) => 323 | this.handleEvent(WebsocketEvent.open, event); 324 | 325 | /** 326 | * Handles the 'error' event of the browser-native websocket. 327 | * @param event to handle. 328 | */ 329 | private handleErrorEvent = (event: Event) => 330 | this.handleEvent(WebsocketEvent.error, event); 331 | 332 | /** 333 | * Handles the 'close' event of the browser-native websocket. 334 | * @param event to handle. 335 | */ 336 | private handleCloseEvent = (event: CloseEvent) => 337 | this.handleEvent(WebsocketEvent.close, event); 338 | 339 | /** 340 | * Handles the 'message' event of the browser-native websocket. 341 | * @param event to handle. 342 | */ 343 | private handleMessageEvent = (event: MessageEvent) => 344 | this.handleEvent(WebsocketEvent.message, event); 345 | 346 | /** 347 | * Dispatch an event to all listeners of the given event-type. 348 | * 349 | * @param type of the event to dispatch. 350 | * @param event to dispatch. 351 | */ 352 | private dispatchEvent( 353 | type: K, 354 | event: WebsocketEventMap[K], 355 | ) { 356 | const eventListeners: WebsocketEventListeners[K] = 357 | this._options.listeners[type]; 358 | const newEventListeners: WebsocketEventListeners[K] = []; 359 | 360 | eventListeners.forEach(({ listener, options }) => { 361 | listener(this, event); // invoke listener with event 362 | 363 | if ( 364 | options === undefined || 365 | options.once === undefined || 366 | !options.once 367 | ) { 368 | newEventListeners.push({ listener, options }); // only keep listener if it isn't a once-listener 369 | } 370 | }); 371 | 372 | this._options.listeners[type] = newEventListeners; // replace old listeners with new listeners that don't include once-listeners 373 | } 374 | 375 | /** 376 | * Handles the given event by dispatching it to all listeners of the given event-type. 377 | * 378 | * @param type of the event to handle. 379 | * @param event to handle. 380 | */ 381 | private handleEvent( 382 | type: K, 383 | event: WebsocketEventMap[K], 384 | ) { 385 | switch (type) { 386 | case WebsocketEvent.close: 387 | this.dispatchEvent(type, event); 388 | this.scheduleConnectionRetryIfNeeded(); // schedule a new connection retry if the websocket was closed by the server 389 | break; 390 | 391 | case WebsocketEvent.open: 392 | if (this.backoff !== undefined && this._lastConnection !== undefined) { 393 | // websocket was reconnected, dispatch reconnect event and reset backoff 394 | const detail: ReconnectEventDetail = { 395 | retries: this.backoff.retries, 396 | lastConnection: new Date(this._lastConnection), 397 | }; 398 | const event: CustomEvent = 399 | new CustomEvent(WebsocketEvent.reconnect, { 400 | detail, 401 | }); 402 | this.dispatchEvent(WebsocketEvent.reconnect, event); 403 | this.backoff.reset(); 404 | } 405 | this._lastConnection = new Date(); 406 | this.dispatchEvent(type, event); // dispatch open event and send buffered data 407 | this.sendBufferedData(); 408 | break; 409 | 410 | case WebsocketEvent.retry: 411 | this.dispatchEvent(type, event); // dispatch retry event and try to connect 412 | this.clearWebsocket(); // clear the old websocket 413 | this.tryConnect(); 414 | break; 415 | 416 | default: 417 | this.dispatchEvent(type, event); // dispatch event to all listeners of the given event-type 418 | break; 419 | } 420 | } 421 | 422 | /** 423 | * Sends buffered data if there is a buffer defined. 424 | */ 425 | private sendBufferedData() { 426 | if (this.buffer === undefined) { 427 | return; // no buffer defined, nothing to send 428 | } 429 | 430 | for ( 431 | let ele = this.buffer.read(); 432 | ele !== undefined; 433 | ele = this.buffer.read() 434 | ) { 435 | this.send(ele); // send buffered data 436 | } 437 | } 438 | 439 | /** 440 | * Schedules a connection-retry if there is a backoff defined and the websocket was not closed by the user. 441 | */ 442 | private scheduleConnectionRetryIfNeeded() { 443 | if (this.closedByUser) { 444 | return; // user closed the websocket, no retry 445 | } 446 | if (this.backoff === undefined) { 447 | return; // no backoff defined, no retry 448 | } 449 | 450 | // handler dispatches the retry event to all listeners of the retry event-type 451 | const handleRetryEvent = (detail: RetryEventDetail) => { 452 | const event: CustomEvent = new CustomEvent( 453 | WebsocketEvent.retry, 454 | { detail }, 455 | ); 456 | this.handleEvent(WebsocketEvent.retry, event); 457 | }; 458 | 459 | // create retry event detail, depending on the 'instantReconnect' option 460 | const retryEventDetail: RetryEventDetail = { 461 | backoff: 462 | this._options.retry.instantReconnect === true ? 0 : this.backoff.next(), 463 | retries: 464 | this._options.retry.instantReconnect === true 465 | ? 0 466 | : this.backoff.retries, 467 | lastConnection: this._lastConnection, 468 | }; 469 | 470 | // schedule a new connection-retry if the maximum number of retries is not reached yet 471 | if ( 472 | this._options.retry.maxRetries === undefined || 473 | retryEventDetail.retries <= this._options.retry.maxRetries 474 | ) { 475 | this.retryTimeout = globalThis.setTimeout( 476 | () => handleRetryEvent(retryEventDetail), 477 | retryEventDetail.backoff, 478 | ); 479 | } 480 | } 481 | 482 | /** 483 | * Cancels the scheduled connection-retry, if there is one. 484 | */ 485 | private cancelScheduledConnectionRetry() { 486 | globalThis.clearTimeout(this.retryTimeout); 487 | } 488 | } 489 | -------------------------------------------------------------------------------- /src/websocket_buffer.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A WebsocketBuffer is used to store messages temporarily until they can be sent. 3 | */ 4 | export interface WebsocketBuffer< 5 | E = string | ArrayBufferLike | Blob | ArrayBufferView, 6 | > { 7 | /** 8 | * Adds an element to the buffer. 9 | * @param element the element to add 10 | */ 11 | add(element: E): void; 12 | 13 | /** 14 | * Reads an element from the buffer. 15 | * @return an element from the buffer or undefined if the buffer is empty 16 | */ 17 | read(): E | undefined; 18 | } 19 | -------------------------------------------------------------------------------- /src/websocket_builder.ts: -------------------------------------------------------------------------------- 1 | import { Backoff } from "./backoff/backoff"; 2 | import { 3 | WebsocketEvent, 4 | WebsocketEventListener, 5 | WebsocketEventListenerOptions, 6 | } from "./websocket_event"; 7 | import { Websocket } from "./websocket"; 8 | import { WebsocketBuffer } from "./websocket_buffer"; 9 | import { WebsocketOptions } from "./websocket_options"; 10 | 11 | /** 12 | * Builder for websockets. 13 | */ 14 | export class WebsocketBuilder { 15 | private readonly _url: string; 16 | 17 | private _protocols?: string | string[]; 18 | private _options?: WebsocketOptions; 19 | 20 | /** 21 | * Creates a new WebsocketBuilder. 22 | * 23 | * @param url the url to connect to 24 | */ 25 | constructor(url: string) { 26 | this._url = url; 27 | } 28 | 29 | /** 30 | * Getter for the url. 31 | * 32 | * @returns the url 33 | */ 34 | get url(): string { 35 | return this._url; 36 | } 37 | 38 | /** 39 | * Adds protocols to the websocket. Subsequent calls to this method will override the previously set protocols. 40 | * 41 | * @param protocols the protocols to add 42 | */ 43 | public withProtocols( 44 | protocols: string | string[] | undefined, 45 | ): WebsocketBuilder { 46 | this._protocols = protocols; 47 | return this; 48 | } 49 | 50 | /** 51 | * Getter for the protocols. 52 | * 53 | * @returns the protocols, undefined if no protocols have been set 54 | */ 55 | get protocols(): string | string[] | undefined { 56 | return this._protocols; 57 | } 58 | 59 | /** 60 | * Sets the maximum number of retries before giving up. No limit if undefined. 61 | * 62 | * @param maxRetries the maximum number of retries before giving up 63 | */ 64 | public withMaxRetries(maxRetries: number | undefined): WebsocketBuilder { 65 | this._options = { 66 | ...this._options, 67 | retry: { ...this._options?.retry, maxRetries }, 68 | }; 69 | return this; 70 | } 71 | 72 | /** 73 | * Getter for the maximum number of retries before giving up. 74 | * 75 | * @returns the maximum number of retries before giving up, undefined if no maximum has been set 76 | */ 77 | get maxRetries(): number | undefined { 78 | return this._options?.retry?.maxRetries; 79 | } 80 | 81 | /** 82 | * Sets wether to reconnect immediately after a connection has been lost, ignoring the backoff strategy for the first retry. 83 | * 84 | * @param instantReconnect wether to reconnect immediately after a connection has been lost 85 | */ 86 | public withInstantReconnect( 87 | instantReconnect: boolean | undefined, 88 | ): WebsocketBuilder { 89 | this._options = { 90 | ...this._options, 91 | retry: { ...this._options?.retry, instantReconnect }, 92 | }; 93 | return this; 94 | } 95 | 96 | /** 97 | * Getter for wether to reconnect immediately after a connection has been lost, ignoring the backoff strategy for the first retry. 98 | * 99 | * @returns wether to reconnect immediately after a connection has been lost, undefined if no value has been set 100 | */ 101 | get instantReconnect(): boolean | undefined { 102 | return this._options?.retry?.instantReconnect; 103 | } 104 | 105 | /** 106 | * Adds a backoff to the websocket. Subsequent calls to this method will override the previously set backoff. 107 | * 108 | * @param backoff the backoff to add 109 | */ 110 | public withBackoff(backoff: Backoff | undefined): WebsocketBuilder { 111 | this._options = { 112 | ...this._options, 113 | retry: { ...this._options?.retry, backoff }, 114 | }; 115 | return this; 116 | } 117 | 118 | /** 119 | * Getter for the backoff. 120 | * 121 | * @returns the backoff, undefined if no backoff has been set 122 | */ 123 | get backoff(): Backoff | undefined { 124 | return this._options?.retry?.backoff; 125 | } 126 | 127 | /** 128 | * Adds a buffer to the websocket. Subsequent calls to this method will override the previously set buffer. 129 | * 130 | * @param buffer the buffer to add 131 | */ 132 | public withBuffer(buffer: WebsocketBuffer | undefined): WebsocketBuilder { 133 | this._options = { ...this._options, buffer }; 134 | return this; 135 | } 136 | 137 | /** 138 | * Getter for the buffer. 139 | * 140 | * @returns the buffer, undefined if no buffer has been set 141 | */ 142 | get buffer(): WebsocketBuffer | undefined { 143 | return this._options?.buffer; 144 | } 145 | 146 | /** 147 | * Adds an 'open' event listener to the websocket. Subsequent calls to this method will add additional listeners that will be 148 | * called in the order they were added. 149 | * 150 | * @param listener the listener to add 151 | * @param options the listener options 152 | */ 153 | public onOpen( 154 | listener: WebsocketEventListener, 155 | options?: WebsocketEventListenerOptions, 156 | ): WebsocketBuilder { 157 | this.addListener(WebsocketEvent.open, listener, options); 158 | return this; 159 | } 160 | 161 | /** 162 | * Adds an 'close' event listener to the websocket. Subsequent calls to this method will add additional listeners that will be 163 | * called in the order they were added. 164 | * 165 | * @param listener the listener to add 166 | * @param options the listener options 167 | */ 168 | public onClose( 169 | listener: WebsocketEventListener, 170 | options?: WebsocketEventListenerOptions, 171 | ): WebsocketBuilder { 172 | this.addListener(WebsocketEvent.close, listener, options); 173 | return this; 174 | } 175 | 176 | /** 177 | * Adds an 'error' event listener to the websocket. Subsequent calls to this method will add additional listeners that will be 178 | * called in the order they were added. 179 | * 180 | * @param listener the listener to add 181 | * @param options the listener options 182 | */ 183 | public onError( 184 | listener: WebsocketEventListener, 185 | options?: WebsocketEventListenerOptions, 186 | ): WebsocketBuilder { 187 | this.addListener(WebsocketEvent.error, listener, options); 188 | return this; 189 | } 190 | 191 | /** 192 | * Adds an 'message' event listener to the websocket. Subsequent calls to this method will add additional listeners that will be 193 | * called in the order they were added. 194 | * 195 | * @param listener the listener to add 196 | * @param options the listener options 197 | */ 198 | public onMessage( 199 | listener: WebsocketEventListener, 200 | options?: WebsocketEventListenerOptions, 201 | ): WebsocketBuilder { 202 | this.addListener(WebsocketEvent.message, listener, options); 203 | return this; 204 | } 205 | 206 | /** 207 | * Adds an 'retry' event listener to the websocket. Subsequent calls to this method will add additional listeners that will be 208 | * called in the order they were added. 209 | * 210 | * @param listener the listener to add 211 | * @param options the listener options 212 | */ 213 | public onRetry( 214 | listener: WebsocketEventListener, 215 | options?: WebsocketEventListenerOptions, 216 | ): WebsocketBuilder { 217 | this.addListener(WebsocketEvent.retry, listener, options); 218 | return this; 219 | } 220 | 221 | /** 222 | * Adds an 'reconnect' event listener to the websocket. Subsequent calls to this method will add additional listeners that will be 223 | * called in the order they were added. 224 | * 225 | * @param listener the listener to add 226 | * @param options the listener options 227 | */ 228 | public onReconnect( 229 | listener: WebsocketEventListener, 230 | options?: WebsocketEventListenerOptions, 231 | ): WebsocketBuilder { 232 | this.addListener(WebsocketEvent.reconnect, listener, options); 233 | return this; 234 | } 235 | 236 | /** 237 | * Builds the websocket. 238 | * 239 | * @return a new websocket, with the set options 240 | */ 241 | public build(): Websocket { 242 | return new Websocket(this._url, this._protocols, this._options); // instantiate the websocket with the set options 243 | } 244 | 245 | /** 246 | * Adds an event listener to the options. 247 | * 248 | * @param event the event to add the listener to 249 | * @param listener the listener to add 250 | * @param options the listener options 251 | */ 252 | private addListener( 253 | event: WebsocketEvent, 254 | listener: WebsocketEventListener, 255 | options?: WebsocketEventListenerOptions, 256 | ): WebsocketBuilder { 257 | this._options = { 258 | ...this._options, 259 | listeners: { 260 | open: this._options?.listeners?.open ?? [], 261 | close: this._options?.listeners?.close ?? [], 262 | error: this._options?.listeners?.error ?? [], 263 | message: this._options?.listeners?.message ?? [], 264 | retry: this._options?.listeners?.retry ?? [], 265 | reconnect: this._options?.listeners?.reconnect ?? [], 266 | [event]: [ 267 | ...(this._options?.listeners?.[event] ?? []), 268 | { listener, options }, 269 | ], 270 | }, 271 | }; 272 | return this; 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /src/websocket_event.ts: -------------------------------------------------------------------------------- 1 | import { Websocket } from "./websocket"; 2 | 3 | /** 4 | * Events that can be fired by the websocket. 5 | */ 6 | export enum WebsocketEvent { 7 | /** Fired when the connection is opened. */ 8 | open = "open", 9 | 10 | /** Fired when the connection is closed. */ 11 | close = "close", 12 | 13 | /** Fired when the connection has been closed because of an error, such as when some data couldn't be sent. */ 14 | error = "error", 15 | 16 | /** Fired when a message is received. */ 17 | message = "message", 18 | 19 | /** Fired when the websocket tries to reconnect after a connection loss. */ 20 | retry = "retry", 21 | 22 | /** Fired when the websocket successfully reconnects after a connection loss. */ 23 | reconnect = "reconnect", 24 | } 25 | 26 | /*** 27 | * Details/properties of a retry-event. 28 | */ 29 | export type RetryEventDetail = { 30 | /** Number of retries that have been made since the connection was lost. */ 31 | readonly retries: number; 32 | 33 | /** Time (ms) waited since the last connection-retry. */ 34 | readonly backoff: number; 35 | 36 | /** Timestamp of when the connection was lost or undefined if the connection has never been established. */ 37 | readonly lastConnection: Date | undefined; 38 | }; 39 | 40 | /** 41 | * Properties of a reconnect-event. 42 | */ 43 | export type ReconnectEventDetail = Omit; 44 | 45 | /** 46 | * Maps websocket events to their corresponding event. 47 | */ 48 | export type WebsocketEventMap = { 49 | [WebsocketEvent.open]: Event; 50 | [WebsocketEvent.close]: CloseEvent; 51 | [WebsocketEvent.error]: Event; 52 | [WebsocketEvent.message]: MessageEvent; 53 | [WebsocketEvent.retry]: CustomEvent; 54 | [WebsocketEvent.reconnect]: CustomEvent; 55 | }; 56 | 57 | /** 58 | * Listener for websocket events. 59 | * */ 60 | export type WebsocketEventListener = ( 61 | instance: Websocket, 62 | ev: WebsocketEventMap[K], 63 | ) => unknown; 64 | 65 | export type WebsocketEventListenerParams = Parameters< 66 | WebsocketEventListener 67 | >; 68 | 69 | /** 70 | * Options for websocket events. 71 | */ 72 | export type WebsocketEventListenerOptions = EventListenerOptions & 73 | AddEventListenerOptions; 74 | 75 | /** 76 | * Listener for websocket events with options. 77 | */ 78 | export type WebsocketEventListenerWithOptions = { 79 | readonly listener: WebsocketEventListener; 80 | readonly options?: WebsocketEventListenerOptions; 81 | }; 82 | 83 | /** 84 | * Maps websocket events to their corresponding event-listeners. 85 | */ 86 | export type WebsocketEventListeners = { 87 | [K in WebsocketEvent]: WebsocketEventListenerWithOptions[]; 88 | }; 89 | -------------------------------------------------------------------------------- /src/websocket_options.ts: -------------------------------------------------------------------------------- 1 | import { WebsocketBuffer } from "./websocket_buffer"; 2 | import { WebsocketConnectionRetryOptions } from "./websocket_retry_options"; 3 | import { WebsocketEventListeners } from "./websocket_event"; 4 | 5 | /** 6 | * Options that can be passed to the Websocket constructor. 7 | */ 8 | export interface WebsocketOptions { 9 | /** 10 | * The Buffer to use. 11 | */ 12 | readonly buffer?: WebsocketBuffer; 13 | 14 | /** 15 | * The options for the connection-retry-strategy. 16 | */ 17 | readonly retry?: WebsocketConnectionRetryOptions; 18 | 19 | /** 20 | * The initial listeners to add to the websocket. 21 | */ 22 | readonly listeners?: WebsocketEventListeners; 23 | } 24 | -------------------------------------------------------------------------------- /src/websocket_retry_options.ts: -------------------------------------------------------------------------------- 1 | import { Backoff } from "./backoff/backoff"; 2 | 3 | /** 4 | * Options for the websockets retry-strategy. 5 | */ 6 | export interface WebsocketConnectionRetryOptions { 7 | /** 8 | * The maximum number of retries before giving up. No limit if undefined. 9 | */ 10 | readonly maxRetries?: number; 11 | 12 | /** 13 | * Wether to reconnect immediately after a connection has been lost, 14 | * ignoring the backoff strategy for the first retry. 15 | */ 16 | readonly instantReconnect?: boolean; 17 | 18 | /** 19 | * The backoff strategy to use. This is used to determine the delay between connection-retries. 20 | */ 21 | readonly backoff?: Backoff; 22 | } 23 | -------------------------------------------------------------------------------- /tests/backoff/constantbackoff.test.ts: -------------------------------------------------------------------------------- 1 | import { ConstantBackoff } from "../../src"; 2 | import { describe, test, expect } from "vitest"; 3 | 4 | describe("Testsuite for ConstantBackoff", () => { 5 | test("Initialization should throw on negative backoff", () => { 6 | expect(() => new ConstantBackoff(-1001)).toThrow(); 7 | expect(() => new ConstantBackoff(-42)).toThrow(); 8 | expect(() => new ConstantBackoff(-1)).toThrow(); 9 | }); 10 | 11 | test("Initialization should not throw on zero backoff", () => { 12 | expect(() => new ConstantBackoff(0)).not.toThrow(); 13 | }); 14 | 15 | test("Initialization should not throw on positive backoff", () => { 16 | expect(() => new ConstantBackoff(1)).not.toThrow(); 17 | expect(() => new ConstantBackoff(42)).not.toThrow(); 18 | expect(() => new ConstantBackoff(1001)).not.toThrow(); 19 | }); 20 | 21 | test("Backoff should be equal to the given backoff", () => { 22 | expect(new ConstantBackoff(1).current).toBe(1); 23 | expect(new ConstantBackoff(42).current).toBe(42); 24 | expect(new ConstantBackoff(1001).current).toBe(1001); 25 | }); 26 | 27 | test("Backoff should be equal to the given backoff after next", () => { 28 | const backoff = new ConstantBackoff(42); 29 | expect(backoff.next()).toBe(42); 30 | expect(backoff.current).toBe(42); 31 | }); 32 | 33 | test("Backoff should be equal to the given backoff after reset", () => { 34 | const backoff = new ConstantBackoff(42); 35 | expect(backoff.next()).toBe(42); 36 | backoff.reset(); 37 | expect(backoff.current).toBe(42); 38 | }); 39 | 40 | test("Retries should be zero after initialization", () => { 41 | expect(new ConstantBackoff(42).retries).toBe(0); 42 | }); 43 | 44 | test("Retries should increment with each next", () => { 45 | const backoff = new ConstantBackoff(42); 46 | for (let i = 0; i < 100; i++) { 47 | expect(backoff.retries).toBe(i); 48 | backoff.next(); 49 | } 50 | }); 51 | 52 | test("Retries should be zero after reset", () => { 53 | const backoff = new ConstantBackoff(42); 54 | for (let i = 0; i < 100; i++) { 55 | backoff.next(); 56 | } 57 | backoff.reset(); 58 | expect(backoff.retries).toBe(0); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /tests/backoff/exponentialbackoff.test.ts: -------------------------------------------------------------------------------- 1 | import { ExponentialBackoff } from "../../src"; 2 | import { describe, test, expect } from "vitest"; 3 | 4 | describe("Testsuite for ExponentialBackoff", () => { 5 | test("Initialization should throw on negative base", () => { 6 | expect(() => new ExponentialBackoff(-1001)).toThrow(); 7 | expect(() => new ExponentialBackoff(-42)).toThrow(); 8 | expect(() => new ExponentialBackoff(-1)).toThrow(); 9 | }); 10 | 11 | test("Initialization should not throw on zero base", () => { 12 | expect(() => new ExponentialBackoff(0)).not.toThrow(); 13 | }); 14 | 15 | test("Initialization should not throw on positive base", () => { 16 | expect(() => new ExponentialBackoff(1)).not.toThrow(); 17 | expect(() => new ExponentialBackoff(42)).not.toThrow(); 18 | expect(() => new ExponentialBackoff(1001)).not.toThrow(); 19 | }); 20 | 21 | test("Initialization should throw on negative max-exponent", () => { 22 | expect(() => new ExponentialBackoff(42, -1001)).toThrow(); 23 | expect(() => new ExponentialBackoff(42, -42)).toThrow(); 24 | expect(() => new ExponentialBackoff(42, -1)).toThrow(); 25 | }); 26 | 27 | test("Initialization should not throw on zero max-exponent", () => { 28 | expect(() => new ExponentialBackoff(42, 0)).not.toThrow(); 29 | }); 30 | 31 | test("Initialization should not throw on positive max-exponent", () => { 32 | expect(() => new ExponentialBackoff(42, 1)).not.toThrow(); 33 | expect(() => new ExponentialBackoff(42, 42)).not.toThrow(); 34 | expect(() => new ExponentialBackoff(42, 1001)).not.toThrow(); 35 | }); 36 | 37 | test("Initialization should not throw on undefined max-exponent", () => { 38 | expect(() => new ExponentialBackoff(42)).not.toThrow(); 39 | }); 40 | 41 | test("Backoff should be equal to the given base", () => { 42 | expect(new ExponentialBackoff(1).current).toBe(1); 43 | expect(new ExponentialBackoff(42).current).toBe(42); 44 | expect(new ExponentialBackoff(1001).current).toBe(1001); 45 | }); 46 | 47 | test("Backoff without max-exponent should double with each next", () => { 48 | const backoff = new ExponentialBackoff(2); 49 | for (let i = 0; i < 10; i++) { 50 | expect(backoff.current).toBe(2 * Math.pow(2, i)); 51 | expect(backoff.next()).toBe(2 * Math.pow(2, i + 1)); 52 | expect(backoff.current).toBe(2 * Math.pow(2, i + 1)); 53 | } 54 | }); 55 | 56 | test("Backoff with max-exponent should not exceed max-exponent", () => { 57 | const backoff = new ExponentialBackoff(2, 3); 58 | for (let i = 0; i < 10; i++) { 59 | expect(backoff.current).toBe(2 * Math.pow(2, Math.min(3, i))); 60 | expect(backoff.next()).toBe(2 * Math.pow(2, Math.min(3, i + 1))); 61 | expect(backoff.current).toBe(2 * Math.pow(2, Math.min(3, i + 1))); 62 | } 63 | }); 64 | 65 | test("Backoff should be equal to the given base after reset", () => { 66 | const backoff = new ExponentialBackoff(42); 67 | backoff.next(); 68 | backoff.reset(); 69 | expect(backoff.current).toBe(42); 70 | }); 71 | 72 | test("Retries should be zero after initialization", () => { 73 | expect(new ExponentialBackoff(42).retries).toBe(0); 74 | }); 75 | 76 | test("Retries should be incremented with each next", () => { 77 | const backoff = new ExponentialBackoff(42); 78 | for (let i = 0; i < 10; i++) { 79 | expect(backoff.retries).toBe(i); 80 | backoff.next(); 81 | } 82 | }); 83 | 84 | test("Retries should be zero after reset", () => { 85 | const backoff = new ExponentialBackoff(42); 86 | for (let i = 0; i < 100; i++) { 87 | backoff.next(); 88 | } 89 | backoff.reset(); 90 | expect(backoff.retries).toBe(0); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /tests/backoff/linearbackoff.test.ts: -------------------------------------------------------------------------------- 1 | import { LinearBackoff } from "../../src"; 2 | import { describe, test, expect } from "vitest"; 3 | 4 | describe("Testsuite for LinearBackoff", () => { 5 | test("Initialization should throw on negative initial-backoff", () => { 6 | expect(() => new LinearBackoff(-1001, 1000)).toThrow(); 7 | expect(() => new LinearBackoff(-42, 1000)).toThrow(); 8 | expect(() => new LinearBackoff(-1, 1000)).toThrow(); 9 | }); 10 | 11 | test("Initialization should not throw on zero initial-backoff", () => { 12 | expect(() => new LinearBackoff(0, 1000)).not.toThrow(); 13 | }); 14 | 15 | test("Initialization should not throw on positive initial-backoff", () => { 16 | expect(() => new LinearBackoff(1, 1000)).not.toThrow(); 17 | expect(() => new LinearBackoff(42, 1000)).not.toThrow(); 18 | expect(() => new LinearBackoff(1001, 1000)).not.toThrow(); 19 | }); 20 | 21 | test("Initialization should throw on negative increment", () => { 22 | expect(() => new LinearBackoff(1000, -1001)).toThrow(); 23 | expect(() => new LinearBackoff(1000, -42)).toThrow(); 24 | expect(() => new LinearBackoff(1000, -1)).toThrow(); 25 | }); 26 | 27 | test("Initialization should not throw on zero increment", () => { 28 | expect(() => new LinearBackoff(1000, 0)).not.toThrow(); 29 | }); 30 | 31 | test("Initialization should not throw on positive increment", () => { 32 | expect(() => new LinearBackoff(1000, 1)).not.toThrow(); 33 | expect(() => new LinearBackoff(1000, 42)).not.toThrow(); 34 | expect(() => new LinearBackoff(1000, 1001)).not.toThrow(); 35 | }); 36 | 37 | test("Initialization should throw on negative max-backoff", () => { 38 | expect(() => new LinearBackoff(1000, 1000, -1001)).toThrow(); 39 | expect(() => new LinearBackoff(1000, 1000, -42)).toThrow(); 40 | expect(() => new LinearBackoff(1000, 1000, -1)).toThrow(); 41 | }); 42 | 43 | test("Initialization should not throw on undefined max-backoff", () => { 44 | expect(() => new LinearBackoff(1000, 1000, undefined)).not.toThrow(); 45 | }); 46 | 47 | test("Initialization should throw on max-backoff smaller than initial-backoff", () => { 48 | expect(() => new LinearBackoff(1000, 1000, 999)).toThrow(); 49 | expect(() => new LinearBackoff(1000, 1000, 42)).toThrow(); 50 | expect(() => new LinearBackoff(1000, 1000, 1)).toThrow(); 51 | }); 52 | 53 | test("Backoff should be equal to the given initial-backoff", () => { 54 | expect(new LinearBackoff(1, 1000).current).toBe(1); 55 | expect(new LinearBackoff(42, 1000).current).toBe(42); 56 | expect(new LinearBackoff(1001, 1000).current).toBe(1001); 57 | }); 58 | 59 | test("Backoff should increment by the given increment with each next", () => { 60 | const backoff = new LinearBackoff(1000, 1000); 61 | for (let i = 0; i < 10; i++) { 62 | expect(backoff.current).toBe(1000 + i * 1000); 63 | expect(backoff.next()).toBe(1000 + (i + 1) * 1000); 64 | expect(backoff.current).toBe(1000 + (i + 1) * 1000); 65 | } 66 | }); 67 | 68 | test("Backoff should increment with the given increment but not exceed the given max-backoff", () => { 69 | const backoff = new LinearBackoff(1000, 1000, 5000); 70 | for (let i = 0; i < 10; i++) { 71 | expect(backoff.current).toBe(Math.min(1000 + i * 1000, 5000)); 72 | expect(backoff.next()).toBe(Math.min(1000 + (i + 1) * 1000, 5000)); 73 | expect(backoff.current).toBe(Math.min(1000 + (i + 1) * 1000, 5000)); 74 | } 75 | }); 76 | 77 | test("Backoff should be equal to the given initial-backoff after reset", () => { 78 | const backoff = new LinearBackoff(42, 1000); 79 | backoff.next(); 80 | backoff.reset(); 81 | expect(backoff.current).toBe(42); 82 | }); 83 | 84 | test("Retries should be zero after initialization", () => { 85 | expect(new LinearBackoff(1000, 1000).retries).toBe(0); 86 | }); 87 | 88 | test("Retries should be incremented with each next", () => { 89 | const backoff = new LinearBackoff(1000, 1000); 90 | for (let i = 0; i < 10; i++) { 91 | expect(backoff.retries).toBe(i); 92 | backoff.next(); 93 | } 94 | }); 95 | 96 | test("Retries should be reset to zero after reset", () => { 97 | const backoff = new LinearBackoff(1000, 1000); 98 | for (let i = 0; i < 100; i++) { 99 | backoff.next(); 100 | } 101 | backoff.reset(); 102 | expect(backoff.retries).toBe(0); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /tests/queue/array_queue.test.ts: -------------------------------------------------------------------------------- 1 | import { ArrayQueue } from "../../src"; 2 | import { describe, test, expect } from "vitest"; 3 | 4 | describe("Testsuite for ArrayQueue", () => { 5 | test("Queue should be empty after initialization", () => { 6 | expect(new ArrayQueue().isEmpty()).toBe(true); 7 | }); 8 | 9 | test("Add should add an element to the queue", () => { 10 | const queue = new ArrayQueue(); 11 | queue.add(1); 12 | expect(queue.length()).toBe(1); 13 | expect(queue.isEmpty()).toBe(false); 14 | expect(queue.read()).toBe(1); 15 | }); 16 | 17 | test("Add multiple elements should add all elements to the queue", () => { 18 | const queue = new ArrayQueue(); 19 | for (let i = 0; i < 100; i++) { 20 | queue.add(i); 21 | expect(queue.length()).toBe(i + 1); 22 | } 23 | }); 24 | 25 | test("Clear should remove all elements from the queue", () => { 26 | const queue = new ArrayQueue(); 27 | for (let i = 0; i < 100; i++) { 28 | queue.add(i); 29 | } 30 | queue.clear(); 31 | expect(queue.length()).toBe(0); 32 | expect(queue.peek()).toBe(undefined); 33 | expect(queue.read()).toBe(undefined); 34 | }); 35 | 36 | test("ForEach should iterate over all elements in the queue", () => { 37 | const queue = new ArrayQueue(); 38 | for (let i = 0; i < 100; i++) { 39 | queue.add(i); 40 | } 41 | let i = 0; 42 | queue.forEach((element) => { 43 | expect(element).toBe(i); 44 | i++; 45 | }); 46 | }); 47 | 48 | test("ForEach should not remove elements from the queue", () => { 49 | const queue = new ArrayQueue(); 50 | for (let i = 0; i < 100; i++) { 51 | queue.add(i); 52 | } 53 | let i = 0; 54 | queue.forEach((element) => { 55 | expect(element).toBe(i); 56 | i++; 57 | }); 58 | const es: number[] = []; 59 | queue.forEach((element) => es.push(element)); 60 | expect(es.length).toBe(100); 61 | }); 62 | 63 | test("Length should be zero on initialization", () => { 64 | expect(new ArrayQueue().length()).toBe(0); 65 | }); 66 | 67 | test("Length should be zero after clear", () => { 68 | const queue = new ArrayQueue(); 69 | for (let i = 0; i < 100; i++) { 70 | queue.add(i); 71 | } 72 | queue.clear(); 73 | expect(queue.length()).toBe(0); 74 | }); 75 | 76 | test("Length should be zero after reading all elements", () => { 77 | const queue = new ArrayQueue(); 78 | for (let i = 0; i < 100; i++) { 79 | queue.add(i); 80 | } 81 | for (let i = 0; i < 100; i++) { 82 | queue.read(); 83 | } 84 | expect(queue.length()).toBe(0); 85 | expect(queue.isEmpty()).toBe(true); 86 | expect(queue.peek()).toBeUndefined(); 87 | expect(queue.read()).toBeUndefined(); 88 | }); 89 | 90 | test("IsEmpty should be true after initialization", () => { 91 | expect(new ArrayQueue().isEmpty()).toBe(true); 92 | }); 93 | 94 | test("IsEmpty should be true after clear", () => { 95 | const queue = new ArrayQueue(); 96 | for (let i = 0; i < 100; i++) { 97 | queue.add(i); 98 | } 99 | queue.clear(); 100 | expect(queue.isEmpty()).toBe(true); 101 | }); 102 | 103 | test("IsEmpty should be true after reading all elements", () => { 104 | const queue = new ArrayQueue(); 105 | for (let i = 0; i < 100; i++) { 106 | queue.add(i); 107 | } 108 | for (let i = 0; i < 100; i++) { 109 | queue.read(); 110 | } 111 | expect(queue.isEmpty()).toBe(true); 112 | }); 113 | 114 | test("Peek should return undefined on initialization", () => { 115 | expect(new ArrayQueue().peek()).toBeUndefined(); 116 | }); 117 | 118 | test("Peek should return undefined after clear", () => { 119 | const queue = new ArrayQueue(); 120 | for (let i = 0; i < 100; i++) { 121 | queue.add(i); 122 | } 123 | queue.clear(); 124 | expect(queue.peek()).toBeUndefined(); 125 | }); 126 | 127 | test("Peek should return the oldest element", () => { 128 | const queue = new ArrayQueue(); 129 | for (let i = 0; i < 100; i++) { 130 | queue.add(i); 131 | expect(queue.peek()).toBe(0); 132 | } 133 | }); 134 | 135 | test("Peek should not remove the oldest element", () => { 136 | const queue = new ArrayQueue(); 137 | for (let i = 0; i < 100; i++) { 138 | queue.add(i); 139 | expect(queue.peek()).toBe(0); 140 | } 141 | }); 142 | 143 | test("Read should return undefined on initialization", () => { 144 | expect(new ArrayQueue().read()).toBeUndefined(); 145 | }); 146 | 147 | test("Read should return undefined after clear", () => { 148 | const queue = new ArrayQueue(); 149 | queue.add(1); 150 | queue.add(2); 151 | queue.clear(); 152 | expect(queue.read()).toBeUndefined(); 153 | }); 154 | 155 | test("Read should return undefined after reading all elements", () => { 156 | const queue = new ArrayQueue(); 157 | queue.add(1); 158 | queue.add(2); 159 | queue.read(); 160 | queue.read(); 161 | expect(queue.read()).toBeUndefined(); 162 | }); 163 | 164 | test("Read should return the oldest element", () => { 165 | const queue = new ArrayQueue(); 166 | queue.add(1); 167 | queue.add(2); 168 | expect(queue.read()).toBe(1); 169 | expect(queue.read()).toBe(2); 170 | }); 171 | }); 172 | -------------------------------------------------------------------------------- /tests/queue/ring_queue.test.ts: -------------------------------------------------------------------------------- 1 | import { RingQueue } from "../../src"; 2 | import { describe, test, expect } from "vitest"; 3 | 4 | describe("Testsuite for RingQueue", () => { 5 | test("Initialization should throw on negative capacity", () => { 6 | expect(() => new RingQueue(-1)).toThrow(); 7 | expect(() => new RingQueue(-42)).toThrow(); 8 | expect(() => new RingQueue(-1001)).toThrow(); 9 | }); 10 | 11 | test("Initialization should throw on zero capacity", () => { 12 | expect(() => new RingQueue(0)).toThrow(); 13 | }); 14 | 15 | test("Initialization should not throw on positive capacity", () => { 16 | expect(() => new RingQueue(1)).not.toThrow(); 17 | expect(() => new RingQueue(42)).not.toThrow(); 18 | expect(() => new RingQueue(1001)).not.toThrow(); 19 | }); 20 | 21 | test("Add should add an element to the queue", () => { 22 | const queue = new RingQueue(42); 23 | queue.add(1); 24 | expect(queue.length()).toBe(1); 25 | expect(queue.isEmpty()).toBe(false); 26 | expect(queue.read()).toBe(1); 27 | }); 28 | 29 | test("Add should overwrite the oldest element if the queue is full", () => { 30 | const queue = new RingQueue(42); 31 | for (let i = 0; i < 100; i++) { 32 | queue.add(i); // 0, 1, 2, ..., 99 33 | } 34 | expect(queue.read()).toBe(58); // 58, 59, ..., 99 35 | }); 36 | 37 | test("Clear should remove all elements from the queue", () => { 38 | const queue = new RingQueue(42); 39 | for (let i = 0; i < 100; i++) { 40 | queue.add(i); 41 | } 42 | queue.clear(); 43 | expect(queue.length()).toBe(0); 44 | expect(queue.peek()).toBe(undefined); 45 | expect(queue.read()).toBe(undefined); 46 | }); 47 | 48 | test("ForEach should iterate over all elements in the queue", () => { 49 | const queue = new RingQueue(42); 50 | for (let i = 0; i < 100; i++) { 51 | queue.add(i); 52 | } 53 | let i = 58; 54 | queue.forEach((element) => { 55 | expect(element).toBe(i); 56 | i++; 57 | }); 58 | }); 59 | 60 | test("ForEach should not remove elements from the queue", () => { 61 | const queue = new RingQueue(42); 62 | for (let i = 0; i < 100; i++) { 63 | queue.add(i); 64 | } 65 | let i = 58; 66 | queue.forEach((element) => { 67 | expect(element).toBe(i); 68 | i++; 69 | }); 70 | queue.forEach((element) => { 71 | expect(queue.read()).toBe(element); 72 | }); 73 | }); 74 | 75 | test("Length should be zero on initialization", () => { 76 | expect(new RingQueue(1).length()).toBe(0); 77 | expect(new RingQueue(42).length()).toBe(0); 78 | expect(new RingQueue(1001).length()).toBe(0); 79 | }); 80 | 81 | test("Length should be zero after clear", () => { 82 | const queue = new RingQueue(42); 83 | queue.add(1); 84 | queue.add(2); 85 | queue.clear(); 86 | expect(queue.length()).toBe(0); 87 | }); 88 | 89 | test("Length should be zero after reading all elements", () => { 90 | const queue = new RingQueue(42); 91 | queue.add(1); 92 | queue.add(2); 93 | queue.read(); 94 | queue.read(); 95 | expect(queue.length()).toBe(0); 96 | }); 97 | 98 | test("Length should be equal to the number of available elements", () => { 99 | const queue = new RingQueue(42); 100 | for (let i = 0; i < 100; i++) { 101 | queue.add(i); 102 | expect(queue.length()).toBe(Math.min(i + 1, 42)); 103 | } 104 | }); 105 | 106 | test("Length should be equal to the capacity after adding more elements than the capacity", () => { 107 | const queue = new RingQueue(42); 108 | for (let i = 0; i < 100; i++) { 109 | queue.add(i); 110 | } 111 | expect(queue.length()).toBe(42); 112 | }); 113 | 114 | test("Length should be equal to the number of available elements after reading", () => { 115 | const queue = new RingQueue(42); 116 | for (let i = 0; i < 100; i++) { 117 | queue.add(i); 118 | expect(queue.length()).toBe(Math.min(i + 1, 42)); 119 | } 120 | for (let i = 0; i < 100; i++) { 121 | queue.read(); 122 | expect(queue.length()).toBe(Math.max(42 - i - 1, 0)); 123 | } 124 | }); 125 | 126 | test("Is empty should be true on initialization", () => { 127 | expect(new RingQueue(1).isEmpty()).toBe(true); 128 | expect(new RingQueue(42).isEmpty()).toBe(true); 129 | expect(new RingQueue(1001).isEmpty()).toBe(true); 130 | }); 131 | 132 | test("Is empty should be true after clear", () => { 133 | const queue = new RingQueue(42); 134 | queue.add(1); 135 | queue.add(2); 136 | queue.clear(); 137 | expect(queue.isEmpty()).toBe(true); 138 | }); 139 | 140 | test("Is empty should be true after reading all elements", () => { 141 | const queue = new RingQueue(42); 142 | queue.add(1); 143 | queue.add(2); 144 | queue.read(); 145 | queue.read(); 146 | expect(queue.isEmpty()).toBe(true); 147 | }); 148 | 149 | test("Is empty should be false after adding an element", () => { 150 | const queue = new RingQueue(42); 151 | queue.add(1); 152 | expect(queue.isEmpty()).toBe(false); 153 | }); 154 | 155 | test("Is empty should be false after adding more elements than the capacity", () => { 156 | const queue = new RingQueue(42); 157 | for (let i = 0; i < 100; i++) { 158 | queue.add(i); 159 | } 160 | expect(queue.isEmpty()).toBe(false); 161 | }); 162 | 163 | test("Peek should return undefined on initialization", () => { 164 | expect(new RingQueue(1).peek()).toBeUndefined(); 165 | expect(new RingQueue(42).peek()).toBeUndefined(); 166 | expect(new RingQueue(1001).peek()).toBeUndefined(); 167 | }); 168 | 169 | test("Peek should return undefined after clear", () => { 170 | const queue = new RingQueue(42); 171 | queue.add(1); 172 | queue.add(2); 173 | queue.clear(); 174 | expect(queue.peek()).toBeUndefined(); 175 | }); 176 | 177 | test("Peek should return the oldest element", () => { 178 | const queue = new RingQueue(42); 179 | for (let i = 0; i < 100; i++) { 180 | queue.add(i); 181 | expect(queue.peek()).toBe(Math.max(i - 42 + 1, 0)); 182 | } 183 | }); 184 | 185 | test("Peek should not remove elements from the queue", () => { 186 | const queue = new RingQueue(42); 187 | for (let i = 0; i < 100; i++) { 188 | queue.add(i); 189 | expect(queue.peek()).toBe(Math.max(i - 42 + 1, 0)); 190 | } 191 | for (let i = 0; i < 100; i++) { 192 | expect(queue.peek()).toBe(58); 193 | } 194 | }); 195 | 196 | test("Read should return undefined on initialization", () => { 197 | expect(new RingQueue(1).read()).toBeUndefined(); 198 | expect(new RingQueue(42).read()).toBeUndefined(); 199 | expect(new RingQueue(1001).read()).toBeUndefined(); 200 | }); 201 | 202 | test("Read should return undefined after clear", () => { 203 | const queue = new RingQueue(42); 204 | queue.add(1); 205 | queue.add(2); 206 | queue.clear(); 207 | expect(queue.read()).toBeUndefined(); 208 | }); 209 | 210 | test("Read should return undefined after reading all elements", () => { 211 | const queue = new RingQueue(42); 212 | queue.add(1); 213 | queue.add(2); 214 | queue.read(); 215 | queue.read(); 216 | expect(queue.read()).toBeUndefined(); 217 | }); 218 | 219 | test("Read should return the oldest element", () => { 220 | const queue = new RingQueue(42); 221 | queue.add(1); 222 | queue.add(2); 223 | expect(queue.read()).toBe(1); 224 | expect(queue.read()).toBe(2); 225 | }); 226 | 227 | test("Read should return the oldest element after adding more elements than the capacity", () => { 228 | const queue = new RingQueue(42); 229 | for (let i = 0; i < 100; i++) { 230 | queue.add(i); 231 | } 232 | for (let i = 0; i < 42; i++) { 233 | expect(queue.read()).toBe(100 - 42 + i); 234 | } 235 | expect(queue.read()).toBeUndefined(); 236 | }); 237 | }); 238 | -------------------------------------------------------------------------------- /tests/websocket.test.ts: -------------------------------------------------------------------------------- 1 | import { WebSocketServer, WebSocket } from "ws"; 2 | import { 3 | ArrayQueue, 4 | Backoff, 5 | ConstantBackoff, 6 | Websocket, 7 | WebsocketBuilder, 8 | } from "../src"; 9 | import { 10 | WebsocketEvent, 11 | WebsocketEventListenerParams, 12 | WebsocketEventListenerWithOptions, 13 | } from "../src"; 14 | import { WebsocketBuffer } from "../src"; 15 | import { 16 | describe, 17 | test, 18 | expect, 19 | beforeAll, 20 | beforeEach, 21 | afterEach, 22 | } from "vitest"; 23 | 24 | describe("Testsuite for Websocket", () => { 25 | const port: number = process.env.PORT ? parseInt(process.env.PORT) : 41337; 26 | const url: string = process.env.URL ?? `ws://localhost:${port}`; 27 | const serverTimeout: number = process.env.SERVER_TIMEOUT 28 | ? parseInt(process.env.SERVER_TIMEOUT) 29 | : 5_000; 30 | const clientTimeout: number = process.env.CLIENT_TIMEOUT 31 | ? parseInt(process.env.CLIENT_TIMEOUT) 32 | : 5_000; 33 | const testTimeout: number = process.env.TEST_TIMEOUT 34 | ? parseInt(process.env.TEST_TIMEOUT) 35 | : 10_000; 36 | 37 | let client: Websocket | undefined; // subject under test 38 | let server: WebSocketServer | undefined; // websocket server used for testing 39 | 40 | /** Before all tests, log the test configuration. */ 41 | beforeAll(() => 42 | console.log( 43 | `Testing websocket on ${url}, server timeout: ${serverTimeout}ms, client timeout: ${clientTimeout}ms`, 44 | ), 45 | ); 46 | 47 | /** Before each test, start a websocket server on the given port. */ 48 | beforeEach(async () => { 49 | await startServer(port, serverTimeout).then((s) => (server = s)); 50 | }, testTimeout); 51 | 52 | /** After each test, stop the websocket server. */ 53 | afterEach(async () => { 54 | await stopClient(client, clientTimeout).then(() => (client = undefined)); 55 | await stopServer(server, serverTimeout).then(() => (server = undefined)); 56 | }, testTimeout); 57 | 58 | describe("Getter/setter tests", () => { 59 | describe("Url", () => { 60 | test("Websocket should return the correct url", () => { 61 | const client = new Websocket(url); 62 | expect(client.url).toBe(url); 63 | }); 64 | }); 65 | 66 | describe("Protocols", () => { 67 | test("Websocket should return the correct protocols when protocols are a string", () => { 68 | const protocols = "protocol1"; 69 | const client = new Websocket(url, protocols); 70 | expect(client.protocols).toEqual(protocols); 71 | }); 72 | 73 | test("Websocket should return the correct protocols when protocols are an array", () => { 74 | const protocols = ["protocol1", "protocol2"]; 75 | const client = new Websocket(url, protocols); 76 | expect(client.protocols).toEqual(protocols); 77 | }); 78 | 79 | test("Websocket should return the correct protocols when protocols are undefined", () => { 80 | const client = new Websocket(url); 81 | expect(client.protocols).toBeUndefined(); 82 | }); 83 | }); 84 | 85 | describe("Buffer", () => { 86 | test("Websocket should return the correct buffer when buffer is undefined", () => { 87 | const client = new Websocket(url); 88 | expect(client.buffer).toBeUndefined(); 89 | }); 90 | 91 | test("Websocket should return the correct buffer when buffer is set", () => { 92 | const buffer: WebsocketBuffer = new ArrayQueue(); 93 | const client = new Websocket(url, undefined, { buffer }); 94 | expect(client.buffer).toBe(buffer); 95 | }); 96 | }); 97 | 98 | describe("MaxRetries", () => { 99 | test("Websocket should return the correct maxRetries when maxRetries is undefined", () => { 100 | const client = new Websocket(url); 101 | expect(client.maxRetries).toBeUndefined(); 102 | }); 103 | 104 | test("Websocket should return the correct maxRetries when maxRetries is set", () => { 105 | const maxRetries = 5; 106 | const client = new Websocket(url, undefined, { retry: { maxRetries } }); 107 | expect(client.maxRetries).toBe(maxRetries); 108 | }); 109 | }); 110 | 111 | describe("InstantReconnect", () => { 112 | test("Websocket should return the correct instantReconnect when instantReconnect is undefined", () => { 113 | const client = new Websocket(url); 114 | expect(client.instantReconnect).toBeUndefined(); 115 | }); 116 | 117 | test("Websocket should return the correct instantReconnect when instantReconnect is set", () => { 118 | const instantReconnect = true; 119 | const client = new Websocket(url, undefined, { 120 | retry: { instantReconnect }, 121 | }); 122 | expect(client.instantReconnect).toBe(instantReconnect); 123 | }); 124 | }); 125 | 126 | describe("Backoff", () => { 127 | test("Websocket should return the correct backoff when backoff is undefined", () => { 128 | const client = new Websocket(url); 129 | expect(client.backoff).toBeUndefined(); 130 | }); 131 | 132 | test("Websocket should return the correct backoff when backoff is set", () => { 133 | const backoff: Backoff = new ConstantBackoff(1000); 134 | const client = new Websocket(url, undefined, { retry: { backoff } }); 135 | expect(client.backoff).toBe(backoff); 136 | }); 137 | }); 138 | 139 | describe("ClosedByUser", () => { 140 | test("Websocket should return false after initialization", () => { 141 | const client = new Websocket(url); 142 | expect(client.closedByUser).toBe(false); 143 | }); 144 | 145 | test("Websocket should return true after the client closes the connection", async () => { 146 | await new Promise>( 147 | (resolve) => { 148 | client = new WebsocketBuilder(url) 149 | .onOpen((instance) => instance.close()) 150 | .onClose((instance, ev) => resolve([instance, ev])) 151 | .build(); 152 | }, 153 | ).then(([instance, ev]) => { 154 | expect(instance).toBe(client); 155 | expect(ev.type).toBe(WebsocketEvent.close); 156 | expect(instance.closedByUser).toBe(true); 157 | }); 158 | }); 159 | 160 | test("Websocket should return false if the server closes the connection", async () => { 161 | await new Promise>( 162 | (resolve) => { 163 | client = new WebsocketBuilder(url) 164 | .onOpen(() => closeServer(server)) 165 | .onClose((instance, ev) => resolve([instance, ev])) 166 | .build(); 167 | }, 168 | ).then(([instance, ev]) => { 169 | expect(instance).toBe(client); 170 | expect(ev.type).toBe(WebsocketEvent.close); 171 | expect(instance.closedByUser).toBe(false); 172 | }); 173 | }); 174 | }); 175 | 176 | describe("LastConnection", () => { 177 | test("Websocket should return undefined after initialization", () => { 178 | const client = new Websocket(url); 179 | expect(client.lastConnection).toBeUndefined(); 180 | }); 181 | 182 | test("Websocket should return the correct date after the client connects to the server", async () => { 183 | await new Promise>( 184 | (resolve) => { 185 | client = new WebsocketBuilder(url) 186 | .onOpen((instance, ev) => resolve([instance, ev])) 187 | .build(); 188 | }, 189 | ).then(([instance, ev]) => { 190 | expect(instance).toBe(client); 191 | expect(ev.type).toBe(WebsocketEvent.open); 192 | expect(instance.lastConnection).not.toBeUndefined(); 193 | }); 194 | }); 195 | }); 196 | 197 | describe("UnderlyingWebsocket", () => { 198 | test("Websocket should return native websocket after initialization", () => { 199 | const client = new Websocket(url); 200 | expect(client.underlyingWebsocket).not.toBeUndefined(); 201 | expect(client.underlyingWebsocket).toBeInstanceOf(window.WebSocket); 202 | }); 203 | 204 | test("Websocket should return the underlying websocket after the client connects to the server", async () => { 205 | await new Promise>( 206 | (resolve) => { 207 | client = new WebsocketBuilder(url) 208 | .onOpen((instance, ev) => resolve([instance, ev])) 209 | .build(); 210 | }, 211 | ).then(([instance, ev]) => { 212 | expect(instance).toBe(client); 213 | expect(ev.type).toBe(WebsocketEvent.open); 214 | expect(instance.underlyingWebsocket).not.toBeUndefined(); 215 | expect(instance.underlyingWebsocket).toBeInstanceOf(window.WebSocket); 216 | }); 217 | }); 218 | 219 | test("Websocket should return the underlying websocket after the client closes the connection", async () => { 220 | await new Promise>( 221 | (resolve) => { 222 | client = new WebsocketBuilder(url) 223 | .onOpen((instance) => instance.close()) 224 | .onClose((instance, ev) => resolve([instance, ev])) 225 | .build(); 226 | }, 227 | ).then(([instance, ev]) => { 228 | expect(instance).toBe(client); 229 | expect(ev.type).toBe(WebsocketEvent.close); 230 | expect(instance.underlyingWebsocket).not.toBeUndefined(); 231 | expect(instance.underlyingWebsocket).toBeInstanceOf(window.WebSocket); 232 | expect(instance.underlyingWebsocket!.readyState).toBe( 233 | WebSocket.CLOSED, 234 | ); 235 | }); 236 | }); 237 | 238 | test("Websocket should return the underlying websocket after the server closes the connection", async () => { 239 | await new Promise>( 240 | (resolve) => { 241 | client = new WebsocketBuilder(url) 242 | .onOpen(() => closeServer(server)) 243 | .onClose((instance, ev) => resolve([instance, ev])) 244 | .build(); 245 | }, 246 | ).then(([instance, ev]) => { 247 | expect(instance).toBe(client); 248 | expect(ev.type).toBe(WebsocketEvent.close); 249 | expect(instance.underlyingWebsocket).not.toBeUndefined(); 250 | expect(instance.underlyingWebsocket).toBeInstanceOf(window.WebSocket); 251 | expect(instance.underlyingWebsocket!.readyState).toBe( 252 | WebSocket.CLOSED, 253 | ); 254 | }); 255 | }); 256 | }); 257 | 258 | describe("ReadyState", () => { 259 | test("Websocket should return the correct readyState after initialization", () => { 260 | const client = new Websocket(url); 261 | expect(client.readyState).toBe(WebSocket.CONNECTING); 262 | }); 263 | 264 | test("Websocket should return the correct readyState after the client connects to the server", async () => { 265 | await new Promise>( 266 | (resolve) => { 267 | client = new WebsocketBuilder(url) 268 | .onOpen((instance, ev) => resolve([instance, ev])) 269 | .build(); 270 | }, 271 | ).then(([instance, ev]) => { 272 | expect(instance).toBe(client); 273 | expect(ev.type).toBe(WebsocketEvent.open); 274 | expect(instance.readyState).toBe(WebSocket.OPEN); 275 | }); 276 | }); 277 | 278 | test("Websocket should return the correct readyState after the client closes the connection", async () => { 279 | await new Promise>( 280 | (resolve) => { 281 | client = new WebsocketBuilder(url) 282 | .onOpen((instance) => instance.close()) 283 | .onClose((instance, ev) => resolve([instance, ev])) 284 | .build(); 285 | }, 286 | ).then(([instance, ev]) => { 287 | expect(instance).toBe(client); 288 | expect(ev.type).toBe(WebsocketEvent.close); 289 | expect(instance.readyState).toBe(WebSocket.CLOSED); 290 | }); 291 | }); 292 | }); 293 | 294 | describe("BufferedAmount", () => { 295 | test("Websocket should return the correct bufferedAmount after initialization", () => { 296 | const client = new Websocket(url); 297 | expect(client.bufferedAmount).toBe(0); 298 | }); 299 | }); 300 | 301 | describe("Extensions", () => { 302 | test("Websocket should return the correct extensions after initialization", () => { 303 | const client = new Websocket(url); 304 | expect(client.extensions).toBe(""); 305 | }); 306 | }); 307 | 308 | describe("BinaryType", () => { 309 | test("Websocket should return the correct binaryType after initialization", () => { 310 | const client = new Websocket(url); 311 | expect(client.binaryType).toBe("blob"); 312 | }); 313 | 314 | test("Websocket should return the correct binaryType after setting it", () => { 315 | const client = new Websocket(url); 316 | client.binaryType = "arraybuffer"; 317 | expect(client.binaryType).toBe("arraybuffer"); 318 | }); 319 | }); 320 | }); 321 | 322 | describe("Event tests", () => { 323 | describe("Open", () => { 324 | test( 325 | "Websocket should fire 'open' when connecting to a server and the underlying websocket should be in readyState 'OPEN'", 326 | async () => { 327 | await new Promise>( 328 | (resolve) => { 329 | client = new WebsocketBuilder(url) 330 | .onOpen((instance, ev) => resolve([instance, ev])) 331 | .build(); 332 | }, 333 | ).then(([instance, ev]) => { 334 | expect(instance).toBe(client); 335 | expect(ev.type).toBe(WebsocketEvent.open); 336 | expect(instance.underlyingWebsocket).not.toBeUndefined(); 337 | expect(instance.underlyingWebsocket!.readyState).toBe( 338 | WebSocket.OPEN, 339 | ); 340 | }); 341 | }, 342 | testTimeout, 343 | ); 344 | 345 | test("Websocket should fire 'open' when reconnecting to a server and the underlying websocket should be in readyState 'OPEN'", async () => { 346 | await new Promise>( 347 | (resolve) => { 348 | client = new WebsocketBuilder(url) 349 | .withBackoff(new ConstantBackoff(0)) 350 | .onOpen((instance, ev) => resolve([instance, ev])) 351 | .build(); 352 | }, 353 | ).then(([instance, ev]) => { 354 | expect(instance).toBe(client); 355 | expect(ev.type).toBe(WebsocketEvent.open); 356 | expect(instance.underlyingWebsocket).not.toBeUndefined(); 357 | expect(instance.underlyingWebsocket!.readyState).toBe(WebSocket.OPEN); 358 | }); 359 | }); 360 | 361 | test("Websocket shouldn't fire 'open' when it was removed from the event listeners", async () => { 362 | let timesOpenFired = 0; 363 | const onOpen = () => timesOpenFired++; 364 | 365 | const clientConnectionPromise = waitForClientToConnectToServer( 366 | server, 367 | clientTimeout, 368 | ); 369 | 370 | await new Promise>( 371 | (resolve) => { 372 | client = new WebsocketBuilder(url) 373 | .withBackoff(new ConstantBackoff(100)) // try to reconnect after 100ms, 'open' should only fire once 374 | .onOpen( 375 | (i, ev) => { 376 | timesOpenFired++; 377 | resolve([i, ev]); 378 | }, 379 | { once: true }, 380 | ) // initial 'open' event, should only fire once 381 | .build(); 382 | }, 383 | ); 384 | 385 | // this resolves after the client has connected to the server, disconnect it right after 386 | await clientConnectionPromise; 387 | expect(timesOpenFired).toBe(1); 388 | expect( 389 | getListenersWithOptions(client, WebsocketEvent.open), 390 | ).toHaveLength(0); // since the initial listener was a 'once'-listener, this should be empty 391 | client!.addEventListener(WebsocketEvent.open, onOpen); // add a new listener 392 | expect( 393 | getListenersWithOptions(client, WebsocketEvent.open), 394 | ).toHaveLength(1); // since the initial listener was a 'once'-listener, this should be empty 395 | server?.clients.forEach((c) => c.close()); 396 | 397 | // wait for the client to reconnect after 100ms 398 | await waitForClientToConnectToServer(server, clientTimeout); 399 | await new Promise((resolve) => setTimeout(resolve, 100)); // wait some extra time for client-side event to be fired 400 | expect(timesOpenFired).toBe(2); 401 | expect( 402 | getListenersWithOptions(client, WebsocketEvent.open), 403 | ).toHaveLength(1); // since the initial listener was a 'once'-listener, this should be empty 404 | 405 | // remove the event-listener, disconnect again 406 | client!.removeEventListener(WebsocketEvent.open, onOpen); 407 | expect( 408 | getListenersWithOptions(client, WebsocketEvent.open), 409 | ).toHaveLength(0); 410 | server?.clients.forEach((c) => c.close()); 411 | 412 | // wait for the client to reconnect after 100ms, 'open' should not fire again and timesOpenFired will still be 2 413 | await waitForClientToConnectToServer(server, clientTimeout); 414 | await new Promise((resolve) => setTimeout(resolve, 100)); 415 | expect(timesOpenFired).toBe(2); 416 | }); 417 | }); 418 | 419 | describe("Close", () => { 420 | test( 421 | "Websocket should fire 'close' when the server closes the connection and the underlying websocket should be in readyState 'CLOSED'", 422 | async () => { 423 | await new Promise>( 424 | (resolve) => { 425 | client = new WebsocketBuilder(url) 426 | .onOpen(() => closeServer(server)) 427 | .onClose((instance, ev) => resolve([instance, ev])) 428 | .build(); 429 | }, 430 | ).then(([instance, ev]) => { 431 | expect(instance).toBe(client); 432 | expect(ev.type).toBe(WebsocketEvent.close); 433 | expect(instance.closedByUser).toBe(false); 434 | expect(instance.underlyingWebsocket).not.toBeUndefined(); 435 | expect(instance.underlyingWebsocket!.readyState).toBe( 436 | WebSocket.CLOSED, 437 | ); 438 | }); 439 | }, 440 | testTimeout, 441 | ); 442 | 443 | test("Websocket should fire 'close' when the client closes the connection and the underlying websocket should be in readyState 'CLOSED'", async () => { 444 | await new Promise>( 445 | (resolve) => { 446 | client = new WebsocketBuilder(url) 447 | .onOpen((instance) => instance.close()) 448 | .onClose((instance, ev) => resolve([instance, ev])) 449 | .build(); 450 | }, 451 | ).then(([instance, ev]) => { 452 | expect(instance).toBe(client); 453 | expect(ev.type).toBe(WebsocketEvent.close); 454 | expect(instance.closedByUser).toBe(true); 455 | expect(instance.underlyingWebsocket).not.toBeUndefined(); 456 | expect(instance.underlyingWebsocket!.readyState).toBe( 457 | WebSocket.CLOSED, 458 | ); 459 | }); 460 | }); 461 | 462 | test("Websocket should fire 'close' when the server closes the connection with a status code other than 1000 and the underlying websocket should be in readyState 'CLOSED'", async () => { 463 | await new Promise>( 464 | (resolve) => { 465 | client = new WebsocketBuilder(url) 466 | .onOpen(() => 467 | server?.clients.forEach((client) => 468 | client.close(1001, "CLOSE_GOING_AWAY"), 469 | ), 470 | ) 471 | .onClose((instance, ev) => resolve([instance, ev])) 472 | .build(); 473 | }, 474 | ).then(([instance, ev]) => { 475 | expect(instance).toBe(client); 476 | expect(ev.type).toBe(WebsocketEvent.close); 477 | expect(ev.code).toBe(1001); 478 | expect(ev.reason).toBe("CLOSE_GOING_AWAY"); 479 | expect(ev.wasClean).toBe(true); 480 | expect(instance.closedByUser).toBe(false); 481 | expect(instance.underlyingWebsocket).not.toBeUndefined(); 482 | expect(instance.underlyingWebsocket!.readyState).toBe( 483 | WebSocket.CLOSED, 484 | ); 485 | }); 486 | }); 487 | 488 | test("Websocket should fire 'close' when the client closes the connection with a status code other than 1000 and the underlying websocket should be in readyState 'CLOSED'", async () => { 489 | await new Promise>( 490 | (resolve) => { 491 | client = new WebsocketBuilder(url) 492 | .onOpen((instance) => 493 | instance.close(4000, "APPLICATION_IS_SHUTTING_DOWN"), 494 | ) 495 | .onClose((instance, ev) => resolve([instance, ev])) 496 | .build(); 497 | }, 498 | ).then(([instance, ev]) => { 499 | expect(instance).toBe(client); 500 | expect(ev.type).toBe(WebsocketEvent.close); 501 | expect(ev.code).toBe(4000); 502 | expect(ev.reason).toBe("APPLICATION_IS_SHUTTING_DOWN"); 503 | expect(ev.wasClean).toBe(true); 504 | expect(instance.closedByUser).toBe(true); 505 | expect(instance.underlyingWebsocket).not.toBeUndefined(); 506 | expect(instance.underlyingWebsocket!.readyState).toBe( 507 | WebSocket.CLOSED, 508 | ); 509 | }); 510 | }); 511 | }); 512 | 513 | describe("Error", () => { 514 | test("Websocket should fire 'error' when the server rejects the connection and the underlying websocket should be in readyState 'CLOSED", async () => { 515 | await stopServer(server, serverTimeout).then( 516 | () => (server = undefined), 517 | ); 518 | await new Promise>( 519 | (resolve) => { 520 | client = new WebsocketBuilder(url) 521 | .onError((instance, ev) => resolve([instance, ev])) 522 | .build(); 523 | }, 524 | ).then(([instance, ev]) => { 525 | expect(instance).toBe(client); 526 | expect(ev.type).toBe(WebsocketEvent.error); 527 | expect(instance.underlyingWebsocket).not.toBeUndefined(); 528 | expect(instance.underlyingWebsocket!.readyState).toBe( 529 | WebSocket.CLOSED, 530 | ); 531 | }); 532 | }); 533 | }); 534 | 535 | describe("Message", () => { 536 | test("Websocket should fire 'message' when the server sends a message", async () => { 537 | await new Promise>( 538 | (resolve) => { 539 | client = new WebsocketBuilder(url) 540 | .onOpen(() => 541 | server?.clients.forEach((client) => client.send("Hello")), 542 | ) 543 | .onMessage((instance, ev) => { 544 | expect(ev.data).toBe("Hello"); 545 | resolve([instance, ev]); 546 | }) 547 | .build(); 548 | }, 549 | ).then(([instance, ev]) => { 550 | expect(instance).toBe(client); 551 | expect(ev.type).toBe(WebsocketEvent.message); 552 | expect(ev.data).toBe("Hello"); 553 | }); 554 | }); 555 | }); 556 | 557 | describe("Retry & Reconnect", () => { 558 | test("Websocket should not emit 'retry' on the first connection attempt, emit it when retrying and emit 'reconnect' when it reconnects", async () => { 559 | let [openCount, retryCount, reconnectCount] = [0, 0, 0]; 560 | const onOpen = () => openCount++; 561 | const onRetry = () => retryCount++; 562 | const onReconnect = () => reconnectCount++; 563 | 564 | await new Promise>( 565 | (resolve) => { 566 | client = new WebsocketBuilder(url) 567 | .withBackoff(new ConstantBackoff(0)) // immediately retry 568 | .onOpen((instance, ev) => resolve([instance, ev])) 569 | .onOpen(onOpen) 570 | .onRetry(onRetry) 571 | .onReconnect(onReconnect) 572 | .build(); 573 | }, 574 | ).then(([instance, ev]) => { 575 | expect(instance).toBe(client); 576 | expect(ev.type).toBe(WebsocketEvent.open); 577 | }); 578 | 579 | // give some time for all handlers to be called 580 | await new Promise((resolve) => setTimeout(resolve, 100)); 581 | expect(openCount).toBe(1); 582 | expect(retryCount).toBe(0); 583 | expect(reconnectCount).toBe(0); 584 | 585 | // disconnect all clients and give some time for the retry to happen 586 | server?.clients.forEach((client) => client.close()); 587 | await new Promise((resolve) => setTimeout(resolve, 100)); 588 | 589 | // ws should have retried & reconnect 590 | expect(openCount).toBe(2); 591 | expect(retryCount).toBe(1); 592 | expect(reconnectCount).toBe(1); 593 | }); 594 | }); 595 | }); 596 | 597 | describe("Reconnect behaviour", () => { 598 | describe("InstantReconnect", () => { 599 | test("Websocket should try to reconnect immediately when instantReconnect is true", async () => { 600 | let [openCount, retryCount, reconnectCount] = [0, 0, 0]; 601 | const onOpen = () => openCount++; 602 | const onRetry = () => retryCount++; 603 | const onReconnect = () => reconnectCount++; 604 | 605 | await new Promise>( 606 | (resolve) => { 607 | client = new WebsocketBuilder(url) 608 | .withBackoff(new ConstantBackoff(1000)) // retry after 1 second 609 | .withInstantReconnect(true) // reconnect immediately, don't wait for the backoff for the first retry 610 | .onOpen((instance, ev) => resolve([instance, ev])) 611 | .onOpen(onOpen) 612 | .onRetry(onRetry) 613 | .onReconnect(onReconnect) 614 | .build(); 615 | }, 616 | ).then(([instance, ev]) => { 617 | expect(instance).toBe(client); 618 | expect(ev.type).toBe(WebsocketEvent.open); 619 | }); 620 | 621 | // give some time for all handlers to be called 622 | await new Promise((resolve) => setTimeout(resolve, 100)); 623 | expect(openCount).toBe(1); 624 | expect(retryCount).toBe(0); 625 | expect(reconnectCount).toBe(0); 626 | 627 | // disconnect all clients and give some time for the retry to happen 628 | server?.clients.forEach((client) => client.close()); 629 | await new Promise((resolve) => setTimeout(resolve, 100)); 630 | 631 | // ws should have retried & reconnect 632 | expect(openCount).toBe(2); 633 | expect(retryCount).toBe(1); 634 | expect(reconnectCount).toBe(1); 635 | }); 636 | 637 | test("Websocket should not try to reconnect immediately when instantReconnect is false", async () => { 638 | let [openCount, retryCount, reconnectCount] = [0, 0, 0]; 639 | const onOpen = () => openCount++; 640 | const onRetry = () => retryCount++; 641 | const onReconnect = () => reconnectCount++; 642 | 643 | await new Promise>( 644 | (resolve) => { 645 | client = new WebsocketBuilder(url) 646 | .withBackoff(new ConstantBackoff(1000)) // retry after 1 second 647 | .withInstantReconnect(false) // reconnect immediately, don't wait for the backoff for the first retry 648 | .onOpen((instance, ev) => resolve([instance, ev])) 649 | .onOpen(onOpen) 650 | .onRetry(onRetry) 651 | .onReconnect(onReconnect) 652 | .build(); 653 | }, 654 | ).then(([instance, ev]) => { 655 | expect(instance).toBe(client); 656 | expect(ev.type).toBe(WebsocketEvent.open); 657 | }); 658 | 659 | // give some time for all handlers to be called 660 | await new Promise((resolve) => setTimeout(resolve, 100)); 661 | expect(openCount).toBe(1); 662 | expect(retryCount).toBe(0); 663 | expect(reconnectCount).toBe(0); 664 | 665 | // disconnect all clients and give some time for the retry to happen 666 | server?.clients.forEach((client) => client.close()); 667 | await new Promise((resolve) => setTimeout(resolve, 100)); 668 | 669 | // ws shouldn't have retried & reconnect 670 | expect(openCount).toBe(1); 671 | expect(retryCount).toBe(0); 672 | expect(reconnectCount).toBe(0); 673 | 674 | // give some time for the retry to happen 675 | await new Promise((resolve) => setTimeout(resolve, 1000)); 676 | expect(openCount).toBe(2); 677 | expect(retryCount).toBe(1); 678 | expect(reconnectCount).toBe(1); 679 | }); 680 | }); 681 | 682 | describe("MaxRetries", () => { 683 | test("Websocket should stop trying to reconnect when maxRetries is reached", async () => { 684 | let [openCount, retryCount, reconnectCount] = [0, 0, 0]; 685 | const onOpen = () => openCount++; 686 | const onRetry = () => retryCount++; 687 | const onReconnect = () => reconnectCount++; 688 | 689 | await new Promise>( 690 | (resolve) => { 691 | client = new WebsocketBuilder(url) 692 | .withBackoff(new ConstantBackoff(0)) // retry after 1 second 693 | .withMaxRetries(5) // retry 5 times 694 | .onOpen((instance, ev) => resolve([instance, ev])) 695 | .onOpen(onOpen) 696 | .onRetry(onRetry) 697 | .onReconnect(onReconnect) 698 | .build(); 699 | }, 700 | ).then(([instance, ev]) => { 701 | expect(instance).toBe(client); 702 | expect(ev.type).toBe(WebsocketEvent.open); 703 | }); 704 | 705 | // give some time for all handlers to be called 706 | await new Promise((resolve) => setTimeout(resolve, 100)); 707 | expect(openCount).toBe(1); 708 | expect(retryCount).toBe(0); 709 | expect(reconnectCount).toBe(0); 710 | 711 | // stop server so that the client can't reconnect 712 | await stopServer(server, serverTimeout); 713 | await new Promise((resolve) => setTimeout(resolve, 100)); 714 | 715 | // ws should have retried but not reconnect 716 | expect(openCount).toBe(1); 717 | expect(retryCount).toBe(5); 718 | expect(reconnectCount).toBe(0); 719 | }); 720 | }); 721 | }); 722 | 723 | describe("Send", () => { 724 | test("Websocket should send a message to the server and the server should receive it", async () => { 725 | const serverReceivedMessage = new Promise((resolve) => { 726 | server?.on("connection", (client) => { 727 | client?.on( 728 | "message", 729 | onStringMessageReceived((str: string) => { 730 | resolve(str); 731 | }), 732 | ); 733 | }); 734 | }); 735 | 736 | await new Promise>( 737 | (resolve) => { 738 | client = new WebsocketBuilder(url) 739 | .onOpen((instance, ev) => { 740 | instance.send("Hello"); 741 | resolve([instance, ev]); 742 | }) 743 | .build(); 744 | }, 745 | ).then(([instance, ev]) => { 746 | expect(instance).toBe(client); 747 | expect(ev.type).toBe(WebsocketEvent.open); 748 | expect(instance.underlyingWebsocket).not.toBeUndefined(); 749 | expect(instance.underlyingWebsocket!.readyState).toBe(WebSocket.OPEN); 750 | }); 751 | 752 | await serverReceivedMessage.then((message) => 753 | expect(message).toBe("Hello"), 754 | ); 755 | }); 756 | 757 | test("Websocket should send a message to the server and the server should receive it as a Uint8Array", async () => { 758 | const serverReceivedMessage = new Promise((resolve) => { 759 | server?.on("connection", (client) => { 760 | client?.on("message", (message: Uint8Array) => { 761 | resolve(message); 762 | }); 763 | }); 764 | }); 765 | 766 | await new Promise>( 767 | (resolve) => { 768 | client = new WebsocketBuilder(url) 769 | .onOpen((instance, ev) => { 770 | instance.send(new Uint8Array([1, 2, 3])); 771 | resolve([instance, ev]); 772 | }) 773 | .build(); 774 | }, 775 | ).then(([instance, ev]) => { 776 | expect(instance).toBe(client); 777 | expect(ev.type).toBe(WebsocketEvent.open); 778 | expect(instance.underlyingWebsocket).not.toBeUndefined(); 779 | expect(instance.underlyingWebsocket!.readyState).toBe(WebSocket.OPEN); 780 | }); 781 | 782 | await serverReceivedMessage.then((message) => { 783 | expect(Buffer.from(message).equals(Buffer.from([1, 2, 3]))).toBe(true); 784 | }); 785 | }); 786 | 787 | test("Websocket should buffer messages sent before the connection is open and send them when the connection is open", async () => { 788 | const messagesReceived: string[] = []; 789 | const serverReceivedMessages = new Promise((resolve) => { 790 | server?.on("connection", (client) => { 791 | client?.on( 792 | "message", 793 | onStringMessageReceived((str: string) => { 794 | messagesReceived.push(str); 795 | if (messagesReceived.length === 2) { 796 | resolve(messagesReceived); 797 | } 798 | }), 799 | ); 800 | }); 801 | }); 802 | 803 | await new Promise>( 804 | (resolve) => { 805 | client = new WebsocketBuilder(url) 806 | .withBuffer(new ArrayQueue()) 807 | .onOpen((instance, ev) => { 808 | setTimeout(() => { 809 | instance.send("Hello2"); 810 | resolve([instance, ev]); 811 | }, 100); 812 | }) 813 | .build(); 814 | client.send("Hello1"); // This message should be buffered 815 | }, 816 | ).then(([instance, ev]) => { 817 | expect(instance).toBe(client); 818 | expect(ev.type).toBe(WebsocketEvent.open); 819 | expect(instance.underlyingWebsocket).not.toBeUndefined(); 820 | expect(instance.underlyingWebsocket!.readyState).toBe(WebSocket.OPEN); 821 | }); 822 | 823 | await serverReceivedMessages.then((messages) => { 824 | expect(messages).toEqual(["Hello1", "Hello2"]); 825 | }); 826 | }); 827 | 828 | test("Websocket send should short circuit if the websocket was closed by user", async () => { 829 | await new Promise>( 830 | (resolve) => { 831 | client = new WebsocketBuilder(url) 832 | .onOpen((instance, ev) => resolve([instance, ev])) 833 | .build(); 834 | }, 835 | ).then(([instance, ev]) => { 836 | expect(instance).toBe(client); 837 | expect(ev.type).toBe(WebsocketEvent.open); 838 | expect(instance.underlyingWebsocket).not.toBeUndefined(); 839 | expect(instance.underlyingWebsocket!.readyState).toBe(WebSocket.OPEN); 840 | 841 | // close the websocket and send a message 842 | instance.close(); 843 | instance.send("This send should short circuit"); 844 | }); 845 | }); 846 | }); 847 | }); 848 | 849 | /** 850 | * Creates a promise that will be rejected after the given amount of milliseconds. The error will be a TimeoutError. 851 | * @param ms the amount of milliseconds to wait before rejecting 852 | * @param msg an optional message to include in the error 853 | */ 854 | const rejectAfter = (ms: number, msg?: string): Promise => 855 | new Promise((_, reject) => 856 | setTimeout( 857 | () => reject(msg ? new Error(`Timeout: ${msg}`) : new Error(`Timeout`)), 858 | ms, 859 | ), 860 | ); 861 | 862 | /** 863 | * Stops the given websocket client. 864 | * @param client the websocket client to stop 865 | * @param timeout the amount of milliseconds to wait before rejecting 866 | */ 867 | const stopClient = ( 868 | client: Websocket | undefined, 869 | timeout: number, 870 | ): Promise => 871 | new Promise((resolve, reject) => { 872 | if (client === undefined) return resolve(); 873 | if (client.underlyingWebsocket?.readyState === WebSocket.CLOSED) 874 | return resolve(); 875 | rejectAfter(timeout, "failed to stop client").catch((err) => reject(err)); 876 | client.addEventListener(WebsocketEvent.close, () => resolve(), { 877 | once: true, 878 | }); 879 | client.close(); 880 | }); 881 | 882 | /** 883 | * Starts a websocket server on the given port. 884 | * @param port the port to start the server on 885 | * @param timeout the amount of milliseconds to wait before rejecting 886 | */ 887 | const startServer = (port: number, timeout: number): Promise => 888 | new Promise((resolve, reject) => { 889 | rejectAfter(timeout, "failed to start server").catch((err) => reject(err)); 890 | const wss = new WebSocketServer({ port }); 891 | wss.on("listening", () => resolve(wss)); 892 | wss.on("error", (err) => reject(err)); 893 | }); 894 | 895 | /** 896 | * Stops the given websocket server. This will terminate all connections. 897 | * @param wss the websocket server to stop 898 | * @param timeout the amount of milliseconds to wait before rejecting 899 | */ 900 | const stopServer = ( 901 | wss: WebSocketServer | undefined, 902 | timeout: number, 903 | ): Promise => 904 | new Promise((resolve, reject) => { 905 | if (wss === undefined) return resolve(); 906 | rejectAfter(timeout, "failed to stop server").catch((err) => reject(err)); 907 | wss.clients.forEach((c) => c.terminate()); 908 | wss.addListener("close", resolve); 909 | wss.close(); 910 | }); 911 | 912 | /** 913 | * Waits for a client to connect to the given websocket server. 914 | * 915 | * @param wss the websocket server to wait for a client to connect to 916 | * @param timeout the amount of milliseconds to wait before rejecting 917 | */ 918 | const waitForClientToConnectToServer = ( 919 | wss: WebSocketServer | undefined, 920 | timeout: number, 921 | ): Promise => 922 | new Promise((resolve, reject) => { 923 | if (wss === undefined) return reject(new Error("wss is undefined")); 924 | rejectAfter(timeout, "failed to wait for client to connect").catch((err) => 925 | reject(err), 926 | ); 927 | wss.on("connection", (client) => resolve(client)); 928 | }); 929 | 930 | /** 931 | * Returns the listeners for the given event type on the given websocket client. 932 | * 933 | * @param client the websocket client to get the listeners from 934 | * @param type the event type to get the listeners for 935 | */ 936 | const getListenersWithOptions = ( 937 | client: Websocket | undefined, 938 | type: K, 939 | ): WebsocketEventListenerWithOptions[] => 940 | client === undefined ? [] : (client["_options"]["listeners"][type] ?? []); 941 | 942 | /** 943 | * Converts a websocket message to a string. 944 | * 945 | * @param message the message to convert to a string 946 | * @param isBinary whether the message is binary 947 | * @returns the message as a string 948 | */ 949 | const wsMessageToString = ( 950 | message: ArrayBuffer | Blob | Buffer | Buffer[], 951 | isBinary: boolean, 952 | ): string => { 953 | if (isBinary) { 954 | throw new Error("Unexpected binary message"); 955 | } else if (!(message instanceof Buffer)) { 956 | throw new Error("Unexpected message type"); 957 | } else return message.toString("utf-8"); 958 | }; 959 | 960 | /** 961 | * Converts a websocket message to a string and calls the given handler. 962 | * 963 | * @param handler the handler to call with the message 964 | */ 965 | const onStringMessageReceived = 966 | (handler: (str: string) => void) => 967 | (message: ArrayBuffer | Blob | Buffer | Buffer[], isBinary: boolean) => { 968 | handler(wsMessageToString(message, isBinary)); 969 | }; 970 | 971 | /** 972 | * Closes the given websocket server and terminates all connections. 973 | * 974 | * @param wss the websocket server to close 975 | */ 976 | const closeServer = (wss: WebSocketServer | undefined) => { 977 | if (wss === undefined) return; 978 | wss.clients.forEach((client) => client.terminate()); 979 | wss.close(); 980 | }; 981 | -------------------------------------------------------------------------------- /tests/websocket_builder.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ArrayQueue, 3 | ConstantBackoff, 4 | Websocket, 5 | WebsocketBuilder, 6 | } from "../src"; 7 | import { WebsocketBuffer } from "../src"; 8 | import { WebsocketEvent, WebsocketEventListenerWithOptions } from "../src"; 9 | import { vi, describe, test, expect } from "vitest"; 10 | 11 | describe("Testsuite for WebSocketBuilder", () => { 12 | const url = "ws://localhost:8080"; 13 | 14 | test("WebsocketBuilder should set url", () => { 15 | const builder = new WebsocketBuilder(url); 16 | expect(builder.url).toBe(url); 17 | 18 | const ws = builder.build(); 19 | expect(ws.url).toBe(url); 20 | }); 21 | 22 | test("WebsocketBuilder should set protocols", () => { 23 | const protocols = ["protocol1", "protocol2"]; 24 | 25 | const builder = new WebsocketBuilder(url).withProtocols(protocols); 26 | expect(builder.protocols).toBe(protocols); 27 | 28 | const ws = builder.build(); 29 | expect(ws.protocols).toBe(protocols); 30 | }); 31 | 32 | test("WebsocketBuilder should set protocols for subsequent calls", () => { 33 | const protocols1 = ["protocol1", "protocol2"]; 34 | const protocols2 = ["protocol3", "protocol4"]; 35 | 36 | const builder = new WebsocketBuilder(url) 37 | .withProtocols(protocols1) 38 | .withProtocols(protocols2); 39 | expect(builder.protocols).toBe(protocols2); 40 | 41 | const ws = builder.build(); 42 | expect(ws.protocols).toBe(protocols2); 43 | }); 44 | 45 | test("WebsocketBuilder should set max-retries", () => { 46 | const maxRetries = 42; 47 | 48 | const builder = new WebsocketBuilder(url).withMaxRetries(maxRetries); 49 | expect(builder.maxRetries).toBe(maxRetries); 50 | 51 | const ws = builder.build(); 52 | expect(ws.maxRetries).toBe(maxRetries); 53 | }); 54 | 55 | test("WebsocketBuilder should set max-retries for subsequent calls", () => { 56 | const maxRetries1 = 42; 57 | const maxRetries2 = 1337; 58 | 59 | const builder = new WebsocketBuilder(url) 60 | .withMaxRetries(maxRetries1) 61 | .withMaxRetries(maxRetries2); 62 | expect(builder.maxRetries).toBe(maxRetries2); 63 | 64 | const ws = builder.build(); 65 | expect(ws.maxRetries).toBe(maxRetries2); 66 | }); 67 | 68 | test("WebsocketBuilder should return undefined for max-retries if not set", () => { 69 | const builder = new WebsocketBuilder(url); 70 | expect(builder.maxRetries).toBeUndefined(); 71 | 72 | const ws = builder.build(); 73 | expect(ws.maxRetries).toBeUndefined(); 74 | }); 75 | 76 | test("WebsocketBuilder should set instant-reconnect", () => { 77 | const instantReconnect = true; 78 | 79 | const builder = new WebsocketBuilder(url).withInstantReconnect( 80 | instantReconnect, 81 | ); 82 | expect(builder.instantReconnect).toBe(instantReconnect); 83 | 84 | const ws = builder.build(); 85 | expect(ws.instantReconnect).toBe(instantReconnect); 86 | }); 87 | 88 | test("WebsocketBuilder should return undefined for instant-reconnect if not set", () => { 89 | const builder = new WebsocketBuilder(url); 90 | expect(builder.instantReconnect).toBeUndefined(); 91 | 92 | const ws = builder.build(); 93 | expect(ws.instantReconnect).toBeUndefined(); 94 | }); 95 | 96 | test("WebsocketBuilder should set backoff", () => { 97 | const backoff = new ConstantBackoff(42); 98 | 99 | const builder = new WebsocketBuilder(url).withBackoff(backoff); 100 | expect(builder.backoff).toBe(backoff); 101 | 102 | const ws = builder.build(); 103 | expect(ws.backoff).toBe(backoff); 104 | }); 105 | 106 | test("WebsocketBuilder should set backoff for subsequent calls", () => { 107 | const backoff1 = new ConstantBackoff(42); 108 | const backoff2 = new ConstantBackoff(1337); 109 | 110 | const builder = new WebsocketBuilder(url) 111 | .withBackoff(backoff1) 112 | .withBackoff(backoff2); 113 | expect(builder.backoff).toBe(backoff2); 114 | 115 | const ws = builder.build(); 116 | expect(ws.backoff).toBe(backoff2); 117 | }); 118 | 119 | test("WebsocketBuilder should return undefined for backoff if not set", () => { 120 | const builder = new WebsocketBuilder(url); 121 | expect(builder.backoff).toBeUndefined(); 122 | 123 | const ws = builder.build(); 124 | expect(ws.backoff).toBeUndefined(); 125 | }); 126 | 127 | test("WebsocketBuilder should set buffer", () => { 128 | const buffer: WebsocketBuffer = new ArrayQueue(); 129 | 130 | const builder = new WebsocketBuilder(url).withBuffer(buffer); 131 | expect(builder.buffer).toBe(buffer); 132 | 133 | const ws = builder.build(); 134 | expect(ws.buffer).toBe(buffer); 135 | }); 136 | 137 | test("WebsocketBuilder should set buffer for subsequent calls", () => { 138 | const buffer1: WebsocketBuffer = new ArrayQueue(); 139 | const buffer2: WebsocketBuffer = new ArrayQueue(); 140 | 141 | const builder = new WebsocketBuilder(url) 142 | .withBuffer(buffer1) 143 | .withBuffer(buffer2); 144 | expect(builder.buffer).toBe(buffer2); 145 | 146 | const ws = builder.build(); 147 | expect(ws.buffer).toBe(buffer2); 148 | }); 149 | 150 | test("WebsocketBuilder should return undefined for buffer if not set", () => { 151 | const builder = new WebsocketBuilder(url); 152 | expect(builder.buffer).toBeUndefined(); 153 | 154 | const ws = builder.build(); 155 | expect(ws.buffer).toBeUndefined(); 156 | }); 157 | 158 | test("WebsocketBuilder should set 'open'-listener", () => { 159 | const listener = vi.fn(); 160 | 161 | const builder = new WebsocketBuilder(url).onOpen(listener); 162 | expect(builder["_options"]!["listeners"]!.open).toStrictEqual< 163 | WebsocketEventListenerWithOptions[] 164 | >([ 165 | { 166 | listener, 167 | options: undefined, 168 | }, 169 | ]); 170 | 171 | const ws = builder.build(); 172 | expect(ws["_options"]["listeners"].open).toStrictEqual< 173 | WebsocketEventListenerWithOptions[] 174 | >([ 175 | { 176 | listener, 177 | options: undefined, 178 | }, 179 | ]); 180 | }); 181 | 182 | test("WebsocketBuilder should set 'open'-listener for subsequent calls", () => { 183 | const listener1 = vi.fn(); 184 | const listener2 = vi.fn(); 185 | 186 | const builder = new WebsocketBuilder(url) 187 | .onOpen(listener1) 188 | .onOpen(listener2); 189 | expect(builder["_options"]!["listeners"]!.open).toStrictEqual< 190 | WebsocketEventListenerWithOptions[] 191 | >([ 192 | { 193 | listener: listener1, 194 | options: undefined, 195 | }, 196 | { listener: listener2, options: undefined }, 197 | ]); 198 | 199 | const ws = builder.build(); 200 | expect(ws["_options"]!["listeners"]!.open).toStrictEqual< 201 | WebsocketEventListenerWithOptions[] 202 | >([ 203 | { 204 | listener: listener1, 205 | options: undefined, 206 | }, 207 | { listener: listener2, options: undefined }, 208 | ]); 209 | }); 210 | 211 | test("WebsocketBuilder should set 'open'-listener with options", () => { 212 | const listener = vi.fn(); 213 | const options = { once: true }; 214 | 215 | const builder = new WebsocketBuilder(url).onOpen(listener, options); 216 | expect(builder["_options"]!["listeners"]!.open).toStrictEqual< 217 | WebsocketEventListenerWithOptions[] 218 | >([{ listener, options }]); 219 | 220 | const ws = builder.build(); 221 | expect(ws["_options"]!["listeners"]!.open).toStrictEqual< 222 | WebsocketEventListenerWithOptions[] 223 | >([{ listener, options }]); 224 | }); 225 | 226 | test("WebsocketBuilder should set 'open'-listener with mixed options", () => { 227 | const listener1 = vi.fn(); 228 | const listener2 = vi.fn(); 229 | const options = { once: true }; 230 | 231 | const builder = new WebsocketBuilder(url) 232 | .onOpen(listener1) 233 | .onOpen(listener2, options); 234 | expect(builder["_options"]!["listeners"]!.open).toStrictEqual< 235 | WebsocketEventListenerWithOptions[] 236 | >([ 237 | { 238 | listener: listener1, 239 | options: undefined, 240 | }, 241 | { listener: listener2, options }, 242 | ]); 243 | 244 | const ws = builder.build(); 245 | expect(ws["_options"]!["listeners"]!.open).toStrictEqual< 246 | WebsocketEventListenerWithOptions[] 247 | >([ 248 | { 249 | listener: listener1, 250 | options: undefined, 251 | }, 252 | { listener: listener2, options }, 253 | ]); 254 | }); 255 | 256 | test("WebsocketBuilder should set 'close'-listener", () => { 257 | const listener = vi.fn(); 258 | 259 | const builder = new WebsocketBuilder(url).onClose(listener); 260 | expect(builder["_options"]!["listeners"]!.close).toStrictEqual< 261 | WebsocketEventListenerWithOptions[] 262 | >([ 263 | { 264 | listener, 265 | options: undefined, 266 | }, 267 | ]); 268 | 269 | const ws = builder.build(); 270 | expect(ws["_options"]!["listeners"]!.close).toStrictEqual< 271 | WebsocketEventListenerWithOptions[] 272 | >([ 273 | { 274 | listener, 275 | options: undefined, 276 | }, 277 | ]); 278 | }); 279 | 280 | test("WebsocketBuilder should set 'close'-listener for subsequent calls", () => { 281 | const listener1 = vi.fn(); 282 | const listener2 = vi.fn(); 283 | 284 | const builder = new WebsocketBuilder(url) 285 | .onClose(listener1) 286 | .onClose(listener2); 287 | expect(builder["_options"]!["listeners"]!.close).toStrictEqual< 288 | WebsocketEventListenerWithOptions[] 289 | >([ 290 | { 291 | listener: listener1, 292 | options: undefined, 293 | }, 294 | { listener: listener2, options: undefined }, 295 | ]); 296 | 297 | const ws = builder.build(); 298 | expect(ws["_options"]!["listeners"]!.close).toStrictEqual< 299 | WebsocketEventListenerWithOptions[] 300 | >([ 301 | { 302 | listener: listener1, 303 | options: undefined, 304 | }, 305 | { listener: listener2, options: undefined }, 306 | ]); 307 | }); 308 | 309 | test("WebsocketBuilder should set 'close'-listener with options", () => { 310 | const listener = vi.fn(); 311 | const options = { once: true }; 312 | 313 | const builder = new WebsocketBuilder(url).onClose(listener, options); 314 | expect(builder["_options"]!["listeners"]!.close).toStrictEqual< 315 | WebsocketEventListenerWithOptions[] 316 | >([ 317 | { 318 | listener, 319 | options, 320 | }, 321 | ]); 322 | 323 | const ws = builder.build(); 324 | expect(ws["_options"]!["listeners"]!.close).toStrictEqual< 325 | WebsocketEventListenerWithOptions[] 326 | >([{ listener, options }]); 327 | }); 328 | 329 | test("WebsocketBuilder should set 'close'-listener with mixed options", () => { 330 | const listener1 = vi.fn(); 331 | const listener2 = vi.fn(); 332 | const options = { once: true }; 333 | 334 | const builder = new WebsocketBuilder(url) 335 | .onClose(listener1) 336 | .onClose(listener2, options); 337 | expect(builder["_options"]!["listeners"]!.close).toStrictEqual< 338 | WebsocketEventListenerWithOptions[] 339 | >([ 340 | { 341 | listener: listener1, 342 | options: undefined, 343 | }, 344 | { listener: listener2, options }, 345 | ]); 346 | 347 | const ws = builder.build(); 348 | expect(ws["_options"]!["listeners"]!.close).toStrictEqual< 349 | WebsocketEventListenerWithOptions[] 350 | >([ 351 | { 352 | listener: listener1, 353 | options: undefined, 354 | }, 355 | { listener: listener2, options }, 356 | ]); 357 | }); 358 | 359 | test("WebsocketBuilder should set 'error'-listener", () => { 360 | const listener = vi.fn(); 361 | 362 | const builder = new WebsocketBuilder(url).onError(listener); 363 | expect(builder["_options"]!["listeners"]!.error).toStrictEqual< 364 | WebsocketEventListenerWithOptions[] 365 | >([ 366 | { 367 | listener, 368 | options: undefined, 369 | }, 370 | ]); 371 | 372 | const ws = builder.build(); 373 | expect(ws["_options"]!["listeners"]!.error).toStrictEqual< 374 | WebsocketEventListenerWithOptions[] 375 | >([ 376 | { 377 | listener, 378 | options: undefined, 379 | }, 380 | ]); 381 | }); 382 | 383 | test("WebsocketBuilder should set 'error'-listener for subsequent calls", () => { 384 | const listener1 = vi.fn(); 385 | const listener2 = vi.fn(); 386 | 387 | const builder = new WebsocketBuilder(url) 388 | .onError(listener1) 389 | .onError(listener2); 390 | expect(builder["_options"]!["listeners"]!.error).toStrictEqual< 391 | WebsocketEventListenerWithOptions[] 392 | >([ 393 | { 394 | listener: listener1, 395 | options: undefined, 396 | }, 397 | { listener: listener2, options: undefined }, 398 | ]); 399 | 400 | const ws = builder.build(); 401 | expect(ws["_options"]!["listeners"]!.error).toStrictEqual< 402 | WebsocketEventListenerWithOptions[] 403 | >([ 404 | { 405 | listener: listener1, 406 | options: undefined, 407 | }, 408 | { listener: listener2, options: undefined }, 409 | ]); 410 | }); 411 | 412 | test("WebsocketBuilder should set 'error'-listener with options", () => { 413 | const listener = vi.fn(); 414 | const options = { once: true }; 415 | 416 | const builder = new WebsocketBuilder(url).onError(listener, options); 417 | expect(builder["_options"]!["listeners"]!.error).toStrictEqual< 418 | WebsocketEventListenerWithOptions[] 419 | >([ 420 | { 421 | listener, 422 | options, 423 | }, 424 | ]); 425 | 426 | const ws = builder.build(); 427 | expect(ws["_options"]!["listeners"]!.error).toStrictEqual< 428 | WebsocketEventListenerWithOptions[] 429 | >([{ listener, options }]); 430 | }); 431 | 432 | test("WebsocketBuilder should set 'error'-listener with mixed options", () => { 433 | const listener1 = vi.fn(); 434 | const listener2 = vi.fn(); 435 | const options = { once: true }; 436 | 437 | const builder = new WebsocketBuilder(url) 438 | .onError(listener1) 439 | .onError(listener2, options); 440 | expect(builder["_options"]!["listeners"]!.error).toStrictEqual< 441 | WebsocketEventListenerWithOptions[] 442 | >([ 443 | { 444 | listener: listener1, 445 | options: undefined, 446 | }, 447 | { listener: listener2, options }, 448 | ]); 449 | 450 | const ws = builder.build(); 451 | expect(ws["_options"]!["listeners"]!.error).toStrictEqual< 452 | WebsocketEventListenerWithOptions[] 453 | >([ 454 | { 455 | listener: listener1, 456 | options: undefined, 457 | }, 458 | { listener: listener2, options }, 459 | ]); 460 | }); 461 | 462 | test("WebsocketBuilder should set 'message'-listener", () => { 463 | const listener = vi.fn(); 464 | 465 | const builder = new WebsocketBuilder(url).onMessage(listener); 466 | expect(builder["_options"]!["listeners"]!.message).toStrictEqual< 467 | WebsocketEventListenerWithOptions[] 468 | >([ 469 | { 470 | listener, 471 | options: undefined, 472 | }, 473 | ]); 474 | 475 | const ws = builder.build(); 476 | expect(ws["_options"]!["listeners"]!.message).toStrictEqual< 477 | WebsocketEventListenerWithOptions[] 478 | >([ 479 | { 480 | listener, 481 | options: undefined, 482 | }, 483 | ]); 484 | }); 485 | 486 | test("WebsocketBuilder should set 'message'-listener for subsequent calls", () => { 487 | const listener1 = vi.fn(); 488 | const listener2 = vi.fn(); 489 | 490 | const builder = new WebsocketBuilder(url) 491 | .onMessage(listener1) 492 | .onMessage(listener2); 493 | expect(builder["_options"]!["listeners"]!.message).toStrictEqual< 494 | WebsocketEventListenerWithOptions[] 495 | >([ 496 | { 497 | listener: listener1, 498 | options: undefined, 499 | }, 500 | { listener: listener2, options: undefined }, 501 | ]); 502 | 503 | const ws = builder.build(); 504 | expect(ws["_options"]!["listeners"]!.message).toStrictEqual< 505 | WebsocketEventListenerWithOptions[] 506 | >([ 507 | { 508 | listener: listener1, 509 | options: undefined, 510 | }, 511 | { listener: listener2, options: undefined }, 512 | ]); 513 | }); 514 | 515 | test("WebsocketBuilder should set 'message'-listener with options", () => { 516 | const listener = vi.fn(); 517 | const options = { once: true }; 518 | 519 | const builder = new WebsocketBuilder(url).onMessage(listener, options); 520 | expect(builder["_options"]!["listeners"]!.message).toStrictEqual< 521 | WebsocketEventListenerWithOptions[] 522 | >([ 523 | { 524 | listener, 525 | options, 526 | }, 527 | ]); 528 | 529 | const ws = builder.build(); 530 | expect(ws["_options"]!["listeners"]!.message).toStrictEqual< 531 | WebsocketEventListenerWithOptions[] 532 | >([ 533 | { 534 | listener, 535 | options, 536 | }, 537 | ]); 538 | }); 539 | 540 | test("WebsocketBuilder should set 'message'-listener with mixed options", () => { 541 | const listener1 = vi.fn(); 542 | const listener2 = vi.fn(); 543 | const options = { once: true }; 544 | 545 | const builder = new WebsocketBuilder(url) 546 | .onMessage(listener1) 547 | .onMessage(listener2, options); 548 | expect(builder["_options"]!["listeners"]!.message).toStrictEqual< 549 | WebsocketEventListenerWithOptions[] 550 | >([ 551 | { 552 | listener: listener1, 553 | options: undefined, 554 | }, 555 | { listener: listener2, options }, 556 | ]); 557 | 558 | const ws = builder.build(); 559 | expect(ws["_options"]!["listeners"]!.message).toStrictEqual< 560 | WebsocketEventListenerWithOptions[] 561 | >([ 562 | { 563 | listener: listener1, 564 | options: undefined, 565 | }, 566 | { listener: listener2, options }, 567 | ]); 568 | }); 569 | 570 | test("WebsocketBuilder should set 'retry'-listener", () => { 571 | const listener = vi.fn(); 572 | 573 | const builder = new WebsocketBuilder(url).onRetry(listener); 574 | expect(builder["_options"]!["listeners"]!.retry).toStrictEqual< 575 | WebsocketEventListenerWithOptions[] 576 | >([ 577 | { 578 | listener, 579 | options: undefined, 580 | }, 581 | ]); 582 | 583 | const ws = builder.build(); 584 | expect(ws["_options"]!["listeners"]!.retry).toStrictEqual< 585 | WebsocketEventListenerWithOptions[] 586 | >([ 587 | { 588 | listener, 589 | options: undefined, 590 | }, 591 | ]); 592 | }); 593 | 594 | test("WebsocketBuilder should set 'retry'-listener for subsequent calls", () => { 595 | const listener1 = vi.fn(); 596 | const listener2 = vi.fn(); 597 | 598 | const builder = new WebsocketBuilder(url) 599 | .onRetry(listener1) 600 | .onRetry(listener2); 601 | expect(builder["_options"]!["listeners"]!.retry).toStrictEqual< 602 | WebsocketEventListenerWithOptions[] 603 | >([ 604 | { 605 | listener: listener1, 606 | options: undefined, 607 | }, 608 | { listener: listener2, options: undefined }, 609 | ]); 610 | 611 | const ws = builder.build(); 612 | expect(ws["_options"]!["listeners"]!.retry).toStrictEqual< 613 | WebsocketEventListenerWithOptions[] 614 | >([ 615 | { 616 | listener: listener1, 617 | options: undefined, 618 | }, 619 | { listener: listener2, options: undefined }, 620 | ]); 621 | }); 622 | 623 | test("WebsocketBuilder should set 'retry'-listener with options", () => { 624 | const listener = vi.fn(); 625 | const options = { once: true }; 626 | 627 | const builder = new WebsocketBuilder(url).onRetry(listener, options); 628 | expect(builder["_options"]!["listeners"]!.retry).toStrictEqual< 629 | WebsocketEventListenerWithOptions[] 630 | >([ 631 | { 632 | listener, 633 | options, 634 | }, 635 | ]); 636 | 637 | const ws = builder.build(); 638 | expect(ws["_options"]!["listeners"]!.retry).toStrictEqual< 639 | WebsocketEventListenerWithOptions[] 640 | >([{ listener, options }]); 641 | }); 642 | 643 | test("WebsocketBuilder should set 'retry'-listener with mixed options", () => { 644 | const listener1 = vi.fn(); 645 | const listener2 = vi.fn(); 646 | const options = { once: true }; 647 | 648 | const builder = new WebsocketBuilder(url) 649 | .onRetry(listener1) 650 | .onRetry(listener2, options); 651 | expect(builder["_options"]!["listeners"]!.retry).toStrictEqual< 652 | WebsocketEventListenerWithOptions[] 653 | >([ 654 | { 655 | listener: listener1, 656 | options: undefined, 657 | }, 658 | { listener: listener2, options }, 659 | ]); 660 | 661 | const ws = builder.build(); 662 | expect(ws["_options"]!["listeners"]!.retry).toStrictEqual< 663 | WebsocketEventListenerWithOptions[] 664 | >([ 665 | { 666 | listener: listener1, 667 | options: undefined, 668 | }, 669 | { listener: listener2, options }, 670 | ]); 671 | }); 672 | 673 | test("WebsocketBuilder should set 'reconnect'-listener", () => { 674 | const listener = vi.fn(); 675 | 676 | const builder = new WebsocketBuilder(url).onReconnect(listener); 677 | expect(builder["_options"]!["listeners"]!.reconnect).toStrictEqual< 678 | WebsocketEventListenerWithOptions[] 679 | >([ 680 | { 681 | listener, 682 | options: undefined, 683 | }, 684 | ]); 685 | 686 | const ws = builder.build(); 687 | expect(ws["_options"]!["listeners"]!.reconnect).toStrictEqual< 688 | WebsocketEventListenerWithOptions[] 689 | >([ 690 | { 691 | listener, 692 | options: undefined, 693 | }, 694 | ]); 695 | }); 696 | 697 | test("WebsocketBuilder should set 'reconnect'-listener for subsequent calls", () => { 698 | const listener1 = vi.fn(); 699 | const listener2 = vi.fn(); 700 | 701 | const builder = new WebsocketBuilder(url) 702 | .onReconnect(listener1) 703 | .onReconnect(listener2); 704 | expect(builder["_options"]!["listeners"]!.reconnect).toStrictEqual< 705 | WebsocketEventListenerWithOptions[] 706 | >([ 707 | { 708 | listener: listener1, 709 | options: undefined, 710 | }, 711 | { listener: listener2, options: undefined }, 712 | ]); 713 | 714 | const ws = builder.build(); 715 | expect(ws["_options"]!["listeners"]!.reconnect).toStrictEqual< 716 | WebsocketEventListenerWithOptions[] 717 | >([ 718 | { 719 | listener: listener1, 720 | options: undefined, 721 | }, 722 | { listener: listener2, options: undefined }, 723 | ]); 724 | }); 725 | 726 | test("WebsocketBuilder should set 'reconnect'-listener with options", () => { 727 | const listener = vi.fn(); 728 | const options = { once: true }; 729 | 730 | const builder = new WebsocketBuilder(url).onReconnect(listener, options); 731 | expect(builder["_options"]!["listeners"]!.reconnect).toStrictEqual< 732 | WebsocketEventListenerWithOptions[] 733 | >([ 734 | { 735 | listener, 736 | options, 737 | }, 738 | ]); 739 | 740 | const ws = builder.build(); 741 | expect(ws["_options"]!["listeners"]!.reconnect).toStrictEqual< 742 | WebsocketEventListenerWithOptions[] 743 | >([ 744 | { 745 | listener, 746 | options, 747 | }, 748 | ]); 749 | }); 750 | 751 | test("WebsocketBuilder should set 'reconnect'-listener with mixed options", () => { 752 | const listener1 = vi.fn(); 753 | const listener2 = vi.fn(); 754 | const options = { once: true }; 755 | 756 | const builder = new WebsocketBuilder(url) 757 | .onReconnect(listener1) 758 | .onReconnect(listener2, options); 759 | expect(builder["_options"]!["listeners"]!.reconnect).toStrictEqual< 760 | WebsocketEventListenerWithOptions[] 761 | >([ 762 | { 763 | listener: listener1, 764 | options: undefined, 765 | }, 766 | { listener: listener2, options }, 767 | ]); 768 | 769 | const ws = builder.build(); 770 | expect(ws["_options"]!["listeners"]!.reconnect).toStrictEqual< 771 | WebsocketEventListenerWithOptions[] 772 | >([ 773 | { 774 | listener: listener1, 775 | options: undefined, 776 | }, 777 | { listener: listener2, options }, 778 | ]); 779 | }); 780 | 781 | test("WebsocketBuilder should return a Websocket instance", () => { 782 | const builder = new WebsocketBuilder(url); 783 | const ws = builder.build(); 784 | 785 | expect(ws).toBeInstanceOf(Websocket); 786 | }); 787 | 788 | test("WebsocketBuilder should create new Websocket instances with subsequent 'build' calls", () => { 789 | const builder = new WebsocketBuilder(url); 790 | const ws1 = builder.build(); 791 | const ws2 = builder.build(); 792 | 793 | expect(ws1).not.toBe(ws2); 794 | }); 795 | }); 796 | -------------------------------------------------------------------------------- /tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 8 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 9 | // "lib": [], /* Specify library files to be included in the compilation. */ 10 | // "allowJs": true, /* Allow javascript files to be compiled. */ 11 | // "checkJs": true, /* Report errors in .js files. */ 12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 13 | "declaration": true, /* Generates corresponding '.d.ts' file. */ 14 | "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | "sourceMap": true, /* Generates corresponding '.map' file. */ 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | "outDir": "./dist/cjs", /* Redirect output structure to the directory. */ 18 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 21 | // "removeComments": true, /* Do not emit comments to output. */ 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": true, /* Enable all strict type-checking options. */ 29 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 30 | // "strictNullChecks": true, /* Enable strict null checks. */ 31 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 33 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 36 | 37 | /* Additional Checks */ 38 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | 43 | /* Module Resolution Options */ 44 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 45 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 46 | "paths": { 47 | // workaround for: https://github.com/vitest-dev/vitest/issues/4567 48 | "rollup/parseAst": ["./node_modules/rollup/dist/parseAst"] 49 | }, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 50 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 51 | // "typeRoots": [], /* List of folders to include type definitions from. */ 52 | // "types": [], /* Type declaration files to be included in compilation. */ 53 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 54 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 55 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 56 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 57 | 58 | /* Source Map Options */ 59 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 60 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 61 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 62 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 63 | 64 | /* Experimental Options */ 65 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 66 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 67 | 68 | /* Advanced Options */ 69 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 70 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 71 | }, 72 | "include": ["src", "tests"], 73 | "exclude": ["node_modules", "dist"] 74 | } 75 | -------------------------------------------------------------------------------- /tsconfig.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 8 | "module": "es6", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 9 | // "lib": [], /* Specify library files to be included in the compilation. */ 10 | // "allowJs": true, /* Allow javascript files to be compiled. */ 11 | // "checkJs": true, /* Report errors in .js files. */ 12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 13 | "declaration": true, /* Generates corresponding '.d.ts' file. */ 14 | "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | "sourceMap": true, /* Generates corresponding '.map' file. */ 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | "outDir": "./dist/esm", /* Redirect output structure to the directory. */ 18 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 21 | // "removeComments": true, /* Do not emit comments to output. */ 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": true, /* Enable all strict type-checking options. */ 29 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 30 | // "strictNullChecks": true, /* Enable strict null checks. */ 31 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 33 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 36 | 37 | /* Additional Checks */ 38 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | 43 | /* Module Resolution Options */ 44 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 45 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 46 | "paths": { 47 | // workaround for: https://github.com/vitest-dev/vitest/issues/4567 48 | "rollup/parseAst": ["./node_modules/rollup/dist/parseAst"] 49 | }, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 50 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 51 | // "typeRoots": [], /* List of folders to include type definitions from. */ 52 | // "types": [], /* Type declaration files to be included in compilation. */ 53 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 54 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 55 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 56 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 57 | 58 | /* Source Map Options */ 59 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 60 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 61 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 62 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 63 | 64 | /* Experimental Options */ 65 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 66 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 67 | 68 | /* Advanced Options */ 69 | // "skipLibCheck": true, /* Skip type checking of declaration files. */ 70 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 71 | }, 72 | "include": ["src", "tests"], 73 | "exclude": ["node_modules", "dist"] 74 | } 75 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | include: ["tests/**/*.{test,spec}.{js,ts,tsx}"], 6 | globals: false, 7 | environment: "jsdom", 8 | coverage: { 9 | enabled: true, 10 | provider: "v8", 11 | reporter: ["text", "lcov"], 12 | }, 13 | }, 14 | resolve: { 15 | extensions: [".ts", ".js", ".json"], 16 | }, 17 | }); 18 | --------------------------------------------------------------------------------