├── .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 |
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 |
--------------------------------------------------------------------------------