├── .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 |
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 | socket.send("toggle")}
118 | >
119 | toggle
120 |
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 |
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 |
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 |
--------------------------------------------------------------------------------