├── .gitignore ├── LICENSE ├── README.md ├── apps ├── docs │ ├── .eslintrc.json │ ├── .gitignore │ ├── README.md │ ├── next.config.js │ ├── package-lock.json │ ├── package.json │ ├── pages │ │ ├── _app.tsx │ │ ├── _meta.json │ │ ├── index.mdx │ │ ├── socket.mdx │ │ └── socket │ │ │ ├── _meta.json │ │ │ ├── quickstart.mdx │ │ │ └── react.mdx │ ├── postcss.config.mjs │ ├── public │ │ ├── favicon.ico │ │ ├── next.svg │ │ └── vercel.svg │ ├── tailwind.config.ts │ ├── theme.config.jsx │ └── tsconfig.json └── site │ ├── .eslintrc.json │ ├── .gitignore │ ├── README.md │ ├── next.config.mjs │ ├── package-lock.json │ ├── package.json │ ├── postcss.config.mjs │ ├── public │ ├── favicon.ico │ └── logo-white.png │ ├── src │ ├── pages │ │ ├── _app.tsx │ │ ├── _document.tsx │ │ └── index.tsx │ └── styles │ │ └── globals.css │ ├── tailwind.config.ts │ └── tsconfig.json ├── examples └── nextjs-connection-status │ ├── .eslintrc.json │ ├── .gitignore │ ├── README.md │ ├── next.config.mjs │ ├── package-lock.json │ ├── package.json │ ├── postcss.config.mjs │ ├── public │ ├── favicon.ico │ ├── next.svg │ └── vercel.svg │ ├── src │ ├── components │ │ ├── ConnectionStatus.module.css │ │ ├── ConnectionStatus.tsx │ │ └── LostConnectionStatus.tsx │ ├── pages │ │ ├── _app.tsx │ │ ├── _document.tsx │ │ ├── api │ │ │ └── hello.ts │ │ └── index.tsx │ ├── resocket.config.ts │ └── styles │ │ └── globals.css │ ├── tailwind.config.ts │ └── tsconfig.json ├── package-lock.json ├── package.json ├── packages └── resocket-socket │ ├── package.json │ ├── src │ ├── __test__ │ │ ├── _setup.ts │ │ ├── event-target.test.ts │ │ └── resocket.test.ts │ ├── errors.ts │ ├── event-target.ts │ ├── events.ts │ ├── index.ts │ ├── react.tsx │ ├── resocket.ts │ └── uitls.ts │ ├── tsconfig.json │ └── tsup.config.ts └── tsconfig.base.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .resocket 4 | .env 5 | playground/ 6 | TODO -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 dev-badace (Github Handle of the author) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SO 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Socket: A Better Reconnecting WebSocket 2 | 3 | Socket logo 4 | 5 | **Socket** is an open-source, developer-friendly reconnecting WebSocket library for JavaScript, designed to enhance your development experience (DX). 6 | 7 | ### Key Features: 8 | 9 | - **Authentication** and **Dynamic URL** support 10 | - Built-in APIs for **Connection Status** and **Lost Connection** handling 11 | - Easy configuration for **Heartbeats** (PING/PONG) 12 | - Listens to **Network** and **Focus** events (configurable) 13 | - Flexible **Stop Retry** options for managing reconnections 14 | - Customizable **Reconnection Delays** 15 | - **Buffering** support during offline periods 16 | 17 | ### Documentation 18 | 19 | You can find the documentation for Socket [here](https://docs.resocket.io/socket). 20 | 21 | ### Motivation 22 | 23 | Hi, I’m [Ace](https://github.com/dev-badace), currently working on a next-gen multiplayer framework for real-time and collaborative apps. My mission is to greatly improve the developer experience (DevEx) for creating multiplayer and real-time applications, and this is my first release! 24 | 25 | Reconnecting WebSockets are challenging—getting them right is even harder. Adding features like heartbeats and other complexities makes it even more error-prone. Throughout my career, I've seen countless reconnecting WebSocket implementations that _work_, but often have edge case bugs and race conditions. Even the best ones aren't immune (like the Liveblocks WebSocket bug I discovered and helped fix: [issue](https://github.com/liveblocks/liveblocks/issues/1459) & [PR](https://github.com/liveblocks/liveblocks/pull/1463)). 26 | 27 | With this project, I aim to provide the community with a DevEx-focused reconnecting WebSocket library that simplifies everything. And if bugs or edge cases are found, they’ll be fixed—for everyone. 28 | 29 | If you’re interested in trying out a new framework, join our [Discord](https://discord.gg/FQb86Sqxhd) for early access. I’m also looking for early adopters. To set expectations, it’ll likely be open-source but behind a paid license for commercial usage (maybe ^^). 30 | 31 | ### Stay Connected 32 | 33 | If you find Socket useful, please consider starring this repository on GitHub! Your support helps others discover the project. 34 | 35 | You can also follow me on Twitter [@\_shibru\_](https://x.com/_shibru_) for updates and feel free to share this project with your network. Let’s make real-time web development smoother together! 36 | 37 | ### Acknowledgements 38 | 39 | This project takes inspiration from some of my past works [reconnecting websocket (counter machine pattern)](https://github.com/dev-badace/party-socket-test) & [partyworks's implementation](https://github.com/Partywork/partyworks/tree/master/packages/partyworks-socket). and the APIs are also hugely inspired by [liveblocks](https://liveblocks.io) (it's a team whose work I respect and admire alot). also thankx to [@threepointone](https://x.com/threepointone) for sponsoring this in the past. 40 | 41 | Resocket ~ Ace 42 | -------------------------------------------------------------------------------- /apps/docs/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /apps/docs/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /apps/docs/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. 20 | 21 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. 22 | 23 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 24 | 25 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 26 | 27 | ## Learn More 28 | 29 | To learn more about Next.js, take a look at the following resources: 30 | 31 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 32 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 33 | 34 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 35 | 36 | ## Deploy on Vercel 37 | 38 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 39 | 40 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 41 | -------------------------------------------------------------------------------- /apps/docs/next.config.js: -------------------------------------------------------------------------------- 1 | const withNextra = require("nextra")({ 2 | theme: "nextra-theme-docs", 3 | themeConfig: "./theme.config.jsx", 4 | latex: true, 5 | flexsearch: { 6 | codeblocks: false, 7 | }, 8 | defaultShowCopyCode: true, 9 | }); 10 | 11 | module.exports = withNextra({ 12 | output: "export", 13 | images: { 14 | unoptimized: true, 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /apps/docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "next": "14.2.5", 13 | "nextra": "^2.13.4", 14 | "nextra-theme-docs": "^2.13.4", 15 | "react": "^18", 16 | "react-dom": "^18" 17 | }, 18 | "devDependencies": { 19 | "@types/node": "^20", 20 | "@types/react": "^18", 21 | "@types/react-dom": "^18", 22 | "eslint": "^8", 23 | "eslint-config-next": "14.2.5", 24 | "postcss": "^8", 25 | "tailwindcss": "^3.4.1", 26 | "typescript": "^5" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /apps/docs/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import type { AppProps } from "next/app"; 2 | import Head from "next/head"; 3 | 4 | export default function App({ Component, pageProps }: AppProps) { 5 | return ( 6 | <> 7 | 8 | 9 | 10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /apps/docs/pages/_meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "index": { 3 | "title": "Resocket", 4 | "theme": { 5 | "typesetting": "article", 6 | "breadcrumb": false 7 | } 8 | }, 9 | "socket": { 10 | "title": "Socket", 11 | "theme": { 12 | "typesetting": "article", 13 | "breadcrumb": false 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /apps/docs/pages/index.mdx: -------------------------------------------------------------------------------- 1 | ## 🎉 Resocket 2 | 3 | At Resocket, we're creating devtools for real-time and multiplayer applications. We're actively looking for early adopters and design partners to help shape the future of our tools. 4 | 5 | We’ve just released our first open-source package: 6 | 7 | [Socket](https://github.com/resocket/socket) — a better reconnecting WebSocket library for everyone, focused on enhancing developer experience. 8 | 9 | Join our [Discord](https://discord.gg/FQb86Sqxhd) server for early updates, getting early access, and to connect with the Resocket community. 10 | 11 | We’re also open to business collaborations. If you're interested, reach out to us at shibru127@gmail.com. 12 | -------------------------------------------------------------------------------- /apps/docs/pages/socket.mdx: -------------------------------------------------------------------------------- 1 | ## Socket 2 | 3 | **Socket** is an open-source, developer-friendly reconnecting WebSocket library for JavaScript, designed to enhance your development experience (DX). 4 | 5 | ### Key Features: 6 | 7 | - **Authentication** and **Dynamic URL** support 8 | - Built-in APIs for **Connection Status** and **Lost Connection** handling 9 | - Easy configuration for **Heartbeats** (PING/PONG) 10 | - Listens to **Network** and **Focus** events (configurable) 11 | - Flexible **Stop Retry** options for managing reconnections 12 | - Customizable **Reconnection Delays** 13 | - **Buffering** support during offline periods 14 | With `Socket`, you can enhance the **reliability** and **efficiency** of your websocket-based applications. 15 | 16 | Follow our [Quickstart Guide](/socket/quickstart/) to get started. 17 | -------------------------------------------------------------------------------- /apps/docs/pages/socket/_meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "quickstart": { 3 | "title": "Get Started", 4 | "theme": { 5 | "typesetting": "article", 6 | "breadcrumb": false 7 | } 8 | }, 9 | "react": { 10 | "title": "React", 11 | "theme": { 12 | "typesetting": "article", 13 | "breadcrumb": false 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /apps/docs/pages/socket/quickstart.mdx: -------------------------------------------------------------------------------- 1 | import { Tabs } from "nextra/components"; 2 | import { Callout } from "nextra/components"; 3 | 4 | ### Quickstart 5 | 6 | In this guide, we will set up a reconnecting WebSocket with heartbeats (ping/pong). We will also explore its configurations 7 | 8 | 9 | **React Quickstart** - if you want to use socket in a react app. you can take 10 | a look at the [React Quickstart](/socket/react) 11 | 12 | 13 | #### Pre requisite 14 | 15 | you should have a local or remote websocket server that you can connect to. 16 | 17 | #### Installation 18 | 19 | 20 | 21 | ```bash 22 | npm install @resocket/socket 23 | ``` 24 | 25 | 26 | 27 | ```bash 28 | pnpm install @resocket/socket 29 | ``` 30 | 31 | 32 | 33 | ```bash 34 | yarn add @resocket/socket 35 | ``` 36 | 37 | 38 | 39 | 40 | #### Setup 41 | 42 | Socket is mostly compatible with the standard WebSocket API. so you can just use it how you'd use a normal websocket 43 | 44 | ```ts 45 | import { Socket } from "@resocket/socket"; 46 | 47 | // WebSocket server endpoint 48 | const ServerUrl = "ws://localhost:8000"; 49 | 50 | /** 51 | * create a new Reconnecting Websocket 52 | * it will automatically reconnect if the connection drops 53 | */ 54 | const socket = new Socket(ServerUrl); 55 | 56 | // Listen for messages from the WebSocket server 57 | socket.addEventListener("message", (e) => { 58 | console.log(e.data); // Logs the WebSocket message 59 | }); 60 | 61 | /** 62 | * subscribe to the connection status changes 63 | * this is useful if you want to add connection status indicators 64 | */ 65 | socket.addEventListener("status", (status) => { 66 | console.log(status); // Possible values: 'connecting', 'reconnecting', 'connected', 'closed', 'disconnected' 67 | }); 68 | ``` 69 | 70 | --- 71 | 72 | #### HeartBeat (Ping/PONG) 73 | 74 | You can easily add heartbeats to your connection with a simple configuration. The example below configures a heartbeat every 30 seconds, ensuring the connection remains active and responsive. 75 | 76 | ```ts 77 | import { Socket, type SocketConfig } from "@resocket/socket"; 78 | 79 | // WebSocket server endpoint 80 | const ServerUrl = "ws://localhost:8000"; 81 | 82 | // WebSocket protocols (optional, use an empty array if not needed) 83 | const SocketProtocols: string[] = []; 84 | 85 | // Socket configuration 86 | const config: SocketConfig = { 87 | /** 88 | * add heartbeats to your connection 89 | * this will send a heartbeat (ping/pong) every 30 seconds 90 | */ 91 | heartbeat: 30000, // 30 seconds 92 | }; 93 | 94 | const socket = new Socket(ServerUrl, SocketProtocols, config); 95 | ``` 96 | 97 | ### Authentication 98 | 99 | Here's one of a few ways you can add authentication to your Websocket connection using the `params` option. 100 | 101 | `params` takes a function that should return an object or a Promise that resolves to an object. this object then is added to the query param of the websocket connection url. 102 | you can use `params` as follows. 103 | 104 | ```ts 105 | import { Socket } from "@resocket/socket"; 106 | 107 | const socket = new Socket("ws://localhost:9000/", [], { 108 | /** 109 | * Whatever you return from this function will be added as an queryParam to the connection url 110 | * 111 | * in our case we return the token, 112 | * ws://localhost:9000/?token=${TOKEN_VALUE_THAT_WAS_RETURNED_FROM_THIS_FUNCTION} 113 | */ 114 | params: async () => { 115 | const token = await MyAuthProvider.getToken(); 116 | 117 | return { 118 | token, 119 | }; 120 | }, 121 | }); 122 | ``` 123 | 124 | ### Connection Timeouts 125 | 126 | you can also add custom connection timeouts for both your `param` function and for the connection itself. 127 | here's the following config 128 | 129 | ```ts 130 | import { Socket } from "@resocket/socket"; 131 | 132 | const socket = new Socket("ws://localhost:9000/", [], { 133 | /** 134 | * if your params function takes longer than 135 | * the paramsTimeout, then it'll automatically fail 136 | * and move to retry 137 | */ 138 | paramsTimeout: 10000, //10 seconds - default is 10 seconds 139 | 140 | /** 141 | * if your websocket connection takes longer than 142 | * the connectionTimeout, then it'll automatically fail 143 | * and move to retry 144 | * 145 | * note: connectionTimeout is started after the params function resolves and socket is starting it's connection 146 | */ 147 | connectionTimeout: 10000, //10 seconds - default is 10seconds 148 | }); 149 | ``` 150 | 151 | ### Closing Websocket Connection 152 | 153 | There are many ways to close the websocket connection both from the server and the client. in this section we will look the configurations and options related to closing the websocket connection. 154 | 155 | ```ts 156 | import { Socket } from "@resocket/socket"; 157 | 158 | const socket = new Socket("ws://localhost:9000/", [], { 159 | /** 160 | * Your socket will not reconnect. if the server closes the websocket connection 161 | * with any of the closeCodes that you provide to the config. 162 | * 163 | * server side - you can call something like socket.close(4000) <- this is example server code 164 | */ 165 | closeCodes: [4000], //example value: [4001, 4002] - defaults to [] 166 | 167 | /** 168 | * Your socket will move to a failed state (disconnected state) 169 | * after 10 consecutive failed attempts to connect 170 | */ 171 | maxRetries: 10, //default is infinity 172 | }); 173 | 174 | /** 175 | * Calling socket.close() will also stop your websocket connection 176 | * the connnection will not try to reconnect if you call close. 177 | * 178 | * after calling .close() the connection will only reconnect when you explicitly call socket.reconnect(); 179 | */ 180 | socket.close(); 181 | ``` 182 | 183 | #### StopRetry 184 | 185 | Sometimes you may also want to stop the retry from the `param` function. 186 | for example if the user doesn't have the correct permissions or some other business logic 187 | you can do this by throwing a `StopRetry` error. 188 | 189 | ```ts 190 | import { Socket, StopRetry } from "@resocket/socket"; 191 | 192 | const socket = new Socket("ws://localhost:9000/", [], { 193 | params: async () => { 194 | const hasPermissions = await MyAuthProvider.hasPermissions(); 195 | 196 | /** 197 | * We're throwing a StopRetry error here 198 | * this will automatically tell Socket to not reconnect 199 | * and we will move on to failed/disconnected state 200 | * 201 | */ 202 | if (!hasPermissions) throw new StopRetry("You're not authorized"); 203 | 204 | //retrurn whatever auth data or any other data you want here 205 | return {}; 206 | }, 207 | }); 208 | 209 | /** 210 | * You can catch the stop retry error on disconnect here 211 | * it'll be either CloseEvent, ErrorEvent, StopRetry or undefined 212 | */ 213 | socket.addEventListener("disconnect", (e) => { 214 | if (e instanceof StopRetry) toast(e.message); 215 | }); 216 | ``` 217 | 218 | ### Customize Reconnection Delays 219 | 220 | there are many ways to customize your websocket reconnection and retries. in this section we will look at the configuration related to reconnection and retries. 221 | 222 | ```ts 223 | import { Socket } from "@resocket/socket"; 224 | 225 | const socket = new Socket("ws://localhost:9000/", [], { 226 | /** 227 | * you can set the reconnection delay grow factor config option 228 | * this let's you control how fast the reconnection delays grow 229 | * 230 | * delay = minReconnectionDelay * Math.pow(reconnectionDelayGrowFactor, retryCount) 231 | */ 232 | reconnectionDelayGrowFactor: 1.3, //default value - 1.3 233 | 234 | /** 235 | * your socket will have atleast the minReconnectionDelay 236 | * before trying to reconnect 237 | */ 238 | minReconnectionDelay: 2000, // Default - (1000 + Math.random() * 4000) 239 | 240 | /** 241 | * your socket will at max wait for maxReconectionDelay 242 | * befoer trying to reconnect 243 | */ 244 | maxReconnectionDelay: 4000, // Default - 10000 (10 seconds) 245 | }); 246 | ``` 247 | 248 | #### getDelay() 249 | 250 | sometimes you may want to add custom delays based on your application logic. you can do this with `getDelay` option. 251 | 252 | 253 | using `getDelay` will override the following config methods. 254 | `minReconnectionDelay`, `maxReconectionDelay`, `reconnectionDelayGrowFactor` 255 | 256 | 257 | ```ts 258 | import { Socket } from "@resocket/socket"; 259 | 260 | const socket = new Socket("ws://localhost:9000/", [], { 261 | getDelay: () => { 262 | //you can return your custom delay here 263 | return Math.random() * Math.random(); 264 | }, 265 | }); 266 | ``` 267 | 268 | ### Customize Heartbeat 269 | 270 | in this section we will take a look at all the config options related to heartbeats. 271 | 272 | ```ts 273 | import { Socket } from "@resocket/socket"; 274 | 275 | const socket = new Socket("ws://localhost:9000/", [], { 276 | /** 277 | * sends a heartbeat on focus events. 278 | * will ignore focus events if set to true 279 | * 280 | * note: only applicable if heartbeatInterval is set 281 | */ 282 | ignoreFocusEvents: true, //default false 283 | 284 | /** 285 | * sends a heartbeat on network offline events. 286 | * will ignore network events if set to true 287 | * 288 | * note: only applicable if heartbeatInterval is set 289 | */ 290 | ignoreFocusEvents: true, //default false 291 | 292 | /** 293 | * maximum number of consecutive missed ping messages 294 | * before the connection is moved to reconnect 295 | * 296 | * note: only applicable if heartbeatInterval is set 297 | */ 298 | maxMissedPings: 2, //default 1 299 | 300 | /** 301 | * the amount of time to wait for server to respond to the ping message 302 | * 303 | * note: only applicable if heartbeatInterval is set 304 | */ 305 | pingTimeout: 2000, //default 3000 (3 seconds) 306 | 307 | /** 308 | * the ping message to send to the server 309 | * 310 | * note: only applicable if heartbeatInterval is set 311 | */ 312 | pingMessage: "PING", //default "ping" 313 | 314 | /** 315 | * the pong message to recieve from the server 316 | * 317 | * note: only applicable if heartbeatInterval is set 318 | */ 319 | pingMessage: "PING", //default "pong" 320 | }); 321 | ``` 322 | 323 | ### Buffering 324 | 325 | Socket has default support for buffering. and exposes options to make it easier to add custom buffering for your application. 326 | 327 | config 1 328 | 329 | ```ts 330 | import { Socket } from "@resocket/socket"; 331 | 332 | const socket = new Socket("ws://localhost:9000/", [], { 333 | /** 334 | * this buffers the message if the connection is dropped 335 | * and sends all the messages upon reconnection 336 | */ 337 | buffering: true, 338 | }); 339 | ``` 340 | 341 | config2 342 | 343 | ```ts 344 | import { Socket } from "@resocket/socket"; 345 | 346 | const socket = new Socket("ws://localhost:9000/", [], { 347 | /** 348 | * this buffers the message if the connection is dropped 349 | * and sends all the messages upon reconnection 350 | * 351 | * this will drop the message from buffering if the messages increase the 352 | * maxEnqueuedMessages threshold 353 | */ 354 | buffering: { maxEnqueuedMessages: 100 }, 355 | }); 356 | ``` 357 | 358 | custom buffering 359 | 360 | ```ts 361 | import { Socket } from "@resocket/socket"; 362 | 363 | let buffer = []; 364 | 365 | const socket = new Socket("ws://localhost:9000/"); 366 | 367 | /** 368 | * send buffered message on connect 369 | */ 370 | socket.addEventListener("open", () => { 371 | buffer.map((message) => { 372 | socket.send(message); 373 | }); 374 | 375 | buffer = []; 376 | }); 377 | 378 | /** 379 | * buffer the message if socket is not ready 380 | */ 381 | if (socket.canSend()) { 382 | socket.send("message"); 383 | } else { 384 | buffer.push("message"); 385 | } 386 | ``` 387 | 388 | ### LostConnection Toast 389 | 390 | this option allows you to handle situations where the connection is lost and does not reconnect within a specified time frame. 391 | 392 | You can use this hook to notify users of connection issues, such as through toast notifications. This hook is triggered with the following events: 393 | 394 | - `"lost"`: When the connection is lost and reconnection attempts are ongoing. 395 | - `"restored"`: When the connection is successfully reestablished. 396 | - `"failed"`: When the connection cannot be restored (rare). 397 | 398 | You can configure the timeout for the lostConnectionTimeout option (default is 5 seconds): 399 | 400 | ```ts 401 | import { Socket } from "@resocket/socket"; 402 | import { toast } from "some-toast-library"; //add your toast library here 403 | 404 | const socket = new Socket("ws://localhost:9000/", [], { 405 | /** 406 | * sets the lost connection timeout 407 | */ 408 | lostConnectionTimeout: 10000, //defaul to 5 seconds 409 | }); 410 | 411 | socket.addEventListener("lostConnection", (event) => { 412 | switch (event) { 413 | case "lost": 414 | toast.warn("Still trying to reconnect..."); 415 | break; 416 | 417 | case "restored": 418 | toast.success("Successfully reconnected again!"); 419 | break; 420 | 421 | case "failed": 422 | toast.error("Could not restore the connection"); 423 | break; 424 | } 425 | }); 426 | ``` 427 | 428 | ### API 429 | 430 | #### Options 431 | 432 | ```ts 433 | type SocketOptions = { 434 | polyfills?: { WebSocket: any }; // Add a custom polyfill for Websockt 435 | 436 | //retries related, 437 | maxReconnectionDelay?: number; //maximum reconnection delay 438 | minReconnectionDelay?: number; //minimum reconnection delay 439 | reconnectionDelayGrowFactor?: number; //how fast the reconnection delay grows 440 | 441 | //a custom delay that will override the above config if provide this argument, useful for more customized delays 442 | getDelay?: (retryCount: number) => number; 443 | maxRetries?: number; //maximum number of consecutive failed attempts before moving to disconnected state 444 | 445 | //connection related 446 | connectionTimeout?: number; // retry if not connected after this time, in ms 447 | paramsTimeout?: number; // retry if params function is not resolved within this time, in ms 448 | 449 | //application related 450 | startClosed?: boolean; // start the socket in 'closed' state. and call .reconnect() to connect 451 | lostConnectionTimeout?: number; // timeout for lostconnection event 452 | closeCodes?: number | number[]; //close the connection if server closed with these closeCodes 453 | 454 | buffer?: boolean | { maxEnqueuedMessages: number }; //buffering related. see the buffering section for more details 455 | 456 | // heartbeat related 457 | heartbeatInterval?: number; //the interval at which we send the heartbeat 458 | maxMissedPingss?: number; //max number of missed consecutive ping messages before moving to reconnect 459 | ignoreFocusEvents?: boolean; //will not send heartbeats on focus event. if set to true 460 | ignoreNetworkEvents?: boolean; //will not send heartbeats on network offline event. if set to true 461 | pingTimeout?: number; //timeout to wait for the pong message after sending the ping 462 | pingMessage?: string; //the ping message to send to the server 463 | pongMessage?: string; //the pong message to recieve from the server 464 | 465 | //debug 466 | debug?: boolean; //set to true. to see the internal logs for debugging 467 | debugLogger?: (...args: any[]) => void; //provide a custom logger 468 | 469 | //custom function for dynamic url 470 | url?: (info: { 471 | retryInfo: RetryInfo; 472 | url: string | URL; 473 | params: any; 474 | }) => string; 475 | 476 | //function for adding auth or other data 477 | params?: (info: RetryInfo) => Promise; 478 | 479 | //do not use this~ 480 | //todo maybe mark this as experimental? - or hide behind advanced config :/ 481 | unstable_connectionResolver?: ( 482 | con: WebSocket, 483 | resolver: () => void, 484 | rejecter: (err?: any) => void 485 | ) => Promise | void; 486 | }; 487 | ``` 488 | 489 | #### Methods 490 | 491 | ```ts 492 | constructor(url: string, protocols: string[] | undefined, options: SocketOptions) 493 | 494 | close() 495 | reconnect() 496 | 497 | getStatus(): Status 498 | send(data: string | ArrayBuffer | Blob | ArrayBufferView) 499 | 500 | addEventListener(type: 'open' | 'close' | 'status' | 'message' | 'error' | 'disconnect', listener: Listener) 501 | removeEventListener(type: 'open' | 'close' | 'status' | 'message' | 'error' | 'disconnect', listener: Listener) 502 | ``` 503 | 504 | #### Attributes 505 | 506 | ```ts 507 | binaryType: BinaryType; 508 | bufferdAmount: number; 509 | extensions: string; 510 | onclose: EventListener; 511 | onerror: EventListener; 512 | onmessage: EventListener; 513 | onopen: EventListener; 514 | protocol: string; 515 | readyState: number; 516 | url: string; 517 | retryCount: number; 518 | lastMessageSent: number; 519 | ``` 520 | -------------------------------------------------------------------------------- /apps/docs/pages/socket/react.mdx: -------------------------------------------------------------------------------- 1 | import { Tabs } from "nextra/components"; 2 | import { Callout } from "nextra/components"; 3 | 4 | ### React 5 | 6 | Socket has first class support for react. In this guide we will be looking at how to use `@resocket/socket` with react. 7 | 8 | #### Pre requisite 9 | 10 | you should have a local or remote websocket server that you can connect to. 11 | 12 | 13 | Our React Support is currently in beta, if you find any issues please report 14 | we'll fix them asap!. 15 | 16 | 17 | #### Installation 18 | 19 | 20 | 21 | ```bash 22 | npm install @resocket/socket 23 | ``` 24 | 25 | 26 | 27 | ```bash 28 | pnpm install @resocket/socket 29 | ``` 30 | 31 | 32 | ```bash 33 | yarn add @resocket/socket 34 | ``` 35 | 36 | 37 | 38 | #### Setup 39 | 40 | 41 | You can also checkout our connection status example 42 | [here](https://github.com/resocket/Socket/tree/master/examples/nextjs-connection-status) 43 | 44 | 45 | Socket provides a convenient factory function that generates custom React hooks. These hooks manage the WebSocket connection and make it accessible across your component tree. 46 | 47 | The setup involves two key steps: 48 | 49 | 1. **Creating the Context**: Use `createSocketContext` to generate a context provider and a custom hook. 50 | 2. **Using the Provider**: Wrap your components with the SocketProvider to grant access to the WebSocket instance within any component using the useSocket hook. 51 | 52 | Below is an example of how to set up and use these tools in your React app: 53 | 54 | ```tsx 55 | import { createSocketContext } from "@resocket/socket/react"; 56 | 57 | /** 58 | * Creates a React context with hooks to manage your WebSocket connection. 59 | * 60 | * - ``: A context provider component that wraps your app or specific components. 61 | * - `useSocket()`: A hook to access your WebSocket instance. 62 | */ 63 | const { SocketProvider, useSocket } = createSocketContext({ 64 | url: "ws://localhost:9000/", 65 | }); 66 | 67 | const Main = () => { 68 | /** 69 | * Wrap your components with to make the WebSocket instance 70 | * available throughout the component tree. Inside any component within 71 | * the provider, use the `useSocket` hook to access the WebSocket instance. 72 | */ 73 | return ( 74 | 75 | 76 | 77 | ); 78 | }; 79 | ``` 80 | 81 | #### useSocket 82 | 83 | The **`useSocket`** hook allows you to access the WebSocket instance directly within your React components. This hook gives you full control over the WebSocket connection, enabling you to send messages, close the connection, and listen for events—just as you would with a regular WebSocket object. 84 | 85 | Below is an example demonstrating how to use the useSocket hook: 86 | 87 | ```tsx socket.tsx 88 | import { createSocketContext } from '@resocket/socket/react 89 | 90 | const { SocketProvider, useSocket } = createSocketContext({ 91 | url: "ws://localhost:9000/", 92 | }); 93 | 94 | 95 | const Toggle = () => { 96 | /** 97 | * Retrieve the WebSocket instance using the `useSocket` hook. 98 | * 99 | * Once you have the instance, you can interact with the WebSocket 100 | * just like you would with a standard WebSocket object. 101 | * 102 | * Example usage: 103 | * socket.send("test"); 104 | * socket.close(); 105 | * socket.addEventListener("message", (e) => { 106 | * console.log(e); 107 | * }); 108 | */ 109 | const socket = useSocket(); 110 | 111 | return ( 112 | 121 | ); 122 | }; 123 | 124 | 125 | ``` 126 | 127 | #### useStatus 128 | 129 | The useStatus hook provides real-time updates on the current status of your WebSocket connection. This hook can return one of the following states, each indicating a specific phase in the WebSocket lifecycle: 130 | 131 | - `"connecting"`: The WebSocket is in the process of connecting to the server. 132 | - `"connected"`: The WebSocket has successfully established a connection. 133 | - `"reconnecting"`: The connection was lost, and it is currently attempting to re-establish a connection. 134 | - `"disconnected"`: The WebSocket connection is completely closed. 135 | 136 | These statuses are particularly useful for creating connection status indicators that update as the connection state changes. 137 | 138 | Here’s an example of how to use the useStatus hook in a React component: 139 | 140 | ```tsx socket.tsx 141 | import styles from "./ConnectionStatus.module.css"; //note this file can be found at https://github.com/resocket/socket/examples/nextjs-connection-status/src/ 142 | import { createSocketContext } from '@resocket/socket/react 143 | 144 | const { SocketProvider, useStatus } = createSocketContext({ 145 | url: "ws://localhost:9000/", 146 | }); 147 | 148 | 149 | export const ConnectionStatus = () => { 150 | 151 | /** 152 | * Retrieves the current connection status of the WebSocket. 153 | * The component will automatically re-render whenever the status changes, 154 | * ensuring the UI stays updated with the latest connection state. 155 | */ 156 | const status = useStatus(); 157 | 158 | return ( 159 |
160 |
161 |
162 |
{status}
163 |
164 |
165 | ); 166 | }; 167 | 168 | ``` 169 | 170 | Automatically unsubscribes when the component is unmounted. 171 | 172 | #### useMessage 173 | 174 | The `useMessage` hook allows you to easily listen for incoming WebSocket messages without needing to manually set up event listeners. This hook simplifies the process, automatically handling subscriptions and clean-up, so you can focus on handling the messages received from the server. 175 | 176 | Here’s an example of how to use the useMessage hook to listen for messages and update your component state: 177 | 178 | ```tsx socket.tsx 179 | import {useState} from "react"; 180 | import { createSocketContext } from '@resocket/socket/react 181 | 182 | const { SocketProvider, useMessage } = createSocketContext({ 183 | url: "ws://localhost:9000/", 184 | }); 185 | 186 | export const Messages = () => { 187 | const [chats, setChats] = useState([]); 188 | 189 | /** 190 | * Listens for incoming messages from the WebSocket connection. 191 | * The callback is triggered every time a message is received. 192 | * 193 | * Example: 194 | * Assuming the message data is in the format: { message: "some string value" } 195 | */ 196 | useMessage((e) => { 197 | const data = JSON.parse(e.data); 198 | setChats((prevChats) => [...prevChats, data.message]); 199 | }); 200 | 201 | 202 | // A basic example displaying the messages received 203 | return chats.map((chat) => { 204 | return
{chat}
205 | }); 206 | }; 207 | 208 | 209 | ``` 210 | 211 | Automatically unsubscribes when the component is unmounted. 212 | 213 | #### useLostConnectionListener 214 | 215 | The useLostConnectionListener hook allows you to handle situations where the connection is lost and does not reconnect within a specified time frame. 216 | 217 | You can use this hook to notify users of connection issues, such as through toast notifications. This hook is triggered with the following events: 218 | 219 | - `"lost"`: When the connection is lost and reconnection attempts are ongoing. 220 | - `"restored"`: When the connection is successfully reestablished. 221 | - `"failed"`: When the connection cannot be restored (rare). 222 | 223 | You can configure the timeout for the lostConnectionTimeout option (default is 5 seconds): 224 | 225 | ```tsx 226 | import { toast } from "my-preferred-toast-library"; 227 | 228 | const { SocketProvider, useLostConnectionListener } = createSocketContext({ 229 | url: "ws://localhost:9000/", 230 | options: { 231 | lostConnectionTimeout: 5000, // Timeout in milliseconds 232 | }, 233 | }); 234 | 235 | function App() { 236 | useLostConnectionListener((event) => { 237 | switch (event) { 238 | case "lost": 239 | toast.warn("Still trying to reconnect..."); 240 | break; 241 | 242 | case "restored": 243 | toast.success("Successfully reconnected again!"); 244 | break; 245 | 246 | case "failed": 247 | toast.error("Could not restore the connection"); 248 | break; 249 | } 250 | }); 251 | } 252 | ``` 253 | 254 | Automatically unsubscribes when the component is unmounted. 255 | 256 | #### Configuration 257 | 258 | let's take a look at more configuration. `createSocketContext` takes all the similar options to the JavaScript/Typescript version. 259 | 260 | 261 | **Api Reference** - You can take a look at all the available options in the 262 | [API Reference](/socket/quickstart#api) 263 | 264 | 265 | ```tsx socket.tsx 266 | import {createSocketContext} from '@resocket/socket/react 267 | 268 | const { SocketProvider, useSocket } = createSocketContext({ 269 | //the url for websocket connection 270 | url: "ws://localhost:9000/", 271 | 272 | //the protocol option in websocket connection. eg: new Websocket(url, protocols) 273 | protocols: [], 274 | 275 | options: { 276 | /** 277 | * sends a heatbeat ping every 5 seconds 278 | */ 279 | heartbeatInterval: 5000, 280 | 281 | /** 282 | * maximum number or retries before moving to a disconnected state. 283 | */ 284 | maxRetries: 5 285 | } 286 | }); 287 | ``` 288 | -------------------------------------------------------------------------------- /apps/docs/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /apps/docs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resocket/socket/f2e37ada82b9c49f40339e7835517c0bf384caa4/apps/docs/public/favicon.ico -------------------------------------------------------------------------------- /apps/docs/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/docs/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/docs/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config: Config = { 4 | content: [ 5 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", 8 | ], 9 | theme: { 10 | extend: { 11 | backgroundImage: { 12 | "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", 13 | "gradient-conic": 14 | "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", 15 | }, 16 | }, 17 | }, 18 | plugins: [], 19 | }; 20 | export default config; 21 | -------------------------------------------------------------------------------- /apps/docs/theme.config.jsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/router"; 2 | import { useConfig } from "nextra-theme-docs"; 3 | 4 | export default { 5 | logo: "Resocket", 6 | project: { 7 | link: "https://github.com/resocket/socket", 8 | }, 9 | chat: { 10 | link: "https://discord.gg/FQb86Sqxhd", 11 | }, 12 | footer: { 13 | text: ( 14 | 15 | Resocket. making the future of 16 | multiplayer devtools 17 | 18 | ), 19 | }, 20 | useNextSeoProps() { 21 | const { asPath } = useRouter(); 22 | if (asPath !== "/") { 23 | return { 24 | titleTemplate: "%s – Resocket", 25 | }; 26 | } else { 27 | return { 28 | title: "Resocket – devtools for multiplayer apps", 29 | }; 30 | } 31 | }, 32 | head: () => { 33 | const { frontMatter } = useConfig(); 34 | 35 | return ( 36 | <> 37 | 44 | 45 | 46 | 47 | 51 | 52 | ); 53 | }, 54 | nextThemes: { 55 | defaultTheme: "dark", 56 | }, 57 | docsRepositoryBase: 58 | "https://github.com/resocket/socket/tree/master/apps/docs", 59 | }; 60 | -------------------------------------------------------------------------------- /apps/docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "paths": { 16 | "@/*": ["./*"] 17 | } 18 | }, 19 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 20 | "exclude": ["node_modules"] 21 | } 22 | -------------------------------------------------------------------------------- /apps/site/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals", 3 | "rules": { 4 | "@next/next/no-img-element": "off" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /apps/site/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /apps/site/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. 20 | 21 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. 22 | 23 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 24 | 25 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 26 | 27 | ## Learn More 28 | 29 | To learn more about Next.js, take a look at the following resources: 30 | 31 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 32 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 33 | 34 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 35 | 36 | ## Deploy on Vercel 37 | 38 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 39 | 40 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 41 | -------------------------------------------------------------------------------- /apps/site/next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | output: "export", 4 | images: { 5 | unoptimized: true, 6 | }, 7 | }; 8 | 9 | export default nextConfig; 10 | -------------------------------------------------------------------------------- /apps/site/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "site", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "react": "^18", 13 | "react-dom": "^18", 14 | "next": "14.2.6" 15 | }, 16 | "devDependencies": { 17 | "typescript": "^5", 18 | "@types/node": "^20", 19 | "@types/react": "^18", 20 | "@types/react-dom": "^18", 21 | "postcss": "^8", 22 | "tailwindcss": "^3.4.1", 23 | "eslint": "^8", 24 | "eslint-config-next": "14.2.6" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /apps/site/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /apps/site/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resocket/socket/f2e37ada82b9c49f40339e7835517c0bf384caa4/apps/site/public/favicon.ico -------------------------------------------------------------------------------- /apps/site/public/logo-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resocket/socket/f2e37ada82b9c49f40339e7835517c0bf384caa4/apps/site/public/logo-white.png -------------------------------------------------------------------------------- /apps/site/src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import "@/styles/globals.css"; 2 | import type { AppProps } from "next/app"; 3 | import Head from "next/head"; 4 | 5 | export default function App({ Component, pageProps }: AppProps) { 6 | return ( 7 | <> 8 | 9 | 10 | 14 | 15 | 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /apps/site/src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from "next/document"; 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /apps/site/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | 3 | export default function Home() { 4 | return ( 5 | <> 6 | 7 | Resocket - Devtools for multiplayer projects 8 | 9 | 10 |
11 | {/* Main Content */} 12 |
13 |

14 | 15 | Resocket Logo 20 | 21 | Resocket 22 |

23 |

24 | At Resocket, we're creating devtools for real-time and 25 | multiplayer applications. We're actively looking for early 26 | adopters and design partners to help shape the future of our tools. 27 |

28 |

29 | We’ve just released our first open-source package: 30 |

31 |

32 | 36 | Socket 37 | {" "} 38 | — a better reconnecting WebSocket library for everyone, focused on 39 | enhancing developer experience. 40 |

41 |

42 | Join our 43 | 47 | {" "} 48 | Discord{" "} 49 | 50 | server for early updates, getting early access, and to connect with 51 | the Resocket community. 52 |

53 |

54 | We’re also open to business collaborations. If you're 55 | interested, reach out to us at{" "} 56 | 60 | shibru127@gmail.com 61 | 62 | . 63 |

64 |
65 | 66 | {/* Join Discord Button at the Bottom */} 67 | 75 |
76 | 77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /apps/site/src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --foreground-rgb: 255, 255, 255; 7 | --background-start-rgb: 0, 0, 0; 8 | --background-end-rgb: 0, 0, 0; 9 | } 10 | 11 | body { 12 | color: rgb(var(--foreground-rgb)); 13 | background: linear-gradient( 14 | to bottom, 15 | transparent, 16 | rgb(var(--background-end-rgb)) 17 | ) 18 | rgb(var(--background-start-rgb)); 19 | } 20 | 21 | @layer utilities { 22 | .text-balance { 23 | text-wrap: balance; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /apps/site/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config: Config = { 4 | content: [ 5 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", 8 | ], 9 | theme: { 10 | extend: { 11 | backgroundImage: { 12 | "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", 13 | "gradient-conic": 14 | "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", 15 | }, 16 | }, 17 | }, 18 | plugins: [], 19 | }; 20 | export default config; 21 | -------------------------------------------------------------------------------- /apps/site/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "paths": { 16 | "@/*": ["./src/*"] 17 | } 18 | }, 19 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 20 | "exclude": ["node_modules"] 21 | } 22 | -------------------------------------------------------------------------------- /examples/nextjs-connection-status/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /examples/nextjs-connection-status/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /examples/nextjs-connection-status/README.md: -------------------------------------------------------------------------------- 1 | # connection status + lost connection example 2 | 3 | ## acknowledgements 4 | 5 | - this example is taken from [liveblocks example](https://github.com/liveblocks/liveblocks/tree/main/examples/nextjs-connection-status) & is implemented using @resocket/socket. which is also exposing similar (some differences) apis, as it's inspired form liveblocks api itself. 6 | 7 | this is a connection status example, where we can see the live status of our socket connection. also we have the ability to show toast notifications in case we lose the connection it's all easily configurable. the connection is failed after 10 consecutive failed attempts. everything is easily configurable 8 | 9 | ```typescript src/resocket.config.ts 10 | import { createSocketContext } from "@resocket/socket/react"; 11 | 12 | export const { 13 | SocketProvider, //provider for react 14 | useSocket, //get the instance of socket 15 | useStatus, //get the status of socket 16 | useLostConnectionListener, //for lost connection popup 17 | } = createSocketContext({ 18 | url: "ws://localhost:9000/server/fdd", 19 | options: { 20 | maxRetries: 5, 21 | 22 | //* [optional] sends a heartbeat every 30 seconds. 23 | heartbeatInterval: 30000, 24 | }, 25 | }); 26 | ``` 27 | -------------------------------------------------------------------------------- /examples/nextjs-connection-status/next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | }; 5 | 6 | export default nextConfig; 7 | -------------------------------------------------------------------------------- /examples/nextjs-connection-status/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextjs-connection-status", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "next": "14.2.6", 13 | "react": "^18", 14 | "react-dom": "^18", 15 | "react-hot-toast": "^2.4.1" 16 | }, 17 | "devDependencies": { 18 | "@types/node": "^20", 19 | "@types/react": "^18", 20 | "@types/react-dom": "^18", 21 | "eslint": "^8", 22 | "eslint-config-next": "14.2.6", 23 | "postcss": "^8", 24 | "tailwindcss": "^3.4.1", 25 | "typescript": "^5" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /examples/nextjs-connection-status/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /examples/nextjs-connection-status/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resocket/socket/f2e37ada82b9c49f40339e7835517c0bf384caa4/examples/nextjs-connection-status/public/favicon.ico -------------------------------------------------------------------------------- /examples/nextjs-connection-status/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/nextjs-connection-status/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/nextjs-connection-status/src/components/ConnectionStatus.module.css: -------------------------------------------------------------------------------- 1 | .userCount { 2 | text-align: center; 3 | padding: 5px; 4 | font-weight: 500; 5 | zoom: 1.5; 6 | } 7 | 8 | .status { 9 | zoom: 2.5; 10 | display: flex; 11 | justify-content: center; 12 | align-items: center; 13 | gap: 8px; 14 | background: #ffffff; 15 | border-radius: 28px; 16 | padding: 6px 12px; 17 | user-select: none; 18 | box-shadow: 0 0 0 1px rgba(31, 41, 55, 0.04), 0 2px 4px rgba(31, 41, 55, 0.06), 19 | 0 4px 16px -2px rgba(31, 41, 55, 0.12); 20 | } 21 | 22 | .status[data-status="initial"], 23 | .status[data-status="connected"] { 24 | --status-block: #22c55e; 25 | } 26 | 27 | .status[data-status="connecting"], 28 | .status[data-status="reconnecting"] { 29 | --status-block: #eab308; 30 | } 31 | 32 | .status[data-status="disconnected"] { 33 | --status-block: #ef4444; 34 | } 35 | 36 | .statusCircle { 37 | position: relative; 38 | background: var(--status-block); 39 | width: 8px; 40 | height: 8px; 41 | border-radius: 9999px; 42 | } 43 | 44 | .statusCircle:before { 45 | content: ""; 46 | position: absolute; 47 | display: block; 48 | top: -1px; 49 | left: -1px; 50 | background: var(--status-block); 51 | width: 10px; 52 | height: 10px; 53 | border-radius: 9999px; 54 | animation: ping 1.5s cubic-bezier(0, 0, 0.2, 1) infinite; 55 | opacity: 0.4; 56 | } 57 | 58 | .statusText { 59 | text-transform: capitalize; 60 | font-size: 12px; 61 | font-weight: 500; 62 | } 63 | 64 | @keyframes ping { 65 | 75%, 66 | to { 67 | transform: scale(2); 68 | opacity: 0; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /examples/nextjs-connection-status/src/components/ConnectionStatus.tsx: -------------------------------------------------------------------------------- 1 | import styles from "./ConnectionStatus.module.css"; 2 | import { useStatus } from "../resocket.config"; 3 | 4 | export const ConnectionStatus = () => { 5 | const status = useStatus(); 6 | 7 | return ( 8 |
9 |
10 |
11 |
{status}
12 |
13 |
14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /examples/nextjs-connection-status/src/components/LostConnectionStatus.tsx: -------------------------------------------------------------------------------- 1 | import { useRef } from "react"; 2 | import toast, { Toaster } from "react-hot-toast"; 3 | import { useLostConnectionListener } from "../resocket.config"; 4 | 5 | export function LostConnectionToasts() { 6 | const toastId = useRef(); 7 | 8 | useLostConnectionListener((event) => { 9 | if (event === "lost") { 10 | toastId.current = toast.loading("lost connection, trying to reconnect…"); 11 | } else if (event === "restored") { 12 | toast.success("Reconnected", { id: toastId.current }); 13 | } else if (event === "failed") { 14 | toast.error("Could not reconnect, please refresh", { 15 | id: toastId.current, 16 | }); 17 | } 18 | }); 19 | 20 | return ( 21 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /examples/nextjs-connection-status/src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import "@/styles/globals.css"; 2 | import type { AppProps } from "next/app"; 3 | 4 | export default function App({ Component, pageProps }: AppProps) { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /examples/nextjs-connection-status/src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from "next/document"; 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /examples/nextjs-connection-status/src/pages/api/hello.ts: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | import type { NextApiRequest, NextApiResponse } from "next"; 3 | 4 | type Data = { 5 | name: string; 6 | }; 7 | 8 | export default function handler( 9 | req: NextApiRequest, 10 | res: NextApiResponse, 11 | ) { 12 | res.status(200).json({ name: "John Doe" }); 13 | } 14 | -------------------------------------------------------------------------------- /examples/nextjs-connection-status/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { ConnectionStatus } from "../components/ConnectionStatus"; 2 | import { LostConnectionToasts } from "../components/LostConnectionStatus"; 3 | import { SocketProvider } from "../resocket.config"; 4 | 5 | export default function Home() { 6 | return ( 7 | 8 |
16 | 17 | 18 |
19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /examples/nextjs-connection-status/src/resocket.config.ts: -------------------------------------------------------------------------------- 1 | import { createSocketContext } from "@resocket/socket/react"; 2 | 3 | export const { 4 | SocketProvider, 5 | useMessage, 6 | useSocket, 7 | useStatus, 8 | useLostConnectionListener, 9 | } = createSocketContext({ 10 | url: "ws://localhost:9000/server/fdd", 11 | options: { 12 | maxRetries: 5, 13 | 14 | //* [optional] sends a heartbeat every 30 seconds. 15 | heartbeatInterval: 30000, 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /examples/nextjs-connection-status/src/styles/globals.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --foreground-rgb: 0, 0, 0; 3 | --background-start-rgb: 214, 219, 220; 4 | --background-end-rgb: 255, 255, 255; 5 | } 6 | * { 7 | box-sizing: border-box; 8 | padding: 0; 9 | margin: 0; 10 | } 11 | 12 | html, 13 | body { 14 | height: 100dvh; 15 | max-width: 100vw; 16 | overflow-x: hidden; 17 | } 18 | 19 | body { 20 | color: rgb(var(--foreground-rgb)); 21 | font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 22 | Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; 23 | background: linear-gradient( 24 | to bottom, 25 | transparent, 26 | rgb(var(--background-end-rgb)) 27 | ) 28 | rgb(var(--background-start-rgb)); 29 | } 30 | 31 | a { 32 | color: inherit; 33 | text-decoration: none; 34 | } 35 | 36 | @media (prefers-color-scheme: dark) { 37 | html { 38 | color-scheme: dark; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /examples/nextjs-connection-status/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config: Config = { 4 | content: [ 5 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", 8 | ], 9 | theme: { 10 | extend: { 11 | backgroundImage: { 12 | "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", 13 | "gradient-conic": 14 | "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", 15 | }, 16 | }, 17 | }, 18 | plugins: [], 19 | }; 20 | export default config; 21 | -------------------------------------------------------------------------------- /examples/nextjs-connection-status/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "paths": { 16 | "@/*": ["./src/*"] 17 | } 18 | }, 19 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 20 | "exclude": ["node_modules"] 21 | } 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@resocket/monorepo", 3 | "private": true, 4 | "description": "the monorepo for resocket", 5 | "workspaces": [ 6 | "packages/*", 7 | "examples/*" 8 | ], 9 | "scripts": { 10 | "dev:init": "npm run build -w @resocket/socket", 11 | "dev": "npm run dev:init && concurrently \"npm run dev -w @resocket/socket\"" 12 | }, 13 | "devDependencies": { 14 | "concurrently": "^8.2.2", 15 | "tsup": "^8.2.4", 16 | "typescript": "^5.5.4" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/resocket-socket/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@resocket/socket", 3 | "version": "0.0.1", 4 | "description": "a better reconnecting websocket for everyone", 5 | "main": "./dist/index.js", 6 | "types": "./dist/index.d.ts", 7 | "exports": { 8 | ".": { 9 | "import": { 10 | "types": "./dist/index.d.mts", 11 | "default": "./dist/index.mjs" 12 | }, 13 | "require": { 14 | "types": "./dist/index.d.ts", 15 | "module": "./dist/index.mjs", 16 | "default": "./dist/index.js" 17 | } 18 | }, 19 | "./react": { 20 | "import": { 21 | "types": "./dist/react.d.mts", 22 | "default": "./dist/react.mjs" 23 | }, 24 | "require": { 25 | "types": "./dist/react.d.ts", 26 | "module": "./dist/react.mjs", 27 | "default": "./dist/react.js" 28 | } 29 | } 30 | }, 31 | "files": [ 32 | "./dist/**/*" 33 | ], 34 | "scripts": { 35 | "build": "tsup", 36 | "dev": "tsup --watch", 37 | "test": "vitest", 38 | "pub": "npm version patch && npm run build && npm publish" 39 | }, 40 | "keywords": [ 41 | "multiplayer", 42 | "websocket", 43 | "websockets", 44 | "realtime" 45 | ], 46 | "author": "", 47 | "license": "ISC", 48 | "devDependencies": { 49 | "@types/react": "^18.3.3", 50 | "@types/ws": "^8.5.11", 51 | "jsdom": "^24.1.1" 52 | }, 53 | "dependencies": { 54 | "vitest": "^2.0.5" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /packages/resocket-socket/src/__test__/_setup.ts: -------------------------------------------------------------------------------- 1 | export class MockWindow { 2 | _listeners: { [event: string]: (() => void)[] } = {}; 3 | 4 | addEventListener(event: "focus" | "offline", handler: () => void) { 5 | if (!this._listeners[event]) { 6 | this._listeners[event] = []; 7 | } 8 | this._listeners[event].push(handler); 9 | } 10 | 11 | notify(event: "focus" | "offline") { 12 | const listeners = this._listeners[event]; 13 | if (listeners) { 14 | listeners.forEach((handler) => { 15 | handler(); 16 | }); 17 | } 18 | } 19 | 20 | //dummy 21 | removeEventListener(_event: "focus" | "offline", _handler: () => void) {} 22 | } 23 | -------------------------------------------------------------------------------- /packages/resocket-socket/src/__test__/event-target.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import { CustomEventTarget } from "../event-target"; 3 | 4 | //todo complete the test cases 5 | describe("event target", () => { 6 | class Tester extends CustomEventTarget {} 7 | 8 | it("should test addEventListener", () => { 9 | const tester = new Tester(); 10 | 11 | expect(tester.addEventListener).toBeDefined(); 12 | 13 | tester.addEventListener("test", () => {}); 14 | tester.addEventListener("test", () => {}); 15 | tester.addEventListener("test", () => {}); 16 | 17 | tester.addEventListener("test2", () => {}); 18 | 19 | //@ts-ignore -- accessing private property 20 | expect(Object.keys(tester._listeners).length).toBe(2); 21 | 22 | //@ts-ignore -- accessing private property 23 | expect(tester._listeners["test"].size).toBe(3); 24 | 25 | //@ts-ignore -- accessing private property 26 | expect(tester._listeners["test2"].size).toBe(1); 27 | 28 | //@ts-ignore -- accessing private property 29 | expect(tester._listeners["stable"]).toBeUndefined(); 30 | 31 | const stableListener = () => {}; 32 | 33 | tester.addEventListener("stable", stableListener); 34 | tester.addEventListener("stable", stableListener); 35 | tester.addEventListener("stable", stableListener); 36 | 37 | //@ts-ignore -- accessing private property 38 | expect(tester._listeners["stable"].size).toBe(1); 39 | }); 40 | 41 | it("should test removeEventListener", async () => { 42 | const tester = new Tester(); 43 | 44 | const stableListener = () => {}; 45 | 46 | tester.addEventListener("stable", stableListener); 47 | tester.addEventListener("stable", stableListener); 48 | tester.addEventListener("stable", stableListener); 49 | 50 | //@ts-ignore -- accessing private property 51 | expect(tester._listeners["stable"].size).toBe(1); 52 | 53 | tester.removeEventListener("stable", () => {}); 54 | tester.removeEventListener("random", () => {}); 55 | 56 | //@ts-ignore -- accessing private property 57 | expect(tester._listeners["stable"].size).toBe(1); 58 | 59 | tester.removeEventListener("stable", stableListener); 60 | 61 | //@ts-ignore -- accessing private property 62 | expect(tester._listeners["stable"].size).toBe(0); 63 | }); 64 | 65 | it("should test dispatchEvent", () => { 66 | const tester = new Tester(); 67 | 68 | let count = 0; 69 | const stableListener = () => { 70 | count++; 71 | }; 72 | 73 | tester.addEventListener("stable", stableListener); 74 | 75 | tester.dispatchEvent("ssss", undefined); 76 | 77 | expect(count).toBe(0); 78 | 79 | tester.dispatchEvent("stable", undefined); 80 | tester.dispatchEvent("stable", undefined); 81 | tester.dispatchEvent("stable", undefined); 82 | tester.dispatchEvent("stable", undefined); 83 | tester.dispatchEvent("stable", undefined); 84 | 85 | expect(count).toBe(5); 86 | }); 87 | 88 | it("should test on", () => { 89 | const tester = new Tester(); 90 | 91 | let count = 0; 92 | 93 | const unsub = tester.on("event", () => { 94 | count++; 95 | }); 96 | 97 | //@ts-ignore -- accessing private property 98 | expect(tester._listeners["event"].size).toBe(1); 99 | 100 | tester.dispatchEvent("random", ""); 101 | 102 | expect(count).toBe(0); 103 | 104 | tester.dispatchEvent("event", ""); 105 | tester.dispatchEvent("event", ""); 106 | 107 | expect(count).toBe(2); 108 | 109 | unsub(); 110 | 111 | //@ts-ignore -- accessing private property 112 | expect(tester._listeners["event"].size).toBe(0); 113 | 114 | tester.dispatchEvent("event", ""); 115 | tester.dispatchEvent("event", ""); 116 | 117 | expect(count).toBe(2); 118 | }); 119 | }); 120 | -------------------------------------------------------------------------------- /packages/resocket-socket/src/__test__/resocket.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @vitest-environment jsdom 3 | */ 4 | 5 | import { expect, beforeEach, afterEach, it, vitest } from "vitest"; 6 | import NodeWebSocket from "ws"; 7 | import { DEFAULT, ReSocket } from "../resocket"; 8 | import { StopRetry } from "../errors"; 9 | import { MockWindow } from "./_setup"; 10 | 11 | const WebSocketServer = NodeWebSocket.Server; 12 | const originalWebSocket = global.WebSocket; 13 | let socketServer: NodeWebSocket.Server; 14 | let mockWindow: MockWindow; 15 | 16 | const PORT = 45789; 17 | const URL = `ws://localhost:${PORT}/`; 18 | const ERROR_URL = "ws://localhost:32423"; 19 | 20 | beforeEach(() => { 21 | mockWindow = new MockWindow(); 22 | (global as any).window = mockWindow; 23 | (global as any).WebSocket = originalWebSocket; 24 | socketServer = new WebSocketServer({ port: PORT }); 25 | }); 26 | 27 | afterEach(() => { 28 | vitest.restoreAllMocks(); 29 | vitest.useRealTimers(); 30 | 31 | return new Promise((resolve) => { 32 | socketServer.clients.forEach((client) => { 33 | client.terminate(); 34 | }); 35 | socketServer.removeAllListeners(); 36 | socketServer.close(() => { 37 | resolve(); 38 | }); 39 | }); 40 | }); 41 | 42 | //jest style done https://vitest.dev/guide/migration#done-callback 43 | function itDone( 44 | name: string, 45 | fn: (resolve: () => void, reject: (e: unknown) => void) => void, 46 | timeout?: number 47 | ) { 48 | it( 49 | name, 50 | () => 51 | new Promise((resolve, reject) => { 52 | fn(resolve, reject); 53 | }), 54 | timeout 55 | ); 56 | } 57 | 58 | const realSetTimeout = globalThis.setTimeout; 59 | function sleep( 60 | ms: number, 61 | { bypassFakeTimers }: { bypassFakeTimers: boolean } = { 62 | bypassFakeTimers: false, 63 | } 64 | ) { 65 | return new Promise((resolve) => { 66 | if (bypassFakeTimers) { 67 | realSetTimeout(resolve, ms); 68 | return; 69 | } 70 | 71 | setTimeout(resolve, ms); 72 | }); 73 | } 74 | it("throws if not created with `new`", () => { 75 | expect(() => { 76 | // @ts-ignore 77 | ReSocket(URL, undefined); 78 | }).toThrow(TypeError); 79 | }); 80 | 81 | itDone("should throw if invalid url", (done) => { 82 | const connection = new ReSocket(ERROR_URL, []); 83 | 84 | connection.addEventListener("error", () => { 85 | connection.close(); 86 | done(); 87 | }); 88 | }); 89 | 90 | itDone("should initialize resocket", (done) => { 91 | const connection = new ReSocket(URL); 92 | 93 | connection.addEventListener("open", () => { 94 | connection.close(); 95 | done(); 96 | }); 97 | }); 98 | 99 | it("[config] should respect startClosed", () => { 100 | const connection = new ReSocket(URL, [], { startClosed: true }); 101 | expect(connection.getStatus()).toBe("initial"); 102 | }); 103 | 104 | itDone("should reconnect on connection lose", (done) => { 105 | let firstConnection: boolean = true; 106 | 107 | socketServer.on("connection", (con) => { 108 | if (firstConnection) { 109 | firstConnection = false; 110 | con.close(); 111 | } 112 | }); 113 | 114 | const connection = new ReSocket(URL, [], { startClosed: true }); 115 | 116 | const expectedStateSequence = [ 117 | "auth", 118 | "connection", 119 | "connected", 120 | "auth", 121 | "connection", 122 | "connected", 123 | ]; 124 | 125 | const recievedStateSequence: string[] = []; 126 | 127 | connection.addEventListener("_internalStateChange", (state) => { 128 | recievedStateSequence.push(state); 129 | 130 | if (recievedStateSequence.length == 6) { 131 | expect(expectedStateSequence).toEqual(recievedStateSequence); 132 | connection.close(); 133 | done(); 134 | } 135 | }); 136 | connection.reconnect(); 137 | }); 138 | 139 | itDone("[config] should respect closeCodes", (done) => { 140 | socketServer.once("connection", (con) => { 141 | con.close(4000); 142 | }); 143 | 144 | const connection = new ReSocket(URL, [], { 145 | startClosed: true, 146 | closeCodes: 4000, 147 | }); 148 | 149 | const expectedStateSequence = ["auth", "connection", "connected", "closed"]; 150 | 151 | const recievedStateSequence: string[] = []; 152 | 153 | connection.addEventListener("_internalStateChange", (state) => { 154 | recievedStateSequence.push(state); 155 | }); 156 | 157 | connection.addEventListener("status", (status) => { 158 | if (status === "closed") { 159 | expect(expectedStateSequence).toEqual(recievedStateSequence); 160 | connection.close(); 161 | done(); 162 | } 163 | }); 164 | 165 | connection.reconnect(); 166 | }); 167 | 168 | itDone("[config] should respect connectionTimeout", (done) => { 169 | vitest.useFakeTimers(); 170 | 171 | const logSpy = vitest.spyOn(console, "log").mockImplementation(() => {}); 172 | const connectionTimeout = 3000; 173 | const connection = new ReSocket(ERROR_URL, [], { 174 | debug: true, //! logspy on don't remove 175 | startClosed: true, 176 | connectionTimeout, 177 | }); 178 | 179 | const expectedStateSequence = ["auth", "connection", "connection_backoff"]; 180 | 181 | const recievedStateSequence: string[] = []; 182 | 183 | connection.addEventListener("_internalStateChange", async (state) => { 184 | recievedStateSequence.push(state); 185 | 186 | if (recievedStateSequence.length === 3) { 187 | vitest.useRealTimers(); 188 | await sleep(10); 189 | expect(logSpy).toHaveBeenCalledTimes(4); 190 | expect(logSpy).toHaveBeenLastCalledWith("RS>", "timeout error"); 191 | expect(expectedStateSequence).toEqual(recievedStateSequence); 192 | connection.close(); 193 | done(); 194 | } 195 | }); 196 | 197 | connection.reconnect(); 198 | vitest.advanceTimersByTime(connectionTimeout); 199 | }); 200 | 201 | itDone("[config] should respect params", (done) => { 202 | const connection = new ReSocket(URL, [], { 203 | startClosed: true, 204 | params: async () => { 205 | return { 206 | data: "ninja hattori", 207 | }; 208 | }, 209 | }); 210 | 211 | connection.addEventListener("status", async (status) => { 212 | if (status === "connected") { 213 | expect(connection.url).toBe(`${URL}?data=ninja+hattori`); 214 | connection.close(); 215 | done(); 216 | } 217 | }); 218 | 219 | connection.reconnect(); 220 | }); 221 | 222 | itDone("[config] should respect paramsTimeout", (done) => { 223 | vitest.useFakeTimers(); 224 | 225 | const logSpy = vitest.spyOn(console, "log").mockImplementation(() => {}); 226 | const paramsTimeout = 3000; 227 | const connection = new ReSocket(URL, [], { 228 | startClosed: true, 229 | debug: true, //!logspy on don't remove 230 | params: async () => { 231 | await sleep(paramsTimeout + 100); 232 | return { 233 | data: "ben 10", 234 | }; 235 | }, 236 | paramsTimeout, 237 | }); 238 | 239 | const expectedStateSequence = ["auth", "auth_backoff"]; 240 | 241 | const recievedStateSequence: string[] = []; 242 | 243 | connection.addEventListener("_internalStateChange", async (state) => { 244 | recievedStateSequence.push(state); 245 | 246 | if (recievedStateSequence.length === 2) { 247 | vitest.useRealTimers(); 248 | await sleep(10); 249 | expect(logSpy).toHaveBeenCalledTimes(3); 250 | expect(logSpy).toHaveBeenLastCalledWith("RS>", "timeout error"); 251 | expect(expectedStateSequence).toEqual(recievedStateSequence); 252 | connection.close(); 253 | done(); 254 | } 255 | }); 256 | 257 | connection.reconnect(); 258 | vitest.advanceTimersByTime(paramsTimeout); 259 | }); 260 | 261 | itDone("should handle params failure and retry", (done) => { 262 | let firstTry = true; 263 | 264 | const connection = new ReSocket(URL, [], { 265 | maxReconnectionDelay: 0, //speed up the reconnection 266 | startClosed: true, 267 | params: async () => { 268 | if (firstTry) { 269 | firstTry = false; 270 | throw new Error("Something went wrong with auth"); 271 | } 272 | return { 273 | data: "ben 10", 274 | }; 275 | }, 276 | }); 277 | 278 | const expectedStateSequence = [ 279 | "auth", 280 | "auth_backoff", 281 | "auth", 282 | "connection", 283 | "connected", 284 | ]; 285 | 286 | const recievedStateSequence: string[] = []; 287 | 288 | connection.addEventListener("_internalStateChange", async (state) => { 289 | recievedStateSequence.push(state); 290 | if (recievedStateSequence.length == 5) { 291 | expect(expectedStateSequence).toEqual(recievedStateSequence); 292 | //!note _internalStateChange will notify before state is changed, so if we close websocket from here the socket may not be defined 293 | //! this will throw an error. only in this edge case which is supposed to internal used only 294 | await sleep(1); 295 | connection.close(); 296 | done(); 297 | } 298 | }); 299 | connection.reconnect(); 300 | }); 301 | 302 | itDone("should respect StopRetry error and move to failed", (done) => { 303 | const connection = new ReSocket(URL, [], { 304 | startClosed: true, 305 | params: async () => { 306 | throw new StopRetry("failure frame manga is good"); 307 | }, 308 | }); 309 | 310 | const expectedStateSequence = ["auth", "auth_backoff", "failed"]; 311 | 312 | const recievedStateSequence: string[] = []; 313 | 314 | connection.addEventListener("_internalStateChange", async (state) => { 315 | recievedStateSequence.push(state); 316 | }); 317 | 318 | connection.addEventListener("disconnect", () => { 319 | expect(expectedStateSequence).toEqual(recievedStateSequence); 320 | done(); 321 | }); 322 | 323 | connection.reconnect(); 324 | }); 325 | 326 | itDone( 327 | "should notify on lost connection - slow initial connection", 328 | async (done) => { 329 | vitest.useFakeTimers(); 330 | 331 | const connection = new ReSocket(URL, [], { 332 | startClosed: true, 333 | //!NOTE - js runtimes. do not handle setTimeout's with infinity. dont use them 334 | paramsTimeout: 999999, 335 | lostConnectionTimeout: 3000, 336 | params: async () => { 337 | await sleep(1000000); 338 | return {}; 339 | }, 340 | }); 341 | 342 | let hasNotified = false; 343 | connection.addEventListener("lostConnection", () => { 344 | hasNotified = true; 345 | }); 346 | 347 | connection.reconnect(); 348 | 349 | vitest.advanceTimersByTime(1000); 350 | expect(hasNotified).toBeFalsy(); 351 | 352 | vitest.advanceTimersByTime(10000); 353 | expect(hasNotified).toBeTruthy(); 354 | 355 | connection.close(); 356 | done(); 357 | } 358 | ); 359 | 360 | itDone("should notify on lost connection - slow reconnection", async (done) => { 361 | let firstConnection = true; 362 | 363 | const connection = new ReSocket(URL, [], { 364 | startClosed: true, 365 | maxReconnectionDelay: 0, 366 | //!NOTE - js runtimes. do not handle setTimeout's with infinity. dont use them 367 | paramsTimeout: 999999, 368 | connectionTimeout: 99999, 369 | lostConnectionTimeout: 3000, 370 | params: async () => { 371 | if (firstConnection) { 372 | firstConnection = false; 373 | return {}; 374 | } 375 | 376 | await sleep(1000000); 377 | return {}; 378 | }, 379 | }); 380 | 381 | let LostConnectionStatus: undefined | any = undefined; 382 | connection.addEventListener("lostConnection", (event) => { 383 | LostConnectionStatus = event; 384 | }); 385 | 386 | connection.addEventListener("status", (status) => { 387 | if (status === "connected") { 388 | vitest.useFakeTimers(); 389 | connection.reconnect(); 390 | 391 | vitest.advanceTimersByTime(100); 392 | expect(LostConnectionStatus).toBeUndefined(); 393 | 394 | vitest.advanceTimersByTime(4000); 395 | expect(LostConnectionStatus).toBe("lost"); 396 | 397 | connection.close(); 398 | expect(LostConnectionStatus).toBe("failed"); 399 | 400 | done(); 401 | } 402 | }); 403 | 404 | connection.reconnect(); 405 | }); 406 | 407 | itDone("[default] should not send ping", async (done) => { 408 | let pingCount = 0; 409 | 410 | vitest.useFakeTimers(); 411 | socketServer.on("connection", (con) => { 412 | con.addEventListener("message", (e) => { 413 | if (e.data === "ping") { 414 | pingCount++; 415 | con.send("pong"); 416 | } 417 | }); 418 | }); 419 | 420 | const connection = new ReSocket(URL, [], { 421 | startClosed: true, 422 | }); 423 | 424 | connection.addEventListener("status", async (status) => { 425 | if (status === "connected") { 426 | expect(pingCount).toBe(0); 427 | 428 | vitest.advanceTimersByTime(10000); 429 | await sleep(5, { bypassFakeTimers: true }); 430 | expect(pingCount).toBe(0); 431 | 432 | vitest.advanceTimersByTime(10000); 433 | await sleep(5, { bypassFakeTimers: true }); 434 | expect(pingCount).toBe(0); 435 | 436 | connection.close(); 437 | done(); 438 | } 439 | }); 440 | 441 | connection.reconnect(); 442 | }); 443 | 444 | itDone( 445 | "[config] should respect heartbeatInterval - send ping", 446 | async (done) => { 447 | let pingCount = 0; 448 | 449 | vitest.useFakeTimers(); 450 | socketServer.on("connection", (con) => { 451 | con.addEventListener("message", (e) => { 452 | if (e.data === "ping") { 453 | pingCount++; 454 | con.send("pong"); 455 | } 456 | }); 457 | }); 458 | 459 | const connection = new ReSocket(URL, [], { 460 | startClosed: true, 461 | heartbeatInterval: 1000, 462 | }); 463 | 464 | connection.addEventListener("status", async (status) => { 465 | if (status === "connected") { 466 | expect(pingCount).toBe(0); 467 | 468 | vitest.advanceTimersByTime(1000); 469 | await sleep(5, { bypassFakeTimers: true }); 470 | expect(pingCount).toBe(1); 471 | 472 | vitest.advanceTimersByTime(1000); 473 | await sleep(5, { bypassFakeTimers: true }); 474 | expect(pingCount).toBe(2); 475 | 476 | vitest.advanceTimersByTime(500); 477 | await sleep(5, { bypassFakeTimers: true }); 478 | expect(pingCount).toBe(2); 479 | 480 | connection.close(); 481 | done(); 482 | } 483 | }); 484 | 485 | connection.reconnect(); 486 | } 487 | ); 488 | 489 | itDone(`[default] should timeout the ping`, async (done) => { 490 | vitest.useFakeTimers(); 491 | socketServer.on("connection", (con) => { 492 | con.addEventListener("message", async (e) => { 493 | if (e.data === "ping") { 494 | vitest.advanceTimersByTime(DEFAULT.pingTimeout); 495 | con.send("pong"); 496 | } 497 | }); 498 | }); 499 | 500 | const connection = new ReSocket(URL, [], { 501 | startClosed: true, 502 | heartbeatInterval: 1000, 503 | }); 504 | 505 | const expectedStateSequence = [ 506 | "auth", 507 | "connection", 508 | "connected", 509 | "ping", 510 | "ping_backoff", 511 | "auth_backoff", 512 | ]; 513 | 514 | const recievedStateSequence: string[] = []; 515 | 516 | connection.addEventListener("status", (status) => { 517 | if (status === "connected") { 518 | vitest.advanceTimersByTime(1005); 519 | } 520 | }); 521 | 522 | connection.addEventListener("_internalStateChange", async (state) => { 523 | recievedStateSequence.push(state); 524 | 525 | if (recievedStateSequence.length == 6) { 526 | expect(expectedStateSequence).toEqual(recievedStateSequence); 527 | 528 | //!note closing form _internalStateChange can cause errors in socket state. it is known and not an issue, _internalStateChange is not meant for using outside testcases & debugging 529 | await sleep(5, { bypassFakeTimers: true }); 530 | connection.close(); 531 | done(); 532 | } 533 | }); 534 | 535 | connection.reconnect(); 536 | }); 537 | 538 | itDone("[config] should respect pingTimeout", async (done) => { 539 | vitest.useFakeTimers(); 540 | socketServer.on("connection", (con) => { 541 | con.addEventListener("message", (e) => { 542 | if (e.data === "ping") { 543 | con.send("pong"); 544 | } 545 | }); 546 | }); 547 | 548 | const connection = new ReSocket(URL, ["test"], { 549 | startClosed: true, 550 | heartbeatInterval: 1000, 551 | pingTimeout: DEFAULT.pingTimeout + 1, 552 | }); 553 | const expectedStateSequence = [ 554 | "auth", 555 | "connection", 556 | "connected", 557 | "ping", 558 | "connected", 559 | ]; 560 | 561 | const recievedStateSequence: string[] = []; 562 | 563 | connection.addEventListener("status", (status) => { 564 | if (status === "connected") { 565 | vitest.advanceTimersByTime(1000); 566 | } 567 | }); 568 | 569 | connection.addEventListener("_internalStateChange", async (state) => { 570 | recievedStateSequence.push(state); 571 | 572 | if (recievedStateSequence.length == 5) { 573 | expect(expectedStateSequence).toEqual(recievedStateSequence); 574 | 575 | //! dont close without async in _internalStateChange 576 | await sleep(2, { bypassFakeTimers: true }); 577 | connection.close(); 578 | done(); 579 | } 580 | }); 581 | 582 | connection.reconnect(); 583 | }); 584 | 585 | itDone( 586 | "[config] should respect maxMissedPings - case auth_backoff", 587 | async (done) => { 588 | vitest.useFakeTimers(); 589 | socketServer.on("connection", (con) => { 590 | con.addEventListener("message", async (e) => { 591 | if (e.data === "ping") { 592 | vitest.advanceTimersByTime(DEFAULT.pingTimeout); 593 | con.send("pong"); 594 | } 595 | }); 596 | }); 597 | 598 | const connection = new ReSocket(URL, [], { 599 | startClosed: true, 600 | heartbeatInterval: 1000, 601 | maxMissedPingss: 2, 602 | }); 603 | 604 | const expectedStateSequence = [ 605 | "auth", 606 | "connection", 607 | "connected", 608 | "ping", 609 | "ping_backoff", 610 | "ping", 611 | "ping_backoff", 612 | "auth_backoff", 613 | ]; 614 | 615 | const recievedStateSequence: string[] = []; 616 | 617 | connection.addEventListener("status", (status) => { 618 | if (status === "connected") { 619 | vitest.advanceTimersByTime(1000); 620 | } 621 | }); 622 | 623 | connection.addEventListener("_internalStateChange", async (state) => { 624 | recievedStateSequence.push(state); 625 | 626 | if (recievedStateSequence.length == 8) { 627 | expect(expectedStateSequence).toEqual(recievedStateSequence); 628 | //! dont close without async in _internalStateChange 629 | await sleep(2, { bypassFakeTimers: true }); 630 | connection.close(); 631 | 632 | done(); 633 | } 634 | }); 635 | 636 | connection.reconnect(); 637 | } 638 | ); 639 | 640 | itDone( 641 | "[config] should respect maxMissedPings - case connected", 642 | async (done) => { 643 | vitest.useFakeTimers(); 644 | 645 | let firstPing = true; 646 | socketServer.on("connection", (con) => { 647 | con.addEventListener("message", async (e) => { 648 | if (e.data === "ping") { 649 | if (firstPing) { 650 | firstPing = false; 651 | vitest.advanceTimersByTime(DEFAULT.pingTimeout); 652 | } 653 | con.send("pong"); 654 | } 655 | }); 656 | }); 657 | 658 | const connection = new ReSocket(URL, ["ccc"], { 659 | startClosed: true, 660 | heartbeatInterval: 1000, 661 | maxMissedPingss: 2, 662 | }); 663 | 664 | const expectedStateSequence = [ 665 | "auth", 666 | "connection", 667 | "connected", 668 | "ping", 669 | "ping_backoff", 670 | "ping", 671 | "connected", 672 | ]; 673 | 674 | const recievedStateSequence: string[] = []; 675 | 676 | connection.addEventListener("status", (status) => { 677 | if (status === "connected") { 678 | vitest.advanceTimersByTime(1000); 679 | } 680 | }); 681 | 682 | connection.addEventListener("_internalStateChange", async (state) => { 683 | recievedStateSequence.push(state); 684 | 685 | if (recievedStateSequence.length == 7) { 686 | expect(expectedStateSequence).toEqual(recievedStateSequence); 687 | //! dont close without async in _internalStateChange 688 | await sleep(2, { bypassFakeTimers: true }); 689 | 690 | connection.close(); 691 | done(); 692 | } 693 | }); 694 | 695 | connection.reconnect(); 696 | } 697 | ); 698 | 699 | itDone("[config] should respect pingMessage", async (done) => { 700 | let pingCount = 0; 701 | 702 | const pingMessage = "scooby dooby doo"; 703 | 704 | vitest.useFakeTimers(); 705 | socketServer.on("connection", (con) => { 706 | con.addEventListener("message", (e) => { 707 | if (e.data === pingMessage) { 708 | pingCount++; 709 | con.send("pong"); 710 | } 711 | }); 712 | }); 713 | 714 | const connection = new ReSocket(URL, [], { 715 | startClosed: true, 716 | heartbeatInterval: 1000, 717 | pingMessage, 718 | }); 719 | 720 | connection.addEventListener("status", async (status) => { 721 | if (status === "connected") { 722 | expect(pingCount).toBe(0); 723 | 724 | vitest.advanceTimersByTime(1000); 725 | await sleep(5, { bypassFakeTimers: true }); 726 | expect(pingCount).toBe(1); 727 | 728 | vitest.advanceTimersByTime(1000); 729 | await sleep(5, { bypassFakeTimers: true }); 730 | expect(pingCount).toBe(2); 731 | 732 | connection.close(); 733 | done(); 734 | } 735 | }); 736 | 737 | connection.reconnect(); 738 | }); 739 | 740 | itDone("[config] should respect pongMessage", async (done) => { 741 | let pingCount = 0; 742 | 743 | const pongMessage = "scooby dooby doo"; 744 | 745 | vitest.useFakeTimers(); 746 | socketServer.on("connection", (con) => { 747 | con.addEventListener("message", (e) => { 748 | if (e.data === "ping") { 749 | pingCount++; 750 | con.send(pongMessage); 751 | } 752 | }); 753 | }); 754 | 755 | const connection = new ReSocket(URL, [], { 756 | startClosed: true, 757 | heartbeatInterval: 1000, 758 | pongMessage, 759 | }); 760 | 761 | connection.addEventListener("status", async (status) => { 762 | if (status === "connected") { 763 | expect(pingCount).toBe(0); 764 | 765 | vitest.advanceTimersByTime(1000); 766 | await sleep(5, { bypassFakeTimers: true }); 767 | expect(pingCount).toBe(1); 768 | 769 | vitest.advanceTimersByTime(1000); 770 | await sleep(5, { bypassFakeTimers: true }); 771 | expect(pingCount).toBe(2); 772 | 773 | connection.close(); 774 | done(); 775 | } 776 | }); 777 | 778 | connection.reconnect(); 779 | }); 780 | 781 | itDone( 782 | "[default] should not send ping on network events - heartbeat is off", 783 | async (done) => { 784 | const connection = new ReSocket(URL, [], { 785 | startClosed: true, 786 | }); 787 | 788 | const expectedStateSequence = ["auth", "connection", "connected"]; 789 | 790 | const recievedStateSequence: string[] = []; 791 | 792 | connection.addEventListener("_internalStateChange", async (state) => { 793 | recievedStateSequence.push(state); 794 | }); 795 | 796 | connection.addEventListener("status", async (status) => { 797 | if (status === "connected") { 798 | expect(expectedStateSequence).toEqual(recievedStateSequence); 799 | 800 | mockWindow.notify("offline"); 801 | expect(recievedStateSequence.includes("ping")).toBeFalsy(); 802 | connection.close(); 803 | done(); 804 | } 805 | }); 806 | connection.reconnect(); 807 | } 808 | ); 809 | 810 | itDone( 811 | "[default] should send ping on network events - heartbeat is set", 812 | async (done) => { 813 | const connection = new ReSocket(URL, [], { 814 | startClosed: true, 815 | heartbeatInterval: 1000, 816 | }); 817 | 818 | const expectedStateSequence = ["auth", "connection", "connected"]; 819 | 820 | const recievedStateSequence: string[] = []; 821 | 822 | connection.addEventListener("_internalStateChange", async (state) => { 823 | recievedStateSequence.push(state); 824 | }); 825 | 826 | connection.addEventListener("status", async (status) => { 827 | if (status === "connected") { 828 | expect(expectedStateSequence).toEqual(recievedStateSequence); 829 | 830 | mockWindow.notify("offline"); 831 | expect(recievedStateSequence.includes("ping")).toBeTruthy(); 832 | connection.close(); 833 | done(); 834 | } 835 | }); 836 | connection.reconnect(); 837 | } 838 | ); 839 | 840 | itDone("[config] should respect ignoreNetworkEvent", async (done) => { 841 | const connection = new ReSocket(URL, [], { 842 | startClosed: true, 843 | heartbeatInterval: 1000, 844 | ignoreNetworkEvents: true, 845 | }); 846 | 847 | const expectedStateSequence = ["auth", "connection", "connected"]; 848 | 849 | const recievedStateSequence: string[] = []; 850 | 851 | connection.addEventListener("_internalStateChange", async (state) => { 852 | recievedStateSequence.push(state); 853 | }); 854 | 855 | connection.addEventListener("status", async (status) => { 856 | if (status === "connected") { 857 | expect(expectedStateSequence).toEqual(recievedStateSequence); 858 | 859 | mockWindow.notify("offline"); 860 | expect(recievedStateSequence.includes("ping")).toBeFalsy(); 861 | connection.close(); 862 | done(); 863 | } 864 | }); 865 | connection.reconnect(); 866 | }); 867 | 868 | itDone( 869 | "[default] should not send ping on focus events - heartbeat is off", 870 | async (done) => { 871 | const connection = new ReSocket(URL, [], { 872 | startClosed: true, 873 | }); 874 | 875 | const expectedStateSequence = ["auth", "connection", "connected"]; 876 | 877 | const recievedStateSequence: string[] = []; 878 | 879 | connection.addEventListener("_internalStateChange", async (state) => { 880 | recievedStateSequence.push(state); 881 | }); 882 | 883 | connection.addEventListener("status", async (status) => { 884 | if (status === "connected") { 885 | expect(expectedStateSequence).toEqual(recievedStateSequence); 886 | 887 | mockWindow.notify("focus"); 888 | expect(recievedStateSequence.includes("ping")).toBeFalsy(); 889 | connection.close(); 890 | done(); 891 | } 892 | }); 893 | connection.reconnect(); 894 | } 895 | ); 896 | 897 | itDone( 898 | "[default] should send ping on focus events - heartbeat is set", 899 | async (done) => { 900 | const connection = new ReSocket(URL, [], { 901 | startClosed: true, 902 | heartbeatInterval: 1000, 903 | }); 904 | 905 | const expectedStateSequence = ["auth", "connection", "connected"]; 906 | 907 | const recievedStateSequence: string[] = []; 908 | 909 | connection.addEventListener("_internalStateChange", async (state) => { 910 | recievedStateSequence.push(state); 911 | }); 912 | 913 | connection.addEventListener("status", async (status) => { 914 | if (status === "connected") { 915 | expect(expectedStateSequence).toEqual(recievedStateSequence); 916 | 917 | mockWindow.notify("focus"); 918 | expect(recievedStateSequence.includes("ping")).toBeTruthy(); 919 | connection.close(); 920 | done(); 921 | } 922 | }); 923 | connection.reconnect(); 924 | } 925 | ); 926 | 927 | itDone("[config] should respect ignoreFocusEvent", async (done) => { 928 | const connection = new ReSocket(URL, [], { 929 | startClosed: true, 930 | heartbeatInterval: 1000, 931 | ignoreFocusEvents: true, 932 | }); 933 | 934 | const expectedStateSequence = ["auth", "connection", "connected"]; 935 | 936 | const recievedStateSequence: string[] = []; 937 | 938 | connection.addEventListener("_internalStateChange", async (state) => { 939 | recievedStateSequence.push(state); 940 | }); 941 | 942 | connection.addEventListener("status", async (status) => { 943 | if (status === "connected") { 944 | expect(expectedStateSequence).toEqual(recievedStateSequence); 945 | 946 | mockWindow.notify("focus"); 947 | expect(recievedStateSequence.includes("ping")).toBeFalsy(); 948 | connection.close(); 949 | done(); 950 | } 951 | }); 952 | connection.reconnect(); 953 | }); 954 | 955 | itDone("[config] should respect maxRetries", async (done) => { 956 | const connnection = new ReSocket(ERROR_URL, [], { 957 | startClosed: true, 958 | maxRetries: 3, 959 | maxReconnectionDelay: 0, 960 | }); 961 | 962 | const expectedStateSequence = [ 963 | "auth", 964 | "connection", 965 | "connection_backoff", 966 | "auth", 967 | "connection", 968 | "connection_backoff", 969 | "auth", 970 | "connection", 971 | "connection_backoff", 972 | "failed", 973 | ]; 974 | 975 | const recievedStateSequence: string[] = []; 976 | 977 | connnection.addEventListener("_internalStateChange", (state) => { 978 | recievedStateSequence.push(state); 979 | }); 980 | 981 | connnection.addEventListener("disconnect", () => { 982 | expect(recievedStateSequence).toEqual(expectedStateSequence); 983 | done(); 984 | }); 985 | 986 | connnection.reconnect(); 987 | }); 988 | 989 | itDone("[behaviour] should test close inside 'open' callback", (done) => { 990 | const logSpy = vitest.spyOn(console, "log").mockImplementation(() => {}); 991 | 992 | const connection = new ReSocket(URL, [], { 993 | startClosed: true, 994 | debug: true, //!logspy on - don't clear 995 | }); 996 | 997 | let first = true; 998 | 999 | const expectedStateSequence = ["auth", "connection", "closed"]; 1000 | const recievedStateSequence: string[] = []; 1001 | 1002 | connection.addEventListener("_internalStateChange", (state) => { 1003 | recievedStateSequence.push(state); 1004 | }); 1005 | 1006 | connection.addEventListener("open", () => { 1007 | if (first) { 1008 | connection.close(); 1009 | first = false; 1010 | } 1011 | }); 1012 | 1013 | connection.addEventListener("disconnect", async () => { 1014 | //! await for the logs to be called. and the connection to be cleaned up from the socketServer 1015 | await sleep(20, { bypassFakeTimers: true }); 1016 | 1017 | expect(expectedStateSequence).toEqual(recievedStateSequence); 1018 | 1019 | expect(logSpy).toHaveBeenCalledTimes(4); 1020 | expect(logSpy.mock.calls[logSpy.mock.calls.length - 2]).toEqual([ 1021 | "RS>", 1022 | "closing websocket", 1023 | ]); 1024 | 1025 | expect(socketServer.clients.size).toBe(0); 1026 | done(); 1027 | }); 1028 | 1029 | connection.reconnect(); 1030 | }); 1031 | 1032 | itDone("[behaviour] should test reconnect inside 'open' callback", (done) => { 1033 | const logSpy = vitest.spyOn(console, "log").mockImplementation(() => {}); 1034 | const connection = new ReSocket(URL, [], { 1035 | startClosed: true, 1036 | debug: true, //!logspy on - don't clear 1037 | }); 1038 | 1039 | let first = true; 1040 | 1041 | const expectedStateSequence = [ 1042 | "auth", 1043 | "connection", 1044 | "auth", 1045 | "connection", 1046 | "connected", 1047 | ]; 1048 | const recievedStateSequence: string[] = []; 1049 | 1050 | connection.addEventListener("open", () => { 1051 | if (first) { 1052 | connection.reconnect(); 1053 | first = false; 1054 | } 1055 | }); 1056 | 1057 | connection.addEventListener("_internalStateChange", async (state) => { 1058 | recievedStateSequence.push(state); 1059 | 1060 | if (recievedStateSequence.length === 5) { 1061 | //! await for the logs to be called. and the connection to be cleaned up from the socketServer 1062 | await sleep(20, { bypassFakeTimers: true }); 1063 | expect(expectedStateSequence).toEqual(recievedStateSequence); 1064 | 1065 | //! since we're awaiting 20ms above we will already be in a connected state. so a log transion for that 1066 | expect(logSpy).toHaveBeenCalledTimes(6); 1067 | expect(logSpy.mock.calls[2]).toEqual(["RS>", "closing websocket"]); 1068 | 1069 | connection.close(); 1070 | 1071 | done(); 1072 | } 1073 | }); 1074 | 1075 | connection.reconnect(); 1076 | }); 1077 | 1078 | itDone("[config] should respect debugLogger", (done) => { 1079 | const warnSpy = vitest.spyOn(console, "warn").mockImplementation(() => {}); 1080 | 1081 | const connection = new ReSocket(URL, [], { 1082 | startClosed: true, 1083 | debug: true, //! debug used for test 1084 | debugLogger: (...args) => { 1085 | console.warn(`CUSTOM> `, ...args); 1086 | }, 1087 | }); 1088 | 1089 | connection.addEventListener("status", (status) => { 1090 | if (status === "connected") { 1091 | expect(warnSpy).toHaveBeenCalledTimes(3); 1092 | 1093 | expect( 1094 | warnSpy.mock.calls.filter((args) => { 1095 | return !!args.includes("CUSTOM>"); 1096 | }).length 1097 | ).toBe(0); 1098 | 1099 | connection.close(); 1100 | done(); 1101 | } 1102 | }); 1103 | 1104 | connection.reconnect(); 1105 | }); 1106 | 1107 | //! testing this first since the remaining tests are dependent on this config behaviour 1108 | itDone("[config] should respect minReconnectionDelay", async (done) => { 1109 | vitest.useFakeTimers(); 1110 | 1111 | const minReconnectionDelay = 5000; 1112 | let numTries = 5; 1113 | 1114 | const connection = new ReSocket(URL, [], { 1115 | startClosed: true, 1116 | minReconnectionDelay, 1117 | params: async () => { 1118 | if (numTries > 0) { 1119 | numTries--; 1120 | throw new Error("Grand blue dreaming~"); 1121 | } 1122 | 1123 | return { info: "kyou kara ore wa. (live action)" }; 1124 | }, 1125 | }); 1126 | 1127 | const expectedDelays: Array = [minReconnectionDelay]; 1128 | for (let i = 0; i < 4; i++) { 1129 | expectedDelays.push( 1130 | Math.min( 1131 | minReconnectionDelay * 1132 | Math.pow(DEFAULT.reconnectionDelayGrowFactor, i + 1), 1133 | DEFAULT.maxReconnectionDelay 1134 | ) 1135 | ); 1136 | } 1137 | 1138 | //@ts-expect-error -- accessing private property 1139 | const delaySpy = vitest.spyOn(connection, "_getNextDelay"); 1140 | 1141 | connection.addEventListener("_internalStateChange", async (state) => { 1142 | if (state === "auth_backoff") { 1143 | await sleep(1, { bypassFakeTimers: true }); 1144 | vitest.advanceTimersByTime(100000); 1145 | } 1146 | }); 1147 | connection.addEventListener("open", () => { 1148 | expect(expectedDelays).toEqual( 1149 | delaySpy.mock.results.map((res) => res.value) 1150 | ); 1151 | 1152 | connection.close(); 1153 | done(); 1154 | }); 1155 | 1156 | connection.reconnect(); 1157 | }); 1158 | 1159 | itDone( 1160 | "[default] [behaviour] should increase the reconnection delay by the grow factor", 1161 | (done) => { 1162 | vitest.useFakeTimers(); 1163 | 1164 | const minReconnectionDelay = 2000; 1165 | let numTries = 10; 1166 | 1167 | const connection = new ReSocket(URL, [], { 1168 | startClosed: true, 1169 | minReconnectionDelay, //!note the default config is randomized so we're using this as a stable base value 1170 | params: async () => { 1171 | if (numTries > 0) { 1172 | numTries--; 1173 | throw new Error("Ninja Boy Rantaro-"); 1174 | } 1175 | 1176 | return { info: "disastrous life of saiki k." }; 1177 | }, 1178 | }); 1179 | 1180 | const expectedDelays: Array = [minReconnectionDelay]; 1181 | for (let i = 0; i < 9; i++) { 1182 | expectedDelays.push( 1183 | Math.min( 1184 | minReconnectionDelay * 1185 | Math.pow(DEFAULT.reconnectionDelayGrowFactor, i + 1), 1186 | DEFAULT.maxReconnectionDelay 1187 | ) 1188 | ); 1189 | } 1190 | 1191 | //@ts-expect-error -- accessing private property 1192 | const delaySpy = vitest.spyOn(connection, "_getNextDelay"); 1193 | 1194 | connection.addEventListener("_internalStateChange", async (state) => { 1195 | if (state === "auth_backoff") { 1196 | await sleep(1, { bypassFakeTimers: true }); 1197 | vitest.advanceTimersByTime(100000); 1198 | } 1199 | }); 1200 | connection.addEventListener("open", () => { 1201 | expect(expectedDelays).toEqual( 1202 | delaySpy.mock.results.map((res) => res.value) 1203 | ); 1204 | 1205 | connection.close(); 1206 | done(); 1207 | }); 1208 | 1209 | connection.reconnect(); 1210 | } 1211 | ); 1212 | 1213 | itDone("[config] should respect reconnectionDelayGrowFactor", async (done) => { 1214 | vitest.useFakeTimers(); 1215 | 1216 | const minReconnectionDelay = 2000; 1217 | const reconnectionDelayGrowFactor = 1.5; 1218 | let numTries = 10; 1219 | 1220 | const connection = new ReSocket(URL, [], { 1221 | startClosed: true, 1222 | reconnectionDelayGrowFactor, 1223 | minReconnectionDelay, //!note the default config is randomized so we're using this as a stable base value 1224 | params: async () => { 1225 | if (numTries > 0) { 1226 | numTries--; 1227 | throw new Error("Slam Dunk!"); 1228 | } 1229 | 1230 | return { info: "bakuman!" }; 1231 | }, 1232 | }); 1233 | 1234 | const expectedDelays: Array = [minReconnectionDelay]; 1235 | for (let i = 0; i < 9; i++) { 1236 | expectedDelays.push( 1237 | Math.min( 1238 | minReconnectionDelay * Math.pow(reconnectionDelayGrowFactor, i + 1), 1239 | DEFAULT.maxReconnectionDelay 1240 | ) 1241 | ); 1242 | } 1243 | 1244 | //@ts-expect-error -- accessing private property 1245 | const delaySpy = vitest.spyOn(connection, "_getNextDelay"); 1246 | 1247 | connection.addEventListener("_internalStateChange", async (state) => { 1248 | if (state === "auth_backoff") { 1249 | await sleep(1, { bypassFakeTimers: true }); 1250 | vitest.advanceTimersByTime(100000); 1251 | } 1252 | }); 1253 | connection.addEventListener("open", () => { 1254 | expect(expectedDelays).toEqual( 1255 | delaySpy.mock.results.map((res) => res.value) 1256 | ); 1257 | 1258 | connection.close(); 1259 | done(); 1260 | }); 1261 | 1262 | connection.reconnect(); 1263 | }); 1264 | 1265 | itDone("[config] should respect maxReconnectionDelay", async (done) => { 1266 | vitest.useFakeTimers(); 1267 | 1268 | const maxReconnectionDelay = 20000; 1269 | const minReconnectionDelay = 1000; 1270 | let numTries = 10; 1271 | 1272 | const connection = new ReSocket(URL, [], { 1273 | startClosed: true, 1274 | maxReconnectionDelay, 1275 | minReconnectionDelay, //!note the default config is randomized so we're using this as a stable base value 1276 | params: async () => { 1277 | if (numTries > 0) { 1278 | numTries--; 1279 | throw new Error("Black clover"); 1280 | } 1281 | 1282 | return { info: "boku no hero academia" }; 1283 | }, 1284 | }); 1285 | 1286 | const expectedDelays: Array = [minReconnectionDelay]; 1287 | for (let i = 0; i < 9; i++) { 1288 | expectedDelays.push( 1289 | Math.min( 1290 | minReconnectionDelay * 1291 | Math.pow(DEFAULT.reconnectionDelayGrowFactor, i + 1), 1292 | maxReconnectionDelay 1293 | ) 1294 | ); 1295 | } 1296 | 1297 | //@ts-expect-error -- accessing private property 1298 | const delaySpy = vitest.spyOn(connection, "_getNextDelay"); 1299 | 1300 | connection.addEventListener("_internalStateChange", async (state) => { 1301 | if (state === "auth_backoff") { 1302 | await sleep(1, { bypassFakeTimers: true }); 1303 | vitest.advanceTimersByTime(100000); 1304 | } 1305 | }); 1306 | connection.addEventListener("open", () => { 1307 | expect(expectedDelays).toEqual( 1308 | delaySpy.mock.results.map((res) => res.value) 1309 | ); 1310 | 1311 | connection.close(); 1312 | done(); 1313 | }); 1314 | 1315 | connection.reconnect(); 1316 | }); 1317 | 1318 | itDone("[config] should respect getDelay", async (done) => { 1319 | vitest.useFakeTimers(); 1320 | 1321 | let numTries = 10; 1322 | 1323 | const expectedDelays: number[] = []; 1324 | 1325 | const connection = new ReSocket(URL, [], { 1326 | startClosed: true, 1327 | getDelay: () => { 1328 | const delay = Math.random() * Math.random(); 1329 | expectedDelays.push(delay); 1330 | return delay; 1331 | }, 1332 | params: async () => { 1333 | if (numTries > 0) { 1334 | numTries--; 1335 | 1336 | //honourable mention to LOOKISM ;_; 1337 | throw new Error("Kuroko no basuke"); 1338 | } 1339 | 1340 | //honourable mention to ELECEED ;_; 1341 | return { info: "Yowamushi pedal" }; 1342 | }, 1343 | }); 1344 | 1345 | //@ts-expect-error -- accessing private property 1346 | const delaySpy = vitest.spyOn(connection, "_getNextDelay"); 1347 | 1348 | connection.addEventListener("_internalStateChange", async (state) => { 1349 | if (state === "auth_backoff") { 1350 | await sleep(1, { bypassFakeTimers: true }); 1351 | vitest.advanceTimersByTime(100000); 1352 | } 1353 | }); 1354 | connection.addEventListener("open", () => { 1355 | expect(expectedDelays).toEqual( 1356 | delaySpy.mock.results.map((res) => res.value) 1357 | ); 1358 | 1359 | connection.close(); 1360 | done(); 1361 | }); 1362 | 1363 | connection.reconnect(); 1364 | }); 1365 | 1366 | itDone( 1367 | "[default] [behaviour] should not buffer messages - case 1 (send before connecting)", 1368 | (done) => { 1369 | let serverRecievedMessagesCount = 0; 1370 | socketServer.addListener("connection", (con) => { 1371 | con.addEventListener("message", () => { 1372 | serverRecievedMessagesCount++; 1373 | }); 1374 | }); 1375 | 1376 | const connection = new ReSocket(URL, [], { startClosed: true }); 1377 | 1378 | connection.send("hello"); 1379 | connection.send("hey"); 1380 | 1381 | connection.reconnect(); 1382 | connection.addEventListener("open", async () => { 1383 | //await for the messages to reach the server (in this case they should not btw) 1384 | await sleep(1); 1385 | 1386 | expect(serverRecievedMessagesCount).toBe(0); 1387 | 1388 | connection.close(); 1389 | done(); 1390 | }); 1391 | } 1392 | ); 1393 | 1394 | itDone( 1395 | "[default] [behaviour] should not buffer messages - case 2 (offline send, connect, send, offline send, connect, send)", 1396 | (done) => { 1397 | let serverRecievedMessages: Array = []; 1398 | socketServer.addListener("connection", (con) => { 1399 | con.addEventListener("message", (e) => { 1400 | serverRecievedMessages.push(e.data); 1401 | }); 1402 | }); 1403 | 1404 | const connection = new ReSocket(URL, [], { 1405 | startClosed: true, 1406 | }); 1407 | 1408 | connection.send("hello"); 1409 | connection.send("hey"); 1410 | 1411 | connection.reconnect(); 1412 | 1413 | let firstOpen = true; 1414 | connection.addEventListener("open", async () => { 1415 | if (firstOpen) { 1416 | firstOpen = false; 1417 | connection.send("lookism"); 1418 | connection.send("eleceed"); 1419 | 1420 | //await for the messages to reach the server (in this case they should not btw) 1421 | await sleep(5); 1422 | 1423 | expect(serverRecievedMessages.length).toBe(2); 1424 | expect(serverRecievedMessages).toEqual(["lookism", "eleceed"]); 1425 | connection.close(); 1426 | 1427 | connection.send("offline - "); 1428 | connection.send("offline - "); 1429 | 1430 | connection.reconnect(); 1431 | return; 1432 | } 1433 | 1434 | //waiitng for the messages to reach the server 1435 | await sleep(5); 1436 | 1437 | connection.send("one piece"); 1438 | connection.send("dragon ball"); 1439 | 1440 | //waiting for messages to reach the server 1441 | await sleep(5); 1442 | 1443 | expect(serverRecievedMessages.length).toBe(4); 1444 | 1445 | expect(serverRecievedMessages).toEqual([ 1446 | "lookism", 1447 | "eleceed", 1448 | "one piece", 1449 | "dragon ball", 1450 | ]); 1451 | 1452 | connection.close(); 1453 | 1454 | done(); 1455 | }); 1456 | } 1457 | ); 1458 | 1459 | itDone("[config] should respect buffer - case boolean", (done) => { 1460 | let serverRecievedMessages: Array = []; 1461 | socketServer.addListener("connection", (con) => { 1462 | con.addEventListener("message", (e) => { 1463 | serverRecievedMessages.push(e.data); 1464 | }); 1465 | }); 1466 | 1467 | const connection = new ReSocket(URL, [], { startClosed: true, buffer: true }); 1468 | 1469 | connection.send("kaiju no 8"); 1470 | connection.send("jujutsu kaisen"); 1471 | 1472 | connection.reconnect(); 1473 | 1474 | let firstOpen = true; 1475 | connection.addEventListener("open", async () => { 1476 | if (firstOpen) { 1477 | firstOpen = false; 1478 | connection.send("lookism"); 1479 | connection.send("eleceed"); 1480 | 1481 | //await for the messages to reach the server (in this case they should not btw) 1482 | await sleep(5); 1483 | 1484 | expect(serverRecievedMessages.length).toBe(4); 1485 | expect(serverRecievedMessages).toEqual([ 1486 | "kaiju no 8", 1487 | "jujutsu kaisen", 1488 | "lookism", 1489 | "eleceed", 1490 | ]); 1491 | connection.close(); 1492 | 1493 | connection.send("teenage mercenary"); 1494 | connection.send("tower of god"); 1495 | 1496 | connection.reconnect(); 1497 | return; 1498 | } 1499 | 1500 | //waiitng for the messages to reach the server 1501 | await sleep(5); 1502 | 1503 | connection.send("one piece"); 1504 | connection.send("dragon ball"); 1505 | //waiting for messages to reach the server 1506 | await sleep(5); 1507 | 1508 | expect(serverRecievedMessages.length).toBe(8); 1509 | 1510 | expect(serverRecievedMessages).toEqual([ 1511 | "kaiju no 8", 1512 | "jujutsu kaisen", 1513 | "lookism", 1514 | "eleceed", 1515 | "teenage mercenary", 1516 | "tower of god", 1517 | "one piece", 1518 | "dragon ball", 1519 | ]); 1520 | 1521 | connection.close(); 1522 | done(); 1523 | }); 1524 | }); 1525 | 1526 | itDone("[config] should respect buffer - case maxEnqueueMessage", (done) => { 1527 | let serverRecievedMessages: Array = []; 1528 | socketServer.addListener("connection", (con) => { 1529 | con.addEventListener("message", (e) => { 1530 | serverRecievedMessages.push(e.data); 1531 | }); 1532 | }); 1533 | 1534 | const connection = new ReSocket(URL, [], { 1535 | startClosed: true, 1536 | buffer: { maxEnqueuedMessages: 1 }, 1537 | }); 1538 | 1539 | connection.send("kaiju no 8"); 1540 | connection.send("jujutsu kaisen"); 1541 | 1542 | connection.reconnect(); 1543 | 1544 | let firstOpen = true; 1545 | connection.addEventListener("open", async () => { 1546 | if (firstOpen) { 1547 | firstOpen = false; 1548 | connection.send("lookism"); 1549 | connection.send("eleceed"); 1550 | 1551 | //await for the messages to reach the server (in this case they should not btw) 1552 | await sleep(5); 1553 | 1554 | expect(serverRecievedMessages.length).toBe(3); 1555 | expect(serverRecievedMessages).toEqual([ 1556 | "kaiju no 8", 1557 | "lookism", 1558 | "eleceed", 1559 | ]); 1560 | connection.close(); 1561 | 1562 | connection.send("teenage mercenary"); 1563 | connection.send("tower of god"); 1564 | 1565 | connection.reconnect(); 1566 | return; 1567 | } 1568 | 1569 | //waiitng for the messages to reach the server 1570 | await sleep(5); 1571 | 1572 | connection.send("one piece"); 1573 | connection.send("dragon ball"); 1574 | //waiting for messages to reach the server 1575 | await sleep(5); 1576 | 1577 | expect(serverRecievedMessages.length).toBe(6); 1578 | 1579 | expect(serverRecievedMessages).toEqual([ 1580 | "kaiju no 8", 1581 | "lookism", 1582 | "eleceed", 1583 | "teenage mercenary", 1584 | "one piece", 1585 | "dragon ball", 1586 | ]); 1587 | 1588 | connection.close(); 1589 | 1590 | done(); 1591 | }); 1592 | }); 1593 | 1594 | itDone("[behaviour] should notify connection status properly", (done) => { 1595 | const connection = new ReSocket(URL, [], { 1596 | startClosed: true, 1597 | }); 1598 | 1599 | const recievedConnectionStatuses: string[] = []; 1600 | 1601 | connection.addEventListener("status", (status) => { 1602 | recievedConnectionStatuses.push(status); 1603 | }); 1604 | 1605 | let firstOpen = true; 1606 | connection.addEventListener("open", async () => { 1607 | if (firstOpen) { 1608 | firstOpen = false; 1609 | 1610 | await sleep(1); 1611 | 1612 | expect(recievedConnectionStatuses.length).toBe(2); 1613 | expect(recievedConnectionStatuses).toEqual(["connecting", "connected"]); 1614 | connection.close(); 1615 | 1616 | await sleep(1); 1617 | 1618 | expect(recievedConnectionStatuses.length).toBe(3); 1619 | expect(recievedConnectionStatuses).toEqual([ 1620 | "connecting", 1621 | "connected", 1622 | "closed", 1623 | ]); 1624 | 1625 | connection.reconnect(); 1626 | await sleep(20); 1627 | 1628 | expect(recievedConnectionStatuses.length).toBe(5); 1629 | expect(recievedConnectionStatuses).toEqual([ 1630 | "connecting", 1631 | "connected", 1632 | "closed", 1633 | "reconnecting", 1634 | "connected", 1635 | ]); 1636 | 1637 | connection.close(); 1638 | done(); 1639 | 1640 | return; 1641 | } 1642 | }); 1643 | 1644 | connection.reconnect(); 1645 | }); 1646 | 1647 | //todo at last add behaviour based test cases ~ 1648 | itDone("[behaviour] should connect, send data, recieve data, close", (done) => { 1649 | socketServer.addListener("connection", (con) => { 1650 | con.addEventListener("message", (e) => { 1651 | con.send(`[echo] ${e.data}`); 1652 | }); 1653 | }); 1654 | const connection = new ReSocket(URL, [], { 1655 | startClosed: true, 1656 | params: async () => { 1657 | return { 1658 | move: "crazy cyclone", 1659 | }; 1660 | }, 1661 | buffer: true, 1662 | }); 1663 | 1664 | connection.addEventListener("message", async (e) => { 1665 | expect(e.data).toBe(`[echo] gomu gomu no... pistol!`); 1666 | expect(connection.url).toBe(URL + "?move=crazy+cyclone"); 1667 | 1668 | connection.close(); 1669 | 1670 | await sleep(5); 1671 | expect(connection.getStatus()).toBe("closed"); 1672 | expect(socketServer.clients.size).toBe(0); 1673 | 1674 | done(); 1675 | }); 1676 | 1677 | connection.send("gomu gomu no... pistol!"); 1678 | 1679 | connection.reconnect(); 1680 | }); 1681 | itDone( 1682 | "[behaviour] should connect, send data, recieve, close, reconnect, send data, close", 1683 | (done) => { 1684 | socketServer.addListener("connection", (con) => { 1685 | con.addEventListener("message", (e) => { 1686 | con.send(`[echo] ${e.data}`); 1687 | }); 1688 | }); 1689 | const connection = new ReSocket(URL, [], { 1690 | startClosed: true, 1691 | params: async () => { 1692 | return { 1693 | move: "crazy cyclone", 1694 | }; 1695 | }, 1696 | buffer: true, 1697 | }); 1698 | 1699 | let firstMessage = true; 1700 | 1701 | connection.addEventListener("message", async (e) => { 1702 | if (firstMessage) { 1703 | firstMessage = false; 1704 | expect(e.data).toBe(`[echo] gomu gomu no... pistol!`); 1705 | expect(connection.url).toBe(URL + "?move=crazy+cyclone"); 1706 | 1707 | connection.close(); 1708 | 1709 | await sleep(5); 1710 | expect(connection.getStatus()).toBe("closed"); 1711 | expect(socketServer.clients.size).toBe(0); 1712 | 1713 | connection.send("gomu gomu no... rocket!"); 1714 | connection.reconnect(); 1715 | expect(connection.getStatus()).toBe("reconnecting"); 1716 | 1717 | return; 1718 | } 1719 | 1720 | expect(e.data).toBe("[echo] gomu gomu no... rocket!"); 1721 | expect(connection.getStatus()).toBe("connected"); 1722 | connection.close(); 1723 | 1724 | await sleep(5); 1725 | expect(connection.getStatus()).toBe("closed"); 1726 | expect(socketServer.clients.size).toBe(0); 1727 | 1728 | done(); 1729 | }); 1730 | 1731 | connection.send("gomu gomu no... pistol!"); 1732 | 1733 | connection.reconnect(); 1734 | expect(connection.getStatus()).toBe("connecting"); 1735 | } 1736 | ); 1737 | 1738 | itDone("[property] should give correct buffered amount", async (done) => { 1739 | const connection = new ReSocket(URL, [], { buffer: true }); 1740 | 1741 | connection.send("hello"); 1742 | connection.send("hey"); 1743 | 1744 | expect(connection.bufferedAmount).toBe(8); 1745 | 1746 | connection.addEventListener("open", async () => { 1747 | //let the buffer drain 1748 | await sleep(1); 1749 | expect(connection.bufferedAmount).toBe(0); 1750 | connection.close(); 1751 | done(); 1752 | }); 1753 | 1754 | connection.reconnect(); 1755 | }); 1756 | 1757 | itDone( 1758 | "[config] should respect buildUrl - case 1 (without reconnect)", 1759 | (done) => { 1760 | socketServer.on("connection", (_con, req) => { 1761 | expect(req.url).toBe("/anime=inazuma+eleven"); 1762 | }); 1763 | 1764 | const connection = new ReSocket(URL, [], { 1765 | startClosed: true, 1766 | url: ({ url }) => { 1767 | return url + "anime=inazuma+eleven"; 1768 | }, 1769 | }); 1770 | 1771 | connection.addEventListener("open", () => { 1772 | expect(connection.url).toBe(URL + "anime=inazuma+eleven"); 1773 | connection.close(); 1774 | done(); 1775 | }); 1776 | 1777 | connection.reconnect(); 1778 | } 1779 | ); 1780 | 1781 | itDone("[config] should respect buildUrl - case 1 (with reconnect)", (done) => { 1782 | const serverRecievedUrls: Array = []; 1783 | 1784 | socketServer.on("connection", (_con, req) => { 1785 | serverRecievedUrls.push(req.url as string); 1786 | }); 1787 | 1788 | const URLs = ["anime=inazuma+eleven", "webtoon=manager+kim"]; 1789 | 1790 | let connectionCounter = 0; 1791 | 1792 | const connection = new ReSocket(URL, [], { 1793 | startClosed: true, 1794 | url: ({ url }) => { 1795 | const dynamicUrl = url + URLs[connectionCounter]; 1796 | connectionCounter++; 1797 | return dynamicUrl; 1798 | }, 1799 | }); 1800 | 1801 | let firstTry = true; 1802 | connection.addEventListener("open", async () => { 1803 | if (firstTry) { 1804 | firstTry = false; 1805 | 1806 | expect(connection.url).toBe(URL + URLs[0]); 1807 | expect(serverRecievedUrls).toEqual(["/anime=inazuma+eleven"]); 1808 | connection.reconnect(); 1809 | 1810 | return; 1811 | } 1812 | 1813 | expect(connection.url).toBe(URL + URLs[1]); 1814 | expect(serverRecievedUrls).toEqual([ 1815 | "/anime=inazuma+eleven", 1816 | "/webtoon=manager+kim", 1817 | ]); 1818 | 1819 | connection.close(); 1820 | done(); 1821 | }); 1822 | 1823 | connection.reconnect(); 1824 | }); 1825 | 1826 | itDone("[property] onopen should work", (done) => { 1827 | const connection = new ReSocket(URL, [], { 1828 | startClosed: true, 1829 | }); 1830 | 1831 | connection.onopen = () => { 1832 | connection.close(); 1833 | done(); 1834 | }; 1835 | connection.reconnect(); 1836 | }); 1837 | 1838 | itDone("[property] onmessage should work", (done) => { 1839 | socketServer.addListener("connection", (con) => { 1840 | con.send("anime - failure frame"); 1841 | }); 1842 | 1843 | const connection = new ReSocket(URL, [], { 1844 | startClosed: true, 1845 | }); 1846 | 1847 | connection.onmessage = (e) => { 1848 | expect(e.data).toBe("anime - failure frame"); 1849 | connection.close(); 1850 | done(); 1851 | }; 1852 | connection.reconnect(); 1853 | }); 1854 | 1855 | itDone("[property] onclose should work", (done) => { 1856 | socketServer.addListener("connection", (con) => { 1857 | con.close(4000); 1858 | }); 1859 | 1860 | const connection = new ReSocket(URL, [], { 1861 | startClosed: true, 1862 | closeCodes: [4000], 1863 | }); 1864 | 1865 | connection.onclose = (e) => { 1866 | connection.close(); 1867 | done(); 1868 | }; 1869 | connection.reconnect(); 1870 | }); 1871 | 1872 | itDone("[property] onerror should work", (done) => { 1873 | const connection = new ReSocket(ERROR_URL, [], { 1874 | startClosed: true, 1875 | }); 1876 | 1877 | connection.onerror = (e) => { 1878 | connection.close(); 1879 | done(); 1880 | }; 1881 | connection.reconnect(); 1882 | }); 1883 | 1884 | itDone( 1885 | "[config] should respect connectionResolver - varaition 1 (non async. success)", 1886 | (done) => { 1887 | socketServer.addListener("connection", (con) => { 1888 | con.send("hello"); 1889 | }); 1890 | 1891 | const connection = new ReSocket(URL, [], { 1892 | startClosed: true, 1893 | unstable_connectionResolver: (con, resolver, rejecter) => { 1894 | con.addEventListener("message", (e) => { 1895 | if (e.data !== "hello") rejecter(); 1896 | 1897 | resolver(); 1898 | }); 1899 | }, 1900 | }); 1901 | 1902 | connection.addEventListener("message", () => { 1903 | connection.close(); 1904 | done(); 1905 | }); 1906 | 1907 | connection.reconnect(); 1908 | } 1909 | ); 1910 | 1911 | itDone( 1912 | "[config] should respect connectionResolver - variation 2 (non-async, fail)", 1913 | (done) => { 1914 | socketServer.addListener("connection", (con) => { 1915 | con.send("hello"); 1916 | }); 1917 | 1918 | const connection = new ReSocket(URL, [], { 1919 | startClosed: true, 1920 | maxRetries: 2, 1921 | maxReconnectionDelay: 0, 1922 | unstable_connectionResolver: (con, resolver, rejecter) => { 1923 | rejecter(); 1924 | }, 1925 | }); 1926 | 1927 | let didRecieveMessage = false; 1928 | 1929 | connection.addEventListener("message", (e) => { 1930 | didRecieveMessage = true; 1931 | }); 1932 | 1933 | connection.addEventListener("_internalStateChange", (state) => { 1934 | if (state === "failed") { 1935 | expect(didRecieveMessage).toBeFalsy(); 1936 | //@ts-expect-error -- internal private property access 1937 | expect(connection._bufferedMessages.length).toBe(2); 1938 | done(); 1939 | } 1940 | }); 1941 | 1942 | connection.reconnect(); 1943 | } 1944 | ); 1945 | 1946 | itDone( 1947 | "[config] should respect connectionResolver - variation 3 (non-async, fail then pass)", 1948 | (done) => { 1949 | let counter = 0; 1950 | socketServer.addListener("connection", (con, req) => { 1951 | con.send("hello " + counter); 1952 | counter++; 1953 | }); 1954 | 1955 | let firstTry = true; 1956 | 1957 | const connection = new ReSocket(URL, [], { 1958 | startClosed: true, 1959 | maxReconnectionDelay: 0, 1960 | 1961 | unstable_connectionResolver: (_con, resolver, rejecter) => { 1962 | if (firstTry) { 1963 | firstTry = false; 1964 | rejecter(); 1965 | return; 1966 | } 1967 | resolver(); 1968 | }, 1969 | }); 1970 | 1971 | let recievedMessages: Array = []; 1972 | 1973 | connection.addEventListener("message", (e) => { 1974 | recievedMessages.push(e.data); 1975 | }); 1976 | 1977 | const expectedStateSequence = [ 1978 | "auth", 1979 | "connection", 1980 | "connection_backoff", 1981 | "auth", 1982 | "connection", 1983 | "connected", 1984 | ]; 1985 | 1986 | const recievedStateSequence: Array = []; 1987 | 1988 | connection.addEventListener("_internalStateChange", async (state) => { 1989 | recievedStateSequence.push(state); 1990 | if (recievedStateSequence.length === 5) { 1991 | expect(recievedMessages.length).toBe(0); 1992 | 1993 | //@ts-expect-error -- internal private property access 1994 | expect(connection._bufferedMessages.length).toBe(1); 1995 | } 1996 | 1997 | if (recievedStateSequence.length === 6) { 1998 | await sleep(1); 1999 | expect(recievedMessages.length).toBe(2); 2000 | expect(recievedMessages).toEqual(["hello 0", "hello 1"]); 2001 | 2002 | //@ts-expect-error -- internal private property access 2003 | expect(connection._bufferedMessages.length).toBe(0); 2004 | 2005 | expect(expectedStateSequence).toEqual(recievedStateSequence); 2006 | 2007 | connection.close(); 2008 | done(); 2009 | } 2010 | }); 2011 | 2012 | connection.reconnect(); 2013 | } 2014 | ); 2015 | 2016 | itDone( 2017 | "[config] should respect connectionResolver - variation 4 (non-async, StopRetry)", 2018 | (done) => { 2019 | socketServer.addListener("connection", (con) => { 2020 | con.send("hello"); 2021 | }); 2022 | 2023 | const connection = new ReSocket(URL, [], { 2024 | startClosed: true, 2025 | unstable_connectionResolver: (con, resolver, rejecter) => { 2026 | //inazuma eleven season 2 opening. the alien arc 2027 | rejecter( 2028 | new StopRetry( 2029 | "Tsuyoku, nareta ze hitori ga dekinakata. bokutachi ga~" 2030 | ) 2031 | ); 2032 | }, 2033 | }); 2034 | 2035 | connection.addEventListener("status", (status) => { 2036 | if (status === "disconnected") { 2037 | done(); 2038 | } 2039 | }); 2040 | 2041 | connection.reconnect(); 2042 | } 2043 | ); 2044 | 2045 | itDone( 2046 | "[config] should respect connectionResolver - variation 5 (async, fail)", 2047 | (done) => { 2048 | socketServer.addListener("connection", (con) => { 2049 | con.send("hello"); 2050 | }); 2051 | 2052 | const connection = new ReSocket(URL, [], { 2053 | startClosed: true, 2054 | maxRetries: 2, 2055 | maxReconnectionDelay: 0, 2056 | unstable_connectionResolver: async (con, resolver, rejecter) => { 2057 | await sleep(15); 2058 | 2059 | //inazuma eleven season 2 opening. the alien arc 2060 | rejecter(); 2061 | }, 2062 | }); 2063 | 2064 | const expectedStateSequence = [ 2065 | "auth", 2066 | "connection", 2067 | "connection_backoff", 2068 | "auth", 2069 | "connection", 2070 | "connection_backoff", 2071 | "failed", 2072 | ]; 2073 | 2074 | const recievedStateSequence: Array = []; 2075 | 2076 | connection.addEventListener("_internalStateChange", (state) => { 2077 | recievedStateSequence.push(state); 2078 | }); 2079 | 2080 | connection.addEventListener("status", (status) => { 2081 | if (status === "disconnected") { 2082 | //@ts-expect-error -- accessing private property 2083 | expect(connection._bufferedMessages.length).toBe(2); 2084 | expect(expectedStateSequence).toEqual(recievedStateSequence); 2085 | done(); 2086 | } 2087 | }); 2088 | 2089 | connection.reconnect(); 2090 | } 2091 | ); 2092 | 2093 | itDone( 2094 | "[config] should respect connectionResolver - variation 6 (async, success)", 2095 | (done) => { 2096 | socketServer.addListener("connection", (con) => { 2097 | con.send("hello"); 2098 | }); 2099 | 2100 | const connection = new ReSocket(URL, [], { 2101 | startClosed: true, 2102 | unstable_connectionResolver: async (con, resolver, rejecter) => { 2103 | await sleep(15); 2104 | 2105 | resolver(); 2106 | }, 2107 | }); 2108 | 2109 | connection.addEventListener("message", () => { 2110 | connection.close(); 2111 | done(); 2112 | }); 2113 | 2114 | connection.reconnect(); 2115 | } 2116 | ); 2117 | 2118 | itDone( 2119 | "[config] should respect connectionResolver - variation 7 (async, fail then pass)", 2120 | (done) => { 2121 | let counter = 0; 2122 | socketServer.addListener("connection", (con, req) => { 2123 | con.send("hello " + counter); 2124 | counter++; 2125 | }); 2126 | 2127 | let firstTry = true; 2128 | 2129 | const connection = new ReSocket(URL, [], { 2130 | startClosed: true, 2131 | maxReconnectionDelay: 0, 2132 | 2133 | unstable_connectionResolver: async (_con, resolver, rejecter) => { 2134 | await sleep(5); 2135 | if (firstTry) { 2136 | firstTry = false; 2137 | rejecter(); 2138 | return; 2139 | } 2140 | resolver(); 2141 | }, 2142 | }); 2143 | 2144 | let recievedMessages: Array = []; 2145 | 2146 | connection.addEventListener("message", (e) => { 2147 | recievedMessages.push(e.data); 2148 | }); 2149 | 2150 | const expectedStateSequence = [ 2151 | "auth", 2152 | "connection", 2153 | "connection_backoff", 2154 | "auth", 2155 | "connection", 2156 | "connected", 2157 | ]; 2158 | 2159 | const recievedStateSequence: Array = []; 2160 | 2161 | connection.addEventListener("_internalStateChange", async (state) => { 2162 | recievedStateSequence.push(state); 2163 | if (recievedStateSequence.length === 5) { 2164 | expect(recievedMessages.length).toBe(0); 2165 | 2166 | //@ts-expect-error -- internal private property access 2167 | expect(connection._bufferedMessages.length).toBe(1); 2168 | } 2169 | 2170 | if (recievedStateSequence.length === 6) { 2171 | await sleep(1); 2172 | expect(recievedMessages.length).toBe(2); 2173 | expect(recievedMessages).toEqual(["hello 0", "hello 1"]); 2174 | 2175 | //@ts-expect-error -- internal private property access 2176 | expect(connection._bufferedMessages.length).toBe(0); 2177 | 2178 | expect(expectedStateSequence).toEqual(recievedStateSequence); 2179 | 2180 | connection.close(); 2181 | done(); 2182 | } 2183 | }); 2184 | 2185 | connection.reconnect(); 2186 | } 2187 | ); 2188 | 2189 | itDone( 2190 | "[config] should respect connectionResolver - variation 8 (async, StopRetry)", 2191 | (done) => { 2192 | socketServer.addListener("connection", (con) => { 2193 | con.send("hello"); 2194 | }); 2195 | 2196 | const connection = new ReSocket(URL, [], { 2197 | startClosed: true, 2198 | unstable_connectionResolver: async (con, resolver, rejecter) => { 2199 | await sleep(2); 2200 | //inazuma eleven season 2 opening. the alien arc 2201 | rejecter( 2202 | new StopRetry( 2203 | "Tsuyoku, nareta ze hitori ga dekinakata. bokutachi ga~" 2204 | ) 2205 | ); 2206 | }, 2207 | }); 2208 | 2209 | connection.addEventListener("message", () => { 2210 | //we should not recieve messages till the connection is connected 2211 | expect(true).toBeFalsy(); 2212 | }); 2213 | 2214 | connection.addEventListener("status", (status) => { 2215 | if (status === "disconnected") { 2216 | done(); 2217 | } 2218 | }); 2219 | 2220 | connection.reconnect(); 2221 | } 2222 | ); 2223 | 2224 | itDone( 2225 | "[config] should respect connectionResolver = variatoin 9 (async after timeout, fail)", 2226 | (done) => { 2227 | socketServer.addListener("connection", (con) => { 2228 | con.send("hello"); 2229 | }); 2230 | 2231 | const connection = new ReSocket(URL, [], { 2232 | startClosed: true, 2233 | maxRetries: 2, 2234 | connectionTimeout: 10, 2235 | maxReconnectionDelay: 0, 2236 | unstable_connectionResolver: async (con, resolver, rejecter) => { 2237 | await sleep(15); 2238 | 2239 | rejecter(); 2240 | }, 2241 | }); 2242 | 2243 | const expectedStateSequence = [ 2244 | "auth", 2245 | "connection", 2246 | "connection_backoff", 2247 | "auth", 2248 | "connection", 2249 | "connection_backoff", 2250 | "failed", 2251 | ]; 2252 | 2253 | const recievedStateSequence: Array = []; 2254 | 2255 | connection.addEventListener("_internalStateChange", (state) => { 2256 | recievedStateSequence.push(state); 2257 | }); 2258 | 2259 | connection.addEventListener("status", (status) => { 2260 | if (status === "disconnected") { 2261 | //@ts-expect-error -- accessing private property 2262 | expect(connection._bufferedMessages.length).toBe(2); 2263 | expect(expectedStateSequence).toEqual(recievedStateSequence); 2264 | done(); 2265 | } 2266 | }); 2267 | 2268 | connection.reconnect(); 2269 | } 2270 | ); 2271 | 2272 | itDone( 2273 | "[config] should respect connectionResolver = variatoin 10 (async after timeout, success)", 2274 | (done) => { 2275 | socketServer.addListener("connection", (con) => { 2276 | con.send("hello"); 2277 | }); 2278 | 2279 | const connection = new ReSocket(URL, [], { 2280 | startClosed: true, 2281 | maxRetries: 2, 2282 | connectionTimeout: 15, 2283 | maxReconnectionDelay: 0, 2284 | unstable_connectionResolver: async (con, resolver, rejecter) => { 2285 | await sleep(20); 2286 | 2287 | //this wont work and would be nooped 2288 | resolver(); 2289 | }, 2290 | }); 2291 | 2292 | const expectedStateSequence = [ 2293 | "auth", 2294 | "connection", 2295 | "connection_backoff", 2296 | "auth", 2297 | "connection", 2298 | "connection_backoff", 2299 | "failed", 2300 | ]; 2301 | 2302 | const recievedStateSequence: Array = []; 2303 | 2304 | connection.addEventListener("_internalStateChange", (state) => { 2305 | recievedStateSequence.push(state); 2306 | }); 2307 | 2308 | connection.addEventListener("status", (status) => { 2309 | if (status === "disconnected") { 2310 | //@ts-expect-error -- accessing private property 2311 | expect(connection._bufferedMessages.length).toBe(2); 2312 | expect(expectedStateSequence).toEqual(recievedStateSequence); 2313 | done(); 2314 | } 2315 | }); 2316 | 2317 | connection.reconnect(); 2318 | } 2319 | ); 2320 | 2321 | itDone( 2322 | "[config] should respect connectionResolver - variatoin 11 (async after timeout, StopRetry)", 2323 | (done) => { 2324 | socketServer.addListener("connection", (con) => { 2325 | con.send("hello"); 2326 | }); 2327 | 2328 | const connection = new ReSocket(URL, [], { 2329 | startClosed: true, 2330 | 2331 | connectionTimeout: 0, 2332 | maxReconnectionDelay: 0, 2333 | maxRetries: 2, 2334 | unstable_connectionResolver: async (con, resolver, rejecter) => { 2335 | await sleep(1); 2336 | //inazuma eleven season 2 opening. the alien arc 2337 | rejecter( 2338 | new StopRetry( 2339 | "Tsuyoku, nareta ze hitori ga dekinakata. bokutachi ga~" 2340 | ) 2341 | ); 2342 | }, 2343 | }); 2344 | 2345 | const expectedStateSequence = [ 2346 | "auth", 2347 | "connection", 2348 | "connection_backoff", 2349 | "auth", 2350 | "connection", 2351 | "connection_backoff", 2352 | "failed", 2353 | ]; 2354 | 2355 | const recievedStateSequence: Array = []; 2356 | 2357 | connection.addEventListener("_internalStateChange", (state) => { 2358 | recievedStateSequence.push(state); 2359 | }); 2360 | 2361 | connection.addEventListener("message", () => { 2362 | expect(true).toBeFalsy(); 2363 | }); 2364 | 2365 | connection.addEventListener("status", (status) => { 2366 | if (status === "disconnected") { 2367 | expect(expectedStateSequence).toEqual(recievedStateSequence); 2368 | done(); 2369 | } 2370 | }); 2371 | 2372 | connection.reconnect(); 2373 | } 2374 | ); 2375 | 2376 | it("[property] stop should work", () => { 2377 | const connection = new ReSocket(URL, [], { 2378 | startClosed: true, 2379 | }); 2380 | 2381 | connection.stop(); 2382 | 2383 | expect(() => connection.reconnect()).toThrow(Error); 2384 | expect(() => connection.close()).toThrow(Error); 2385 | }); 2386 | 2387 | it("[property] isUsable should work", () => { 2388 | const connection = new ReSocket(URL, [], { 2389 | startClosed: true, 2390 | }); 2391 | 2392 | expect(connection.isUsable()).toBeTruthy(); 2393 | connection.stop(); 2394 | 2395 | expect(connection.isUsable()).toBeFalsy(); 2396 | }); 2397 | -------------------------------------------------------------------------------- /packages/resocket-socket/src/errors.ts: -------------------------------------------------------------------------------- 1 | export class TimeoutError extends Error { 2 | constructor(msg: string) { 3 | super(msg); 4 | } 5 | } 6 | 7 | export class StopRetry extends Error { 8 | constructor(msg: string) { 9 | super(msg); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/resocket-socket/src/event-target.ts: -------------------------------------------------------------------------------- 1 | type Listener = (data: T) => void; 2 | 3 | export class CustomEventTarget { 4 | private _listeners: { 5 | [key in keyof EventMap]?: Set>; 6 | }; 7 | 8 | constructor() { 9 | this._listeners = {}; 10 | } 11 | 12 | addEventListener( 13 | type: K, 14 | callback: Listener 15 | ): void { 16 | if (!(type in this._listeners)) { 17 | this._listeners[type] = new Set(); 18 | } 19 | this._listeners[type]!.add(callback); 20 | } 21 | 22 | on(type: K, callback: Listener) { 23 | this.addEventListener(type, callback); 24 | return () => this.removeEventListener(type, callback); 25 | } 26 | 27 | removeEventListener( 28 | type: K, 29 | callback: Listener 30 | ): void { 31 | if (type in this._listeners) { 32 | this._listeners[type]?.delete(callback); 33 | } 34 | } 35 | 36 | dispatchEvent(type: K, detail: EventMap[K]): void { 37 | if (type in this._listeners) { 38 | this._listeners[type]?.forEach((listener) => { 39 | listener(detail); 40 | }); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /packages/resocket-socket/src/events.ts: -------------------------------------------------------------------------------- 1 | export class ErrorEvent extends Event { 2 | public message: string; 3 | public error: Error; 4 | constructor(error: Error, target: any) { 5 | super("error", target); 6 | this.message = error.message; 7 | this.error = error; 8 | } 9 | } 10 | 11 | export class CloseEvent extends Event { 12 | public code: number; 13 | public reason: string; 14 | public wasClean = true; 15 | constructor(code = 1000, reason = "", target: any) { 16 | super("close", target); 17 | this.code = code; 18 | this.reason = reason; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/resocket-socket/src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | Socket, 3 | ReSocketOptions as SocketOptions, 4 | Status as SocketStatus, 5 | Message as SocketMessage, 6 | LostConnectionStatus, 7 | } from "./resocket"; 8 | export { StopRetry, TimeoutError } from "./errors"; 9 | export { CloseEvent, ErrorEvent } from "./events"; 10 | -------------------------------------------------------------------------------- /packages/resocket-socket/src/react.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | createContext, 3 | useContext, 4 | useEffect, 5 | useRef, 6 | useState, 7 | useSyncExternalStore, 8 | } from "react"; 9 | import { 10 | CloseEvent, 11 | ErrorEvent, 12 | LostConnectionStatus, 13 | Socket, 14 | SocketOptions, 15 | SocketStatus, 16 | StopRetry, 17 | } from "./index"; 18 | 19 | type Listener = (data: T) => void; 20 | 21 | interface CreateSocketConfig { 22 | url: string; 23 | protocols?: string; 24 | options?: SocketOptions; 25 | } 26 | 27 | interface UseSocketOptions { 28 | onStatusChange?: Listener; 29 | onMessage?: Listener>; 30 | onDisconnect?: Listener; 31 | onLostConnection?: Listener; 32 | } 33 | 34 | //* partyworks style factory function for react. we can create as many resocket instances as we want 35 | export const createSocketContext = (config: CreateSocketConfig) => { 36 | const SocketContext = createContext(null); 37 | 38 | let counter = 0; 39 | let inital = true; 40 | function SocketProvider(props: { children: React.ReactNode }) { 41 | const [socket] = useState( 42 | () => 43 | new Socket(config.url, config.protocols, { 44 | ...config.options, 45 | startClosed: true, //we only connect on the client side 46 | }) 47 | ); 48 | 49 | useEffect(() => { 50 | counter++; 51 | 52 | if (inital && !config.options?.startClosed) 53 | (inital = false), socket.reconnect(); 54 | 55 | return () => { 56 | counter--; 57 | 58 | if (counter < 1) { 59 | inital = true; 60 | socket.close(); 61 | } 62 | }; 63 | }, []); 64 | 65 | return ( 66 | 67 | {props.children} 68 | 69 | ); 70 | } 71 | 72 | function useSocket(listeners?: UseSocketOptions) { 73 | const socket = useContext(SocketContext); 74 | const listenersRef = useRef(listeners); 75 | 76 | if (!socket || socket === null) 77 | throw new Error("accessing socket before initialization"); 78 | 79 | useEffect(() => { 80 | listenersRef.current = listeners; 81 | }, [listeners]); 82 | 83 | useEffect(() => { 84 | const unsubs: Array = [ 85 | socket.on("message", (e) => { 86 | listenersRef.current?.onMessage?.(e); 87 | }), 88 | socket.on("status", (e) => { 89 | listenersRef.current?.onStatusChange?.(e); 90 | }), 91 | socket.on("disconnect", (e) => { 92 | listenersRef.current?.onDisconnect?.(e); 93 | }), 94 | socket.on("lostConnection", (e) => { 95 | listenersRef.current?.onLostConnection?.(e); 96 | }), 97 | ]; 98 | 99 | return () => { 100 | unsubs.map((unsub) => unsub()); 101 | }; 102 | }, [socket]); 103 | 104 | return socket; 105 | } 106 | 107 | function useStatus() { 108 | const socket = useSocket(); 109 | 110 | const snapshot = socket.getStatus; 111 | return useSyncExternalStore( 112 | (notify) => { 113 | return socket.on("status", notify); 114 | }, 115 | snapshot, 116 | snapshot 117 | ); 118 | } 119 | 120 | function useLostConnectionListener(callback: Listener) { 121 | const socket = useSocket(); 122 | const ref = useRef(callback); 123 | 124 | useEffect(() => { 125 | ref.current = callback; 126 | }, [callback]); 127 | 128 | useEffect(() => socket.on("lostConnection", ref.current), [socket]); 129 | } 130 | 131 | function useMessage(callback: Listener>) { 132 | const socket = useSocket(); 133 | const ref = useRef(callback); 134 | 135 | useEffect(() => { 136 | ref.current = callback; 137 | }, [callback]); 138 | 139 | useEffect(() => socket.on("message", ref.current), [socket]); 140 | } 141 | 142 | return { 143 | SocketProvider, 144 | useSocket, 145 | useMessage, 146 | useStatus, 147 | useLostConnectionListener, 148 | }; 149 | }; 150 | -------------------------------------------------------------------------------- /packages/resocket-socket/src/resocket.ts: -------------------------------------------------------------------------------- 1 | import { StopRetry, TimeoutError } from "./errors"; 2 | import { CustomEventTarget } from "./event-target"; 3 | import { CloseEvent, ErrorEvent } from "./events"; 4 | import { cloneEvent, timeoutPromise } from "./uitls"; 5 | 6 | export interface WebSocketEventMap { 7 | open: Event; 8 | message: MessageEvent; 9 | error: ErrorEvent; 10 | close: CloseEvent; 11 | 12 | //todo heh, improve this type 13 | disconnect: CloseEvent | ErrorEvent | StopRetry | undefined; 14 | 15 | status: Status; 16 | lostConnection: LostConnectionStatus; 17 | 18 | _internalStateChange: State; 19 | } 20 | 21 | type State = 22 | | "initial" 23 | | "auth" 24 | | "auth_backoff" 25 | | "connection" 26 | | "connection_backoff" 27 | | "connected" 28 | | "ping" 29 | | "ping_backoff" 30 | | "failed" 31 | | "closed" 32 | | "stopped"; 33 | 34 | export type Status = 35 | | "initial" 36 | | "connecting" 37 | | "connected" 38 | | "reconnecting" 39 | | "disconnected" 40 | | "closed"; 41 | 42 | export type LostConnectionStatus = "lost" | "restored" | "failed"; 43 | 44 | type RetryInfo = { 45 | retryCount: number; 46 | paramsRetryCount: number; 47 | connectionRetryCount: number; 48 | }; 49 | 50 | //todo improve this type 51 | type StateDataMap = { 52 | initial: undefined; 53 | auth: undefined; 54 | auth_backoff: any; 55 | connection: any; 56 | connection_backoff: any; 57 | connected: WebSocket; 58 | failed: Error; 59 | closed: any; 60 | ping: undefined; 61 | ping_backoff: undefined; 62 | stopped: undefined; 63 | }; 64 | 65 | export type ReSocketOptions = { 66 | url?: (info: { 67 | retryInfo: RetryInfo; 68 | url: string | URL; 69 | params: any; 70 | }) => string; 71 | params?: (info: RetryInfo) => Promise; 72 | 73 | //todo maybe mark this as experimental? - or hide behind advanced config :/ 74 | unstable_connectionResolver?: ( 75 | con: WebSocket, 76 | resolver: () => void, 77 | rejecter: (err?: any) => void 78 | ) => Promise | void; 79 | 80 | //todo add a proper type for the websocket interface 81 | polyfills?: { WebSocket: any }; 82 | 83 | //retries related, 84 | maxReconnectionDelay?: number; 85 | minReconnectionDelay?: number; 86 | reconnectionDelayGrowFactor?: number; 87 | //a custom delay that will override the above config if provide this argument, useful for more customized delays 88 | getDelay?: (retryCount: number) => number; 89 | maxRetries?: number; 90 | 91 | //connection related 92 | connectionTimeout?: number; 93 | paramsTimeout?: number; 94 | 95 | //application related 96 | startClosed?: boolean; 97 | lostConnectionTimeout?: number; 98 | closeCodes?: number | number[]; 99 | 100 | buffer?: boolean | { maxEnqueuedMessages: number }; 101 | 102 | // heartbeat related 103 | heartbeatInterval?: number; 104 | maxMissedPingss?: number; 105 | ignoreFocusEvents?: boolean; 106 | ignoreNetworkEvents?: boolean; 107 | pingTimeout?: number; //timeout to wait for the pong message after sending the ping 108 | pingMessage?: string; 109 | pongMessage?: string; 110 | 111 | //debug 112 | debug?: boolean; 113 | debugLogger?: (...args: any[]) => void; 114 | }; 115 | 116 | type LostConnection = { 117 | lostConnectionTimeout?: ReturnType; //timeout for when a connection is lost 118 | didLoseConnection: boolean; 119 | }; 120 | 121 | export const DEFAULT = { 122 | //todo change this to query maybe 123 | paramsTimeout: 10000, 124 | connectionTimeout: 10000, 125 | maxRetries: Infinity, 126 | buffer: { maxEnqueuedMessages: 0 }, //same as no buffering, by default we don't buffer 127 | pingTimeout: 3000, 128 | maxMissedPings: 0, 129 | ignoreFocusEvents: false, 130 | ignoreNetworkEvents: false, 131 | pingMessage: "ping", 132 | pongMessage: "pong", 133 | lostConnectionTimeout: 5000, 134 | maxReconnectionDelay: 10000, 135 | minReconnectionDelay: 1000 + Math.random() * 4000, 136 | reconnectionDelayGrowFactor: 1.3, 137 | }; 138 | 139 | export type Message = string | ArrayBuffer | Blob | ArrayBufferView; 140 | 141 | export class ReSocket extends CustomEventTarget { 142 | private _state: State = "initial"; 143 | private _cleanupFns: Array<(() => void) | undefined> = []; 144 | private _socket: WebSocket | null = null; 145 | private _lostConnection: LostConnection = { didLoseConnection: false }; 146 | 147 | private _binaryType: BinaryType = "blob"; 148 | private _successCounter = 0; 149 | private _paramsRetryCount = 0; 150 | private _connectionRetryCount = 0; 151 | private _missedPingsCount = 0; 152 | private _enqueuedMessages: Array = []; 153 | private _bufferedMessages: Array = []; 154 | 155 | private _status: Status = "initial"; 156 | private get _retryCount() { 157 | return this._paramsRetryCount + this._connectionRetryCount; 158 | } 159 | private _options: ReSocketOptions; 160 | private _debugLogger = console.log.bind(console); 161 | private WebSocket: typeof WebSocket = WebSocket; 162 | 163 | private _lastMessageSent: number = 0; 164 | 165 | constructor( 166 | private _url: string | URL, 167 | private _protocols: string | string[] | undefined = undefined, 168 | options: ReSocketOptions = {} 169 | ) { 170 | super(); 171 | 172 | this._options = options; 173 | 174 | if (this._options.debugLogger) 175 | this._debugLogger = this._options.debugLogger; 176 | 177 | if (options.polyfills) { 178 | if (options.polyfills.WebSocket) 179 | this.WebSocket = options.polyfills.WebSocket; 180 | } 181 | 182 | //log the error message if no WebSocket implementation available, and no polyfill was provided either 183 | if (!this.WebSocket) { 184 | console.error(` 185 | ‼️ No WebSocket implementation available. You should define options.WebSocket. 186 | 187 | For example, if you're using node.js, run \`npm install ws\`, and then in your code: 188 | 189 | import {ReSocket} from '@resocket/socket'; 190 | import WS from 'ws'; 191 | 192 | const resocket = new ReSocket('wss://localhost:1999', { 193 | polyfills: { 194 | WebSocket: WS 195 | } 196 | }) 197 | `); 198 | } 199 | 200 | this.attachWindowEvents(); 201 | 202 | this.addEventListener("status", this.handleLostConnection); 203 | 204 | if (!this._options.startClosed) this.transition("auth"); 205 | } 206 | 207 | static get CONNECTING() { 208 | return 0; 209 | } 210 | static get OPEN() { 211 | return 1; 212 | } 213 | static get CLOSING() { 214 | return 2; 215 | } 216 | static get CLOSED() { 217 | return 3; 218 | } 219 | 220 | get CONNECTING() { 221 | return ReSocket.CONNECTING; 222 | } 223 | get OPEN() { 224 | return ReSocket.OPEN; 225 | } 226 | get CLOSING() { 227 | return ReSocket.CLOSING; 228 | } 229 | get CLOSED() { 230 | return ReSocket.CLOSED; 231 | } 232 | 233 | get binaryType() { 234 | return this._socket ? this._socket.binaryType : this._binaryType; 235 | } 236 | 237 | set binaryType(value: BinaryType) { 238 | this._binaryType = value; 239 | if (this._socket) { 240 | this._socket.binaryType = value; 241 | } 242 | } 243 | 244 | get retryCount(): number { 245 | return Math.max(this._retryCount, 0); 246 | } 247 | 248 | get bufferedAmount(): number { 249 | const bytes = this._enqueuedMessages.reduce((acc, message) => { 250 | if (typeof message === "string") { 251 | acc += message.length; // not byte size 252 | } else if (message instanceof Blob) { 253 | acc += message.size; 254 | } else { 255 | acc += message.byteLength; 256 | } 257 | return acc; 258 | }, 0); 259 | 260 | return bytes + (this._socket ? this._socket.bufferedAmount : 0); 261 | } 262 | 263 | get extensions(): string { 264 | return this._socket ? this._socket.extensions : ""; 265 | } 266 | 267 | get protocol(): string { 268 | return this._socket ? this._socket.protocol : ""; 269 | } 270 | 271 | get readyState(): number { 272 | if (this._socket) { 273 | return this._socket.readyState; 274 | } 275 | 276 | const status = this.getStatus(); 277 | 278 | return ["closed", "failed", "diconnected"].includes(status) 279 | ? ReSocket.CLOSED 280 | : ReSocket.CONNECTING; 281 | } 282 | 283 | get url(): string { 284 | return this._socket ? this._socket.url : ""; 285 | } 286 | 287 | private _getNextDelay() { 288 | //if this is a function that means we need to override via this 289 | if (typeof this._options.getDelay === "function") { 290 | return this._options.getDelay(this._retryCount); 291 | } 292 | 293 | const { 294 | minReconnectionDelay = DEFAULT.minReconnectionDelay, 295 | maxReconnectionDelay = DEFAULT.maxReconnectionDelay, 296 | reconnectionDelayGrowFactor = DEFAULT.reconnectionDelayGrowFactor, 297 | } = this._options; 298 | 299 | let delay = 0; 300 | if (this._retryCount > 0) { 301 | delay = 302 | minReconnectionDelay * 303 | Math.pow(reconnectionDelayGrowFactor, this._retryCount - 1); 304 | if (delay > maxReconnectionDelay) { 305 | delay = maxReconnectionDelay; 306 | } 307 | } 308 | 309 | return delay; 310 | } 311 | 312 | private authentication() { 313 | const { params, paramsTimeout = DEFAULT.paramsTimeout } = this._options; 314 | 315 | if (!params) { 316 | this.transition("connection"); 317 | return; 318 | } 319 | 320 | return this.transitionAsync({ 321 | handler: timeoutPromise( 322 | params({ 323 | retryCount: this._retryCount, 324 | connectionRetryCount: this._connectionRetryCount, 325 | paramsRetryCount: this._paramsRetryCount, 326 | }), 327 | paramsTimeout, 328 | "params timeout" 329 | ), 330 | nextState: "connection", 331 | errorState: "auth_backoff", 332 | }); 333 | } 334 | 335 | private authenticationError(error: any) { 336 | this._paramsRetryCount++; 337 | this._missedPingsCount = 0; 338 | 339 | const { maxRetries = DEFAULT.maxRetries } = this._options; 340 | 341 | if (error instanceof TimeoutError) this._debug("timeout error"); 342 | 343 | //if the user threw a stop retry, we'll moved to failed 344 | if (error instanceof StopRetry) { 345 | this.transition("failed", error); 346 | return; 347 | } 348 | 349 | //max auth retry reached 350 | if (this._retryCount >= maxRetries) { 351 | this.transition("failed", new StopRetry("max retry attempt reached")); 352 | return; 353 | } 354 | 355 | const timeout = setTimeout(() => { 356 | this.transition("auth"); 357 | }, this._getNextDelay()); 358 | 359 | return () => { 360 | clearTimeout(timeout); 361 | }; 362 | } 363 | 364 | private flushBuffer() { 365 | this._enqueuedMessages.forEach((msg) => { 366 | this._socket!.send(msg); 367 | }); 368 | this._enqueuedMessages = []; 369 | this._lastMessageSent = Date.now(); 370 | } 371 | 372 | private onSocketOpen = (event: Event) => { 373 | //todo maybe we should send the messages on the transition to connected? 374 | this.flushBuffer(); 375 | 376 | if (this.onopen) { 377 | this.onopen(event); 378 | } 379 | 380 | this.dispatchEvent("open", cloneEvent(event)); 381 | }; 382 | 383 | private onSocketError = (event: Event) => { 384 | if (this.onerror) { 385 | this.onerror(event as ErrorEvent); 386 | } 387 | 388 | //dispatch the event 389 | this.dispatchEvent("error", cloneEvent(event) as ErrorEvent); 390 | 391 | //here we take it as the socket is still usable 392 | //? maybe send this to ping/pong just to make sure. 393 | if (this._socket?.readyState === 1) return; 394 | 395 | // we try reconnect, on our side, if it's anything other than connected we don't do anything 396 | if ( 397 | this._state === "connected" || 398 | this._state === "ping" || 399 | this._state === "ping_backoff" 400 | ) 401 | this.transition("auth"); 402 | }; 403 | 404 | private onSocketClose = (event: CloseEvent) => { 405 | if (this.onclose) { 406 | this.onclose(event); 407 | } 408 | 409 | //dispatch the event 410 | this.dispatchEvent("close", cloneEvent(event) as CloseEvent); 411 | 412 | const { closeCodes } = this._options; 413 | 414 | if (typeof closeCodes !== "undefined") { 415 | const closeCodesArray = 416 | typeof closeCodes === "number" ? [closeCodes] : closeCodes; 417 | 418 | for (let code of closeCodesArray) { 419 | if (event.code === code) { 420 | //our signal to stop retry 421 | this.transition("closed"); 422 | return; 423 | } 424 | } 425 | } 426 | 427 | // we try reconnect, on our side, if it's anything other than connected we don't do anything 428 | if ( 429 | this._state === "connected" || 430 | this._state === "ping" || 431 | this._state === "ping_backoff" 432 | ) 433 | this.transition("auth"); 434 | }; 435 | 436 | private shouldBuffer(force: boolean) { 437 | return ( 438 | (this._socket !== null && this._bufferedMessages.length > 0 && !force) || 439 | (this._options.unstable_connectionResolver && this._socket === null) 440 | ); 441 | } 442 | 443 | private onSocketMessage = ( 444 | event: MessageEvent, 445 | force: boolean = false 446 | ) => { 447 | if (this.shouldBuffer(force)) { 448 | this._debug("[buffering] added to buffer ", event.data); 449 | 450 | this._bufferedMessages.push(event); 451 | return; 452 | } 453 | 454 | if (this.onmessage) { 455 | this.onmessage(event); 456 | } 457 | this.dispatchEvent("message", cloneEvent(event) as MessageEvent); 458 | }; 459 | 460 | //helper 461 | private addSocketEventListeners(socket: WebSocket) { 462 | socket.addEventListener("open", this.onSocketOpen); 463 | socket.addEventListener("message", this.onSocketMessage); 464 | socket.addEventListener("close", this.onSocketClose); 465 | socket.addEventListener("error", this.onSocketError); 466 | } 467 | 468 | //helper 469 | private removeSocketEventListeners(socket: WebSocket) { 470 | socket.removeEventListener("open", this.onSocketOpen); 471 | socket.removeEventListener("message", this.onSocketMessage); 472 | socket.removeEventListener("close", this.onSocketClose); 473 | socket.removeEventListener("error", this.onSocketError); 474 | } 475 | 476 | private closeSocket(socket: WebSocket) { 477 | socket.close(); 478 | this.removeSocketEventListeners(socket); 479 | } 480 | 481 | private async _connectSocket(url: string | URL) { 482 | const { connectionTimeout = DEFAULT.connectionTimeout } = this._options; 483 | let con: WebSocket | null = null; 484 | let cleanupRejectRef: (v: any) => void; 485 | let stateChangeListenerRef: (v: any) => void; 486 | 487 | const connectSock = new Promise((resolve, reject) => { 488 | const stateChangeListener = () => { 489 | reject("status changes abort"); 490 | }; 491 | stateChangeListenerRef = stateChangeListener; 492 | this.addEventListener("_internalStateChange", stateChangeListener); 493 | 494 | const conn = new WebSocket(url, this._protocols); 495 | con = conn; 496 | 497 | const cleanupReject = (e: any) => { 498 | reject(conn); 499 | }; 500 | cleanupRejectRef = cleanupReject; 501 | 502 | const resolver = () => { 503 | conn.removeEventListener("close", cleanupReject); 504 | conn.removeEventListener("error", cleanupReject); 505 | this.removeEventListener("_internalStateChange", stateChangeListener); 506 | 507 | //! should be cleanedup by the cleanups 508 | this._socket = con; 509 | 510 | resolve(conn); 511 | }; 512 | 513 | conn.addEventListener("open", (e) => { 514 | //! we let connectionResolver resolve the connection instead. 515 | if (this._options.unstable_connectionResolver) { 516 | this._options.unstable_connectionResolver(conn, resolver, reject); 517 | return; 518 | } 519 | 520 | resolver(); 521 | }); 522 | 523 | conn.addEventListener("close", cleanupReject); 524 | conn.addEventListener("error", cleanupReject); 525 | 526 | //these will still be called on unstable_connectionResolver 527 | this.addSocketEventListeners(conn); 528 | }); 529 | 530 | try { 531 | const con = await timeoutPromise( 532 | connectSock, 533 | connectionTimeout, 534 | "connection timeout" 535 | ); 536 | 537 | return con; 538 | } catch (error) { 539 | this.removeEventListener("_internalStateChange", stateChangeListenerRef!); 540 | //The case where the conn is timeout, but the conn succeeds, this will leave a rouge conn 541 | //given a normal timeout of say 10sec it's higly unlike to happen 542 | if (con) { 543 | (con as WebSocket).removeEventListener("close", cleanupRejectRef!); 544 | (con as WebSocket).removeEventListener("error", cleanupRejectRef!); 545 | 546 | this.removeSocketEventListeners(con as WebSocket); 547 | 548 | (con as WebSocket).close(); 549 | } 550 | 551 | throw error; 552 | } 553 | } 554 | 555 | private _buildUrl(data?: object) { 556 | if (this._options.url) { 557 | return this._options.url({ 558 | params: data, 559 | retryInfo: { 560 | retryCount: this._retryCount, 561 | connectionRetryCount: this._connectionRetryCount, 562 | paramsRetryCount: this._paramsRetryCount, 563 | }, 564 | url: this._url, 565 | }); 566 | } 567 | 568 | let url = this._url; 569 | 570 | if (data) { 571 | url += `?${new URLSearchParams( 572 | Object.fromEntries( 573 | Object.entries(data).filter(([_, v]) => v !== null && v !== undefined) 574 | ) 575 | ).toString()}`; 576 | } 577 | 578 | return url; 579 | } 580 | 581 | private connection(data: object) { 582 | return this.transitionAsync({ 583 | handler: this._connectSocket(this._buildUrl(data)), 584 | nextState: "connected", 585 | errorState: "connection_backoff", 586 | 587 | //! note - this can be triggered from closing the socket on the websocket.open callback, websocket.message, websocket.error, websoket.close 588 | staleSuccess: (socket) => { 589 | if (this._socket && this._socket === socket) { 590 | this._debug("removing stale socket"); 591 | this._socket = null; 592 | } 593 | this.closeSocket(socket); 594 | }, 595 | }); 596 | } 597 | 598 | private connectionError(error: any) { 599 | this._connectionRetryCount++; 600 | 601 | const { maxRetries = DEFAULT.maxRetries } = this._options; 602 | 603 | if (error instanceof TimeoutError) this._debug("timeout error"); 604 | 605 | //if the user threw a stop retry, we'll moved to failed 606 | if (error instanceof StopRetry) { 607 | this.transition("failed", error); 608 | return; 609 | } 610 | 611 | //max retry reached 612 | if (this._retryCount >= maxRetries) { 613 | this.transition("failed", new StopRetry("max retry attempt reached")); 614 | return; 615 | } 616 | 617 | const timeout = setTimeout(() => { 618 | this.transition("auth"); 619 | }, this._getNextDelay()); 620 | 621 | return () => { 622 | clearTimeout(timeout); 623 | }; 624 | } 625 | 626 | private clearBufferOnConnect() { 627 | for (let messageEvent of this._bufferedMessages) 628 | this.onSocketMessage(messageEvent, true); 629 | this._bufferedMessages = []; 630 | } 631 | 632 | private connected(_socket: WebSocket) { 633 | this._paramsRetryCount = 0; 634 | this._connectionRetryCount = 0; 635 | this._successCounter++; 636 | 637 | this.clearBufferOnConnect(); 638 | 639 | const { heartbeatInterval } = this._options; 640 | let timeout: NodeJS.Timeout | undefined; 641 | 642 | if (typeof heartbeatInterval === "number") { 643 | timeout = setTimeout(() => { 644 | this.transition("ping"); 645 | }, heartbeatInterval); 646 | } 647 | 648 | return () => { 649 | clearTimeout(timeout); 650 | }; 651 | } 652 | 653 | private async heartbeat(con: WebSocket): Promise { 654 | const { 655 | pingMessage = DEFAULT.pingMessage, 656 | pongMessage = DEFAULT.pongMessage, 657 | pingTimeout = DEFAULT.pingTimeout, 658 | } = this._options; 659 | 660 | //send the ping 661 | con.send(pingMessage); 662 | 663 | //await the pong 664 | return new Promise((res, rej) => { 665 | const timeout = setTimeout(() => { 666 | this._debug(`[warn] no pong recieved`); 667 | con.removeEventListener("message", messageHandler); 668 | rej(); 669 | }, pingTimeout); 670 | 671 | const messageHandler = (e: MessageEvent) => { 672 | if (e.data === pongMessage) { 673 | clearTimeout(timeout); 674 | con.removeEventListener("message", messageHandler); 675 | res(); 676 | } 677 | }; 678 | 679 | con.addEventListener("message", messageHandler); 680 | }); 681 | } 682 | 683 | private ping() { 684 | return this.transitionAsync({ 685 | handler: this.heartbeat(this._socket!), 686 | nextState: "connected", 687 | errorState: "ping_backoff", 688 | }); 689 | } 690 | 691 | private ping_backoff(): undefined { 692 | this._missedPingsCount++; 693 | const { maxMissedPingss = DEFAULT.maxMissedPings } = this._options; 694 | 695 | if (this._missedPingsCount >= maxMissedPingss) { 696 | this.transition("auth_backoff"); 697 | return; 698 | } 699 | this.transition("ping"); 700 | } 701 | 702 | private failed(data: any) { 703 | this.removeWindowEvents(); 704 | this.dispatchEvent("disconnect", data); 705 | 706 | return () => { 707 | //when leaving the failed state reattach the event listeners 708 | this.attachWindowEvents(); 709 | }; 710 | } 711 | 712 | private closed(data: any) { 713 | this.removeWindowEvents(); 714 | this.dispatchEvent("disconnect", data); 715 | return () => { 716 | //when leaving the failed state reattach the event listeners 717 | this.attachWindowEvents(); 718 | }; 719 | } 720 | 721 | private stopped() { 722 | this.removeWindowEvents(); 723 | this.dispatchEvent("disconnect", undefined); 724 | this.removeEventListener("status", this.handleLostConnection); 725 | } 726 | 727 | // for both lost connection. and slow inittial connection 728 | private handleLostConnection = (status: Status) => { 729 | const { lostConnectionTimeout = DEFAULT.lostConnectionTimeout } = 730 | this._options; 731 | 732 | if (status === "connected") { 733 | clearTimeout(this._lostConnection.lostConnectionTimeout); 734 | this._lostConnection.lostConnectionTimeout = undefined; 735 | if (this._lostConnection.didLoseConnection) { 736 | this._lostConnection.didLoseConnection = false; 737 | 738 | this.dispatchEvent("lostConnection", "restored"); 739 | } 740 | return; 741 | } 742 | if ( 743 | (status === "connecting" || status === "reconnecting") && 744 | !this._lostConnection.lostConnectionTimeout 745 | ) { 746 | if (!this._lostConnection.didLoseConnection) { 747 | this._lostConnection.lostConnectionTimeout = setTimeout(() => { 748 | this._lostConnection.didLoseConnection = true; 749 | this._lostConnection.lostConnectionTimeout = undefined; 750 | 751 | this.dispatchEvent("lostConnection", "lost"); 752 | }, lostConnectionTimeout); 753 | } 754 | return; 755 | } 756 | 757 | //todo maybe we want to add a new status 'closed'. since 'closed' is not necessarily 'failed' (semantically) :/ 758 | if (status === "disconnected" || status === "closed") { 759 | clearTimeout(this._lostConnection.lostConnectionTimeout); 760 | this._lostConnection.lostConnectionTimeout = undefined; 761 | this._lostConnection.didLoseConnection = false; 762 | 763 | this.dispatchEvent("lostConnection", "failed"); 764 | return; 765 | } 766 | }; 767 | 768 | private attachWindowEvents() { 769 | const { 770 | ignoreFocusEvents = DEFAULT.ignoreFocusEvents, 771 | ignoreNetworkEvents = DEFAULT.ignoreNetworkEvents, 772 | } = this._options; 773 | 774 | if (typeof window !== "undefined") { 775 | if (!ignoreFocusEvents) 776 | window.addEventListener("focus", this.tryHeartbeat); 777 | 778 | if (!ignoreNetworkEvents) { 779 | window.addEventListener("online", this.tryHeartbeat); 780 | window.addEventListener("offline", this.tryHeartbeat); 781 | } 782 | } 783 | } 784 | 785 | private removeWindowEvents() { 786 | if (typeof window !== "undefined") { 787 | window.removeEventListener("online", this.tryHeartbeat); 788 | window.removeEventListener("offline", this.tryHeartbeat); 789 | window.removeEventListener("focus", this.tryHeartbeat); 790 | } 791 | } 792 | 793 | private tryHeartbeat = () => { 794 | this._debug(`[event] focus or offline or online`); 795 | 796 | if (!this.canTransition("ping")) { 797 | this._debug(`[invalid transition] ${this._state} -> ping`); 798 | return; 799 | } 800 | if (this.canTransition("ping")) this.transition("ping"); 801 | }; 802 | 803 | private buffer(msg: Message) { 804 | const { buffer } = this._options; 805 | 806 | if (buffer && typeof buffer === "boolean") { 807 | this._enqueuedMessages.push(msg); 808 | } else if (buffer) { 809 | if (this._enqueuedMessages.length < buffer.maxEnqueuedMessages) 810 | this._enqueuedMessages.push(msg); 811 | } 812 | } 813 | 814 | private _debug(...args: unknown[]) { 815 | if (this._options.debug) { 816 | this._debugLogger("RS>", ...args); 817 | } 818 | } 819 | 820 | private canTransition(target: State) { 821 | if (this._state === "stopped") return false; 822 | 823 | switch (target) { 824 | case "auth_backoff": { 825 | return this._state === "auth" || this._state === "ping_backoff"; 826 | } 827 | 828 | case "connection": { 829 | return this._state === "auth"; 830 | } 831 | 832 | case "connection_backoff": { 833 | return this._state === "connection"; 834 | } 835 | 836 | case "connected": { 837 | return this._state === "connection" || this._state === "ping"; 838 | } 839 | 840 | case "ping": { 841 | return ( 842 | (this._state === "connected" || this._state === "ping_backoff") && 843 | typeof this._options.heartbeatInterval === "number" 844 | ); 845 | } 846 | case "ping_backoff": { 847 | return this._state === "ping"; 848 | } 849 | 850 | case "auth": 851 | case "closed": 852 | case "failed": 853 | case "stopped": 854 | return true; 855 | 856 | default: 857 | return false; 858 | } 859 | } 860 | 861 | private doTransition(target: State, data?: any) { 862 | //cleanup the current async stuff 863 | 864 | while (this._cleanupFns.length > 0) { 865 | const fn = this._cleanupFns.pop(); 866 | 867 | if (fn) fn(); 868 | } 869 | 870 | this._debug(`[transition] `, this._state, " -> ", target); 871 | 872 | this._state = target; 873 | 874 | this.dispatchEvent("_internalStateChange", target); //here we are closing 875 | 876 | switch (target) { 877 | case "auth": { 878 | this._cleanupFns.push(this.authentication()); 879 | break; 880 | } 881 | 882 | case "auth_backoff": { 883 | this._cleanupFns.push(this.authenticationError(data)); 884 | break; 885 | } 886 | 887 | case "connection": { 888 | this._cleanupFns.push(this.connection(data)); 889 | break; 890 | } 891 | 892 | case "connection_backoff": { 893 | this._cleanupFns.push(this.connectionError(data)); 894 | break; 895 | } 896 | 897 | case "connected": { 898 | this._cleanupFns.push(this.connected(data)); 899 | break; 900 | } 901 | 902 | case "ping": { 903 | this._cleanupFns.push(this.ping()); 904 | break; 905 | } 906 | 907 | case "ping_backoff": { 908 | this._cleanupFns.push(this.ping_backoff()); 909 | break; 910 | } 911 | 912 | case "failed": { 913 | this._cleanupFns.push(this.failed(data)); 914 | break; 915 | } 916 | 917 | case "closed": { 918 | this._cleanupFns.push(this.closed(data)); 919 | break; 920 | } 921 | 922 | case "stopped": { 923 | this.stopped(); 924 | break; 925 | } 926 | } 927 | 928 | //get the current status 929 | let prevStatus = this._status; 930 | //get the updated status 931 | const newStatus = this.getStatus(); 932 | //update the current status 933 | this._status = newStatus; 934 | 935 | //since we do not want to dispatch unnecessary status updates on every state transition. we'll only dispatch if the status actually changed 936 | if (prevStatus !== newStatus) this.dispatchEvent("status", newStatus); 937 | } 938 | 939 | //useful for when we want to do cleanups when leaving certain states 940 | private cleanupCurrentState(target: State) { 941 | switch (this._state) { 942 | case "connected": 943 | case "ping": 944 | case "ping_backoff": { 945 | //when leaving the happy connected states to a non-connected state we cleanup the socket 946 | if ( 947 | target !== "ping" && 948 | target !== "connected" && 949 | target !== "ping_backoff" 950 | ) { 951 | this._debug("closing websocket"); 952 | this.closeSocket(this._socket!); 953 | this._socket = null; 954 | } 955 | 956 | break; 957 | } 958 | 959 | case "connection": { 960 | // if connectoin state has already set the socket. but now we've moved on to a non-connected state. we also cleanup the socket 961 | if (this._socket && target !== "connected") { 962 | this._debug("closing websocket"); 963 | this.closeSocket(this._socket!); 964 | this._socket = null; 965 | } 966 | 967 | break; 968 | } 969 | } 970 | } 971 | 972 | private transition(target: T, data?: StateDataMap[T]) { 973 | if (!this.canTransition(target)) 974 | throw new Error(`[invalid transition] ${this._state} -> ${target}`); 975 | 976 | this.cleanupCurrentState(target); 977 | this.doTransition(target, data); 978 | } 979 | 980 | private transitionAsync({ 981 | handler, 982 | nextState, 983 | errorState, 984 | staleSuccess, 985 | }: { 986 | handler: Promise; 987 | nextState: State; 988 | errorState: State; 989 | staleSuccess?: (data: T) => void; 990 | }) { 991 | let stale: boolean = false; 992 | 993 | const transitionHandler = async () => { 994 | try { 995 | const data = await handler; 996 | 997 | if (stale) { 998 | if (typeof staleSuccess === "function") staleSuccess(data); 999 | return; 1000 | } 1001 | 1002 | this.transition(nextState, data); 1003 | } catch (error) { 1004 | if (stale) return; 1005 | 1006 | this.transition(errorState, error); 1007 | } 1008 | }; 1009 | 1010 | transitionHandler(); 1011 | 1012 | return () => { 1013 | stale = true; 1014 | }; 1015 | } 1016 | 1017 | public onopen: ((event: Event) => void) | null = null; 1018 | public onmessage: ((event: MessageEvent) => void) | null = null; 1019 | public onerror: ((event: ErrorEvent) => void) | null = null; 1020 | public onclose: ((event: CloseEvent) => void) | null = null; 1021 | 1022 | get lastMessageSent() { 1023 | return this._lastMessageSent; 1024 | } 1025 | 1026 | getStatus = (): Status => { 1027 | switch (this._state) { 1028 | case "auth": 1029 | case "auth_backoff": 1030 | // case "connection": 1031 | case "connection_backoff": 1032 | return this._successCounter > 0 ? "reconnecting" : "connecting"; 1033 | 1034 | case "connection": { 1035 | return this._socket 1036 | ? "connected" 1037 | : this._successCounter > 0 1038 | ? "reconnecting" 1039 | : "connecting"; 1040 | } 1041 | 1042 | case "connected": 1043 | case "ping": 1044 | case "ping_backoff": //'ping_backoff' is considered 'connected' as it will either move us to a connected or reconnecting state 1045 | return "connected"; 1046 | 1047 | case "stopped": 1048 | case "failed": 1049 | return "disconnected"; 1050 | 1051 | case "initial": 1052 | case "closed": 1053 | return this._successCounter > 0 ? "closed" : "initial"; 1054 | 1055 | default: 1056 | throw new Error(`invalid state, this will never happen`, this._state); 1057 | } 1058 | }; 1059 | 1060 | //todo semantically open should only work if the socket is closed. else it should throw an error. or noop. right now it works more closer to a 'reconnect' command semantically 1061 | open() { 1062 | this.transition("auth"); 1063 | } 1064 | 1065 | reconnect() { 1066 | this.transition("auth"); 1067 | } 1068 | 1069 | close() { 1070 | this.transition("closed"); 1071 | } 1072 | 1073 | stop() { 1074 | this.transition("stopped"); 1075 | } 1076 | 1077 | isUsable() { 1078 | return this._state !== "stopped"; 1079 | } 1080 | 1081 | canSend() { 1082 | if (this._socket !== null && this._socket.readyState === 1) return true; 1083 | return false; 1084 | } 1085 | 1086 | //we're returning a boolean here, this'll help for custom enqueueing when offline 1087 | send(data: Message) { 1088 | if (this.canSend()) { 1089 | this._socket!.send(data); 1090 | this._lastMessageSent = Date.now(); 1091 | return true; 1092 | } 1093 | 1094 | this.buffer(data); 1095 | return false; 1096 | } 1097 | } 1098 | 1099 | type PublicResocket = Omit< 1100 | ReSocket, 1101 | "addEventListener" | "removeEventListener" | "dispatchEvent" 1102 | > & 1103 | (new ( 1104 | url: string | URL, 1105 | protocols?: string | string[] | undefined, 1106 | options?: ReSocketOptions 1107 | ) => PublicResocket) & 1108 | CustomEventTarget>; 1109 | 1110 | export type Socket = PublicResocket; 1111 | export const Socket = ReSocket as any as PublicResocket; 1112 | -------------------------------------------------------------------------------- /packages/resocket-socket/src/uitls.ts: -------------------------------------------------------------------------------- 1 | import { TimeoutError } from "./errors"; 2 | import { CloseEvent, ErrorEvent } from "./events"; 3 | 4 | export const timeoutPromise = ( 5 | func: Promise, 6 | timeout: number, 7 | timeoutErrMsg: string 8 | ): Promise => { 9 | return new Promise((resolve, reject) => { 10 | let timeoutId: NodeJS.Timeout; 11 | const timeoutPromise = new Promise((_resolve, _reject) => { 12 | timeoutId = setTimeout(() => { 13 | _reject(new TimeoutError(timeoutErrMsg)); 14 | }, timeout); 15 | }); 16 | 17 | Promise.race([func, timeoutPromise]) 18 | .then((data) => { 19 | resolve(data as T); 20 | }) 21 | .catch((error) => { 22 | reject(error); 23 | }) 24 | .finally(() => { 25 | clearTimeout(timeoutId); 26 | }); 27 | }); 28 | }; 29 | 30 | function cloneEventBrowser(e: Event) { 31 | return new (e as any).constructor(e.type, e) as Event; 32 | } 33 | 34 | function cloneEventNode(e: Event) { 35 | if ("data" in e) { 36 | const evt = new MessageEvent(e.type, e); 37 | return evt; 38 | } 39 | 40 | if ("code" in e || "reason" in e) { 41 | const evt = new CloseEvent( 42 | // @ts-expect-error we need to fix event/listener types 43 | (e.code || 1999) as number, 44 | // @ts-expect-error we need to fix event/listener types 45 | (e.reason || "unknown reason") as string, 46 | e 47 | ); 48 | return evt; 49 | } 50 | 51 | if ("error" in e) { 52 | const evt = new ErrorEvent(e.error as Error, e); 53 | return evt; 54 | } 55 | 56 | const evt = new Event(e.type, e); 57 | return evt; 58 | } 59 | 60 | const isNode = 61 | typeof process !== "undefined" && 62 | typeof process.versions?.node !== "undefined" && 63 | typeof document === "undefined"; 64 | 65 | export const cloneEvent = isNode ? cloneEventNode : cloneEventBrowser; 66 | -------------------------------------------------------------------------------- /packages/resocket-socket/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "jsx": "react", 5 | "incremental": false 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/resocket-socket/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig({ 4 | entry: ["src/index.ts", "src/react.tsx"], 5 | dts: true, 6 | splitting: true, 7 | clean: true, 8 | minify: true, 9 | target: "esnext", 10 | format: ["esm", "cjs"], 11 | external: ["react"], 12 | sourcemap: true, 13 | }); 14 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "ESNext" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 22 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 26 | 27 | /* Modules */ 28 | "module": "ESNext" /* Specify what module code is generated. */, 29 | // "rootDir": "./", /* Specify the root folder within your source files. */ 30 | "moduleResolution": "Node" /* Specify how TypeScript looks up a file from a given module specifier. */, 31 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 32 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 33 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 34 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 35 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 36 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 37 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 38 | // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ 39 | // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ 40 | // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ 41 | // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ 42 | "resolveJsonModule": true /* Enable importing .json files. */, 43 | // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ 44 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 45 | 46 | /* JavaScript Support */ 47 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 48 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 49 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 50 | 51 | /* Emit */ 52 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 53 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 54 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 55 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 56 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 57 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 58 | // "outDir": "./", /* Specify an output folder for all emitted files. */ 59 | // "removeComments": true, /* Disable emitting comments. */ 60 | // "noEmit": true, /* Disable emitting files from a compilation. */ 61 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 62 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 63 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 64 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 65 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 66 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 67 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 68 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 69 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 70 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 71 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 72 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 73 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 74 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 75 | 76 | /* Interop Constraints */ 77 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 78 | // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ 79 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 80 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, 81 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 82 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 83 | 84 | /* Type Checking */ 85 | "strict": true /* Enable all strict type-checking options. */, 86 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 87 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 88 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 89 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 90 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 91 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 92 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 93 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 94 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 95 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 96 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 97 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 98 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 99 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 100 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 101 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 102 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 103 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 104 | 105 | /* Completeness */ 106 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 107 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 108 | } 109 | } 110 | --------------------------------------------------------------------------------