├── .nvmrc ├── dist ├── esm │ ├── lib │ │ ├── types.js │ │ ├── validators.js │ │ ├── types.d.ts │ │ ├── utils.d.ts │ │ ├── validators.d.ts │ │ ├── utils.js │ │ ├── dataTransformer.d.ts │ │ ├── constants.js │ │ ├── constants.d.ts │ │ └── dataTransformer.js │ ├── index.d.ts │ └── index.js └── cjs │ ├── package.json │ ├── lib │ ├── types.js.map │ ├── types.js │ ├── validators.js │ ├── validators.js.map │ ├── constants.js.map │ ├── utils.js.map │ ├── utils.js │ ├── constants.js │ ├── dataTransformer.js.map │ └── dataTransformer.js │ ├── index.js.map │ └── index.js ├── scripts ├── check-commonjs-works.cjs ├── check-esm-works.ts ├── create-package-json-for-commonjs.cjs └── update-changelog.ts ├── src ├── lib │ ├── types.ts │ ├── validators.ts │ ├── utils.ts │ ├── constants.ts │ └── dataTransformer.ts └── index.ts ├── SECURITY.md ├── __tests__ ├── helpers │ └── delay.ts └── index │ ├── send.test.ts │ ├── utils.test.ts │ ├── disconnect.test.ts │ ├── reconnectivity.test.ts │ ├── connectionOptions.test.ts │ ├── state.test.ts │ ├── retryConnectionDelay.test.ts │ ├── dataTransformers.test.ts │ ├── eventListeners.test.ts │ └── messageQueue.test.ts ├── .github ├── dependabot.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── prettier.yml │ ├── node.js.yml │ ├── publish.yml │ └── codeql-analysis.yml ├── tsconfig.cjs.json ├── jest.config.js ├── .editorconfig ├── FUTURE.md ├── LICENSE ├── BACKGROUND.md ├── .gitignore ├── tsconfig.json ├── CONTRIBUTING.md ├── TODO.md ├── CODE_OF_CONDUCT.md ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 22 -------------------------------------------------------------------------------- /dist/esm/lib/types.js: -------------------------------------------------------------------------------- 1 | export {}; 2 | -------------------------------------------------------------------------------- /dist/esm/lib/validators.js: -------------------------------------------------------------------------------- 1 | export {}; 2 | -------------------------------------------------------------------------------- /dist/cjs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "commonjs" 3 | } -------------------------------------------------------------------------------- /scripts/check-commonjs-works.cjs: -------------------------------------------------------------------------------- 1 | const Sarus = require('../dist/cjs/index.js'); -------------------------------------------------------------------------------- /scripts/check-esm-works.ts: -------------------------------------------------------------------------------- 1 | import Sarus from '../dist/esm/index.js'; 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/lib/types.ts: -------------------------------------------------------------------------------- 1 | export type GenericFunction = (...args: unknown[]) => void; 2 | -------------------------------------------------------------------------------- /dist/esm/lib/types.d.ts: -------------------------------------------------------------------------------- 1 | export type GenericFunction = (...args: unknown[]) => void; 2 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | Email paul@anephenix.com 6 | -------------------------------------------------------------------------------- /dist/esm/lib/utils.d.ts: -------------------------------------------------------------------------------- 1 | declare function validateWebSocketUrl(rawUrl: string): URL; 2 | export { validateWebSocketUrl }; 3 | -------------------------------------------------------------------------------- /__tests__/helpers/delay.ts: -------------------------------------------------------------------------------- 1 | export const delay = (duration: number) => 2 | new Promise((resolve) => setTimeout(resolve, duration)); 3 | -------------------------------------------------------------------------------- /dist/cjs/lib/types.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"types.js","sourceRoot":"","sources":["../../../src/lib/types.ts"],"names":[],"mappings":""} -------------------------------------------------------------------------------- /dist/cjs/lib/types.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | //# sourceMappingURL=types.js.map -------------------------------------------------------------------------------- /dist/cjs/lib/validators.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | //# sourceMappingURL=validators.js.map -------------------------------------------------------------------------------- /dist/cjs/lib/validators.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"validators.js","sourceRoot":"","sources":["../../../src/lib/validators.ts"],"names":[],"mappings":""} -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" # See documentation for possible values 4 | directory: "/" # Location of package manifests 5 | schedule: 6 | interval: "daily" 7 | -------------------------------------------------------------------------------- /scripts/create-package-json-for-commonjs.cjs: -------------------------------------------------------------------------------- 1 | const content = `{ 2 | "type": "commonjs" 3 | }`; 4 | 5 | const fs = require('node:fs'); 6 | const path = require('node:path'); 7 | const packageJsonPath = path.join(__dirname, '../dist/cjs/package.json'); 8 | fs.writeFileSync(packageJsonPath, content, 'utf8'); -------------------------------------------------------------------------------- /tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "CommonJS", 5 | "outDir": "./dist/cjs", 6 | "declaration": true, 7 | "allowJs": false, 8 | "emitDeclarationOnly": false, 9 | "noEmit": false, 10 | "sourceMap": true, 11 | } 12 | } -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | preset: 'ts-jest/presets/default-esm', 3 | extensionsToTreatAsEsm: ['.ts'], 4 | testEnvironment: 'jsdom', 5 | clearMocks: true, 6 | coverageDirectory: 'coverage', 7 | transform: { 8 | '^.+\\.tsx?$': ['ts-jest', { useESM: true }], 9 | }, 10 | testPathIgnorePatterns: ['/__tests__/helpers/delay.ts'], 11 | moduleNameMapper: { 12 | '^(\\.{1,2}/.*)\\.js$': '$1', 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /dist/cjs/lib/constants.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"constants.js","sourceRoot":"","sources":["../../../src/lib/constants.ts"],"names":[],"mappings":";;;AAGa,QAAA,iBAAiB,GAAkB,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;AAEhE;;;;GAIG;AACU,QAAA,cAAc,GAAG,CAAC,MAAM,EAAE,OAAO,EAAE,SAAS,EAAE,OAAO,CAAU,CAAC;AAG7E;;;;GAIG;AACU,QAAA,kBAAkB,GAAkB,CAAC,SAAS,EAAE,OAAO,CAAC,CAAC;AAEtE;;;;;;;;;GASG;AACU,QAAA,8BAA8B,GAA4B;IACrE,IAAI,EAAE,EAAE;IACR,OAAO,EAAE,EAAE;IACX,KAAK,EAAE,EAAE;IACT,KAAK,EAAE,EAAE;CACV,CAAC"} -------------------------------------------------------------------------------- /dist/cjs/lib/utils.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"utils.js","sourceRoot":"","sources":["../../../src/lib/utils.ts"],"names":[],"mappings":";;AAkBS,oDAAoB;AAlB7B,iDAAmD;AAEnD,SAAS,oBAAoB,CAAC,MAAc;IAC1C,0EAA0E;IAC1E,2EAA2E;IAC3E,QAAQ;IACR,MAAM,GAAG,GAAQ,IAAI,GAAG,CAAC,MAAM,CAAC,CAAC;IACjC,kDAAkD;IAClD,iEAAiE;IACjE,MAAM,EAAE,QAAQ,EAAE,GAAG,GAAG,CAAC;IACzB,IAAI,CAAC,gCAAiB,CAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC;QAC1C,MAAM,IAAI,KAAK,CACb,qEAAqE,QAAQ,YAAY,CAC1F,CAAC;IACJ,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC"} -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | end_of_line = lf 9 | 10 | # Unix-style newlines with a newline ending every file 11 | [{src,__tests__}/**.ts] 12 | insert_final_newline = true 13 | indent_style = space 14 | indent_size = 2 15 | trim_trailing_whitespace = true 16 | max_line_length = 80 17 | 18 | [package.json] 19 | indent_style = space 20 | indent_size = 2 21 | 22 | [jest.config.js] 23 | indent_style = tab 24 | -------------------------------------------------------------------------------- /src/lib/validators.ts: -------------------------------------------------------------------------------- 1 | import type { GenericFunction } from "./types.js"; 2 | 3 | export interface EventListenersInterface { 4 | open: GenericFunction[]; 5 | message: GenericFunction[]; 6 | error: GenericFunction[]; 7 | close: GenericFunction[]; 8 | [key: string]: GenericFunction[]; 9 | } 10 | 11 | export interface PartialEventListenersInterface { 12 | open?: GenericFunction[]; 13 | message?: GenericFunction[]; 14 | error?: GenericFunction[]; 15 | close?: GenericFunction[]; 16 | [key: string]: GenericFunction[] | undefined; 17 | } 18 | -------------------------------------------------------------------------------- /dist/esm/lib/validators.d.ts: -------------------------------------------------------------------------------- 1 | import type { GenericFunction } from "./types.js"; 2 | export interface EventListenersInterface { 3 | open: GenericFunction[]; 4 | message: GenericFunction[]; 5 | error: GenericFunction[]; 6 | close: GenericFunction[]; 7 | [key: string]: GenericFunction[]; 8 | } 9 | export interface PartialEventListenersInterface { 10 | open?: GenericFunction[]; 11 | message?: GenericFunction[]; 12 | error?: GenericFunction[]; 13 | close?: GenericFunction[]; 14 | [key: string]: GenericFunction[] | undefined; 15 | } 16 | -------------------------------------------------------------------------------- /__tests__/index/send.test.ts: -------------------------------------------------------------------------------- 1 | // File Dependencies 2 | import Sarus from "../../src/index"; 3 | import { WS } from "jest-websocket-mock"; 4 | 5 | const url: string = "ws://localhost:1234"; 6 | 7 | describe("sending websocket messages", () => { 8 | it("should send a message to the WebSocket server", async () => { 9 | const server: WS = new WS(url); 10 | const sarus: Sarus = new Sarus({ url }); 11 | await server.connected; 12 | sarus.send("Hello server"); 13 | await expect(server).toReceiveMessage("Hello server"); 14 | server.close(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/prettier.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | permissions: 3 | contents: read 4 | 5 | on: 6 | push: 7 | branches: 8 | - master 9 | pull_request: 10 | 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.ref }} 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | lint: 17 | name: Lint 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v4.1.0 22 | - name: Use Node.js 22 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: 22 26 | 27 | - name: Clean NPM install 28 | run: npm clean-install 29 | - name: Check with prettier 30 | run: npm run check-prettier 31 | -------------------------------------------------------------------------------- /dist/esm/lib/utils.js: -------------------------------------------------------------------------------- 1 | import { ALLOWED_PROTOCOLS } from "./constants.js"; 2 | function validateWebSocketUrl(rawUrl) { 3 | // Alternatively, we can also check with URL.canParse(), but since we need 4 | // the URL object anyway to validate the protocol, we go ahead and parse it 5 | // here. 6 | const url = new URL(rawUrl); 7 | // TypeError, as specified by WHATWG URL Standard: 8 | // https://url.spec.whatwg.org/#url-class (see constructor steps) 9 | const { protocol } = url; 10 | if (!ALLOWED_PROTOCOLS.includes(protocol)) { 11 | throw new Error(`Expected the WebSocket URL to have protocol 'ws:' or 'wss:', got '${protocol}' instead.`); 12 | } 13 | return url; 14 | } 15 | export { validateWebSocketUrl }; 16 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { ALLOWED_PROTOCOLS } from "./constants.js"; 2 | 3 | function validateWebSocketUrl(rawUrl: string) { 4 | // Alternatively, we can also check with URL.canParse(), but since we need 5 | // the URL object anyway to validate the protocol, we go ahead and parse it 6 | // here. 7 | const url: URL = new URL(rawUrl); 8 | // TypeError, as specified by WHATWG URL Standard: 9 | // https://url.spec.whatwg.org/#url-class (see constructor steps) 10 | const { protocol } = url; 11 | if (!ALLOWED_PROTOCOLS.includes(protocol)) { 12 | throw new Error( 13 | `Expected the WebSocket URL to have protocol 'ws:' or 'wss:', got '${protocol}' instead.`, 14 | ); 15 | } 16 | return url; 17 | } 18 | 19 | export { validateWebSocketUrl }; 20 | -------------------------------------------------------------------------------- /dist/cjs/lib/utils.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.validateWebSocketUrl = validateWebSocketUrl; 4 | const constants_js_1 = require("./constants.js"); 5 | function validateWebSocketUrl(rawUrl) { 6 | // Alternatively, we can also check with URL.canParse(), but since we need 7 | // the URL object anyway to validate the protocol, we go ahead and parse it 8 | // here. 9 | const url = new URL(rawUrl); 10 | // TypeError, as specified by WHATWG URL Standard: 11 | // https://url.spec.whatwg.org/#url-class (see constructor steps) 12 | const { protocol } = url; 13 | if (!constants_js_1.ALLOWED_PROTOCOLS.includes(protocol)) { 14 | throw new Error(`Expected the WebSocket URL to have protocol 'ws:' or 'wss:', got '${protocol}' instead.`); 15 | } 16 | return url; 17 | } 18 | //# sourceMappingURL=utils.js.map -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | permissions: 6 | contents: read 7 | 8 | on: 9 | push: 10 | branches: [master] 11 | pull_request: 12 | branches: [master] 13 | 14 | jobs: 15 | build: 16 | env: 17 | CC_TEST_REPORTER_ID: 3a5853627354f7cd705f3d9dc81fb83028c8a901a950a3d0d38a090776f8c5ce 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v2 21 | - name: Use Node.js 22 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: 22 25 | - run: npm i 26 | - run: npm t 27 | -------------------------------------------------------------------------------- /__tests__/index/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { validateWebSocketUrl } from "../../src/lib/utils"; 2 | 3 | describe("validateWebsocketUrl", () => { 4 | describe("when the url is valid", () => { 5 | it("should return the URL object", () => { 6 | const url = "ws://example.com"; 7 | expect(validateWebSocketUrl(url)).toEqual(new URL(url)); 8 | }); 9 | }); 10 | 11 | describe("when the url is invalid", () => { 12 | it("should throw an error", () => { 13 | const scenarios = [ 14 | { url: "invalid-url", message: "Invalid URL: invalid-url" }, 15 | { 16 | url: "http://websocket.com", 17 | message: 18 | "Expected the WebSocket URL to have protocol 'ws:' or 'wss:', got 'http:' instead.", 19 | }, 20 | ]; 21 | 22 | for (const { url, message } of scenarios) { 23 | expect(() => validateWebSocketUrl(url)).toThrow(message); 24 | } 25 | }); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /dist/esm/lib/dataTransformer.d.ts: -------------------------------------------------------------------------------- 1 | export declare const serialize: (data: string | object | number | ArrayBuffer | Uint8Array | null | boolean) => string; 2 | export declare function serializeSingle(data: string | object | number | ArrayBuffer | Uint8Array | null | boolean): object | string | number | boolean | null; 3 | /** 4 | * Deserializes the data stored in sessionStorage/localStorage 5 | * @param {string} data - the data that we want to deserialize 6 | * @returns {*} The deserialized data 7 | */ 8 | export declare const deserialize: (data: string | null) => unknown[] | unknown | null; 9 | export type Base64EncodedData = { 10 | __sarus_type: "binary"; 11 | format: "arraybuffer" | "uint8array"; 12 | data: string; 13 | }; 14 | type DeserializeSingleParms = string | object | number | Base64EncodedData | null | boolean; 15 | type DeserializeSingleReturn = string | object | number | ArrayBuffer | Uint8Array | null | boolean; 16 | export declare function deserializeSingle(parsed: DeserializeSingleParms): DeserializeSingleReturn; 17 | export {}; 18 | -------------------------------------------------------------------------------- /FUTURE.md: -------------------------------------------------------------------------------- 1 | Scenario: 2 | 3 | - You have created a new WebSocket connection, but some time has passed 4 | between when the previous WebSocket connection closed and the new WebSocket 5 | connection was opened. You want to retrieve any messages that might have been 6 | sent by the server during that time. 7 | 8 | Feature: 9 | 10 | - Retrieve messages that would have been delivered by the WebSocket server 11 | during a period of being disconnected (requires using a WebSocket server 12 | which supports retrieving messages for a client to receive). 13 | 14 | Scenario: You want attempts to reconnect to the server to have some form 15 | of exponential backoff. 16 | 17 | This feels like a feature that could be supported by [Hub](https://github.com/anephenix/hub), 18 | as that is where WebSocket server logic is kept to support this. 19 | 20 | Feature: 21 | 22 | - Introduce an exponential back-off strategy to Sarus in the near future. 23 | 24 | Note - there is a `retryConnectionDelay` option - perhaps this could be used to 25 | support the feature by passing a function that implements exponential back-off. 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-2023 The Sarus Authors 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 | -------------------------------------------------------------------------------- /BACKGROUND.md: -------------------------------------------------------------------------------- 1 | ### Background 2 | 3 | WebSockets are great, but using them in real-world applications requires 4 | handling cases where: 5 | 6 | - The WebSocket connection can close unexpectedly, such as when losing 7 | Internet access for a brief period of time. 8 | 9 | - You want to send messages to the WebSocket connection, but if the connection 10 | is closed then the messages will not be received by the server. 11 | 12 | - The messages that would be sent from the client to the server get lost when 13 | the user refreshes the page in the browser. 14 | 15 | To handle these cases, you will have to write some JavaScript that essentially 16 | wraps access to the WebSocket protocol and handles those cases. 17 | 18 | Sarus is a library designed to do exactly that. It has the following features: 19 | 20 | - Handle reconnecting a WebSocket connection if it closes unexpectedly. 21 | 22 | - Make sure event listeners that were attached to the origin WebSocket instance 23 | are attached to subsequent WebSocket instances. 24 | 25 | - Record messages to deliver if the WebSocket connection is closed, and deliver 26 | them once there is an open WebSocket connection. 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | out 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | .husky -------------------------------------------------------------------------------- /dist/esm/lib/constants.js: -------------------------------------------------------------------------------- 1 | export const ALLOWED_PROTOCOLS = ["ws:", "wss:"]; 2 | /** 3 | * A definitive list of events for a WebSocket client to listen on 4 | * @constant 5 | * @type {array} 6 | */ 7 | export const WS_EVENT_NAMES = ["open", "close", "message", "error"]; 8 | /** 9 | * Persistent data storage types 10 | * @constant 11 | * @type {array} 12 | */ 13 | export const DATA_STORAGE_TYPES = ["session", "local"]; 14 | /** 15 | * Default event listeners object in case none is passed to the class 16 | * @constant 17 | * @type {object} 18 | * @property {array} open - An array of functions to be called when the WebSocket opens 19 | * @property {array} message - An array of functions to be called when the WebSocket receives a message 20 | * @property {array} error - An array of functions to be called when the WebSocket encounters an error 21 | * @property {array} close - An array of functions to be called when the WebSocket closes 22 | * @property {array} [key] - An array of functions to be called when the WebSocket emits an event with the name of the key 23 | */ 24 | export const DEFAULT_EVENT_LISTENERS_OBJECT = { 25 | open: [], 26 | message: [], 27 | error: [], 28 | close: [], 29 | }; 30 | -------------------------------------------------------------------------------- /dist/esm/lib/constants.d.ts: -------------------------------------------------------------------------------- 1 | import type { EventListenersInterface } from "./validators.js"; 2 | export declare const ALLOWED_PROTOCOLS: Array; 3 | /** 4 | * A definitive list of events for a WebSocket client to listen on 5 | * @constant 6 | * @type {array} 7 | */ 8 | export declare const WS_EVENT_NAMES: readonly ["open", "close", "message", "error"]; 9 | export type WsEventName = (typeof WS_EVENT_NAMES)[number]; 10 | /** 11 | * Persistent data storage types 12 | * @constant 13 | * @type {array} 14 | */ 15 | export declare const DATA_STORAGE_TYPES: Array; 16 | /** 17 | * Default event listeners object in case none is passed to the class 18 | * @constant 19 | * @type {object} 20 | * @property {array} open - An array of functions to be called when the WebSocket opens 21 | * @property {array} message - An array of functions to be called when the WebSocket receives a message 22 | * @property {array} error - An array of functions to be called when the WebSocket encounters an error 23 | * @property {array} close - An array of functions to be called when the WebSocket closes 24 | * @property {array} [key] - An array of functions to be called when the WebSocket emits an event with the name of the key 25 | */ 26 | export declare const DEFAULT_EVENT_LISTENERS_OBJECT: EventListenersInterface; 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */, 4 | "module": "ESNext" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, 5 | "allowJs": false /* Allow javascript files to be compiled. */, 6 | "checkJs": false /* Report errors in .js files. */, 7 | "outDir": "./dist/esm" /* Redirect output structure to the directory. */, 8 | "rootDir": "./src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */, 9 | "strict": true /* Enable all strict type-checking options. */, 10 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 11 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */, 12 | "declaration": true, 13 | "moduleResolution": "node", 14 | "declarationDir": "./dist/esm" 15 | }, 16 | "typeRoots": ["@types/window-or-global"], 17 | "include": ["src/index.ts", "src/**/*"], 18 | "exclude": ["node_modules", "__tests__"] 19 | } 20 | -------------------------------------------------------------------------------- /__tests__/index/disconnect.test.ts: -------------------------------------------------------------------------------- 1 | // File Dependencies 2 | import Sarus from "../../src/index"; 3 | import { WS } from "jest-websocket-mock"; 4 | 5 | const url = "ws://localhost:1234"; 6 | 7 | describe("disconnecting the WebSocket connection", () => { 8 | it("should disconnect from the WebSocket server, and disable automatic reconnections", async () => { 9 | const server: WS = new WS(url); 10 | const sarus: Sarus = new Sarus({ url, reconnectAutomatically: true }); 11 | const mockReconnect = jest.fn(); 12 | sarus.reconnect = mockReconnect; 13 | await server.connected; 14 | sarus.disconnect(); 15 | expect(sarus.reconnectAutomatically).toBe(false); 16 | await server.closed; 17 | expect(sarus.ws?.readyState).toBe(3); 18 | expect(sarus.reconnect).toHaveBeenCalledTimes(0); 19 | server.close(); 20 | }); 21 | 22 | it("should allow the developer to override disabling automatica reconnections", async () => { 23 | const server: WS = new WS(url); 24 | const sarus: Sarus = new Sarus({ url, reconnectAutomatically: true }); 25 | const mockReconnect = jest.fn(); 26 | sarus.reconnect = mockReconnect; 27 | await server.connected; 28 | sarus.disconnect(true); 29 | expect(sarus.reconnectAutomatically).toBe(true); 30 | await server.closed; 31 | expect(sarus.reconnect).toHaveBeenCalledTimes(1); 32 | server.close(); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | // Dependencies 2 | import type { EventListenersInterface } from "./validators.js"; 3 | 4 | export const ALLOWED_PROTOCOLS: Array = ["ws:", "wss:"]; 5 | 6 | /** 7 | * A definitive list of events for a WebSocket client to listen on 8 | * @constant 9 | * @type {array} 10 | */ 11 | export const WS_EVENT_NAMES = ["open", "close", "message", "error"] as const; 12 | export type WsEventName = (typeof WS_EVENT_NAMES)[number]; 13 | 14 | /** 15 | * Persistent data storage types 16 | * @constant 17 | * @type {array} 18 | */ 19 | export const DATA_STORAGE_TYPES: Array = ["session", "local"]; 20 | 21 | /** 22 | * Default event listeners object in case none is passed to the class 23 | * @constant 24 | * @type {object} 25 | * @property {array} open - An array of functions to be called when the WebSocket opens 26 | * @property {array} message - An array of functions to be called when the WebSocket receives a message 27 | * @property {array} error - An array of functions to be called when the WebSocket encounters an error 28 | * @property {array} close - An array of functions to be called when the WebSocket closes 29 | * @property {array} [key] - An array of functions to be called when the WebSocket emits an event with the name of the key 30 | */ 31 | export const DEFAULT_EVENT_LISTENERS_OBJECT: EventListenersInterface = { 32 | open: [], 33 | message: [], 34 | error: [], 35 | close: [], 36 | }; 37 | -------------------------------------------------------------------------------- /dist/cjs/lib/constants.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.DEFAULT_EVENT_LISTENERS_OBJECT = exports.DATA_STORAGE_TYPES = exports.WS_EVENT_NAMES = exports.ALLOWED_PROTOCOLS = void 0; 4 | exports.ALLOWED_PROTOCOLS = ["ws:", "wss:"]; 5 | /** 6 | * A definitive list of events for a WebSocket client to listen on 7 | * @constant 8 | * @type {array} 9 | */ 10 | exports.WS_EVENT_NAMES = ["open", "close", "message", "error"]; 11 | /** 12 | * Persistent data storage types 13 | * @constant 14 | * @type {array} 15 | */ 16 | exports.DATA_STORAGE_TYPES = ["session", "local"]; 17 | /** 18 | * Default event listeners object in case none is passed to the class 19 | * @constant 20 | * @type {object} 21 | * @property {array} open - An array of functions to be called when the WebSocket opens 22 | * @property {array} message - An array of functions to be called when the WebSocket receives a message 23 | * @property {array} error - An array of functions to be called when the WebSocket encounters an error 24 | * @property {array} close - An array of functions to be called when the WebSocket closes 25 | * @property {array} [key] - An array of functions to be called when the WebSocket emits an event with the name of the key 26 | */ 27 | exports.DEFAULT_EVENT_LISTENERS_OBJECT = { 28 | open: [], 29 | message: [], 30 | error: [], 31 | close: [], 32 | }; 33 | //# sourceMappingURL=constants.js.map -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | There are a number of ways to contribute to the project: 2 | 3 | * Making feature requests 4 | * Submitting new features 5 | * Reporting bugs 6 | * Adding and improving documentation 7 | * General queries. 8 | 9 | Below is a list of suggested ways to approach each: 10 | 11 | ### Making feature requests 12 | 13 | Suggestions are welcome. The best way to make them is to [create an issue](https://github.com/anephenix/sarus/issues/new), and add the label "enhancement" to it. 14 | 15 | We will then take a look and see what we can do about it. 16 | 17 | ### Submitting new features 18 | 19 | If you have a feature that you think would be good to include in Sarus, then that suggestion is welcome too. 20 | 21 | Again, [create an issue](https://github.com/anephenix/sarus/issues/new), and add the label "enhancement". 22 | 23 | If the new feature is given the thumbs up, submit a Pull Request to the repo with the code for it. 24 | 25 | ### Reporting bugs 26 | 27 | We try hard to prevent any bugs from occurring in Sarus, but that doesn't guarantee that they won't happen. 28 | 29 | If you come across a bug and wish to report it, [create an issue](https://github.com/anephenix/sarus/issues/new) and add the label "bug". 30 | 31 | If you have a way to replicate the error, please provide as much detail as possible in the issue. That will help with finding a way to fix it. 32 | 33 | ### Adding and improving documentation 34 | 35 | You can make additions and improvements to the documentation via submitting a Pull Request. 36 | 37 | ### General queries 38 | 39 | [create an issue](https://github.com/anephenix/sarus/issues/new), and add the label "question". 40 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to npm 2 | permissions: 3 | contents: read # Required for actions/checkout to read the repository 4 | id-token: write # Optional, for OIDC-based authentication if using cloud services 5 | 6 | on: 7 | push: 8 | tags: 9 | - 'v*' # Triggers only for tags that start with 'v' (e.g., v1.0, v1.1.0) 10 | workflow_dispatch: 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@v3 19 | 20 | - name: Use Node.js 22 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: 22 24 | registry-url: 'https://registry.npmjs.org' 25 | always-auth: true 26 | 27 | - name: Install dependencies 28 | run: npm install 29 | 30 | - name: Run tests 31 | run: npm test 32 | 33 | - name: Build project 34 | run: npm run build 35 | 36 | publish: 37 | needs: build 38 | runs-on: ubuntu-latest 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | - name: Use Node.js 22 43 | uses: actions/setup-node@v4 44 | with: 45 | node-version: 22 46 | registry-url: 'https://registry.npmjs.org' 47 | always-auth: true 48 | 49 | - name: Install dependencies 50 | run: npm install 51 | 52 | - name: Run build 53 | run: npm run build 54 | 55 | - name: Lint the package.json 56 | run: npm run lint:package 57 | 58 | - name: Publish to npm 59 | run: npm publish 60 | env: 61 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | 3 | - [x] Support `reconnectAutomatically` option in `Sarus` initialization 4 | - [x] Pass eventListeners into `Sarus` at initialization 5 | - [x] Find a way to test the onopen event in the tests 6 | - [x] Make sure that the right event listeners are provided at initialization 7 | - [x] Add an event listener after initialization 8 | - [x] Prevent the same function being added multiple times to an event listener 9 | - [x] Remove an event listener function by passing the function 10 | - [x] Remove an event listener function by passing the name 11 | - [x] Throw an error when removing an event listener does not find it 12 | - [x] Support `doNotThrowError: true` when removing an event listener 13 | - [x] Test sending a WebSocket message 14 | - [x] Make sure that adding/removing event listeners after initialization is bound on WebSocket as well 15 | - [x] Implement message queue with in-memory as default 16 | - [x] Make sure test assertion guarantees order of messages being extracted from queue 17 | - [x] Make retryProcessTimePeriod configurable 18 | - [x] Implement message queue with session storage as an option 19 | - [x] Implement message queue with local storage as an option 20 | - [x] Make the storageKey configurable 21 | - [x] Test loading messages stored in sessionStorage 22 | - [x] Test loading messages stored in localStorage 23 | - [x] Implement a way to configure WebSocket client options 24 | - [x] Work out what to do when the message storage limit is reached (technically the browser will throw a QUOTA_EXCEEDED_ERR) 25 | - [x] Work out how to support sending binary data instead of string data 26 | - [x] Think about how to support higher-level use cases of WebSockets (rpc, pubsub) via a plugin architecture. 27 | - [x] TypeScript definitions 28 | 29 | ## This is a feature for Hub rather than for Sarus 30 | 31 | - [ ] Implement a way to retrieve messages from a server, based on a key indicator 32 | -------------------------------------------------------------------------------- /__tests__/index/reconnectivity.test.ts: -------------------------------------------------------------------------------- 1 | // File Dependencies 2 | import Sarus from "../../src/index"; 3 | import { WS } from "jest-websocket-mock"; 4 | import { delay } from "../helpers/delay"; 5 | 6 | const url: string = "ws://localhost:1234"; 7 | 8 | describe("automatic reconnectivity", () => { 9 | it("should reconnect the WebSocket connection when it is severed", async () => { 10 | const server: WS = new WS(url); 11 | const mockConnect = jest.fn(); 12 | const sarus: Sarus = new Sarus({ url }); 13 | await server.connected; 14 | sarus.connect = mockConnect; 15 | const setTimeout = jest.spyOn(window, "setTimeout"); 16 | server.close(); 17 | await delay(1000); 18 | expect(sarus.connect).toHaveBeenCalled(); 19 | expect(setTimeout).toHaveBeenCalledTimes(2); 20 | }); 21 | 22 | it("should not reconnect if automatic reconnection is disabled", async () => { 23 | const server: WS = new WS(url); 24 | const mockConnect = jest.fn(); 25 | const sarus: Sarus = new Sarus({ 26 | url, 27 | reconnectAutomatically: false, 28 | }); 29 | await server.connected; 30 | sarus.connect = mockConnect; 31 | server.close(); 32 | expect(sarus.connect).toHaveBeenCalledTimes(0); 33 | }); 34 | 35 | describe("if a websocket is closed and meant to reconnect automatically", () => { 36 | it("should remove all eventListeners on the closed websocket before reconnecting", async () => { 37 | const server: WS = new WS(url); 38 | const mockReconnect = jest.fn(); 39 | const sarus: Sarus = new Sarus({ 40 | url, 41 | }); 42 | await server.connected; 43 | sarus.reconnect = mockReconnect; 44 | server.close(); 45 | await delay(1000); 46 | expect(sarus.reconnect).toHaveBeenCalled(); 47 | // @ts-ignore 48 | expect(sarus.ws?.listeners?.open?.length).toBe(0); 49 | // @ts-ignore 50 | expect(sarus.ws?.listeners?.message?.length).toBe(0); 51 | // @ts-ignore 52 | expect(sarus.ws?.listeners?.error?.length).toBe(0); 53 | // @ts-ignore 54 | expect(sarus.ws?.listeners?.close?.length).toBe(0); 55 | }); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /dist/cjs/lib/dataTransformer.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"dataTransformer.js","sourceRoot":"","sources":["../../../src/lib/dataTransformer.ts"],"names":[],"mappings":"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"} -------------------------------------------------------------------------------- /__tests__/index/connectionOptions.test.ts: -------------------------------------------------------------------------------- 1 | // File Dependencies 2 | import Sarus from "../../src/index"; 3 | import { WS } from "jest-websocket-mock"; 4 | 5 | const url: string = "ws://localhost:1234"; 6 | const stringProtocol: string = "hybi-00"; 7 | const arrayOfProtocols: Array = ["hybi-07", "hybi-00"]; 8 | const binaryTypes: Array = ["blob", "arraybuffer"]; 9 | 10 | describe("connection options", () => { 11 | let server: WS; 12 | 13 | beforeAll(() => { 14 | server = new WS(url); 15 | }); 16 | 17 | afterAll(() => { 18 | server.close(); 19 | }); 20 | 21 | it("should correctly validate invalid WebSocket URLs", () => { 22 | // Testing with jest-websocket-mock will not give us a TypeError here. 23 | // We re-throw the error therefore. Testing it in a browser we can 24 | // see that a TypeError is handled correctly. 25 | expect(() => { 26 | new Sarus({ url: "invalid-url" }); 27 | }).toThrow("invalid"); 28 | 29 | expect(() => { 30 | new Sarus({ url: "http://wrong-protocol" }); 31 | }).toThrow("have protocol"); 32 | 33 | expect(() => { 34 | new Sarus({ url: "https://also-wrong-protocol" }); 35 | }).toThrow("have protocol"); 36 | 37 | new Sarus({ url: "ws://this-will-pass" }); 38 | new Sarus({ url: "wss://this-too-shall-pass" }); 39 | }); 40 | 41 | it("should set the WebSocket protocols value to an empty string if nothing is passed", async () => { 42 | const sarus: Sarus = new Sarus({ url }); 43 | await server.connected; 44 | expect(sarus.ws?.protocol).toBe(""); 45 | }); 46 | 47 | it("should set the WebSocket protocols value to a string if a string is passed", async () => { 48 | const sarus: Sarus = new Sarus({ url, protocols: stringProtocol }); 49 | await server.connected; 50 | expect(sarus.ws?.protocol).toBe(stringProtocol); 51 | }); 52 | 53 | it("should set the WebSocket protocols value to the first value in an array if an array is passed", async () => { 54 | const sarus: Sarus = new Sarus({ url, protocols: arrayOfProtocols }); 55 | await server.connected; 56 | expect(sarus.ws?.protocol).toBe(arrayOfProtocols[0]); 57 | }); 58 | 59 | it("should set the binaryType of the WebSocket, if passed as an option", async () => { 60 | const sarus: Sarus = new Sarus({ url, binaryType: binaryTypes[0] }); 61 | await server.connected; 62 | expect(sarus.ws?.binaryType).toBe(binaryTypes[0]); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '45 3 * * 1' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 37 | # Learn more: 38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@v2 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v1 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v1 72 | -------------------------------------------------------------------------------- /dist/esm/lib/dataTransformer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Serializes the data for storing in sessionStorage/localStorage 3 | * @param {*} data - the data that we want to serialize 4 | * @returns {string} - the serialized data 5 | */ 6 | // Helper: ArrayBuffer/Uint8Array to base64 7 | function bufferToBase64(buffer) { 8 | let binary = ""; 9 | const bytes = new Uint8Array(buffer); 10 | const len = bytes.byteLength; 11 | for (let i = 0; i < len; i++) { 12 | binary += String.fromCharCode(bytes[i]); 13 | } 14 | return btoa(binary); 15 | } 16 | // Helper: base64 to ArrayBuffer 17 | function base64ToBuffer(base64) { 18 | const binary = atob(base64); 19 | const len = binary.length; 20 | const bytes = new Uint8Array(len); 21 | for (let i = 0; i < len; i++) { 22 | bytes[i] = binary.charCodeAt(i); 23 | } 24 | return bytes.buffer; 25 | } 26 | // Helper: Blob to base64 (async, but we use ArrayBuffer for storage) 27 | export const serialize = (data) => { 28 | // If it's an array, serialize each element 29 | if (Array.isArray(data)) { 30 | return JSON.stringify(data.map(serializeSingle)); 31 | } 32 | return JSON.stringify(serializeSingle(data)); 33 | }; 34 | export function serializeSingle(data) { 35 | if (data instanceof ArrayBuffer) { 36 | return { 37 | __sarus_type: "binary", 38 | format: "arraybuffer", 39 | data: bufferToBase64(data), 40 | }; 41 | } 42 | if (data instanceof Uint8Array) { 43 | return { 44 | __sarus_type: "binary", 45 | format: "uint8array", 46 | data: bufferToBase64(data.buffer), 47 | }; 48 | } 49 | if (typeof Blob !== "undefined" && data instanceof Blob) { 50 | throw new Error("Blob serialization is not supported synchronously. Convert Blob to ArrayBuffer or Uint8Array before sending."); 51 | } 52 | return data; 53 | } 54 | /** 55 | * Deserializes the data stored in sessionStorage/localStorage 56 | * @param {string} data - the data that we want to deserialize 57 | * @returns {*} The deserialized data 58 | */ 59 | export const deserialize = (data) => { 60 | if (!data) 61 | return null; 62 | const parsed = JSON.parse(data); 63 | if (Array.isArray(parsed)) { 64 | return parsed.map(deserializeSingle); 65 | } 66 | return deserializeSingle(parsed); 67 | }; 68 | export function deserializeSingle(parsed) { 69 | if (typeof parsed === "object" && 70 | parsed !== null && 71 | parsed.__sarus_type === "binary") { 72 | const { format, data } = parsed; 73 | const buffer = base64ToBuffer(data); 74 | if (format === "arraybuffer") 75 | return buffer; 76 | if (format === "uint8array") 77 | return new Uint8Array(buffer); 78 | } 79 | return parsed; 80 | } 81 | -------------------------------------------------------------------------------- /__tests__/index/state.test.ts: -------------------------------------------------------------------------------- 1 | // File Dependencies 2 | import Sarus from "../../src/index"; 3 | import { WS } from "jest-websocket-mock"; 4 | import { delay } from "../helpers/delay"; 5 | 6 | const url: string = "ws://localhost:1234"; 7 | const sarusConfig = { 8 | url, 9 | retryConnectionDelay: 1, 10 | }; 11 | 12 | describe("state machine", () => { 13 | it("cycles through a closed connection correctly", async () => { 14 | let server: WS = new WS(url); 15 | 16 | // In the beginning, the state is "connecting" 17 | const sarus: Sarus = new Sarus(sarusConfig); 18 | // Since Sarus jumps into connecting directly, 1 connection attempt is made 19 | // right in the beginning, but none have failed 20 | expect(sarus.state).toStrictEqual({ 21 | kind: "connecting", 22 | failedConnectionAttempts: 0, 23 | }); 24 | 25 | // We wait until we are connected, and see a "connected" state 26 | await server.connected; 27 | expect(sarus.state.kind).toBe("connected"); 28 | 29 | // When the connection drops, the state will be "closed" 30 | server.close(); 31 | await server.closed; 32 | expect(sarus.state).toStrictEqual({ 33 | kind: "closed", 34 | }); 35 | 36 | // We wait a while, and the status is "connecting" again 37 | await delay(1); 38 | // In the beginning, no connection attempts have been made, since in the 39 | // case of a closed connection, we wait a bit until we try to connect again. 40 | expect(sarus.state).toStrictEqual({ 41 | kind: "connecting", 42 | failedConnectionAttempts: 0, 43 | }); 44 | 45 | // We restart the server and let the Sarus instance reconnect: 46 | server = new WS(url); 47 | 48 | // When we connect in our mock server, we are "connected" again 49 | await server.connected; 50 | expect(sarus.state.kind).toBe("connected"); 51 | 52 | // Cleanup 53 | server.close(); 54 | }); 55 | 56 | it("cycles through disconnect() correctly", async () => { 57 | let server: WS = new WS(url); 58 | 59 | // Same initial state transition as above 60 | const sarus: Sarus = new Sarus(sarusConfig); 61 | expect(sarus.state.kind).toBe("connecting"); 62 | await server.connected; 63 | expect(sarus.state.kind).toBe("connected"); 64 | 65 | // The user can disconnect and the state will be "disconnected" 66 | sarus.disconnect(); 67 | expect(sarus.state.kind).toBe("disconnected"); 68 | await server.closed; 69 | 70 | // The user can now reconnect, and the state will be "connecting", and then 71 | // "connected" again 72 | sarus.connect(); 73 | expect(sarus.state.kind).toBe("connecting"); 74 | await server.connected; 75 | // XXX for some reason the test will fail without waiting 10 ms here 76 | await delay(10); 77 | expect(sarus.state.kind).toBe("connected"); 78 | server.close(); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /scripts/update-changelog.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from 'node:child_process'; 2 | import { readFileSync, writeFileSync } from 'node:fs'; 3 | import { fileURLToPath } from 'node:url'; 4 | import { dirname, join } from 'node:path'; 5 | 6 | const __filename = fileURLToPath(import.meta.url); 7 | const __dirname = dirname(__filename); 8 | 9 | // Paths 10 | const packageJsonPath = join(__dirname, '../package.json'); 11 | const changelogPath = join(__dirname, '../CHANGELOG.md'); 12 | 13 | // Get current version from package.json 14 | const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')); 15 | const currentVersion = packageJson.version; 16 | 17 | // Bump patch version 18 | const [major, minor, patch] = currentVersion.split('.').map(Number); 19 | const nextVersion = `${major}.${minor}.${patch + 1}`; 20 | 21 | // Get previous version from git tags 22 | const previousVersion = execSync('git describe --tags --abbrev=0 HEAD^').toString().trim(); 23 | 24 | // Get commit messages between previous version and current version 25 | const commitMessages = execSync(`git log ${previousVersion}..HEAD --pretty=format:"- %s"`).toString().trim(); 26 | 27 | // Get current date 28 | function getOrdinalSuffix(day: number): string { 29 | if (day > 3 && day < 21) return 'th'; // Covers 11th to 19th 30 | switch (day % 10) { 31 | case 1: return 'st'; 32 | case 2: return 'nd'; 33 | case 3: return 'rd'; 34 | default: return 'th'; 35 | } 36 | } 37 | 38 | function formatDateToString(): string { 39 | const days: string[] = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; 40 | const months: string[] = [ 41 | 'January', 'February', 'March', 'April', 'May', 'June', 42 | 'July', 'August', 'September', 'October', 'November', 'December' 43 | ]; 44 | 45 | const today: Date = new Date(); 46 | const dayName: string = days[today.getDay()]; 47 | const day: number = today.getDate(); 48 | const monthName: string = months[today.getMonth()]; 49 | const year: number = today.getFullYear(); 50 | 51 | const ordinalSuffix: string = getOrdinalSuffix(day); 52 | 53 | return `${dayName} ${day}${ordinalSuffix} ${monthName}, ${year}`; 54 | } 55 | 56 | const currentDate = formatDateToString(); 57 | 58 | // Read current CHANGELOG.md content 59 | const changelogContent = readFileSync(changelogPath, 'utf-8'); 60 | 61 | // Create new changelog entry 62 | const newChangelogEntry = `### ${nextVersion} - ${currentDate} 63 | 64 | ${commitMessages} 65 | `; 66 | 67 | // Insert new changelog entry at the top 68 | // const updatedChangelogContent = newChangelogEntry + changelogContent; 69 | const changelogLines = changelogContent.split('\n'); 70 | changelogLines.splice(2, 0, newChangelogEntry); 71 | const updatedChangelogContent = changelogLines.join('\n'); 72 | 73 | // Save updated CHANGELOG.md 74 | writeFileSync(changelogPath, updatedChangelogContent, 'utf-8'); 75 | 76 | console.log('CHANGELOG.md updated successfully.'); -------------------------------------------------------------------------------- /dist/cjs/lib/dataTransformer.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.deserialize = exports.serialize = void 0; 4 | exports.serializeSingle = serializeSingle; 5 | exports.deserializeSingle = deserializeSingle; 6 | /** 7 | * Serializes the data for storing in sessionStorage/localStorage 8 | * @param {*} data - the data that we want to serialize 9 | * @returns {string} - the serialized data 10 | */ 11 | // Helper: ArrayBuffer/Uint8Array to base64 12 | function bufferToBase64(buffer) { 13 | let binary = ""; 14 | const bytes = new Uint8Array(buffer); 15 | const len = bytes.byteLength; 16 | for (let i = 0; i < len; i++) { 17 | binary += String.fromCharCode(bytes[i]); 18 | } 19 | return btoa(binary); 20 | } 21 | // Helper: base64 to ArrayBuffer 22 | function base64ToBuffer(base64) { 23 | const binary = atob(base64); 24 | const len = binary.length; 25 | const bytes = new Uint8Array(len); 26 | for (let i = 0; i < len; i++) { 27 | bytes[i] = binary.charCodeAt(i); 28 | } 29 | return bytes.buffer; 30 | } 31 | // Helper: Blob to base64 (async, but we use ArrayBuffer for storage) 32 | const serialize = (data) => { 33 | // If it's an array, serialize each element 34 | if (Array.isArray(data)) { 35 | return JSON.stringify(data.map(serializeSingle)); 36 | } 37 | return JSON.stringify(serializeSingle(data)); 38 | }; 39 | exports.serialize = serialize; 40 | function serializeSingle(data) { 41 | if (data instanceof ArrayBuffer) { 42 | return { 43 | __sarus_type: "binary", 44 | format: "arraybuffer", 45 | data: bufferToBase64(data), 46 | }; 47 | } 48 | if (data instanceof Uint8Array) { 49 | return { 50 | __sarus_type: "binary", 51 | format: "uint8array", 52 | data: bufferToBase64(data.buffer), 53 | }; 54 | } 55 | if (typeof Blob !== "undefined" && data instanceof Blob) { 56 | throw new Error("Blob serialization is not supported synchronously. Convert Blob to ArrayBuffer or Uint8Array before sending."); 57 | } 58 | return data; 59 | } 60 | /** 61 | * Deserializes the data stored in sessionStorage/localStorage 62 | * @param {string} data - the data that we want to deserialize 63 | * @returns {*} The deserialized data 64 | */ 65 | const deserialize = (data) => { 66 | if (!data) 67 | return null; 68 | const parsed = JSON.parse(data); 69 | if (Array.isArray(parsed)) { 70 | return parsed.map(deserializeSingle); 71 | } 72 | return deserializeSingle(parsed); 73 | }; 74 | exports.deserialize = deserialize; 75 | function deserializeSingle(parsed) { 76 | if (typeof parsed === "object" && 77 | parsed !== null && 78 | parsed.__sarus_type === "binary") { 79 | const { format, data } = parsed; 80 | const buffer = base64ToBuffer(data); 81 | if (format === "arraybuffer") 82 | return buffer; 83 | if (format === "uint8array") 84 | return new Uint8Array(buffer); 85 | } 86 | return parsed; 87 | } 88 | //# sourceMappingURL=dataTransformer.js.map -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at hello@anephenix.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@anephenix/sarus", 3 | "version": "0.7.13", 4 | "description": "A WebSocket JavaScript library", 5 | "type": "module", 6 | "main": "./dist/cjs/index.js", 7 | "module": "./dist/esm/index.js", 8 | "exports": { 9 | ".": { 10 | "types": { 11 | "import": "./dist/esm/index.d.ts", 12 | "require": "./dist/cjs/index.js" 13 | }, 14 | "import": "./dist/esm/index.js", 15 | "require": "./dist/cjs/index.js", 16 | "default": "./dist/esm/index.js" 17 | } 18 | }, 19 | "files": [ 20 | "dist", 21 | "README.md" 22 | ], 23 | "contributors": [ 24 | { 25 | "name": "Paul Jensen", 26 | "email": "paulbjensen@gmail.com", 27 | "url": "https://paulbjensen.co.uk" 28 | }, 29 | { 30 | "name": "Chris Lajoie", 31 | "email": "chris@ettaviation.com" 32 | }, 33 | { 34 | "name": "Justus Perlwitz", 35 | "email": "justus@jwpconsulting.net", 36 | "url": "https://www.jwpconsulting.net" 37 | } 38 | ], 39 | "devDependencies": { 40 | "@babel/parser": "^7.22.5", 41 | "@babel/types": "^7.22.5", 42 | "@size-limit/preset-small-lib": "^12.0.0", 43 | "@types/jest": "^30.0.0", 44 | "@types/node": "^25.0.0", 45 | "@types/node-localstorage": "^1.3.3", 46 | "@types/websocket": "^1.0.5", 47 | "@types/window-or-global": "^1.0.6", 48 | "@types/ws": "^8.18.1", 49 | "dom-storage": "^2.1.0", 50 | "husky": "^9.1.7", 51 | "ip-regex": "^5.0.0", 52 | "jest": "30.2.0", 53 | "jest-environment-jsdom": "^30.0.0", 54 | "jest-websocket-mock": "^2.4.0", 55 | "jsdoc": "^4.0.2", 56 | "jsdom": "^27.0.0", 57 | "prettier": "^3.0.3", 58 | "publint": "^0.3.14", 59 | "size-limit": "^12.0.0", 60 | "ts-jest": "^29.1.0", 61 | "typescript": "^5.7.3" 62 | }, 63 | "scripts": { 64 | "build:esm": "tsc --project tsconfig.json", 65 | "build:cjs": "tsc --project tsconfig.cjs.json", 66 | "build": "npm run build:esm && npm run build:cjs && node scripts/create-package-json-for-commonjs.cjs && npm run verify-build", 67 | "check-prettier": "prettier src __tests__ --check", 68 | "lint:package": "publint", 69 | "prepare-patch-release": "npm run update-changelog && git add CHANGELOG.md && git commit -m \"Updated changelog\" && npm version patch", 70 | "prettier": "prettier src __tests__ --write", 71 | "publish-patch-release": "npm run prepare-patch-release && git push origin master && git push --tags", 72 | "size": "size-limit", 73 | "test": "jest --coverage", 74 | "update-changelog": "node --experimental-strip-types scripts/update-changelog.ts", 75 | "watch": "tsc --project tsconfig.json --watch", 76 | "prepare": "husky", 77 | "verify-build": "node --experimental-strip-types scripts/check-esm-works.ts && node scripts/check-commonjs-works.cjs" 78 | }, 79 | "repository": { 80 | "type": "git", 81 | "url": "git+https://github.com/anephenix/sarus.git" 82 | }, 83 | "keywords": [ 84 | "websocket" 85 | ], 86 | "author": "Paul Jensen ", 87 | "maintainers": [ 88 | { 89 | "name": "Paul Jensen", 90 | "email": "paul@anephenix.com" 91 | } 92 | ], 93 | "size-limit": [ 94 | { 95 | "path": "dist/**/*.js", 96 | "limit": "10 kB" 97 | } 98 | ], 99 | "license": "MIT", 100 | "bugs": { 101 | "url": "https://github.com/anephenix/sarus/issues" 102 | }, 103 | "homepage": "https://github.com/anephenix/sarus#readme", 104 | "dependencies": { 105 | "node-localstorage": "^3.0.5" 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/lib/dataTransformer.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Serializes the data for storing in sessionStorage/localStorage 3 | * @param {*} data - the data that we want to serialize 4 | * @returns {string} - the serialized data 5 | */ 6 | // Helper: ArrayBuffer/Uint8Array to base64 7 | function bufferToBase64(buffer: ArrayBuffer): string { 8 | let binary = ""; 9 | const bytes = new Uint8Array(buffer); 10 | const len = bytes.byteLength; 11 | for (let i = 0; i < len; i++) { 12 | binary += String.fromCharCode(bytes[i]); 13 | } 14 | return btoa(binary); 15 | } 16 | 17 | // Helper: base64 to ArrayBuffer 18 | function base64ToBuffer(base64: string): ArrayBuffer { 19 | const binary = atob(base64); 20 | const len = binary.length; 21 | const bytes = new Uint8Array(len); 22 | for (let i = 0; i < len; i++) { 23 | bytes[i] = binary.charCodeAt(i); 24 | } 25 | return bytes.buffer; 26 | } 27 | 28 | // Helper: Blob to base64 (async, but we use ArrayBuffer for storage) 29 | 30 | export const serialize = ( 31 | data: string | object | number | ArrayBuffer | Uint8Array | null | boolean, 32 | ) => { 33 | // If it's an array, serialize each element 34 | if (Array.isArray(data)) { 35 | return JSON.stringify(data.map(serializeSingle)); 36 | } 37 | return JSON.stringify(serializeSingle(data)); 38 | }; 39 | 40 | export function serializeSingle( 41 | data: string | object | number | ArrayBuffer | Uint8Array | null | boolean, 42 | ): object | string | number | boolean | null { 43 | if (data instanceof ArrayBuffer) { 44 | return { 45 | __sarus_type: "binary", 46 | format: "arraybuffer", 47 | data: bufferToBase64(data), 48 | }; 49 | } 50 | if (data instanceof Uint8Array) { 51 | return { 52 | __sarus_type: "binary", 53 | format: "uint8array", 54 | data: bufferToBase64(data.buffer as ArrayBuffer), 55 | }; 56 | } 57 | if (typeof Blob !== "undefined" && data instanceof Blob) { 58 | throw new Error( 59 | "Blob serialization is not supported synchronously. Convert Blob to ArrayBuffer or Uint8Array before sending.", 60 | ); 61 | } 62 | return data; 63 | } 64 | 65 | /** 66 | * Deserializes the data stored in sessionStorage/localStorage 67 | * @param {string} data - the data that we want to deserialize 68 | * @returns {*} The deserialized data 69 | */ 70 | export const deserialize = ( 71 | data: string | null, 72 | ): unknown[] | unknown | null => { 73 | if (!data) return null; 74 | const parsed = JSON.parse(data); 75 | if (Array.isArray(parsed)) { 76 | return parsed.map(deserializeSingle); 77 | } 78 | return deserializeSingle(parsed); 79 | }; 80 | 81 | export type Base64EncodedData = { 82 | __sarus_type: "binary"; 83 | format: "arraybuffer" | "uint8array"; 84 | data: string; 85 | }; 86 | 87 | type DeserializeSingleParms = 88 | | string 89 | | object 90 | | number 91 | | Base64EncodedData 92 | | null 93 | | boolean; 94 | type DeserializeSingleReturn = 95 | | string 96 | | object 97 | | number 98 | | ArrayBuffer 99 | | Uint8Array 100 | | null 101 | | boolean; 102 | 103 | export function deserializeSingle( 104 | parsed: DeserializeSingleParms, 105 | ): DeserializeSingleReturn { 106 | if ( 107 | typeof parsed === "object" && 108 | parsed !== null && 109 | (parsed as Base64EncodedData).__sarus_type === "binary" 110 | ) { 111 | const { format, data } = parsed as Base64EncodedData; 112 | 113 | const buffer = base64ToBuffer(data); 114 | if (format === "arraybuffer") return buffer; 115 | if (format === "uint8array") return new Uint8Array(buffer); 116 | } 117 | return parsed; 118 | } 119 | -------------------------------------------------------------------------------- /__tests__/index/retryConnectionDelay.test.ts: -------------------------------------------------------------------------------- 1 | // File Dependencies 2 | import Sarus from "../../src/index"; 3 | import { WS } from "jest-websocket-mock"; 4 | import { calculateRetryDelayFactor } from "../../src/index"; 5 | import type { ExponentialBackoffParams } from "../../src/index"; 6 | 7 | const url = "ws://localhost:1234"; 8 | 9 | const condition = (func: () => boolean) => { 10 | return new Promise((resolve) => { 11 | const check = () => { 12 | if (func()) return resolve(); 13 | setTimeout(check, 10); 14 | }; 15 | check(); 16 | }); 17 | }; 18 | 19 | describe("retry connection delay", () => { 20 | describe("by default", () => { 21 | it("should delay the reconnection attempt by 1 second", async () => { 22 | const server = new WS(url); 23 | const sarus = new Sarus({ url }); 24 | await server.connected; 25 | server.close(); 26 | await condition(() => { 27 | return sarus.ws?.readyState === 3; 28 | }); 29 | const timeThen: number = Date.now(); 30 | const newServer = new WS(url); 31 | await newServer.connected; 32 | await condition(() => { 33 | return sarus.ws?.readyState === 1; 34 | }); 35 | const timeNow: number = Date.now(); 36 | expect(timeNow - timeThen).toBeGreaterThanOrEqual(1000); 37 | expect(timeNow - timeThen).toBeLessThan(3000); 38 | return newServer.close(); 39 | }); 40 | 41 | describe("when passed as a number", () => { 42 | it("should delay the reconnection attempt by that number", async () => { 43 | const server = new WS(url); 44 | const sarus = new Sarus({ url, retryConnectionDelay: 500 }); 45 | await server.connected; 46 | server.close(); 47 | await condition(() => { 48 | return sarus.ws?.readyState === 3; 49 | }); 50 | const timeThen: number = Date.now(); 51 | const newServer = new WS(url); 52 | await newServer.connected; 53 | await condition(() => { 54 | return sarus.ws?.readyState === 1; 55 | }); 56 | expect(sarus.ws?.readyState).toBe(1); 57 | const timeNow: number = Date.now(); 58 | expect(timeNow - timeThen).toBeGreaterThanOrEqual(400); 59 | expect(timeNow - timeThen).toBeLessThan(1000); 60 | return newServer.close(); 61 | }); 62 | }); 63 | }); 64 | }); 65 | 66 | describe("Exponential backoff delay", () => { 67 | describe("with rate 2, backoffLimit 8000 ms", () => { 68 | // The initial delay shall be 1 s 69 | const initialDelay = 1000; 70 | const exponentialBackoff: ExponentialBackoffParams = { 71 | backoffRate: 2, 72 | // We put the ceiling at exactly 8000 ms 73 | backoffLimit: 8000, 74 | }; 75 | const attempts: [number, number][] = [ 76 | [1000, 0], 77 | [2000, 1], 78 | [4000, 2], 79 | [8000, 3], 80 | [8000, 4], 81 | ]; 82 | it("will never be more than 8000 ms with rate set to 2", () => { 83 | for (const [delay, failedAttempts] of attempts) { 84 | expect( 85 | calculateRetryDelayFactor( 86 | exponentialBackoff, 87 | initialDelay, 88 | failedAttempts, 89 | ), 90 | ).toBe(delay); 91 | } 92 | }); 93 | 94 | it("should delay reconnection attempts exponentially", async () => { 95 | // Somehow we need to convince typescript here that "WebSocket" is 96 | // totally valid. Could be because it doesn't assume WebSocket is part of 97 | // global / the index key is missing 98 | const webSocketSpy = jest.spyOn(global, "WebSocket"); 99 | const setTimeoutSpy = jest.spyOn(global, "setTimeout"); 100 | const sarus = new Sarus({ url, exponentialBackoff }); 101 | expect(sarus.state).toStrictEqual({ 102 | kind: "connecting", 103 | failedConnectionAttempts: 0, 104 | }); 105 | let instance: WebSocket; 106 | // Get the first WebSocket instance, and ... 107 | [instance] = webSocketSpy.mock.instances; 108 | if (!instance.onopen) { 109 | throw new Error(); 110 | } 111 | // tell the sarus instance that it is open, and ... 112 | instance.onopen(new Event("open")); 113 | if (!instance.onclose) { 114 | throw new Error(); 115 | } 116 | // close it immediately 117 | instance.onclose(new CloseEvent("close")); 118 | expect(sarus.state).toStrictEqual({ 119 | kind: "closed", 120 | }); 121 | 122 | let cb: Sarus["connect"]; 123 | // We iteratively call sarus.connect() and let it fail, seeing 124 | // if it reaches 8000 as a delay and stays there 125 | for (const [delay, failedAttempts] of attempts) { 126 | const call = 127 | setTimeoutSpy.mock.calls[setTimeoutSpy.mock.calls.length - 1]; 128 | if (!call) { 129 | throw new Error(); 130 | } 131 | // Make sure that setTimeout was called with the correct delay 132 | expect(call[1]).toBe(delay); 133 | cb = call[0]; 134 | cb(); 135 | // Get the most recent WebSocket instance 136 | instance = 137 | webSocketSpy.mock.instances[webSocketSpy.mock.instances.length - 1]; 138 | if (!instance.onclose) { 139 | throw new Error(); 140 | } 141 | instance.onclose(new CloseEvent("close")); 142 | expect(sarus.state).toStrictEqual({ 143 | kind: "connecting", 144 | failedConnectionAttempts: failedAttempts + 1, 145 | }); 146 | } 147 | }); 148 | }); 149 | }); 150 | -------------------------------------------------------------------------------- /__tests__/index/dataTransformers.test.ts: -------------------------------------------------------------------------------- 1 | // File Dependencies 2 | import { 3 | serialize, 4 | deserialize, 5 | serializeSingle, 6 | deserializeSingle, 7 | } from "../../src/lib/dataTransformer"; 8 | 9 | describe("#serialize", () => { 10 | describe("when passed a javascript variable", () => { 11 | it("should serialize the javascript variable into a JSON string", () => { 12 | const payload: object = { name: "Paul Jensen" }; 13 | const serializedPayload: string = serialize(payload); 14 | expect(serializedPayload).toEqual(JSON.stringify(payload)); 15 | }); 16 | }); 17 | 18 | describe("when passed an ArrayBuffer", () => { 19 | it("should serialize the ArrayBuffer into a base64-encoded string", () => { 20 | const buffer: ArrayBuffer = new ArrayBuffer(8); 21 | const serializedBuffer: string = serialize(buffer); 22 | expect(serializedBuffer).toEqual( 23 | JSON.stringify({ 24 | __sarus_type: "binary", 25 | format: "arraybuffer", 26 | data: btoa(String.fromCharCode(...new Uint8Array(buffer))), 27 | }), 28 | ); 29 | }); 30 | }); 31 | 32 | describe("when passed a Uint8Array", () => { 33 | it("should serialize the Uint8Array into a base64-encoded string", () => { 34 | const uint8Array: Uint8Array = new Uint8Array([1, 2, 3, 4, 5]); 35 | const serializedUint8Array: string = serialize(uint8Array); 36 | expect(serializedUint8Array).toEqual( 37 | JSON.stringify({ 38 | __sarus_type: "binary", 39 | format: "uint8array", 40 | data: btoa(String.fromCharCode(...uint8Array)), 41 | }), 42 | ); 43 | }); 44 | }); 45 | 46 | describe("when passed a Blob", () => { 47 | it("should throw an error", () => { 48 | const blob: Blob = new Blob(["Hello, world!"], { type: "text/plain" }); 49 | expect(() => serialize(blob)).toThrow( 50 | "Blob serialization is not supported synchronously. Convert Blob to ArrayBuffer or Uint8Array before sending.", 51 | ); 52 | }); 53 | }); 54 | 55 | describe("when passed null", () => { 56 | it("should return null", () => { 57 | expect(serialize(null)).toEqual("null"); 58 | }); 59 | }); 60 | 61 | describe("when passed an array", () => { 62 | it("should serialize each element in the array", () => { 63 | const payload: Array = [ 64 | { name: "Paul Jensen" }, 65 | { name: "Jane Doe" }, 66 | ]; 67 | const serializedPayload: string = serialize(payload); 68 | expect(serializedPayload).toEqual( 69 | JSON.stringify([{ name: "Paul Jensen" }, { name: "Jane Doe" }]), 70 | ); 71 | }); 72 | }); 73 | 74 | describe("when passed a number", () => { 75 | it("should return the number as a string", () => { 76 | const payload: number = 42; 77 | const serializedPayload: string = serialize(payload); 78 | expect(serializedPayload).toEqual(JSON.stringify(payload)); 79 | }); 80 | }); 81 | 82 | describe("when passed a boolean", () => { 83 | it("should return the boolean as a string", () => { 84 | const payload: boolean = true; 85 | const serializedPayload: string = serialize(payload); 86 | expect(serializedPayload).toEqual(JSON.stringify(payload)); 87 | }); 88 | }); 89 | }); 90 | 91 | describe("#deserialize", () => { 92 | describe("when passed a JSON-stringified piece of data", () => { 93 | it("should return the deserialized data", () => { 94 | const payload: object = { name: "Paul Jensen" }; 95 | const serialisedPayload: string = serialize(payload); 96 | expect(deserialize(serialisedPayload)).toEqual(payload); 97 | }); 98 | }); 99 | describe("when passed null", () => { 100 | it("should return null", () => { 101 | expect(deserialize(null)).toEqual(null); 102 | }); 103 | }); 104 | 105 | describe("when passed a base64-encoded ArrayBuffer", () => { 106 | it("should return the ArrayBuffer", () => { 107 | const buffer: ArrayBuffer = new ArrayBuffer(8); 108 | const serializedBuffer: string = serialize(buffer); 109 | expect(deserialize(serializedBuffer)).toEqual(buffer); 110 | }); 111 | }); 112 | 113 | describe("when passed a base64-encoded Uint8Array", () => { 114 | it("should return the Uint8Array", () => { 115 | const uint8Array: Uint8Array = new Uint8Array([1, 2, 3, 4, 5]); 116 | const serializedUint8Array: string = serialize(uint8Array); 117 | expect(deserialize(serializedUint8Array)).toEqual(uint8Array); 118 | }); 119 | }); 120 | 121 | describe("#serializeSingle", () => { 122 | describe("when passed an ArrayBuffer", () => { 123 | it("should return an object with __sarus_type, format, and data properties", () => { 124 | const buffer: ArrayBuffer = new ArrayBuffer(8); 125 | const result = serializeSingle(buffer); 126 | expect(result).toEqual({ 127 | __sarus_type: "binary", 128 | format: "arraybuffer", 129 | data: btoa(String.fromCharCode(...new Uint8Array(buffer))), 130 | }); 131 | }); 132 | }); 133 | 134 | describe("when passed a Uint8Array", () => { 135 | it("should return an object with __sarus_type, format, and data properties", () => { 136 | const uint8Array: Uint8Array = new Uint8Array([1, 2, 3, 4, 5]); 137 | const result = serializeSingle(uint8Array); 138 | expect(result).toEqual({ 139 | __sarus_type: "binary", 140 | format: "uint8array", 141 | data: btoa(String.fromCharCode(...uint8Array)), 142 | }); 143 | }); 144 | }); 145 | }); 146 | 147 | describe("#deserializeSingle", () => { 148 | describe("when passed a serialized ArrayBuffer", () => { 149 | it("should return the ArrayBuffer", () => { 150 | const buffer: ArrayBuffer = new ArrayBuffer(8); 151 | const serializedBuffer = serializeSingle(buffer); 152 | expect(deserializeSingle(serializedBuffer)).toEqual(buffer); 153 | }); 154 | }); 155 | 156 | describe("when passed a serialized Uint8Array", () => { 157 | it("should return the Uint8Array", () => { 158 | const uint8Array: Uint8Array = new Uint8Array([1, 2, 3, 4, 5]); 159 | const serializedUint8Array = serializeSingle(uint8Array); 160 | expect(deserializeSingle(serializedUint8Array)).toEqual(uint8Array); 161 | }); 162 | }); 163 | }); 164 | }); 165 | -------------------------------------------------------------------------------- /__tests__/index/eventListeners.test.ts: -------------------------------------------------------------------------------- 1 | // File Dependencies 2 | import Sarus from "../../src/index"; 3 | import { WS } from "jest-websocket-mock"; 4 | 5 | const url = "ws://localhost:1234"; 6 | 7 | describe("eventListeners", () => { 8 | it("should bind eventListeners that are passed during initialization", async () => { 9 | const server: WS = new WS("ws://localhost:1234"); 10 | const mockOpen = jest.fn(); 11 | const mockParseMessage = jest.fn(); 12 | const mockClose = jest.fn(); 13 | const mockError = jest.fn(); 14 | new Sarus({ 15 | url, 16 | eventListeners: { 17 | open: [mockOpen], 18 | message: [mockParseMessage], 19 | close: [mockClose], 20 | error: [mockError], 21 | }, 22 | }); 23 | await server.connected; 24 | expect(mockOpen).toHaveBeenCalledTimes(1); 25 | server.send("hello world"); 26 | expect(mockParseMessage).toHaveBeenCalledTimes(1); 27 | server.error(); 28 | expect(mockError).toHaveBeenCalledTimes(1); 29 | server.close(); 30 | expect(mockClose).toHaveBeenCalledTimes(1); 31 | }); 32 | 33 | it("should prefill any missing eventListener events during initialization", () => { 34 | const myFunc: () => void = () => {}; 35 | const sarus: Sarus = new Sarus({ 36 | url, 37 | eventListeners: { 38 | open: [], 39 | error: [], 40 | close: [], 41 | message: [myFunc], 42 | }, 43 | }); 44 | expect(sarus.eventListeners.open).toEqual([]); 45 | expect(sarus.eventListeners.message).toEqual([myFunc]); 46 | expect(sarus.eventListeners.close).toEqual([]); 47 | expect(sarus.eventListeners.error).toEqual([]); 48 | }); 49 | 50 | it("should allow an eventListener object to pass in some events but omit others", () => { 51 | const myFunc: () => void = () => {}; 52 | const sarus: Sarus = new Sarus({ 53 | url, 54 | eventListeners: { 55 | message: [myFunc], 56 | }, 57 | }); 58 | expect(sarus.eventListeners.open).toEqual([]); 59 | expect(sarus.eventListeners.message).toEqual([myFunc]); 60 | expect(sarus.eventListeners.close).toEqual([]); 61 | expect(sarus.eventListeners.error).toEqual([]); 62 | }); 63 | 64 | it("should prevent an event being added multiple times to an event listener", () => { 65 | const myFunc = () => {}; 66 | const sarus = new Sarus({ 67 | url, 68 | eventListeners: { 69 | open: [], 70 | error: [], 71 | close: [], 72 | message: [myFunc], 73 | }, 74 | }); 75 | 76 | const addAnExistingListener = () => { 77 | sarus.on("message", myFunc); 78 | }; 79 | expect(addAnExistingListener).toThrow(); 80 | }); 81 | 82 | it("should allow an event listener to be added after initialization", () => { 83 | const myFunc: () => void = () => {}; 84 | const anotherFunc: () => void = () => {}; 85 | const sarus: Sarus = new Sarus({ 86 | url, 87 | eventListeners: { 88 | open: [], 89 | error: [], 90 | close: [], 91 | message: [myFunc], 92 | }, 93 | }); 94 | 95 | sarus.on("message", anotherFunc); 96 | expect(sarus.eventListeners.message).toEqual([myFunc, anotherFunc]); 97 | }); 98 | 99 | it("should bind any added event listeners after initialization to the WebSocket", async () => { 100 | const server: WS = new WS(url); 101 | const myFunc = jest.fn(); 102 | const anotherFunc = jest.fn(); 103 | const sarus: Sarus = new Sarus({ 104 | url, 105 | eventListeners: { 106 | open: [], 107 | error: [], 108 | close: [], 109 | message: [myFunc], 110 | }, 111 | }); 112 | await server.connected; 113 | server.send("hello world"); 114 | expect(myFunc).toHaveBeenCalledTimes(1); 115 | sarus.on("message", anotherFunc); 116 | expect(sarus.eventListeners.message).toEqual([myFunc, anotherFunc]); 117 | server.send("hello world"); 118 | expect(myFunc).toHaveBeenCalledTimes(2); 119 | expect(anotherFunc).toHaveBeenCalledTimes(1); 120 | server.close(); 121 | }); 122 | 123 | it("should allow an event listener to be removed by passing the function name", () => { 124 | const myFunc: () => void = () => {}; 125 | const sarus: Sarus = new Sarus({ 126 | url, 127 | eventListeners: { 128 | open: [], 129 | error: [], 130 | close: [], 131 | message: [myFunc], 132 | }, 133 | }); 134 | 135 | sarus.off("message", myFunc.name); 136 | expect(sarus.eventListeners.message).toEqual([]); 137 | }); 138 | 139 | it("should allow an event listener to be removed by passing the function", () => { 140 | const myFunc: () => void = () => {}; 141 | const sarus: Sarus = new Sarus({ 142 | url, 143 | eventListeners: { 144 | open: [], 145 | error: [], 146 | close: [], 147 | message: [myFunc], 148 | }, 149 | }); 150 | 151 | sarus.off("message", myFunc); 152 | expect(sarus.eventListeners.message).toEqual([]); 153 | }); 154 | 155 | it("should throw an error if a function cannot be found when trying to remove it from an event listener", () => { 156 | const myFunc: () => void = () => {}; 157 | const anotherFunc: () => void = () => {}; 158 | const sarus: Sarus = new Sarus({ 159 | url, 160 | eventListeners: { 161 | open: [], 162 | error: [], 163 | close: [], 164 | message: [myFunc], 165 | }, 166 | }); 167 | 168 | const removeANonExistentListener = () => { 169 | sarus.off("message", anotherFunc); 170 | }; 171 | expect(removeANonExistentListener).toThrow(); 172 | }); 173 | 174 | it("should throw an error if a function name cannot be found when trying to remove it from an event listener", () => { 175 | const myFunc: () => void = () => {}; 176 | const anotherFunc: () => void = () => {}; 177 | const sarus: Sarus = new Sarus({ 178 | url, 179 | eventListeners: { 180 | open: [], 181 | error: [], 182 | close: [], 183 | message: [myFunc], 184 | }, 185 | }); 186 | 187 | const removeANonExistentListener = () => { 188 | sarus.off("message", anotherFunc.name); 189 | }; 190 | expect(removeANonExistentListener).toThrow(); 191 | }); 192 | 193 | it("should not throw an error, if a function cannot be found when trying to remove it from an event listener, and additional doNotThrowError is passed", () => { 194 | const myFunc: () => void = () => {}; 195 | const anotherFunc: () => void = () => {}; 196 | const sarus: Sarus = new Sarus({ 197 | url, 198 | eventListeners: { 199 | open: [], 200 | error: [], 201 | close: [], 202 | message: [myFunc], 203 | }, 204 | }); 205 | 206 | sarus.off("message", anotherFunc, { doNotThrowError: true }); 207 | }); 208 | }); 209 | -------------------------------------------------------------------------------- /__tests__/index/messageQueue.test.ts: -------------------------------------------------------------------------------- 1 | // File Dependencies 2 | import Sarus, { type SarusClassParams } from "../../src/index"; 3 | import { WS } from "jest-websocket-mock"; 4 | import { delay } from "../helpers/delay"; 5 | 6 | const url: string = "ws://localhost:1234"; 7 | 8 | describe("message queue", () => { 9 | //Implement message queue with in-memory as default 10 | it("should queue messages for delivery", async () => { 11 | const server: WS = new WS(url); 12 | const sarus: Sarus = new Sarus({ url }); 13 | await server.connected; 14 | sarus.send("Hello server"); 15 | await expect(server).toReceiveMessage("Hello server"); 16 | sarus.ws?.close(); 17 | sarus.send("Hello again"); 18 | await delay(1000); 19 | await server.connected; 20 | await expect(server).toReceiveMessage("Hello again"); 21 | await server.close(); 22 | }); 23 | 24 | it("should queue messages for delivery when server is offline for a bit", async () => { 25 | const server: WS = new WS(url); 26 | const sarus: Sarus = new Sarus({ url }); 27 | await server.connected; 28 | sarus.send("Hello server"); 29 | await expect(server).toReceiveMessage("Hello server"); 30 | await server.close(); 31 | sarus.send("Hello again"); 32 | sarus.send("Here is another message"); 33 | expect(sarus.storageType).toEqual("memory"); 34 | expect(sarus.messages).toEqual(["Hello again", "Here is another message"]); 35 | const newServer = new WS(url); 36 | await newServer.connected; 37 | const messageOne = await newServer.nextMessage; 38 | const messageTwo = await newServer.nextMessage; 39 | expect(messageOne).toBe("Hello again"); 40 | expect(messageTwo).toBe("Here is another message"); 41 | expect(sarus.messages).toEqual([]); 42 | await newServer.close(); 43 | }); 44 | 45 | it("should allow the developer to provide a custom retryProcessTimePeriod", () => { 46 | const sarus: Sarus = new Sarus({ url, retryProcessTimePeriod: 25 }); 47 | expect(sarus.retryProcessTimePeriod).toBe(25); 48 | }); 49 | 50 | const applyStorageTest = async ( 51 | storageType: Storage, 52 | sarusConfig: SarusClassParams, 53 | ) => { 54 | storageType.clear(); 55 | const server: WS = new WS(url); 56 | const sarus: Sarus = new Sarus(sarusConfig); 57 | expect(sarus.storageType).toBe(sarusConfig.storageType); 58 | await server.connected; 59 | sarus.send("Hello server"); 60 | await expect(server).toReceiveMessage("Hello server"); 61 | await server.close(); 62 | sarus.send("Hello again"); 63 | sarus.send("Here is another message"); 64 | expect(sarus.messages).toEqual(["Hello again", "Here is another message"]); 65 | const newServer = new WS(url); 66 | await newServer.connected; 67 | const messageOne = await newServer.nextMessage; 68 | const messageTwo = await newServer.nextMessage; 69 | expect(sarus.messages).toEqual([]); 70 | expect(messageOne).toBe("Hello again"); 71 | expect(messageTwo).toBe("Here is another message"); 72 | await newServer.close(); 73 | }; 74 | 75 | it("should allow the developer to use sessionStorage for storing messages", async () => { 76 | await applyStorageTest(sessionStorage, { url, storageType: "session" }); 77 | }); 78 | 79 | it("should allow the developer to use localStorage for storing messages", async () => { 80 | await applyStorageTest(localStorage, { url, storageType: "local" }); 81 | }); 82 | 83 | it("should allow the developer to use a custom storageKey", () => { 84 | const sarus: Sarus = new Sarus({ 85 | url, 86 | storageType: "local", 87 | storageKey: "sarusWS", 88 | }); 89 | expect(sarus.storageKey).toBe("sarusWS"); 90 | }); 91 | 92 | const retrieveMessagesFromStorage = (sarusConfig: SarusClassParams) => { 93 | const sarusOne: Sarus = new Sarus(sarusConfig); 94 | expect(sarusOne.messages).toEqual([]); 95 | sarusOne.send("Hello world"); 96 | sarusOne.send("Hello again"); 97 | sarusOne.disconnect(); 98 | const sarusTwo: Sarus = new Sarus(sarusConfig); 99 | expect(sarusTwo.messages).toEqual(["Hello world", "Hello again"]); 100 | return sarusTwo; 101 | }; 102 | 103 | const processExistingMessagesFromStorage = async ( 104 | sarusConfig: SarusClassParams, 105 | ) => { 106 | const sarusTwo = retrieveMessagesFromStorage(sarusConfig); 107 | const server: WS = new WS(url); 108 | const messageOne = await server.nextMessage; 109 | const messageTwo = await server.nextMessage; 110 | expect(sarusTwo.messages).toEqual([]); 111 | expect(messageOne).toBe("Hello world"); 112 | expect(messageTwo).toBe("Hello again"); 113 | await server.close(); 114 | }; 115 | 116 | it("should load any existing messages from previous sessionStorage on initialization", () => { 117 | retrieveMessagesFromStorage({ url, storageType: "session" }); 118 | sessionStorage.clear(); 119 | }); 120 | 121 | it("should load any existing messages from previous localStorage on initialization", () => { 122 | retrieveMessagesFromStorage({ url, storageType: "local" }); 123 | localStorage.clear(); 124 | }); 125 | 126 | it("should process any existing messages from previous sessionStorage on initialization", async () => { 127 | await processExistingMessagesFromStorage({ 128 | url, 129 | storageType: "session", 130 | reconnectAutomatically: true, 131 | }); 132 | }); 133 | 134 | it("should process any existing messages from previous localStorage on initialization", async () => { 135 | await processExistingMessagesFromStorage({ 136 | url, 137 | storageType: "local", 138 | reconnectAutomatically: true, 139 | }); 140 | }); 141 | 142 | it("should queue and deliver ArrayBuffer messages", async () => { 143 | const server: WS = new WS(url); 144 | const sarus: Sarus = new Sarus({ url }); 145 | await server.connected; 146 | const buffer = new Uint8Array([1, 2, 3, 4]).buffer; 147 | sarus.send(buffer); 148 | const received = await server.nextMessage; 149 | // WebSocket mock returns ArrayBuffer for binary 150 | expect(received instanceof ArrayBuffer).toBe(true); 151 | expect(new Uint8Array(received as ArrayBuffer)).toEqual( 152 | new Uint8Array([1, 2, 3, 4]), 153 | ); 154 | await server.close(); 155 | }); 156 | 157 | it("should queue and deliver Uint8Array messages", async () => { 158 | const server: WS = new WS(url); 159 | const sarus: Sarus = new Sarus({ url }); 160 | await server.connected; 161 | const arr = new Uint8Array([5, 6, 7, 8]); 162 | sarus.send(arr); 163 | const received = await server.nextMessage; 164 | // Always expect ArrayBuffer for binary 165 | // Just check that it can be wrapped and matches 166 | expect(new Uint8Array(received as ArrayBuffer)).toEqual(arr); 167 | await server.close(); 168 | }); 169 | 170 | it("should persist and restore ArrayBuffer messages in localStorage", async () => { 171 | localStorage.clear(); 172 | const sarus: Sarus = new Sarus({ url, storageType: "local" }); 173 | const buffer = new Uint8Array([9, 10, 11]).buffer; 174 | sarus.send(buffer); 175 | sarus.disconnect(); 176 | // Simulate page reload 177 | const sarus2: Sarus = new Sarus({ url, storageType: "local" }); 178 | expect(sarus2.messages.length).toBe(1); 179 | expect(new Uint8Array(sarus2.messages[0] as ArrayBuffer)).toEqual( 180 | new Uint8Array([9, 10, 11]), 181 | ); 182 | localStorage.clear(); 183 | }); 184 | 185 | it("should persist and restore Uint8Array messages in sessionStorage", async () => { 186 | sessionStorage.clear(); 187 | const sarus: Sarus = new Sarus({ url, storageType: "session" }); 188 | const arr = new Uint8Array([12, 13, 14]); 189 | sarus.send(arr); 190 | sarus.disconnect(); 191 | // Simulate page reload 192 | const sarus2: Sarus = new Sarus({ url, storageType: "session" }); 193 | expect(sarus2.messages.length).toBe(1); 194 | expect(new Uint8Array(sarus2.messages[0] as ArrayBuffer)).toEqual(arr); 195 | sessionStorage.clear(); 196 | }); 197 | 198 | // Ensure mock server is closed between tests to avoid port conflicts 199 | afterEach(() => { 200 | WS.clean(); 201 | localStorage.clear(); 202 | sessionStorage.clear(); 203 | }); 204 | }); 205 | -------------------------------------------------------------------------------- /dist/cjs/index.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"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"} -------------------------------------------------------------------------------- /dist/esm/index.d.ts: -------------------------------------------------------------------------------- 1 | import type { PartialEventListenersInterface, EventListenersInterface } from "./lib/validators.js"; 2 | import type { GenericFunction } from "./lib/types.js"; 3 | export type { GenericFunction } from "./lib/types.js"; 4 | import type { LocalStorage } from "node-localstorage"; 5 | export interface ExponentialBackoffParams { 6 | backoffRate: number; 7 | backoffLimit: number; 8 | } 9 | export declare function calculateRetryDelayFactor(params: ExponentialBackoffParams, initialDelay: number, failedConnectionAttempts: number): number; 10 | export interface SarusClassParams { 11 | url: string; 12 | binaryType?: BinaryType; 13 | protocols?: string | Array; 14 | eventListeners?: PartialEventListenersInterface; 15 | retryProcessTimePeriod?: number; 16 | reconnectAutomatically?: boolean; 17 | retryConnectionDelay?: boolean | number; 18 | exponentialBackoff?: ExponentialBackoffParams; 19 | storageType?: string; 20 | storageKey?: string; 21 | } 22 | /** 23 | * The Sarus client class 24 | * @constructor 25 | * @param {Object} param0 - An object containing parameters 26 | * @param {string} param0.url - The url for the WebSocket client to connect to 27 | * @param {string} param0.binaryType - The optional type of binary data transmitted over the WebSocket connection 28 | * @param {string\array} param0.protocols - An optional string or array of strings for the sub-protocols that the WebSocket will use 29 | * @param {object} param0.eventListeners - An optional object containing event listener functions keyed to websocket events 30 | * @param {boolean} param0.reconnectAutomatically - An optional boolean flag to indicate whether to reconnect automatically when a websocket connection is severed 31 | * @param {number} param0.retryProcessTimePeriod - An optional number for how long the time period between retrying to send a messgae to a WebSocket server should be 32 | * @param {boolean|number} param0.retryConnectionDelay - An optional parameter for whether to delay WebSocket reconnection attempts by a time period. If true, the delay is 1000ms, otherwise it is the number passed. The default value when this parameter is undefined will be interpreted as 1000ms. 33 | * @param {ExponentialBackoffParams} param0.exponentialBackoff - An optional containing configuration for exponential backoff. If this parameter is undefined, exponential backoff is disabled. The minimum delay is determined by retryConnectionDelay. If retryConnectionDelay is set is false, this setting will not be in effect. 34 | * @param {string} param0.storageType - An optional string specifying the type of storage to use for persisting messages in the message queue 35 | * @param {string} param0.storageKey - An optional string specifying the key used to store the messages data against in sessionStorage/localStorage 36 | * @returns {object} The class instance 37 | */ 38 | export default class Sarus { 39 | url: URL; 40 | binaryType?: BinaryType; 41 | protocols?: string | Array; 42 | eventListeners: EventListenersInterface; 43 | retryProcessTimePeriod?: number; 44 | reconnectAutomatically?: boolean; 45 | retryConnectionDelay: number; 46 | exponentialBackoff?: ExponentialBackoffParams; 47 | storageType: string; 48 | storageKey: string; 49 | messageStore: LocalStorage | unknown[]; 50 | ws: WebSocket; 51 | state: { 52 | kind: "connecting"; 53 | failedConnectionAttempts: number; 54 | } | { 55 | kind: "connected"; 56 | } | { 57 | kind: "disconnected"; 58 | } | { 59 | kind: "closed"; 60 | }; 61 | constructor(props: SarusClassParams); 62 | /** 63 | * Fetches the messages from the message queue 64 | * @returns {array} the messages in the message queue, as an array 65 | */ 66 | get messages(): unknown[]; 67 | /** 68 | * Sets the messages to store in the message queue 69 | * @param {*} data - the data payload to set for the messages in the message queue 70 | * @returns {void} - set does not return 71 | */ 72 | set messages(data: unknown[]); 73 | /** 74 | * Adds a message to the messages in the message queue that are kept in persistent storage 75 | * @param {*} data - the message 76 | * @returns {array} the messages in the message queue 77 | */ 78 | addMessageToStore(data: unknown): unknown[] | null; 79 | /** 80 | * Adds a messge to the message queue 81 | * @param {*} data - the data payload to put on the message queue 82 | */ 83 | addMessage(data: unknown): void; 84 | /** 85 | * Removes a message from the message queue that is in persistent storage 86 | * @param {*} messages - the messages in the message queue 87 | */ 88 | removeMessageFromStore(messages: unknown[]): void; 89 | /** 90 | * Removes a message from the message queue 91 | */ 92 | removeMessage(): unknown; 93 | /** 94 | * Audits the eventListeners object parameter with validations, and a prefillMissingEvents step 95 | * This ensures that the eventListeners object is the right format for binding events to WebSocket clients 96 | * @param {object} eventListeners - The eventListeners object parameter 97 | * @returns {object} The eventListeners object parameter, with any missing events prefilled in 98 | */ 99 | auditEventListeners(eventListeners: PartialEventListenersInterface | undefined): { 100 | open: GenericFunction[]; 101 | message: GenericFunction[]; 102 | error: GenericFunction[]; 103 | close: GenericFunction[]; 104 | }; 105 | /** 106 | * Connects the WebSocket client, and attaches event listeners 107 | */ 108 | connect(): void; 109 | /** 110 | * Reconnects the WebSocket client based on the retryConnectionDelay and 111 | * ExponentialBackoffParam setting. 112 | */ 113 | reconnect(): void; 114 | /** 115 | * Disconnects the WebSocket client from the server, and changes the 116 | * reconnectAutomatically flag to disable automatic reconnection, unless the 117 | * developer passes a boolean flag to not do that. 118 | * @param {boolean} overrideDisableReconnect 119 | */ 120 | disconnect(overrideDisableReconnect?: boolean): void; 121 | /** 122 | * Adds a function to trigger on the occurrence of an event with the specified event name 123 | * @param {string} eventName - The name of the event in the eventListeners object 124 | * @param {function} eventFunc - The function to trigger when the event occurs 125 | */ 126 | on(eventName: string, eventFunc: GenericFunction): void; 127 | /** 128 | * Finds a function in a eventListener's event list, by functon or by function name 129 | * @param {string} eventName - The name of the event in the eventListeners object 130 | * @param {function|string} eventFuncOrName - Either the function to remove, or the name of the function to remove 131 | * @returns {function|undefined} The existing function, or nothing 132 | */ 133 | findFunction(eventName: string, eventFuncOrName: string | GenericFunction): GenericFunction | undefined; 134 | /** 135 | * Raises an error if the existing function is not present, and if the client is configured to throw an error 136 | * @param {function|undefined} existingFunc 137 | * @param {object} opts - An optional object to pass that contains extra configuration options 138 | * @param {boolean} opts.doNotThrowError - A boolean flag that indicates whether to not throw an error if the function to remove is not found in the list 139 | */ 140 | raiseErrorIfFunctionIsMissing(existingFunc: GenericFunction | undefined, opts?: { 141 | doNotThrowError: boolean; 142 | } | undefined): void; 143 | /** 144 | * Removes a function from an eventListener events list for that event 145 | * @param {string} eventName - The name of the event in the eventListeners object 146 | * @param {function|string} eventFuncOrName - Either the function to remove, or the name of the function to remove 147 | * @param {object} opts - An optional object to pass that contains extra configuration options 148 | * @param {boolean} opts.doNotThrowError - A boolean flag that indicates whether to not throw an error if the function to remove is not found in the list 149 | */ 150 | off(eventName: string, eventFuncOrName: GenericFunction | string, opts?: { 151 | doNotThrowError: boolean; 152 | } | undefined): void; 153 | /** 154 | * Puts data on a message queue, and then processes the message queue to get the data sent to the WebSocket server 155 | * @param {*} data - The data payload to put the on message queue 156 | */ 157 | send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void; 158 | /** 159 | * Sends a message over the WebSocket, removes the message from the queue, 160 | * and calls proces again if there is another message to process. 161 | * @param {string | ArrayBuffer | Uint8Array} data - The data payload to send over the WebSocket 162 | */ 163 | processMessage(data: string | ArrayBufferLike | Blob | ArrayBufferView): void; 164 | /** 165 | * Processes messages that are on the message queue. Handles looping through the list, as well as retrying message 166 | * dispatch if the WebSocket connection is not open. 167 | */ 168 | process(): void; 169 | /** 170 | * Attaches the event listeners to the WebSocket instance. 171 | * Also attaches an additional eventListener on the 'close' event to trigger a reconnection 172 | * of the WebSocket, unless configured not to. 173 | */ 174 | attachEventListeners(): void; 175 | /** 176 | * Removes the event listeners from a closed WebSocket instance, so that 177 | * they are cleaned up 178 | */ 179 | removeEventListeners(): void; 180 | /** 181 | * Sets the binary type for the WebSocket, if such an option is set 182 | */ 183 | setBinaryType(): void; 184 | } 185 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sarus 2 | 3 | A WebSocket JavaScript library. 4 | 5 | [![npm version](https://badge.fury.io/js/%40anephenix%2Fsarus.svg)](https://badge.fury.io/js/%40anephenix%2Fsarus) ![example workflow](https://github.com/anephenix/sarus/actions/workflows/node.js.yml/badge.svg) [![Socket Badge](https://socket.dev/api/badge/npm/package/@anephenix/sarus)](https://socket.dev/npm/package/@anephenix/sarus) 6 | 7 | ### Features 8 | 9 | - Automatically reconnects WebSocket connections when they are severed 10 | - Handles rebinding eventListener functions to new WebSocket connections created to replace closed connections 11 | - Uses a message queue to dispatch messages over the WebSocket connection, which means: 12 | - Messages don't get lost if the WebSocket connection is not open 13 | - Message sending gets retried if the WebSocket connection is not open 14 | - Messages can be persisted in browser storage, so that they remain even after webpage refreshes. 15 | 16 | ### Install 17 | 18 | ``` 19 | npm i @anephenix/sarus 20 | ``` 21 | 22 | ### Usage 23 | 24 | After installing Sarus, you can use the client library with your frontend 25 | codebase: 26 | 27 | ```javascript 28 | import Sarus from '@anephenix/sarus'; 29 | 30 | const sarus = new Sarus({ 31 | url: 'wss://ws.anephenix.com', 32 | }); 33 | ``` 34 | 35 | Sarus creates a WebSocket connection to the url. You can then attach event 36 | listener functions to that WebSocket client via `sarus` for events like: 37 | 38 | - When the socket receives a message 39 | - When an error occurs on the socket 40 | - When the socket is closed 41 | - When a new socket is opened 42 | 43 | Here's an example of attaching events on client initialization: 44 | 45 | ```javascript 46 | // Log a message that the connection is open 47 | const noteOpened = () => console.log('Connection opened'); 48 | 49 | // Assuming that the WebSocket server is sending JSON data, 50 | // you can use this to parse the data payload; 51 | const parseMessage = (event) => { 52 | const message = JSON.parse(event.data); 53 | // Then do what you like with the message 54 | }; 55 | 56 | // Log a message that the connection has closed 57 | const noteClosed = () => console.log('Connection closed'); 58 | 59 | // If an error occurs, throw the error 60 | const throwError = (error) => throw error; 61 | 62 | // Create the Sarus instance with the event listeners 63 | const sarus = new Sarus({ 64 | url: 'wss://ws.anephenix.com', 65 | eventListeners: { 66 | open: [noteOpened], 67 | message: [parseMessage], 68 | close: [noteClosed], 69 | error: [throwError], 70 | }, 71 | }); 72 | ``` 73 | 74 | You can specify all of the event listeners at initialisation, or just one of them: 75 | 76 | ```javascript 77 | 78 | // Assuming that the WebSocket server is sending JSON data, 79 | // you can use this to parse the data payload; 80 | const parseMessage = (event) => { 81 | const message = JSON.parse(event.data); 82 | // Then do what you like with the message 83 | }; 84 | 85 | 86 | // Create the Sarus instance with the event listeners 87 | const sarus = new Sarus({ 88 | url: 'wss://ws.anephenix.com', 89 | eventListeners: { 90 | message: [parseMessage], 91 | }, 92 | }); 93 | ``` 94 | 95 | You can also add eventListeners after client initialization: 96 | 97 | ```javascript 98 | /* 99 | A function that stores messages in the browser's LocalStorage, possibly 100 | for debugging, or for event stream processing on the client side. 101 | */ 102 | const storeMessage = (event) => { 103 | const store = window.localStorage; 104 | let record = store.getItem('messages'); 105 | if (!record) { 106 | record = []; 107 | } else { 108 | record = JSON.parse(record); 109 | } 110 | record.push(event.data); 111 | store.setItem('messages', JSON.stringify(record)); 112 | }; 113 | 114 | // Attach the storeMessage function to Sarus when it receives a message from 115 | // the WebSocket server 116 | sarus.on('message', storeMessage); 117 | ``` 118 | 119 | You can also use it to send messages to the WebSocket server: 120 | 121 | ```javascript 122 | sarus.send('Hello world'); 123 | ``` 124 | 125 | #### Automatic WebSocket reconnection 126 | 127 | WebSockets can close unexpectedly. When a WebSocket instance is closed, it 128 | cannot be reopened. To re-establish a WebSocket connection, you have to create 129 | a new `WebSocket` instance to replace the closed instance. 130 | 131 | Usually you would handle this by writing some JavaScript to wrap the WebSocket 132 | interface, and trigger opening a new WebSocket connection upon a close event 133 | occurring. 134 | 135 | Sarus will do this automatically for you. 136 | 137 | It does this by attaching a `connect` function on the `close` event happening 138 | on the `WebSocket` instance. If the `WebSocket` instance closes, the `connect` 139 | function will simply create a new `WebSocket` instance with the same 140 | parameters that were passed to the previous instance. 141 | 142 | The `connect` function is called immediately by default, and it will repeat 143 | this until it gets a `WebSocket` instance whose connection is open. 144 | 145 | If you do not want the WebSocket to reconnect automatically, you can pass the 146 | `reconnectAutomatically` parameter into the sarus client at the point of 147 | initializing the client, like the example below. 148 | 149 | ```javascript 150 | const sarus = new Sarus({ 151 | url: 'wss://ws.anephenix.com', 152 | reconnectAutomatically: false, 153 | }); 154 | ``` 155 | 156 | #### Disconnecting a WebSocket connection 157 | 158 | There may be a case where you wish to close a WebSocket connection (such as 159 | when logging out of a service). Sarus provides a way to do that: 160 | 161 | ```javascript 162 | sarus.disconnect(); 163 | ``` 164 | 165 | Calling that function on the sarus client will do 2 things: 166 | 167 | 1. Set the `reconnectAutomatically` flag to false. 168 | 2. Close the WebSocket connection. 169 | 170 | Event listeners listening on the WebSocket's close event will still trigger, 171 | but the client will not attempt to reconnect automatically. 172 | 173 | If you wish to close the WebSocket but not override the `reconnectAutomatically` flag, pass this: 174 | 175 | ```javascript 176 | sarus.disconnect(true); 177 | ``` 178 | 179 | The client will attempt to reconnect automatically. 180 | 181 | #### Delaying WebSocket reconnection attempts 182 | 183 | When a connection is severed and the sarus client tries to reconnect 184 | automatically, it will do so with a delay of 1000ms (1 second). 185 | 186 | If you pass a number, then it will delay the reconnection attempt by that time 187 | (in miliseconds): 188 | 189 | ```javascript 190 | const sarus = new Sarus({ 191 | url: 'wss://ws.anephenix.com', 192 | retryConnectionDelay: 500, // equivalent to 500ms or 1/2 second 193 | }); 194 | ``` 195 | 196 | ** NOTE ** 197 | 198 | In the past this option needed to be explicitly passed, but we decided to 199 | change it to be enabled by default. Without it, any disconnection could result 200 | in thousands of attempted reconnections by one client in the space of a few 201 | seconds. 202 | 203 | #### Attaching and removing event listeners 204 | 205 | When a WebSocket connection is closed, any functions attached to events emitted 206 | by that WebSocket instance need to be attached to the new WebSocket instance. 207 | This means that you end up writing some JavaScript that handles attaching event 208 | listener functions to new WebSocket instances when they get created to replace 209 | closed instances. 210 | 211 | Sarus does this for you. You have 2 ways to attach functions to your WebSocket 212 | event listeners - either when creating the Sarus instance, or after it exists: 213 | 214 | ```javascript 215 | // Log a message that the connection is open 216 | const noteOpened = () => console.log('Connection opened'); 217 | 218 | // Assuming that the WebSocket server is sending JSON data, 219 | // you can use this to parse the data payload; 220 | const parseMessage = (event) => { 221 | const message = JSON.parse(event.data); 222 | // Then do what you like with the message 223 | }; 224 | 225 | // Log a message that the connection has closed 226 | const noteClosed = () => console.log('Connection closed'); 227 | 228 | // If an error occurs, throw the error 229 | const throwError = (error) => throw error; 230 | 231 | // Create the Sarus instance with the event listeners 232 | const sarus = new Sarus({ 233 | url: 'wss://ws.anephenix.com', 234 | eventListeners: { 235 | open: [noteOpened], 236 | message: [parseMessage], 237 | close: [notedClosed], 238 | error: [throwError], 239 | }, 240 | }); 241 | ``` 242 | 243 | In this example, those functions will be bound to the WebSocket instance. If 244 | the WebSocket instance's connection closes, a new WebSocket instance is 245 | created by Sarus to reconnect automatically. The event listeners set in Sarus 246 | will be attached to that new WebSocket instance automatically. 247 | 248 | That is one way that Sarus allows you to bind event listeners to events on the 249 | WebSocket connection. Another way to do it is to call the `on` function on the 250 | Sarus instance, like this: 251 | 252 | ```javascript 253 | /* 254 | A function that stores messages in the browser's LocalStorage, possibly 255 | for debugging, or for event stream processing on the client side. 256 | */ 257 | const storeMessage = (event) => { 258 | const store = window.localStorage; 259 | let record = store.getItem('messages'); 260 | if (!record) { 261 | record = []; 262 | } else { 263 | record = JSON.parse(record); 264 | } 265 | record.push(event.data); 266 | store.setItem('messages', JSON.stringify(record)); 267 | }; 268 | 269 | // Attach the storeMessage function to Sarus when it receives a message from 270 | // the WebSocket server 271 | sarus.on('message', storeMessage); 272 | ``` 273 | 274 | If you want to remove a function from a WebSocket event listener, you can do 275 | that by calling the `off` function on Sarus like this: 276 | 277 | ```javascript 278 | // Pass the function variable 279 | sarus.off('message', storeMessage); 280 | 281 | // You can also pass the name of the function as well 282 | sarus.off('message', 'storeMessage'); 283 | ``` 284 | 285 | If you attempt to remove an event listener function which is not in the list of 286 | event listeners, then an error will be thrown by Sarus. This is a deliberate 287 | behaviour of Sarus. Rather than silently failing to remove a function because 288 | it was not there (or perhaps there was a misspelling of the function name), it 289 | will explicitly throw an error, so that the developer can be made aware of it 290 | and handle it as they wish. 291 | 292 | If the developer is happy for an event listener removal to fail without 293 | throwing an error, they can pass this to the `off` function: 294 | 295 | ```javascript 296 | sarus.off('message', 'myNonExistentFunction', { doNotThrowError: true }); 297 | ``` 298 | 299 | #### Queuing messages for delivery when the WebSocket connection is severed 300 | 301 | Sending a message from a Websocket client to the server depends on the 302 | WebSocket connection being open. If the connection is closed, then you will 303 | need to either prevent the messages from being sent (block message delivery), 304 | or you will need to queue the messages for delivery (queue message delivery). 305 | Either option requires writing some JavaScript to do that. 306 | 307 | To handle this case, Sarus implements a client-based message queue, so that 308 | messages are sent only when there is an open WebSocket connection. 309 | 310 | The message queue is stored in memory. If the web page is refreshed, then the 311 | messages in the queue will be lost. If you want to persist the messages in the 312 | queue between web page refreshes, you can pass an option to Sarus to have the 313 | messages stored using the [sessionStorage protocol](https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage): 314 | 315 | ```javascript 316 | const sarus = new Sarus({ 317 | url: 'wss://ws.anephenix.com', 318 | storageType: 'session', 319 | }); 320 | ``` 321 | 322 | The sessionStorage protocol guarantees that messages are stored between 323 | web page refreshes, but only in the context of that web page's browser tab or 324 | window. The messages will not persist in new browser tabs/windows, or after the 325 | browser has been closed. 326 | 327 | If you want the storage of those messages to persist beyond web page sessions, 328 | then you can use the [localStorage protocol](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) as the storage mechanism: 329 | 330 | ```javascript 331 | const sarus = new Sarus({ 332 | url: 'wss://ws.anephenix.com', 333 | storageType: 'local', 334 | }); 335 | ``` 336 | 337 | LocalStorage guarantees that the messages are persisted beyond browsers being 338 | closed and reopened, as well as when the page is opened in a new tab/window. 339 | 340 | **NOTE** When persisting messages, be careful that the messages are safe to 341 | persist in browser storage, and do not contain sensitive information. If you 342 | want messages to be wiped when the user closes the browser, use 'session' as 343 | the storage type. 344 | 345 | **NOTE** Each web browser implements arbitrary limits for how much data can be 346 | stored in sessionStorage/localStorage for a domain. When that limit is reached, 347 | the web browser will throw a QUOTA_EXCEEDED_ERR error. The limits tend to be in 348 | the 5MB-10MB range, but do vary between browsers. 349 | 350 | If you think that there is a potential case for you ending up queuing at least 351 | 5MB of data in messages to send to a WebSocket server, then you may want to 352 | wrap `sarus.send` function calls in a try/catch statement, so as to handle 353 | those messages, should they occur. 354 | 355 | ### Exponential backoff 356 | 357 | Configure exponential backoff like so: 358 | 359 | ```typescript 360 | import Sarus from '@anephenix/sarus'; 361 | 362 | const sarus = new Sarus({ 363 | url: 'wss://ws.anephenix.com', 364 | exponentialBackoff: { 365 | // Exponential factor, here 2 will result in 366 | // 1 s, 2 s, 4 s, and so on increasing delays 367 | backoffRate: 2, 368 | // Never wait more than 2000 seconds 369 | backoffLimit: 2000, 370 | }, 371 | }); 372 | ``` 373 | 374 | When a connection attempt repeatedly fails, decreasing the delay 375 | exponentially between each subsequent reconnection attempt is called 376 | [Exponential backoff](https://en.wikipedia.org/wiki/Exponential_backoff). The 377 | idea is that if a connection attempt failed after 1 second, and 2 seconds, then it is 378 | not necessary to check it on the 3rd second, since the probability of a 379 | reconnection succeeding on the third attempt is most likely not going up. 380 | Therefore, increasing the delay between each attempt factors in the assumption 381 | that a connection is not more likely to succeed by repeatedly probing in regular 382 | intervals. 383 | 384 | This decreases both the load on the client, as well as on the server. For 385 | a client, fewer websocket connection attempts decrease the load on the client 386 | and on the network connection. For the server, should websocket requests fail 387 | within, then the load for handling repeatedly failing requests will fall 388 | as well. Furthermore, the burden on the network will also be decreased. Should 389 | for example a server refuse to accept websocket connections for one client, 390 | then there is the possibility that other clients will also not be able to connect. 391 | 392 | Sarus implements _truncated exponential backoff_, meaning that the maximum 393 | reconnection delay is capped by another factor `backoffLimit` and will never 394 | exceed it. The exponential backoff rate itself is determined by `backoffRate`. 395 | If `backoffRate` is 2, then the delays will be 1 s, 2 s, 4 s, and so on. 396 | 397 | The algorithm for reconnection looks like this in pseudocode: 398 | 399 | ```javascript 400 | // Configurable 401 | const backoffRate = 2; 402 | // The maximum delay will be 400s 403 | const backoffLimit = 400; 404 | let notConnected = false; 405 | let connectionAttempts = 1; 406 | while (notConnected) { 407 | const delay = Math.min( 408 | Math.pow(connectionAttempts, backoffRate), 409 | backoffLimit, 410 | ); 411 | await delay(delay); 412 | notConnected = tryToConnect(); 413 | connectionAttempts += 1; 414 | } 415 | ``` 416 | 417 | ### Advanced options 418 | 419 | Sarus has a number of other options that you can pass to the client during 420 | initialization. They are listed in the example below: 421 | 422 | ```javascript 423 | const sarus = new Sarus({ 424 | url: 'wss.anephenix.com', 425 | protocols: 'hybi-00', 426 | retryProcessTimePeriod: 25, 427 | storageKey: 'messageQueue', 428 | }); 429 | ``` 430 | 431 | The `protocols` property is used to specify the sub-protocol that the WebSocket 432 | connection should use. You can pass either a string, or an array of strings. 433 | 434 | The `retryProcessTimePeriod` property is used to help buffer the time between 435 | trying to resend a message over a WebSocket connection. By default it is a 436 | number, 50 (for 50 miliseconds). You can adjust this value in the client 437 | instance. 438 | 439 | The `storageKey` property is a key that is used with sessionStorage and 440 | localStorage to store and retrieve the messages in the message queue. By 441 | default it is set to 'sarus'. You can set this to another string value if 442 | you wish. You can also inspect the message queue independently of Sarus by 443 | making calls to the sessionStorage/localStorage api with that key. 444 | 445 | ### Using the library in your frontend code with babel, webpack, rollup, etc. 446 | 447 | The code for the library is written using ES2015 features, and the idea is that 448 | developers can directly load that code into their application, rather than 449 | loading it as an external dependency in a transpiled and minified format. 450 | 451 | This gives the developer the freedom to use it as they wish with the frontend 452 | tools that they use, be it Babel, WebPack, Rollup, or even Browserify. 453 | 454 | ### Developing locally and running tests 455 | 456 | ``` 457 | npm t 458 | ``` 459 | 460 | This will run tests using jest and with code coverage enabled. 461 | 462 | ### License and Credits 463 | 464 | © 2025 Anephenix Ltd. Sarus is licensed under the MIT License. 465 | -------------------------------------------------------------------------------- /dist/esm/index.js: -------------------------------------------------------------------------------- 1 | // File Dependencies 2 | import { DATA_STORAGE_TYPES } from "./lib/constants.js"; 3 | import { serialize, deserialize } from "./lib/dataTransformer.js"; 4 | import { validateWebSocketUrl } from "./lib/utils.js"; 5 | /** 6 | * Retrieves the storage API for the browser 7 | * @param {string} storageType - The storage type (local or session) 8 | * @returns {Storage} - the storage API 9 | */ 10 | const getStorage = (storageType) => { 11 | switch (storageType) { 12 | case "local": 13 | return window.localStorage; 14 | case "session": 15 | return window.sessionStorage; 16 | } 17 | }; 18 | /** 19 | * Retrieves the messages in the message queue from one of either 20 | * sessionStorage or localStorage. 21 | * @param {Object} param0 - An object containing parameters 22 | * @param {string} param0.storageKey - The key used for storing the data 23 | * @param {string} param0.storageType - The type of storage used 24 | * @returns {*} 25 | */ 26 | const getMessagesFromStore = ({ storageType, storageKey, }) => { 27 | if (DATA_STORAGE_TYPES.indexOf(storageType) !== -1) { 28 | const storage = getStorage(storageType); 29 | const rawData = (storage === null || storage === void 0 ? void 0 : storage.getItem(storageKey)) || null; 30 | const result = deserialize(rawData); 31 | return Array.isArray(result) ? result : []; 32 | } 33 | }; 34 | /* 35 | * Calculate the exponential backoff delay for a given number of connection 36 | * attempts. 37 | * @param {ExponentialBackoffParams} params - configuration parameters for 38 | * exponential backoff. 39 | * @param {number} initialDelay - the initial delay before any backoff is 40 | * applied 41 | * @param {number} failedConnectionAttempts - the number of connection attempts 42 | * that have previously failed 43 | * @returns {void} - set does not return 44 | */ 45 | export function calculateRetryDelayFactor(params, initialDelay, failedConnectionAttempts) { 46 | return Math.min(initialDelay * params.backoffRate ** failedConnectionAttempts, params.backoffLimit); 47 | } 48 | /** 49 | * The Sarus client class 50 | * @constructor 51 | * @param {Object} param0 - An object containing parameters 52 | * @param {string} param0.url - The url for the WebSocket client to connect to 53 | * @param {string} param0.binaryType - The optional type of binary data transmitted over the WebSocket connection 54 | * @param {string\array} param0.protocols - An optional string or array of strings for the sub-protocols that the WebSocket will use 55 | * @param {object} param0.eventListeners - An optional object containing event listener functions keyed to websocket events 56 | * @param {boolean} param0.reconnectAutomatically - An optional boolean flag to indicate whether to reconnect automatically when a websocket connection is severed 57 | * @param {number} param0.retryProcessTimePeriod - An optional number for how long the time period between retrying to send a messgae to a WebSocket server should be 58 | * @param {boolean|number} param0.retryConnectionDelay - An optional parameter for whether to delay WebSocket reconnection attempts by a time period. If true, the delay is 1000ms, otherwise it is the number passed. The default value when this parameter is undefined will be interpreted as 1000ms. 59 | * @param {ExponentialBackoffParams} param0.exponentialBackoff - An optional containing configuration for exponential backoff. If this parameter is undefined, exponential backoff is disabled. The minimum delay is determined by retryConnectionDelay. If retryConnectionDelay is set is false, this setting will not be in effect. 60 | * @param {string} param0.storageType - An optional string specifying the type of storage to use for persisting messages in the message queue 61 | * @param {string} param0.storageKey - An optional string specifying the key used to store the messages data against in sessionStorage/localStorage 62 | * @returns {object} The class instance 63 | */ 64 | export default class Sarus { 65 | constructor(props) { 66 | var _a; 67 | /* 68 | * Track the current state of the Sarus object. See the diagram below. 69 | * 70 | * reconnect() ┌──────┐ 71 | * ┌───────────────────────────────│closed│ 72 | * │ └──────┘ 73 | * │ ▲ 74 | * ▼ │ this.ws.onclose 75 | * ┌──────────┐ this.ws.onopen ┌───┴─────┐ 76 | * │connecting├───────────────────────►│connected│ 77 | * └──────────┘ └───┬─────┘ 78 | * ▲ │ disconnect() 79 | * │ ▼ 80 | * │ reconnect() ┌────────────┐ 81 | * └─────────────────────────────┤disconnected│ 82 | * └────────────┘ 83 | * 84 | * connect(), disconnect() are generally called by the user 85 | * 86 | * When disconnected by the WebSocket itself (i.e., this.ws.onclose), 87 | * this.reconnect() is called automatically if reconnection is enabled. 88 | * this.reconnect() can also be called by the user, for example if 89 | * this.disconnect() was purposefully called and reconnection is desired. 90 | * 91 | * The current state is specified by the 'kind' property of state 92 | * Each state can have additional data contained in properties other than 93 | * 'kind'. Those properties might be unique to one state, or contained in 94 | * several states. To access a property, it might be necessary to narrow down 95 | * the 'kind' of state. 96 | * 97 | * The initial state is connecting, as a Sarus client tries to connect right 98 | * after the constructor wraps up. 99 | */ 100 | this.state = { 101 | kind: "connecting", 102 | failedConnectionAttempts: 0, 103 | }; 104 | // Extract the properties that are passed to the class 105 | const { url, binaryType, protocols, eventListeners, // = DEFAULT_EVENT_LISTENERS_OBJECT, 106 | reconnectAutomatically, retryProcessTimePeriod, // TODO - write a test case to check this 107 | retryConnectionDelay, exponentialBackoff, storageType = "memory", storageKey = "sarus", } = props; 108 | this.eventListeners = this.auditEventListeners(eventListeners); 109 | // Sets the WebSocket server url for the client to connect to. 110 | this.url = validateWebSocketUrl(url); 111 | // Sets the binaryType of the data being sent over the connection 112 | this.binaryType = binaryType; 113 | // Sets an optional protocols value, which can be either a string or an array of strings 114 | this.protocols = protocols; 115 | /* 116 | When attempting to re-send a message when the WebSocket connection is 117 | not open, there is a retry process time period of 50ms. It can be set 118 | to another value by the developer. 119 | */ 120 | this.retryProcessTimePeriod = retryProcessTimePeriod || 50; 121 | /* 122 | If a WebSocket connection is severed, Sarus is configured to reconnect to 123 | the WebSocket server url automatically, unless specified otherwise by the 124 | developer at initialization 125 | */ 126 | this.reconnectAutomatically = !(reconnectAutomatically === false); 127 | /* 128 | This handles whether to add a time delay to reconnecting the WebSocket 129 | client. If true, a 1000ms delay is added. If a number, that number (as 130 | miliseconds) is used as the delay. Default is true. 131 | */ 132 | // Either retryConnectionDelay is 133 | // undefined => default to 1000 134 | // true => default to 1000 135 | // false => default to 1000 136 | // a number => set it to that number 137 | this.retryConnectionDelay = 138 | (_a = (typeof retryConnectionDelay === "boolean" 139 | ? undefined 140 | : retryConnectionDelay)) !== null && _a !== void 0 ? _a : 1000; 141 | /* 142 | When a exponential backoff parameter object is provided, reconnection 143 | attemptions will be increasingly delayed by an exponential factor. 144 | This feature is disabled by default. 145 | */ 146 | this.exponentialBackoff = exponentialBackoff; 147 | /* 148 | Sets the storage type for the messages in the message queue. By default 149 | it is an in-memory option, but can also be set as 'session' for 150 | sessionStorage or 'local' for localStorage data persistence. 151 | */ 152 | this.storageType = storageType; 153 | /* 154 | When using 'session' or 'local' as the storageType, the storage key is 155 | used as the key for calls to sessionStorage/localStorage getItem/setItem. 156 | 157 | It can also be configured by the developer during initialization. 158 | */ 159 | this.storageKey = storageKey; 160 | /* 161 | When initializing the client, if we are using sessionStorage/localStorage 162 | for storing messages in the messageQueue, then we want to retrieve any 163 | that might have been persisted there. 164 | 165 | Say the user has done a page refresh, we want to make sure that messages 166 | that were meant to be sent to the server make their way there. 167 | 168 | If no messages were persisted, or we are using in-memory message storage, 169 | then we simply set the messages property to an empty array; 170 | */ 171 | this.messages = this.messages || []; 172 | // This binds the process function call. 173 | this.reconnect = this.reconnect.bind(this); 174 | this.connect = this.connect.bind(this); 175 | this.process = this.process.bind(this); 176 | this.connect(); 177 | } 178 | /* 179 | Gets the messages from the message queue. 180 | */ 181 | /** 182 | * Fetches the messages from the message queue 183 | * @returns {array} the messages in the message queue, as an array 184 | */ 185 | get messages() { 186 | var _a; 187 | const { storageType, storageKey, messageStore } = this; 188 | return ((_a = getMessagesFromStore({ storageType, storageKey })) !== null && _a !== void 0 ? _a : messageStore); 189 | } 190 | /** 191 | * Sets the messages to store in the message queue 192 | * @param {*} data - the data payload to set for the messages in the message queue 193 | * @returns {void} - set does not return 194 | */ 195 | set messages(data) { 196 | const { storageType, storageKey } = this; 197 | if (DATA_STORAGE_TYPES.indexOf(storageType) !== -1) { 198 | const storage = getStorage(storageType); 199 | if (storage) 200 | storage.setItem(storageKey, serialize(data)); 201 | } 202 | if (storageType === "memory") { 203 | this.messageStore = data; 204 | } 205 | } 206 | /** 207 | * Adds a message to the messages in the message queue that are kept in persistent storage 208 | * @param {*} data - the message 209 | * @returns {array} the messages in the message queue 210 | */ 211 | addMessageToStore(data) { 212 | const { messages, storageType } = this; 213 | if (DATA_STORAGE_TYPES.indexOf(storageType) === -1) 214 | return null; 215 | this.messages = [...messages, data]; 216 | return this.messages; 217 | } 218 | /** 219 | * Adds a messge to the message queue 220 | * @param {*} data - the data payload to put on the message queue 221 | */ 222 | addMessage(data) { 223 | const { messages } = this; 224 | this.addMessageToStore(data) || messages.push(data); 225 | } 226 | /** 227 | * Removes a message from the message queue that is in persistent storage 228 | * @param {*} messages - the messages in the message queue 229 | */ 230 | removeMessageFromStore(messages) { 231 | const newArray = [...messages]; 232 | newArray.shift(); 233 | this.messages = newArray; 234 | } 235 | /** 236 | * Removes a message from the message queue 237 | */ 238 | removeMessage() { 239 | const { messages, storageType } = this; 240 | if (DATA_STORAGE_TYPES.indexOf(storageType) === -1) { 241 | return this.messages.shift(); 242 | } 243 | this.removeMessageFromStore(messages); 244 | } 245 | /** 246 | * Audits the eventListeners object parameter with validations, and a prefillMissingEvents step 247 | * This ensures that the eventListeners object is the right format for binding events to WebSocket clients 248 | * @param {object} eventListeners - The eventListeners object parameter 249 | * @returns {object} The eventListeners object parameter, with any missing events prefilled in 250 | */ 251 | auditEventListeners(eventListeners) { 252 | return { 253 | open: (eventListeners === null || eventListeners === void 0 ? void 0 : eventListeners.open) || [], 254 | message: (eventListeners === null || eventListeners === void 0 ? void 0 : eventListeners.message) || [], 255 | error: (eventListeners === null || eventListeners === void 0 ? void 0 : eventListeners.error) || [], 256 | close: (eventListeners === null || eventListeners === void 0 ? void 0 : eventListeners.close) || [], 257 | }; 258 | } 259 | /** 260 | * Connects the WebSocket client, and attaches event listeners 261 | */ 262 | connect() { 263 | // If we aren't already connecting, we are now 264 | if (this.state.kind !== "connecting") { 265 | this.state = { kind: "connecting", failedConnectionAttempts: 0 }; 266 | } 267 | this.ws = new WebSocket(this.url, this.protocols); 268 | this.setBinaryType(); 269 | this.attachEventListeners(); 270 | if (this.messages.length > 0) 271 | this.process(); 272 | } 273 | /** 274 | * Reconnects the WebSocket client based on the retryConnectionDelay and 275 | * ExponentialBackoffParam setting. 276 | */ 277 | reconnect() { 278 | const { retryConnectionDelay, exponentialBackoff } = this; 279 | // If we are already in a "connecting" state, we need to refer to the 280 | // current amount of connection attemps to correctly calculate the 281 | // exponential delay -- if exponential backoff is enabled. 282 | const failedConnectionAttempts = this.state.kind === "connecting" 283 | ? this.state.failedConnectionAttempts 284 | : 0; 285 | // If no exponential backoff is enabled, retryConnectionDelay will 286 | // be scaled by a factor of 1 and it will stay the original value. 287 | const delay = exponentialBackoff 288 | ? calculateRetryDelayFactor(exponentialBackoff, retryConnectionDelay, failedConnectionAttempts) 289 | : retryConnectionDelay; 290 | setTimeout(this.connect, delay); 291 | } 292 | /** 293 | * Disconnects the WebSocket client from the server, and changes the 294 | * reconnectAutomatically flag to disable automatic reconnection, unless the 295 | * developer passes a boolean flag to not do that. 296 | * @param {boolean} overrideDisableReconnect 297 | */ 298 | disconnect(overrideDisableReconnect) { 299 | var _a; 300 | this.state = { kind: "disconnected" }; 301 | // We do this to prevent automatic reconnections; 302 | if (!overrideDisableReconnect) { 303 | this.reconnectAutomatically = false; 304 | } 305 | (_a = this.ws) === null || _a === void 0 ? void 0 : _a.close(); 306 | } 307 | /** 308 | * Adds a function to trigger on the occurrence of an event with the specified event name 309 | * @param {string} eventName - The name of the event in the eventListeners object 310 | * @param {function} eventFunc - The function to trigger when the event occurs 311 | */ 312 | on(eventName, eventFunc) { 313 | const eventFunctions = this.eventListeners[eventName]; 314 | if (eventFunctions && eventFunctions.indexOf(eventFunc) !== -1) { 315 | throw new Error(`${eventFunc.name} has already been added to this event Listener`); 316 | } 317 | if (eventFunctions && Array.isArray(eventFunctions)) { 318 | this.eventListeners[eventName].push(eventFunc); 319 | } 320 | } 321 | /** 322 | * Finds a function in a eventListener's event list, by functon or by function name 323 | * @param {string} eventName - The name of the event in the eventListeners object 324 | * @param {function|string} eventFuncOrName - Either the function to remove, or the name of the function to remove 325 | * @returns {function|undefined} The existing function, or nothing 326 | */ 327 | findFunction(eventName, eventFuncOrName) { 328 | if (typeof eventFuncOrName === "string") { 329 | const byName = (f) => f.name === eventFuncOrName; 330 | return this.eventListeners[eventName].find(byName); 331 | } 332 | if (this.eventListeners[eventName].indexOf(eventFuncOrName) !== -1) { 333 | return eventFuncOrName; 334 | } 335 | } 336 | /** 337 | * Raises an error if the existing function is not present, and if the client is configured to throw an error 338 | * @param {function|undefined} existingFunc 339 | * @param {object} opts - An optional object to pass that contains extra configuration options 340 | * @param {boolean} opts.doNotThrowError - A boolean flag that indicates whether to not throw an error if the function to remove is not found in the list 341 | */ 342 | raiseErrorIfFunctionIsMissing(existingFunc, opts) { 343 | if (!existingFunc) { 344 | if (!(opts === null || opts === void 0 ? void 0 : opts.doNotThrowError)) { 345 | throw new Error("Function does not exist in eventListener list"); 346 | } 347 | } 348 | } 349 | /** 350 | * Removes a function from an eventListener events list for that event 351 | * @param {string} eventName - The name of the event in the eventListeners object 352 | * @param {function|string} eventFuncOrName - Either the function to remove, or the name of the function to remove 353 | * @param {object} opts - An optional object to pass that contains extra configuration options 354 | * @param {boolean} opts.doNotThrowError - A boolean flag that indicates whether to not throw an error if the function to remove is not found in the list 355 | */ 356 | off(eventName, eventFuncOrName, opts) { 357 | const existingFunc = this.findFunction(eventName, eventFuncOrName); 358 | if (existingFunc) { 359 | const index = this.eventListeners[eventName].indexOf(existingFunc); 360 | this.eventListeners[eventName].splice(index, 1); 361 | } 362 | else { 363 | this.raiseErrorIfFunctionIsMissing(existingFunc, opts); 364 | } 365 | } 366 | /** 367 | * Puts data on a message queue, and then processes the message queue to get the data sent to the WebSocket server 368 | * @param {*} data - The data payload to put the on message queue 369 | */ 370 | send(data) { 371 | const callProcessAfterwards = this.messages.length === 0; 372 | this.addMessage(data); 373 | if (callProcessAfterwards) 374 | this.process(); 375 | } 376 | /** 377 | * Sends a message over the WebSocket, removes the message from the queue, 378 | * and calls proces again if there is another message to process. 379 | * @param {string | ArrayBuffer | Uint8Array} data - The data payload to send over the WebSocket 380 | */ 381 | processMessage(data) { 382 | var _a; 383 | // If the message is a base64-wrapped object (from legacy or manual insert), decode it 384 | (_a = this.ws) === null || _a === void 0 ? void 0 : _a.send(data); 385 | this.removeMessage(); 386 | if (this.messages.length > 0) 387 | this.process(); 388 | } 389 | /** 390 | * Processes messages that are on the message queue. Handles looping through the list, as well as retrying message 391 | * dispatch if the WebSocket connection is not open. 392 | */ 393 | process() { 394 | const { messages } = this; 395 | const data = messages[0]; 396 | if (!data && messages.length === 0) 397 | return; 398 | if (this.ws && this.ws.readyState === 1) { 399 | this.processMessage(data); 400 | } 401 | else { 402 | setTimeout(this.process, this.retryProcessTimePeriod); 403 | } 404 | } 405 | /** 406 | * Attaches the event listeners to the WebSocket instance. 407 | * Also attaches an additional eventListener on the 'close' event to trigger a reconnection 408 | * of the WebSocket, unless configured not to. 409 | */ 410 | attachEventListeners() { 411 | this.ws.onopen = (e) => { 412 | for (const f of this.eventListeners.open) { 413 | f(e); 414 | } 415 | this.state = { kind: "connected" }; 416 | }; 417 | this.ws.onmessage = (e) => { 418 | for (const f of this.eventListeners.message) { 419 | f(e); 420 | } 421 | }; 422 | this.ws.onerror = (e) => { 423 | for (const f of this.eventListeners.error) { 424 | f(e); 425 | } 426 | }; 427 | this.ws.onclose = (e) => { 428 | for (const f of this.eventListeners.close) { 429 | f(e); 430 | } 431 | if (this.reconnectAutomatically) { 432 | // If we have previously been "connecting", we carry over the amount 433 | // of failed connection attempts and add 1, since the current 434 | // connection attempt failed. We stay "connecting" instead of 435 | // "closed", since we've never been fully "connected" in the first 436 | // place. 437 | if (this.state.kind === "connecting") { 438 | this.state = { 439 | kind: "connecting", 440 | failedConnectionAttempts: this.state.failedConnectionAttempts + 1, 441 | }; 442 | } 443 | else { 444 | // If we were in a different state, we assume that our connection 445 | // freshly closed and have not made any failed connection attempts. 446 | this.state = { kind: "closed" }; 447 | } 448 | this.removeEventListeners(); 449 | this.reconnect(); 450 | } 451 | }; 452 | } 453 | /** 454 | * Removes the event listeners from a closed WebSocket instance, so that 455 | * they are cleaned up 456 | */ 457 | removeEventListeners() { 458 | this.ws.onopen = null; 459 | this.ws.onclose = null; 460 | this.ws.onmessage = null; 461 | this.ws.onerror = null; 462 | } 463 | /** 464 | * Sets the binary type for the WebSocket, if such an option is set 465 | */ 466 | setBinaryType() { 467 | const { binaryType } = this; 468 | if (binaryType && this.ws) 469 | this.ws.binaryType = binaryType; 470 | } 471 | } 472 | -------------------------------------------------------------------------------- /dist/cjs/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.calculateRetryDelayFactor = calculateRetryDelayFactor; 4 | // File Dependencies 5 | const constants_js_1 = require("./lib/constants.js"); 6 | const dataTransformer_js_1 = require("./lib/dataTransformer.js"); 7 | const utils_js_1 = require("./lib/utils.js"); 8 | /** 9 | * Retrieves the storage API for the browser 10 | * @param {string} storageType - The storage type (local or session) 11 | * @returns {Storage} - the storage API 12 | */ 13 | const getStorage = (storageType) => { 14 | switch (storageType) { 15 | case "local": 16 | return window.localStorage; 17 | case "session": 18 | return window.sessionStorage; 19 | } 20 | }; 21 | /** 22 | * Retrieves the messages in the message queue from one of either 23 | * sessionStorage or localStorage. 24 | * @param {Object} param0 - An object containing parameters 25 | * @param {string} param0.storageKey - The key used for storing the data 26 | * @param {string} param0.storageType - The type of storage used 27 | * @returns {*} 28 | */ 29 | const getMessagesFromStore = ({ storageType, storageKey, }) => { 30 | if (constants_js_1.DATA_STORAGE_TYPES.indexOf(storageType) !== -1) { 31 | const storage = getStorage(storageType); 32 | const rawData = (storage === null || storage === void 0 ? void 0 : storage.getItem(storageKey)) || null; 33 | const result = (0, dataTransformer_js_1.deserialize)(rawData); 34 | return Array.isArray(result) ? result : []; 35 | } 36 | }; 37 | /* 38 | * Calculate the exponential backoff delay for a given number of connection 39 | * attempts. 40 | * @param {ExponentialBackoffParams} params - configuration parameters for 41 | * exponential backoff. 42 | * @param {number} initialDelay - the initial delay before any backoff is 43 | * applied 44 | * @param {number} failedConnectionAttempts - the number of connection attempts 45 | * that have previously failed 46 | * @returns {void} - set does not return 47 | */ 48 | function calculateRetryDelayFactor(params, initialDelay, failedConnectionAttempts) { 49 | return Math.min(initialDelay * params.backoffRate ** failedConnectionAttempts, params.backoffLimit); 50 | } 51 | /** 52 | * The Sarus client class 53 | * @constructor 54 | * @param {Object} param0 - An object containing parameters 55 | * @param {string} param0.url - The url for the WebSocket client to connect to 56 | * @param {string} param0.binaryType - The optional type of binary data transmitted over the WebSocket connection 57 | * @param {string\array} param0.protocols - An optional string or array of strings for the sub-protocols that the WebSocket will use 58 | * @param {object} param0.eventListeners - An optional object containing event listener functions keyed to websocket events 59 | * @param {boolean} param0.reconnectAutomatically - An optional boolean flag to indicate whether to reconnect automatically when a websocket connection is severed 60 | * @param {number} param0.retryProcessTimePeriod - An optional number for how long the time period between retrying to send a messgae to a WebSocket server should be 61 | * @param {boolean|number} param0.retryConnectionDelay - An optional parameter for whether to delay WebSocket reconnection attempts by a time period. If true, the delay is 1000ms, otherwise it is the number passed. The default value when this parameter is undefined will be interpreted as 1000ms. 62 | * @param {ExponentialBackoffParams} param0.exponentialBackoff - An optional containing configuration for exponential backoff. If this parameter is undefined, exponential backoff is disabled. The minimum delay is determined by retryConnectionDelay. If retryConnectionDelay is set is false, this setting will not be in effect. 63 | * @param {string} param0.storageType - An optional string specifying the type of storage to use for persisting messages in the message queue 64 | * @param {string} param0.storageKey - An optional string specifying the key used to store the messages data against in sessionStorage/localStorage 65 | * @returns {object} The class instance 66 | */ 67 | class Sarus { 68 | constructor(props) { 69 | var _a; 70 | /* 71 | * Track the current state of the Sarus object. See the diagram below. 72 | * 73 | * reconnect() ┌──────┐ 74 | * ┌───────────────────────────────│closed│ 75 | * │ └──────┘ 76 | * │ ▲ 77 | * ▼ │ this.ws.onclose 78 | * ┌──────────┐ this.ws.onopen ┌───┴─────┐ 79 | * │connecting├───────────────────────►│connected│ 80 | * └──────────┘ └───┬─────┘ 81 | * ▲ │ disconnect() 82 | * │ ▼ 83 | * │ reconnect() ┌────────────┐ 84 | * └─────────────────────────────┤disconnected│ 85 | * └────────────┘ 86 | * 87 | * connect(), disconnect() are generally called by the user 88 | * 89 | * When disconnected by the WebSocket itself (i.e., this.ws.onclose), 90 | * this.reconnect() is called automatically if reconnection is enabled. 91 | * this.reconnect() can also be called by the user, for example if 92 | * this.disconnect() was purposefully called and reconnection is desired. 93 | * 94 | * The current state is specified by the 'kind' property of state 95 | * Each state can have additional data contained in properties other than 96 | * 'kind'. Those properties might be unique to one state, or contained in 97 | * several states. To access a property, it might be necessary to narrow down 98 | * the 'kind' of state. 99 | * 100 | * The initial state is connecting, as a Sarus client tries to connect right 101 | * after the constructor wraps up. 102 | */ 103 | this.state = { 104 | kind: "connecting", 105 | failedConnectionAttempts: 0, 106 | }; 107 | // Extract the properties that are passed to the class 108 | const { url, binaryType, protocols, eventListeners, // = DEFAULT_EVENT_LISTENERS_OBJECT, 109 | reconnectAutomatically, retryProcessTimePeriod, // TODO - write a test case to check this 110 | retryConnectionDelay, exponentialBackoff, storageType = "memory", storageKey = "sarus", } = props; 111 | this.eventListeners = this.auditEventListeners(eventListeners); 112 | // Sets the WebSocket server url for the client to connect to. 113 | this.url = (0, utils_js_1.validateWebSocketUrl)(url); 114 | // Sets the binaryType of the data being sent over the connection 115 | this.binaryType = binaryType; 116 | // Sets an optional protocols value, which can be either a string or an array of strings 117 | this.protocols = protocols; 118 | /* 119 | When attempting to re-send a message when the WebSocket connection is 120 | not open, there is a retry process time period of 50ms. It can be set 121 | to another value by the developer. 122 | */ 123 | this.retryProcessTimePeriod = retryProcessTimePeriod || 50; 124 | /* 125 | If a WebSocket connection is severed, Sarus is configured to reconnect to 126 | the WebSocket server url automatically, unless specified otherwise by the 127 | developer at initialization 128 | */ 129 | this.reconnectAutomatically = !(reconnectAutomatically === false); 130 | /* 131 | This handles whether to add a time delay to reconnecting the WebSocket 132 | client. If true, a 1000ms delay is added. If a number, that number (as 133 | miliseconds) is used as the delay. Default is true. 134 | */ 135 | // Either retryConnectionDelay is 136 | // undefined => default to 1000 137 | // true => default to 1000 138 | // false => default to 1000 139 | // a number => set it to that number 140 | this.retryConnectionDelay = 141 | (_a = (typeof retryConnectionDelay === "boolean" 142 | ? undefined 143 | : retryConnectionDelay)) !== null && _a !== void 0 ? _a : 1000; 144 | /* 145 | When a exponential backoff parameter object is provided, reconnection 146 | attemptions will be increasingly delayed by an exponential factor. 147 | This feature is disabled by default. 148 | */ 149 | this.exponentialBackoff = exponentialBackoff; 150 | /* 151 | Sets the storage type for the messages in the message queue. By default 152 | it is an in-memory option, but can also be set as 'session' for 153 | sessionStorage or 'local' for localStorage data persistence. 154 | */ 155 | this.storageType = storageType; 156 | /* 157 | When using 'session' or 'local' as the storageType, the storage key is 158 | used as the key for calls to sessionStorage/localStorage getItem/setItem. 159 | 160 | It can also be configured by the developer during initialization. 161 | */ 162 | this.storageKey = storageKey; 163 | /* 164 | When initializing the client, if we are using sessionStorage/localStorage 165 | for storing messages in the messageQueue, then we want to retrieve any 166 | that might have been persisted there. 167 | 168 | Say the user has done a page refresh, we want to make sure that messages 169 | that were meant to be sent to the server make their way there. 170 | 171 | If no messages were persisted, or we are using in-memory message storage, 172 | then we simply set the messages property to an empty array; 173 | */ 174 | this.messages = this.messages || []; 175 | // This binds the process function call. 176 | this.reconnect = this.reconnect.bind(this); 177 | this.connect = this.connect.bind(this); 178 | this.process = this.process.bind(this); 179 | this.connect(); 180 | } 181 | /* 182 | Gets the messages from the message queue. 183 | */ 184 | /** 185 | * Fetches the messages from the message queue 186 | * @returns {array} the messages in the message queue, as an array 187 | */ 188 | get messages() { 189 | var _a; 190 | const { storageType, storageKey, messageStore } = this; 191 | return ((_a = getMessagesFromStore({ storageType, storageKey })) !== null && _a !== void 0 ? _a : messageStore); 192 | } 193 | /** 194 | * Sets the messages to store in the message queue 195 | * @param {*} data - the data payload to set for the messages in the message queue 196 | * @returns {void} - set does not return 197 | */ 198 | set messages(data) { 199 | const { storageType, storageKey } = this; 200 | if (constants_js_1.DATA_STORAGE_TYPES.indexOf(storageType) !== -1) { 201 | const storage = getStorage(storageType); 202 | if (storage) 203 | storage.setItem(storageKey, (0, dataTransformer_js_1.serialize)(data)); 204 | } 205 | if (storageType === "memory") { 206 | this.messageStore = data; 207 | } 208 | } 209 | /** 210 | * Adds a message to the messages in the message queue that are kept in persistent storage 211 | * @param {*} data - the message 212 | * @returns {array} the messages in the message queue 213 | */ 214 | addMessageToStore(data) { 215 | const { messages, storageType } = this; 216 | if (constants_js_1.DATA_STORAGE_TYPES.indexOf(storageType) === -1) 217 | return null; 218 | this.messages = [...messages, data]; 219 | return this.messages; 220 | } 221 | /** 222 | * Adds a messge to the message queue 223 | * @param {*} data - the data payload to put on the message queue 224 | */ 225 | addMessage(data) { 226 | const { messages } = this; 227 | this.addMessageToStore(data) || messages.push(data); 228 | } 229 | /** 230 | * Removes a message from the message queue that is in persistent storage 231 | * @param {*} messages - the messages in the message queue 232 | */ 233 | removeMessageFromStore(messages) { 234 | const newArray = [...messages]; 235 | newArray.shift(); 236 | this.messages = newArray; 237 | } 238 | /** 239 | * Removes a message from the message queue 240 | */ 241 | removeMessage() { 242 | const { messages, storageType } = this; 243 | if (constants_js_1.DATA_STORAGE_TYPES.indexOf(storageType) === -1) { 244 | return this.messages.shift(); 245 | } 246 | this.removeMessageFromStore(messages); 247 | } 248 | /** 249 | * Audits the eventListeners object parameter with validations, and a prefillMissingEvents step 250 | * This ensures that the eventListeners object is the right format for binding events to WebSocket clients 251 | * @param {object} eventListeners - The eventListeners object parameter 252 | * @returns {object} The eventListeners object parameter, with any missing events prefilled in 253 | */ 254 | auditEventListeners(eventListeners) { 255 | return { 256 | open: (eventListeners === null || eventListeners === void 0 ? void 0 : eventListeners.open) || [], 257 | message: (eventListeners === null || eventListeners === void 0 ? void 0 : eventListeners.message) || [], 258 | error: (eventListeners === null || eventListeners === void 0 ? void 0 : eventListeners.error) || [], 259 | close: (eventListeners === null || eventListeners === void 0 ? void 0 : eventListeners.close) || [], 260 | }; 261 | } 262 | /** 263 | * Connects the WebSocket client, and attaches event listeners 264 | */ 265 | connect() { 266 | // If we aren't already connecting, we are now 267 | if (this.state.kind !== "connecting") { 268 | this.state = { kind: "connecting", failedConnectionAttempts: 0 }; 269 | } 270 | this.ws = new WebSocket(this.url, this.protocols); 271 | this.setBinaryType(); 272 | this.attachEventListeners(); 273 | if (this.messages.length > 0) 274 | this.process(); 275 | } 276 | /** 277 | * Reconnects the WebSocket client based on the retryConnectionDelay and 278 | * ExponentialBackoffParam setting. 279 | */ 280 | reconnect() { 281 | const { retryConnectionDelay, exponentialBackoff } = this; 282 | // If we are already in a "connecting" state, we need to refer to the 283 | // current amount of connection attemps to correctly calculate the 284 | // exponential delay -- if exponential backoff is enabled. 285 | const failedConnectionAttempts = this.state.kind === "connecting" 286 | ? this.state.failedConnectionAttempts 287 | : 0; 288 | // If no exponential backoff is enabled, retryConnectionDelay will 289 | // be scaled by a factor of 1 and it will stay the original value. 290 | const delay = exponentialBackoff 291 | ? calculateRetryDelayFactor(exponentialBackoff, retryConnectionDelay, failedConnectionAttempts) 292 | : retryConnectionDelay; 293 | setTimeout(this.connect, delay); 294 | } 295 | /** 296 | * Disconnects the WebSocket client from the server, and changes the 297 | * reconnectAutomatically flag to disable automatic reconnection, unless the 298 | * developer passes a boolean flag to not do that. 299 | * @param {boolean} overrideDisableReconnect 300 | */ 301 | disconnect(overrideDisableReconnect) { 302 | var _a; 303 | this.state = { kind: "disconnected" }; 304 | // We do this to prevent automatic reconnections; 305 | if (!overrideDisableReconnect) { 306 | this.reconnectAutomatically = false; 307 | } 308 | (_a = this.ws) === null || _a === void 0 ? void 0 : _a.close(); 309 | } 310 | /** 311 | * Adds a function to trigger on the occurrence of an event with the specified event name 312 | * @param {string} eventName - The name of the event in the eventListeners object 313 | * @param {function} eventFunc - The function to trigger when the event occurs 314 | */ 315 | on(eventName, eventFunc) { 316 | const eventFunctions = this.eventListeners[eventName]; 317 | if (eventFunctions && eventFunctions.indexOf(eventFunc) !== -1) { 318 | throw new Error(`${eventFunc.name} has already been added to this event Listener`); 319 | } 320 | if (eventFunctions && Array.isArray(eventFunctions)) { 321 | this.eventListeners[eventName].push(eventFunc); 322 | } 323 | } 324 | /** 325 | * Finds a function in a eventListener's event list, by functon or by function name 326 | * @param {string} eventName - The name of the event in the eventListeners object 327 | * @param {function|string} eventFuncOrName - Either the function to remove, or the name of the function to remove 328 | * @returns {function|undefined} The existing function, or nothing 329 | */ 330 | findFunction(eventName, eventFuncOrName) { 331 | if (typeof eventFuncOrName === "string") { 332 | const byName = (f) => f.name === eventFuncOrName; 333 | return this.eventListeners[eventName].find(byName); 334 | } 335 | if (this.eventListeners[eventName].indexOf(eventFuncOrName) !== -1) { 336 | return eventFuncOrName; 337 | } 338 | } 339 | /** 340 | * Raises an error if the existing function is not present, and if the client is configured to throw an error 341 | * @param {function|undefined} existingFunc 342 | * @param {object} opts - An optional object to pass that contains extra configuration options 343 | * @param {boolean} opts.doNotThrowError - A boolean flag that indicates whether to not throw an error if the function to remove is not found in the list 344 | */ 345 | raiseErrorIfFunctionIsMissing(existingFunc, opts) { 346 | if (!existingFunc) { 347 | if (!(opts === null || opts === void 0 ? void 0 : opts.doNotThrowError)) { 348 | throw new Error("Function does not exist in eventListener list"); 349 | } 350 | } 351 | } 352 | /** 353 | * Removes a function from an eventListener events list for that event 354 | * @param {string} eventName - The name of the event in the eventListeners object 355 | * @param {function|string} eventFuncOrName - Either the function to remove, or the name of the function to remove 356 | * @param {object} opts - An optional object to pass that contains extra configuration options 357 | * @param {boolean} opts.doNotThrowError - A boolean flag that indicates whether to not throw an error if the function to remove is not found in the list 358 | */ 359 | off(eventName, eventFuncOrName, opts) { 360 | const existingFunc = this.findFunction(eventName, eventFuncOrName); 361 | if (existingFunc) { 362 | const index = this.eventListeners[eventName].indexOf(existingFunc); 363 | this.eventListeners[eventName].splice(index, 1); 364 | } 365 | else { 366 | this.raiseErrorIfFunctionIsMissing(existingFunc, opts); 367 | } 368 | } 369 | /** 370 | * Puts data on a message queue, and then processes the message queue to get the data sent to the WebSocket server 371 | * @param {*} data - The data payload to put the on message queue 372 | */ 373 | send(data) { 374 | const callProcessAfterwards = this.messages.length === 0; 375 | this.addMessage(data); 376 | if (callProcessAfterwards) 377 | this.process(); 378 | } 379 | /** 380 | * Sends a message over the WebSocket, removes the message from the queue, 381 | * and calls proces again if there is another message to process. 382 | * @param {string | ArrayBuffer | Uint8Array} data - The data payload to send over the WebSocket 383 | */ 384 | processMessage(data) { 385 | var _a; 386 | // If the message is a base64-wrapped object (from legacy or manual insert), decode it 387 | (_a = this.ws) === null || _a === void 0 ? void 0 : _a.send(data); 388 | this.removeMessage(); 389 | if (this.messages.length > 0) 390 | this.process(); 391 | } 392 | /** 393 | * Processes messages that are on the message queue. Handles looping through the list, as well as retrying message 394 | * dispatch if the WebSocket connection is not open. 395 | */ 396 | process() { 397 | const { messages } = this; 398 | const data = messages[0]; 399 | if (!data && messages.length === 0) 400 | return; 401 | if (this.ws && this.ws.readyState === 1) { 402 | this.processMessage(data); 403 | } 404 | else { 405 | setTimeout(this.process, this.retryProcessTimePeriod); 406 | } 407 | } 408 | /** 409 | * Attaches the event listeners to the WebSocket instance. 410 | * Also attaches an additional eventListener on the 'close' event to trigger a reconnection 411 | * of the WebSocket, unless configured not to. 412 | */ 413 | attachEventListeners() { 414 | this.ws.onopen = (e) => { 415 | for (const f of this.eventListeners.open) { 416 | f(e); 417 | } 418 | this.state = { kind: "connected" }; 419 | }; 420 | this.ws.onmessage = (e) => { 421 | for (const f of this.eventListeners.message) { 422 | f(e); 423 | } 424 | }; 425 | this.ws.onerror = (e) => { 426 | for (const f of this.eventListeners.error) { 427 | f(e); 428 | } 429 | }; 430 | this.ws.onclose = (e) => { 431 | for (const f of this.eventListeners.close) { 432 | f(e); 433 | } 434 | if (this.reconnectAutomatically) { 435 | // If we have previously been "connecting", we carry over the amount 436 | // of failed connection attempts and add 1, since the current 437 | // connection attempt failed. We stay "connecting" instead of 438 | // "closed", since we've never been fully "connected" in the first 439 | // place. 440 | if (this.state.kind === "connecting") { 441 | this.state = { 442 | kind: "connecting", 443 | failedConnectionAttempts: this.state.failedConnectionAttempts + 1, 444 | }; 445 | } 446 | else { 447 | // If we were in a different state, we assume that our connection 448 | // freshly closed and have not made any failed connection attempts. 449 | this.state = { kind: "closed" }; 450 | } 451 | this.removeEventListeners(); 452 | this.reconnect(); 453 | } 454 | }; 455 | } 456 | /** 457 | * Removes the event listeners from a closed WebSocket instance, so that 458 | * they are cleaned up 459 | */ 460 | removeEventListeners() { 461 | this.ws.onopen = null; 462 | this.ws.onclose = null; 463 | this.ws.onmessage = null; 464 | this.ws.onerror = null; 465 | } 466 | /** 467 | * Sets the binary type for the WebSocket, if such an option is set 468 | */ 469 | setBinaryType() { 470 | const { binaryType } = this; 471 | if (binaryType && this.ws) 472 | this.ws.binaryType = binaryType; 473 | } 474 | } 475 | exports.default = Sarus; 476 | //# sourceMappingURL=index.js.map -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // File Dependencies 2 | import { DATA_STORAGE_TYPES } from "./lib/constants.js"; 3 | import { serialize, deserialize } from "./lib/dataTransformer.js"; 4 | import type { 5 | PartialEventListenersInterface, 6 | EventListenersInterface, 7 | } from "./lib/validators.js"; 8 | import type { GenericFunction } from "./lib/types.js"; 9 | export type { GenericFunction } from "./lib/types.js"; 10 | import { validateWebSocketUrl } from "./lib/utils.js"; 11 | import type { LocalStorage } from "node-localstorage"; 12 | 13 | interface StorageParams { 14 | storageType: string; 15 | storageKey: string; 16 | } 17 | 18 | /** 19 | * Retrieves the storage API for the browser 20 | * @param {string} storageType - The storage type (local or session) 21 | * @returns {Storage} - the storage API 22 | */ 23 | const getStorage = (storageType: string) => { 24 | switch (storageType) { 25 | case "local": 26 | return window.localStorage; 27 | case "session": 28 | return window.sessionStorage; 29 | } 30 | }; 31 | 32 | /** 33 | * Retrieves the messages in the message queue from one of either 34 | * sessionStorage or localStorage. 35 | * @param {Object} param0 - An object containing parameters 36 | * @param {string} param0.storageKey - The key used for storing the data 37 | * @param {string} param0.storageType - The type of storage used 38 | * @returns {*} 39 | */ 40 | const getMessagesFromStore = ({ 41 | storageType, 42 | storageKey, 43 | }: StorageParams): unknown[] | undefined => { 44 | if (DATA_STORAGE_TYPES.indexOf(storageType) !== -1) { 45 | const storage = getStorage(storageType); 46 | const rawData: null | string = storage?.getItem(storageKey) || null; 47 | const result = deserialize(rawData); 48 | return Array.isArray(result) ? result : []; 49 | } 50 | }; 51 | 52 | export interface ExponentialBackoffParams { 53 | backoffRate: number; 54 | backoffLimit: number; 55 | } 56 | 57 | /* 58 | * Calculate the exponential backoff delay for a given number of connection 59 | * attempts. 60 | * @param {ExponentialBackoffParams} params - configuration parameters for 61 | * exponential backoff. 62 | * @param {number} initialDelay - the initial delay before any backoff is 63 | * applied 64 | * @param {number} failedConnectionAttempts - the number of connection attempts 65 | * that have previously failed 66 | * @returns {void} - set does not return 67 | */ 68 | export function calculateRetryDelayFactor( 69 | params: ExponentialBackoffParams, 70 | initialDelay: number, 71 | failedConnectionAttempts: number, 72 | ): number { 73 | return Math.min( 74 | initialDelay * params.backoffRate ** failedConnectionAttempts, 75 | params.backoffLimit, 76 | ); 77 | } 78 | 79 | export interface SarusClassParams { 80 | url: string; 81 | binaryType?: BinaryType; 82 | protocols?: string | Array; 83 | eventListeners?: PartialEventListenersInterface; 84 | retryProcessTimePeriod?: number; 85 | reconnectAutomatically?: boolean; 86 | retryConnectionDelay?: boolean | number; 87 | exponentialBackoff?: ExponentialBackoffParams; 88 | storageType?: string; 89 | storageKey?: string; 90 | } 91 | 92 | /** 93 | * The Sarus client class 94 | * @constructor 95 | * @param {Object} param0 - An object containing parameters 96 | * @param {string} param0.url - The url for the WebSocket client to connect to 97 | * @param {string} param0.binaryType - The optional type of binary data transmitted over the WebSocket connection 98 | * @param {string\array} param0.protocols - An optional string or array of strings for the sub-protocols that the WebSocket will use 99 | * @param {object} param0.eventListeners - An optional object containing event listener functions keyed to websocket events 100 | * @param {boolean} param0.reconnectAutomatically - An optional boolean flag to indicate whether to reconnect automatically when a websocket connection is severed 101 | * @param {number} param0.retryProcessTimePeriod - An optional number for how long the time period between retrying to send a messgae to a WebSocket server should be 102 | * @param {boolean|number} param0.retryConnectionDelay - An optional parameter for whether to delay WebSocket reconnection attempts by a time period. If true, the delay is 1000ms, otherwise it is the number passed. The default value when this parameter is undefined will be interpreted as 1000ms. 103 | * @param {ExponentialBackoffParams} param0.exponentialBackoff - An optional containing configuration for exponential backoff. If this parameter is undefined, exponential backoff is disabled. The minimum delay is determined by retryConnectionDelay. If retryConnectionDelay is set is false, this setting will not be in effect. 104 | * @param {string} param0.storageType - An optional string specifying the type of storage to use for persisting messages in the message queue 105 | * @param {string} param0.storageKey - An optional string specifying the key used to store the messages data against in sessionStorage/localStorage 106 | * @returns {object} The class instance 107 | */ 108 | export default class Sarus { 109 | // Constructor params 110 | url: URL; 111 | binaryType?: BinaryType; 112 | protocols?: string | Array; 113 | eventListeners: EventListenersInterface; 114 | retryProcessTimePeriod?: number; 115 | reconnectAutomatically?: boolean; 116 | retryConnectionDelay: number; 117 | exponentialBackoff?: ExponentialBackoffParams; 118 | storageType: string; 119 | storageKey: string; 120 | 121 | // Internally set 122 | messageStore!: LocalStorage | unknown[]; 123 | ws!: WebSocket; 124 | /* 125 | * Track the current state of the Sarus object. See the diagram below. 126 | * 127 | * reconnect() ┌──────┐ 128 | * ┌───────────────────────────────│closed│ 129 | * │ └──────┘ 130 | * │ ▲ 131 | * ▼ │ this.ws.onclose 132 | * ┌──────────┐ this.ws.onopen ┌───┴─────┐ 133 | * │connecting├───────────────────────►│connected│ 134 | * └──────────┘ └───┬─────┘ 135 | * ▲ │ disconnect() 136 | * │ ▼ 137 | * │ reconnect() ┌────────────┐ 138 | * └─────────────────────────────┤disconnected│ 139 | * └────────────┘ 140 | * 141 | * connect(), disconnect() are generally called by the user 142 | * 143 | * When disconnected by the WebSocket itself (i.e., this.ws.onclose), 144 | * this.reconnect() is called automatically if reconnection is enabled. 145 | * this.reconnect() can also be called by the user, for example if 146 | * this.disconnect() was purposefully called and reconnection is desired. 147 | * 148 | * The current state is specified by the 'kind' property of state 149 | * Each state can have additional data contained in properties other than 150 | * 'kind'. Those properties might be unique to one state, or contained in 151 | * several states. To access a property, it might be necessary to narrow down 152 | * the 'kind' of state. 153 | * 154 | * The initial state is connecting, as a Sarus client tries to connect right 155 | * after the constructor wraps up. 156 | */ 157 | state: 158 | | { kind: "connecting"; failedConnectionAttempts: number } 159 | | { kind: "connected" } 160 | | { kind: "disconnected" } 161 | | { kind: "closed" } = { 162 | kind: "connecting", 163 | failedConnectionAttempts: 0, 164 | }; 165 | 166 | constructor(props: SarusClassParams) { 167 | // Extract the properties that are passed to the class 168 | const { 169 | url, 170 | binaryType, 171 | protocols, 172 | eventListeners, // = DEFAULT_EVENT_LISTENERS_OBJECT, 173 | reconnectAutomatically, 174 | retryProcessTimePeriod, // TODO - write a test case to check this 175 | retryConnectionDelay, 176 | exponentialBackoff, 177 | storageType = "memory", 178 | storageKey = "sarus", 179 | } = props; 180 | 181 | this.eventListeners = this.auditEventListeners(eventListeners); 182 | 183 | // Sets the WebSocket server url for the client to connect to. 184 | this.url = validateWebSocketUrl(url); 185 | 186 | // Sets the binaryType of the data being sent over the connection 187 | this.binaryType = binaryType; 188 | 189 | // Sets an optional protocols value, which can be either a string or an array of strings 190 | this.protocols = protocols; 191 | 192 | /* 193 | When attempting to re-send a message when the WebSocket connection is 194 | not open, there is a retry process time period of 50ms. It can be set 195 | to another value by the developer. 196 | */ 197 | this.retryProcessTimePeriod = retryProcessTimePeriod || 50; 198 | 199 | /* 200 | If a WebSocket connection is severed, Sarus is configured to reconnect to 201 | the WebSocket server url automatically, unless specified otherwise by the 202 | developer at initialization 203 | */ 204 | this.reconnectAutomatically = !(reconnectAutomatically === false); 205 | 206 | /* 207 | This handles whether to add a time delay to reconnecting the WebSocket 208 | client. If true, a 1000ms delay is added. If a number, that number (as 209 | miliseconds) is used as the delay. Default is true. 210 | */ 211 | // Either retryConnectionDelay is 212 | // undefined => default to 1000 213 | // true => default to 1000 214 | // false => default to 1000 215 | // a number => set it to that number 216 | this.retryConnectionDelay = 217 | (typeof retryConnectionDelay === "boolean" 218 | ? undefined 219 | : retryConnectionDelay) ?? 1000; 220 | 221 | /* 222 | When a exponential backoff parameter object is provided, reconnection 223 | attemptions will be increasingly delayed by an exponential factor. 224 | This feature is disabled by default. 225 | */ 226 | this.exponentialBackoff = exponentialBackoff; 227 | 228 | /* 229 | Sets the storage type for the messages in the message queue. By default 230 | it is an in-memory option, but can also be set as 'session' for 231 | sessionStorage or 'local' for localStorage data persistence. 232 | */ 233 | this.storageType = storageType; 234 | 235 | /* 236 | When using 'session' or 'local' as the storageType, the storage key is 237 | used as the key for calls to sessionStorage/localStorage getItem/setItem. 238 | 239 | It can also be configured by the developer during initialization. 240 | */ 241 | this.storageKey = storageKey; 242 | 243 | /* 244 | When initializing the client, if we are using sessionStorage/localStorage 245 | for storing messages in the messageQueue, then we want to retrieve any 246 | that might have been persisted there. 247 | 248 | Say the user has done a page refresh, we want to make sure that messages 249 | that were meant to be sent to the server make their way there. 250 | 251 | If no messages were persisted, or we are using in-memory message storage, 252 | then we simply set the messages property to an empty array; 253 | */ 254 | this.messages = this.messages || []; 255 | 256 | // This binds the process function call. 257 | this.reconnect = this.reconnect.bind(this); 258 | this.connect = this.connect.bind(this); 259 | this.process = this.process.bind(this); 260 | this.connect(); 261 | } 262 | 263 | /* 264 | Gets the messages from the message queue. 265 | */ 266 | 267 | /** 268 | * Fetches the messages from the message queue 269 | * @returns {array} the messages in the message queue, as an array 270 | */ 271 | get messages() { 272 | const { storageType, storageKey, messageStore } = this; 273 | return (getMessagesFromStore({ storageType, storageKey }) ?? 274 | messageStore) as unknown[]; 275 | } 276 | 277 | /** 278 | * Sets the messages to store in the message queue 279 | * @param {*} data - the data payload to set for the messages in the message queue 280 | * @returns {void} - set does not return 281 | */ 282 | set messages(data: unknown[]) { 283 | const { storageType, storageKey } = this; 284 | if (DATA_STORAGE_TYPES.indexOf(storageType) !== -1) { 285 | const storage = getStorage(storageType); 286 | if (storage) storage.setItem(storageKey, serialize(data)); 287 | } 288 | if (storageType === "memory") { 289 | this.messageStore = data; 290 | } 291 | } 292 | 293 | /** 294 | * Adds a message to the messages in the message queue that are kept in persistent storage 295 | * @param {*} data - the message 296 | * @returns {array} the messages in the message queue 297 | */ 298 | addMessageToStore(data: unknown) { 299 | const { messages, storageType } = this; 300 | if (DATA_STORAGE_TYPES.indexOf(storageType) === -1) return null; 301 | this.messages = [...messages, data]; 302 | return this.messages; 303 | } 304 | 305 | /** 306 | * Adds a messge to the message queue 307 | * @param {*} data - the data payload to put on the message queue 308 | */ 309 | addMessage(data: unknown) { 310 | const { messages } = this; 311 | this.addMessageToStore(data) || messages.push(data); 312 | } 313 | 314 | /** 315 | * Removes a message from the message queue that is in persistent storage 316 | * @param {*} messages - the messages in the message queue 317 | */ 318 | removeMessageFromStore(messages: unknown[]) { 319 | const newArray = [...messages]; 320 | newArray.shift(); 321 | this.messages = newArray; 322 | } 323 | 324 | /** 325 | * Removes a message from the message queue 326 | */ 327 | removeMessage() { 328 | const { messages, storageType } = this; 329 | if (DATA_STORAGE_TYPES.indexOf(storageType) === -1) { 330 | return this.messages.shift(); 331 | } 332 | this.removeMessageFromStore(messages); 333 | } 334 | 335 | /** 336 | * Audits the eventListeners object parameter with validations, and a prefillMissingEvents step 337 | * This ensures that the eventListeners object is the right format for binding events to WebSocket clients 338 | * @param {object} eventListeners - The eventListeners object parameter 339 | * @returns {object} The eventListeners object parameter, with any missing events prefilled in 340 | */ 341 | auditEventListeners( 342 | eventListeners: PartialEventListenersInterface | undefined, 343 | ) { 344 | return { 345 | open: eventListeners?.open || [], 346 | message: eventListeners?.message || [], 347 | error: eventListeners?.error || [], 348 | close: eventListeners?.close || [], 349 | }; 350 | } 351 | 352 | /** 353 | * Connects the WebSocket client, and attaches event listeners 354 | */ 355 | connect() { 356 | // If we aren't already connecting, we are now 357 | if (this.state.kind !== "connecting") { 358 | this.state = { kind: "connecting", failedConnectionAttempts: 0 }; 359 | } 360 | this.ws = new WebSocket(this.url, this.protocols); 361 | this.setBinaryType(); 362 | this.attachEventListeners(); 363 | if (this.messages.length > 0) this.process(); 364 | } 365 | 366 | /** 367 | * Reconnects the WebSocket client based on the retryConnectionDelay and 368 | * ExponentialBackoffParam setting. 369 | */ 370 | reconnect() { 371 | const { retryConnectionDelay, exponentialBackoff } = this; 372 | // If we are already in a "connecting" state, we need to refer to the 373 | // current amount of connection attemps to correctly calculate the 374 | // exponential delay -- if exponential backoff is enabled. 375 | const failedConnectionAttempts = 376 | this.state.kind === "connecting" 377 | ? this.state.failedConnectionAttempts 378 | : 0; 379 | 380 | // If no exponential backoff is enabled, retryConnectionDelay will 381 | // be scaled by a factor of 1 and it will stay the original value. 382 | const delay = exponentialBackoff 383 | ? calculateRetryDelayFactor( 384 | exponentialBackoff, 385 | retryConnectionDelay, 386 | failedConnectionAttempts, 387 | ) 388 | : retryConnectionDelay; 389 | 390 | setTimeout(this.connect, delay); 391 | } 392 | 393 | /** 394 | * Disconnects the WebSocket client from the server, and changes the 395 | * reconnectAutomatically flag to disable automatic reconnection, unless the 396 | * developer passes a boolean flag to not do that. 397 | * @param {boolean} overrideDisableReconnect 398 | */ 399 | disconnect(overrideDisableReconnect?: boolean) { 400 | this.state = { kind: "disconnected" }; 401 | // We do this to prevent automatic reconnections; 402 | if (!overrideDisableReconnect) { 403 | this.reconnectAutomatically = false; 404 | } 405 | this.ws?.close(); 406 | } 407 | 408 | /** 409 | * Adds a function to trigger on the occurrence of an event with the specified event name 410 | * @param {string} eventName - The name of the event in the eventListeners object 411 | * @param {function} eventFunc - The function to trigger when the event occurs 412 | */ 413 | on(eventName: string, eventFunc: GenericFunction) { 414 | const eventFunctions = this.eventListeners[eventName]; 415 | if (eventFunctions && eventFunctions.indexOf(eventFunc) !== -1) { 416 | throw new Error( 417 | `${eventFunc.name} has already been added to this event Listener`, 418 | ); 419 | } 420 | if (eventFunctions && Array.isArray(eventFunctions)) { 421 | this.eventListeners[eventName].push(eventFunc); 422 | } 423 | } 424 | 425 | /** 426 | * Finds a function in a eventListener's event list, by functon or by function name 427 | * @param {string} eventName - The name of the event in the eventListeners object 428 | * @param {function|string} eventFuncOrName - Either the function to remove, or the name of the function to remove 429 | * @returns {function|undefined} The existing function, or nothing 430 | */ 431 | findFunction(eventName: string, eventFuncOrName: string | GenericFunction) { 432 | if (typeof eventFuncOrName === "string") { 433 | const byName = (f: GenericFunction) => f.name === eventFuncOrName; 434 | return this.eventListeners[eventName].find(byName); 435 | } 436 | if (this.eventListeners[eventName].indexOf(eventFuncOrName) !== -1) { 437 | return eventFuncOrName; 438 | } 439 | } 440 | 441 | /** 442 | * Raises an error if the existing function is not present, and if the client is configured to throw an error 443 | * @param {function|undefined} existingFunc 444 | * @param {object} opts - An optional object to pass that contains extra configuration options 445 | * @param {boolean} opts.doNotThrowError - A boolean flag that indicates whether to not throw an error if the function to remove is not found in the list 446 | */ 447 | raiseErrorIfFunctionIsMissing( 448 | existingFunc: GenericFunction | undefined, 449 | opts?: 450 | | { 451 | doNotThrowError: boolean; 452 | } 453 | | undefined, 454 | ) { 455 | if (!existingFunc) { 456 | if (!opts?.doNotThrowError) { 457 | throw new Error("Function does not exist in eventListener list"); 458 | } 459 | } 460 | } 461 | 462 | /** 463 | * Removes a function from an eventListener events list for that event 464 | * @param {string} eventName - The name of the event in the eventListeners object 465 | * @param {function|string} eventFuncOrName - Either the function to remove, or the name of the function to remove 466 | * @param {object} opts - An optional object to pass that contains extra configuration options 467 | * @param {boolean} opts.doNotThrowError - A boolean flag that indicates whether to not throw an error if the function to remove is not found in the list 468 | */ 469 | off( 470 | eventName: string, 471 | eventFuncOrName: GenericFunction | string, 472 | opts?: { doNotThrowError: boolean } | undefined, 473 | ) { 474 | const existingFunc = this.findFunction(eventName, eventFuncOrName); 475 | if (existingFunc) { 476 | const index = this.eventListeners[eventName].indexOf(existingFunc); 477 | this.eventListeners[eventName].splice(index, 1); 478 | } else { 479 | this.raiseErrorIfFunctionIsMissing(existingFunc, opts); 480 | } 481 | } 482 | 483 | /** 484 | * Puts data on a message queue, and then processes the message queue to get the data sent to the WebSocket server 485 | * @param {*} data - The data payload to put the on message queue 486 | */ 487 | send(data: string | ArrayBufferLike | Blob | ArrayBufferView) { 488 | const callProcessAfterwards = this.messages.length === 0; 489 | this.addMessage(data); 490 | if (callProcessAfterwards) this.process(); 491 | } 492 | 493 | /** 494 | * Sends a message over the WebSocket, removes the message from the queue, 495 | * and calls proces again if there is another message to process. 496 | * @param {string | ArrayBuffer | Uint8Array} data - The data payload to send over the WebSocket 497 | */ 498 | processMessage(data: string | ArrayBufferLike | Blob | ArrayBufferView) { 499 | // If the message is a base64-wrapped object (from legacy or manual insert), decode it 500 | this.ws?.send(data); 501 | this.removeMessage(); 502 | if (this.messages.length > 0) this.process(); 503 | } 504 | 505 | /** 506 | * Processes messages that are on the message queue. Handles looping through the list, as well as retrying message 507 | * dispatch if the WebSocket connection is not open. 508 | */ 509 | process() { 510 | const { messages } = this; 511 | const data = messages[0] as 512 | | string 513 | | ArrayBufferLike 514 | | Blob 515 | | ArrayBufferView; 516 | if (!data && messages.length === 0) return; 517 | if (this.ws && this.ws.readyState === 1) { 518 | this.processMessage(data); 519 | } else { 520 | setTimeout(this.process, this.retryProcessTimePeriod); 521 | } 522 | } 523 | 524 | /** 525 | * Attaches the event listeners to the WebSocket instance. 526 | * Also attaches an additional eventListener on the 'close' event to trigger a reconnection 527 | * of the WebSocket, unless configured not to. 528 | */ 529 | attachEventListeners() { 530 | this.ws.onopen = (e: Event) => { 531 | for (const f of this.eventListeners.open) { 532 | f(e); 533 | } 534 | this.state = { kind: "connected" }; 535 | }; 536 | this.ws.onmessage = (e: MessageEvent) => { 537 | for (const f of this.eventListeners.message) { 538 | f(e); 539 | } 540 | }; 541 | this.ws.onerror = (e: Event) => { 542 | for (const f of this.eventListeners.error) { 543 | f(e); 544 | } 545 | }; 546 | this.ws.onclose = (e: CloseEvent) => { 547 | for (const f of this.eventListeners.close) { 548 | f(e); 549 | } 550 | if (this.reconnectAutomatically) { 551 | // If we have previously been "connecting", we carry over the amount 552 | // of failed connection attempts and add 1, since the current 553 | // connection attempt failed. We stay "connecting" instead of 554 | // "closed", since we've never been fully "connected" in the first 555 | // place. 556 | if (this.state.kind === "connecting") { 557 | this.state = { 558 | kind: "connecting", 559 | failedConnectionAttempts: this.state.failedConnectionAttempts + 1, 560 | }; 561 | } else { 562 | // If we were in a different state, we assume that our connection 563 | // freshly closed and have not made any failed connection attempts. 564 | this.state = { kind: "closed" }; 565 | } 566 | this.removeEventListeners(); 567 | this.reconnect(); 568 | } 569 | }; 570 | } 571 | 572 | /** 573 | * Removes the event listeners from a closed WebSocket instance, so that 574 | * they are cleaned up 575 | */ 576 | removeEventListeners() { 577 | this.ws.onopen = null; 578 | this.ws.onclose = null; 579 | this.ws.onmessage = null; 580 | this.ws.onerror = null; 581 | } 582 | 583 | /** 584 | * Sets the binary type for the WebSocket, if such an option is set 585 | */ 586 | setBinaryType() { 587 | const { binaryType } = this; 588 | if (binaryType && this.ws) this.ws.binaryType = binaryType; 589 | } 590 | } 591 | --------------------------------------------------------------------------------