├── .github
└── workflows
│ └── ci.yml
├── .gitignore
├── CHANGELOG.md
├── LICENSE
├── README.md
├── assets
├── adapter.excalidraw
├── adapter.png
└── adapter_dark.png
├── docker-compose.yml
├── lib
├── index.ts
├── sharded-adapter.ts
└── util.ts
├── package-lock.json
├── package.json
├── test
├── custom-parser.ts
├── index.ts
├── specifics.ts
├── test-runner.ts
└── util.ts
└── tsconfig.json
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | pull_request:
6 | schedule:
7 | - cron: '0 0 * * 0'
8 |
9 | permissions:
10 | contents: read
11 |
12 | jobs:
13 | test-node:
14 | runs-on: ubuntu-latest
15 |
16 | strategy:
17 | matrix:
18 | node-version:
19 | - 14
20 | - 20
21 |
22 | services:
23 | redis:
24 | image: redis:7
25 | options: >-
26 | --health-cmd "redis-cli ping"
27 | --health-interval 10s
28 | --health-timeout 5s
29 | --health-retries 5
30 | ports:
31 | - 6379:6379
32 |
33 | redis-cluster:
34 | image: grokzen/redis-cluster:7.0.10
35 | options: >-
36 | --health-cmd "redis-cli -p 7005 ping"
37 | --health-interval 10s
38 | --health-timeout 5s
39 | --health-retries 5
40 | ports:
41 | - "7000-7005:7000-7005"
42 |
43 | steps:
44 | - name: Checkout repository
45 | uses: actions/checkout@v4
46 |
47 | - name: Use Node.js ${{ matrix.node-version }}
48 | uses: actions/setup-node@v4
49 | with:
50 | node-version: ${{ matrix.node-version }}
51 |
52 | - name: Install dependencies
53 | run: npm ci
54 |
55 | - name: Run tests
56 | run: npm test
57 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | lib-cov
2 | *.seed
3 | *.log
4 | *.csv
5 | *.dat
6 | *.out
7 | *.pid
8 | *.gz
9 |
10 | pids
11 | logs
12 | results
13 |
14 | npm-debug.log
15 | node_modules
16 | .idea
17 | .nyc_output/
18 | dist/
19 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # History
2 |
3 | - [8.3.0](830-2024-03-13) (Mar 2024)
4 | - [8.2.1](821-2023-05-14) (May 2023)
5 | - [8.2.0](820-2023-05-02) (May 2023)
6 | - [8.1.0](810-2023-02-08) (Feb 2023)
7 | - [8.0.1](801-2023-01-11) (Jan 2023)
8 | - [**8.0.0**](#800-2022-12-07) (Dec 2022)
9 | - [7.2.0](#720-2022-05-03) (May 2022)
10 | - [7.1.0](#710-2021-11-29) (Nov 2021)
11 | - [7.0.1](#701-2021-11-15) (Nov 2021)
12 | - [**7.0.0**](#700-2021-05-11) (May 2021)
13 | - [6.1.0](#610-2021-03-12) (Mar 2021)
14 | - [6.0.1](#601-2020-11-14) (Nov 2020)
15 | - [**6.0.0**](#600-2020-11-12) (Nov 2020)
16 | - [5.4.0](#540-2020-09-02) (Sep 2020)
17 | - [5.3.0](#530-2020-06-04) (Jun 2020)
18 | - [5.2.0](#520-2017-08-24) (Aug 2017)
19 | - [5.1.0](#510-2017-06-04) (Jun 2017)
20 |
21 |
22 |
23 | # Release notes
24 |
25 | ## [8.3.0](https://github.com/socketio/socket.io-redis-adapter/compare/8.2.1...8.3.0) (2024-03-13)
26 |
27 |
28 | ### Bug Fixes
29 |
30 | * **sharded:** allow to target a specific socket ID in dynamic mode ([#525](https://github.com/socketio/socket.io-redis-adapter/issues/525)) ([cca38dc](https://github.com/socketio/socket.io-redis-adapter/commit/cca38dc24d0b5dd797c440b58795314cbeaf89f0))
31 | * **sharded:** fix count in fetchSockets() method ([#523](https://github.com/socketio/socket.io-redis-adapter/issues/523)) ([bd32763](https://github.com/socketio/socket.io-redis-adapter/commit/bd32763043a2eb79a21dffd8820f20e598348adf))
32 | * **sharded:** fix SSUBSCRIBE memory leak with ioredis ([#529](https://github.com/socketio/socket.io-redis-adapter/issues/529)) ([2113e8d](https://github.com/socketio/socket.io-redis-adapter/commit/2113e8d9eff9e13f9bbd9b603b93f42de512eb44))
33 |
34 |
35 | ### Features
36 |
37 | * **sharded:** add an option for dynamic private channels ([#526](https://github.com/socketio/socket.io-redis-adapter/issues/526)) ([50220f4](https://github.com/socketio/socket.io-redis-adapter/commit/50220f49cd73047e9f70afcb18c9ac62c716bd3d))
38 |
39 |
40 |
41 | ## [8.2.1](https://github.com/socketio/socket.io-redis-adapter/compare/8.2.0...8.2.1) (2023-05-14)
42 |
43 |
44 | ### Bug Fixes
45 |
46 | * **sharded:** ensure compatibility with ioredis ([42c8ab6](https://github.com/socketio/socket.io-redis-adapter/commit/42c8ab6764a3d4c855b27eea35b4e0cda9c34b37))
47 | * **sharded:** properly unsubscribe when closing ([2da8d9e](https://github.com/socketio/socket.io-redis-adapter/commit/2da8d9e57afbed3201f818bca77ac17ce9636fa3))
48 |
49 |
50 |
51 | ## [8.2.0](https://github.com/socketio/socket.io-redis-adapter/compare/8.1.0...8.2.0) (2023-05-02)
52 |
53 |
54 | ### Bug Fixes
55 |
56 | * cleanup error handler to prevent memory leak ([#490](https://github.com/socketio/socket.io-redis-adapter/issues/490)) ([b5da02d](https://github.com/socketio/socket.io-redis-adapter/commit/b5da02d779490f73c6c041999d10be1c98494f84))
57 |
58 |
59 | ### Features
60 |
61 | #### Sharded Pub/Sub
62 |
63 | Sharded Pub/Sub was introduced in Redis 7.0 in order to help scaling the usage of Pub/Sub in cluster mode.
64 |
65 | Reference: https://redis.io/docs/manual/pubsub/#sharded-pubsub
66 |
67 | A dedicated adapter can be created with the `createShardedAdapter()` method:
68 |
69 | ```js
70 | import { Server } from 'socket.io';
71 | import { createClient } from 'redis';
72 | import { createShardedAdapter } from '@socket.io/redis-adapter';
73 |
74 | const pubClient = createClient({ host: 'localhost', port: 6379 });
75 | const subClient = pubClient.duplicate();
76 |
77 | await Promise.all([
78 | pubClient.connect(),
79 | subClient.connect()
80 | ]);
81 |
82 | const io = new Server({
83 | adapter: createShardedAdapter(pubClient, subClient)
84 | });
85 |
86 | io.listen(3000);
87 | ```
88 |
89 | Minimum requirements:
90 |
91 | - Redis 7.0
92 | - [`redis@4.6.0`](https://github.com/redis/node-redis/commit/3b1bad229674b421b2bc6424155b20d4d3e45bd1)
93 |
94 | Added in [e70b1bd](https://github.com/socketio/socket.io-redis-adapter/commit/e70b1bde105d88eaa43272ff094c5540981a66d3).
95 |
96 | #### Support for node-redis cluster
97 |
98 | The `redis` package now supports Redis cluster.
99 |
100 | Added in [77ef42c](https://github.com/socketio/socket.io-redis-adapter/commit/77ef42c95d1ab637c33e2f69af5e0f7a12072629).
101 |
102 | #### Subscription modes
103 |
104 | The `subscriptionMode` option allows to configure how many Redis Pub/Sub channels are used:
105 |
106 | - "static": 2 channels per namespace
107 |
108 | Useful when used with dynamic namespaces.
109 |
110 | - "dynamic": (2 + 1 per public room) channels per namespace
111 |
112 | The default value, useful when some rooms have a low number of clients (so only a few Socket.IO servers are notified).
113 |
114 | ```js
115 | const io = new Server({
116 | adapter: createShardedAdapter(pubClient, subClient, {
117 | subscriptionMode: "static"
118 | })
119 | });
120 | ```
121 |
122 | Added in [d3388bf](https://github.com/socketio/socket.io-redis-adapter/commit/d3388bf7b5b64ff6d2c25a874f4956273c8e3f58).
123 |
124 | ### Credits
125 |
126 | Huge thanks to [@winchell](https://github.com/winchell) for helping!
127 |
128 |
129 |
130 | ## [8.1.0](https://github.com/socketio/socket.io-redis-adapter/compare/8.0.1...8.1.0) (2023-02-08)
131 |
132 | The `socket.io-adapter` package was added to the list of `peerDependencies`, in order to fix sync issues with the version imported by the `socket.io` package (see [f07ff7b](https://github.com/socketio/socket.io-redis-adapter/commit/f07ff7bd33817ac14d8d87ba55225e7936469429)).
133 |
134 | ### Features
135 |
136 | #### Automatic removal of empty child namespaces
137 |
138 | The `close()` method was implemented, in order to be used with the new `cleanupEmptyChildNamespaces` option.
139 |
140 | Reference: https://github.com/socketio/socket.io/releases/tag/4.6.0
141 |
142 | Added in [fe89f7e](https://github.com/socketio/socket.io-redis-adapter/commit/fe89f7e5fe9676d0054b77de147fb244034a441e).
143 |
144 |
145 |
146 | ## [8.0.1](https://github.com/socketio/socket.io-redis-adapter/compare/8.0.0...8.0.1) (2023-01-11)
147 |
148 | This release pins the `socket.io-adapter` package to version `~2.4.0` instead of `^2.4.0`.
149 |
150 |
151 |
152 | ## [8.0.0](https://github.com/socketio/socket.io-redis-adapter/compare/7.2.0...8.0.0) (2022-12-07)
153 |
154 |
155 | ### Dependencies
156 |
157 | * bump notepack.io to version ~3.0.1 ([#464](https://github.com/socketio/socket.io-redis-adapter/issues/464)) ([c96b2e7](https://github.com/socketio/socket.io-redis-adapter/commit/c96b2e72b1183dce45c9d2dcb94fcdf57b1a5141))
158 |
159 |
160 | ### Features
161 |
162 | * add option to allow usage of custom parser ([#471](https://github.com/socketio/socket.io-redis-adapter/issues/471)) ([73f6320](https://github.com/socketio/socket.io-redis-adapter/commit/73f6320006f39945c961678116ceee80f30efcf6))
163 |
164 | Example with [msgpackr](https://github.com/kriszyp/msgpackr):
165 |
166 | ```js
167 | import { unpack, pack } from "msgpackr";
168 |
169 | io.adapter(createAdapter(pubClient, subClient, {
170 | parser: {
171 | encode(val) {
172 | return pack(val);
173 | },
174 | decode(val) {
175 | return unpack(val);
176 | }
177 | }
178 | }));
179 | ```
180 |
181 | * remove deprecated methods ([fb760d9](https://github.com/socketio/socket.io-redis-adapter/commit/fb760d9d778ed8129543bf8321d87e4fd9cca711))
182 |
183 |
184 | ### BREAKING CHANGES
185 |
186 | * the remoteJoin(), remoteLeave(), remoteDisconnect()
187 | and sockets() methods are removed in favor of the official alternatives
188 |
189 | Related: https://github.com/socketio/socket.io/commit/b25495c069031674da08e19aed68922c7c7a0e28
190 |
191 | * the format of Date objects is modified in a non
192 | backward-compatible way, as notepack.io now implements the MessagePack
193 | Timestamp extension type.
194 |
195 | Reference: https://github.com/msgpack/msgpack/blob/master/spec.md#timestamp-extension-type
196 |
197 | Previous versions of the adapter will not be able to parse the Date
198 | objects sent by newer versions.
199 |
200 | - Reference: https://github.com/darrachequesne/notepack/releases/tag/3.0.0
201 | - Diff: https://github.com/darrachequesne/notepack/compare/2.3.0...3.0.1
202 |
203 |
204 |
205 | ## [7.2.0](https://github.com/socketio/socket.io-redis-adapter/compare/7.1.0...7.2.0) (2022-05-03)
206 |
207 |
208 | ### Bug Fixes
209 |
210 | * add support for ioredis v5 ([#453](https://github.com/socketio/socket.io-redis-adapter/issues/453)) ([d2faa8a](https://github.com/socketio/socket.io-redis-adapter/commit/d2faa8a55a9ef206976a1ef35041d068997324f9))
211 |
212 |
213 | ### Features
214 |
215 | * broadcast and expect multiple acks ([e4c40cc](https://github.com/socketio/socket.io-redis-adapter/commit/e4c40cc8a9ad8803f03bcbbfd6b713f3c082ee28))
216 |
217 | This feature was added in `socket.io@4.5.0`:
218 |
219 | ```js
220 | io.timeout(1000).emit("some-event", (err, responses) => {
221 | // ...
222 | });
223 | ```
224 |
225 | Thanks to this change, it will now work with multiple Socket.IO servers.
226 |
227 |
228 |
229 | ## [7.1.0](https://github.com/socketio/socket.io-redis-adapter/compare/7.0.1...7.1.0) (2021-11-29)
230 |
231 |
232 | ### Features
233 |
234 | * add support for redis v4 ([aa681b3](https://github.com/socketio/socket.io-redis-adapter/commit/aa681b3bc914358d206ab35761d291a466ac18da))
235 | * do not emit "error" events anymore ([8e5c84f](https://github.com/socketio/socket.io-redis-adapter/commit/8e5c84f7edcda85a6f7e36c04ebd74152c1cade1))
236 | * send response to the requesting node only ([f66de11](https://github.com/socketio/socket.io-redis-adapter/commit/f66de114a4581b692da759015def0373c619aab7))
237 |
238 |
239 |
240 | ## [7.0.1](https://github.com/socketio/socket.io-redis-adapter/compare/7.0.0...7.0.1) (2021-11-15)
241 |
242 |
243 | ### Bug Fixes
244 |
245 | * allow numeric rooms ([214b5d1](https://github.com/socketio/socket.io-redis-adapter/commit/214b5d1a8d4f1bc037712ed53dceba7ee55ea643))
246 | * ignore sessionStore in the fetchSockets method ([c5dce43](https://github.com/socketio/socket.io-redis-adapter/commit/c5dce438950491b608ed8ed46369b8f120fa82e4))
247 |
248 |
249 |
250 | ## [7.0.0](https://github.com/socketio/socket.io-redis-adapter/compare/6.1.0...7.0.0) (2021-05-11)
251 |
252 |
253 | ### Features
254 |
255 | * implement the serverSideEmit functionality ([3a0f29f](https://github.com/socketio/socket.io-redis-adapter/commit/3a0f29fbe322f280f48f92b3aac0fcc94d698ee8))
256 | * remove direct redis dependency ([c68a47c](https://github.com/socketio/socket.io-redis-adapter/commit/c68a47c4948554125dac0e317e19947a4d3d3251))
257 | * rename the package to `@socket.io/redis-adapter` ([3cac178](https://github.com/socketio/socket.io-redis-adapter/commit/3cac1789c558a3ece5bb222d73f097952b55c340))
258 |
259 |
260 | ### BREAKING CHANGES
261 |
262 | * the library will no longer create Redis clients on behalf of the user.
263 |
264 | Before:
265 |
266 | ```js
267 | io.adapter(redisAdapter({ host: "localhost", port: 6379 }));
268 | ```
269 |
270 | After:
271 |
272 | ```js
273 | const pubClient = createClient({ host: "localhost", port: 6379 });
274 | const subClient = pubClient.duplicate();
275 |
276 | io.adapter(redisAdapter(pubClient, subClient));
277 | ```
278 |
279 |
280 | ## [6.1.0](https://github.com/socketio/socket.io-redis/compare/6.0.1...6.1.0) (2021-03-12)
281 |
282 |
283 | ### Features
284 |
285 | * implement utility methods from Socket.IO v4 ([468c3c8](https://github.com/socketio/socket.io-redis/commit/468c3c8008ddd0c89b2fc2054d874e9e706f0948))
286 |
287 |
288 | ### Performance Improvements
289 |
290 | * remove one round-trip for the requester ([6c8d770](https://github.com/socketio/socket.io-redis/commit/6c8d7701962bee4acf83568f8e998876d3549fb8))
291 |
292 |
293 | ## [6.0.1](https://github.com/socketio/socket.io-redis/compare/6.0.0...6.0.1) (2020-11-14)
294 |
295 |
296 | ### Bug Fixes
297 |
298 | * **typings:** properly expose the createAdapter method ([0d2d69c](https://github.com/socketio/socket.io-redis/commit/0d2d69cc78aa3418a7b5a6231a13ea4028dd74a3))
299 | * fix broadcasting ([#361](https://github.com/socketio/socket.io-redis/issues/361)) ([3334d99](https://github.com/socketio/socket.io-redis/commit/3334d99e1b6e2f80485c73133381a18798b24bc0))
300 |
301 |
302 |
303 | ## [6.0.0](https://github.com/socketio/socket.io-redis/compare/5.4.0...6.0.0) (2020-11-12)
304 |
305 |
306 | ### Features
307 |
308 | * add support for Socket.IO v3 ([d9bcb19](https://github.com/socketio/socket.io-redis/commit/d9bcb1935940d7ad414ba7154de51cdc4a7d45b1))
309 |
310 | ### BREAKING CHANGES:
311 |
312 | - all the requests (for inter-node communication) now return a Promise instead of accepting a callback
313 |
314 | Before:
315 |
316 | ```js
317 | io.of('/').adapter.allRooms((err, rooms) => {
318 | console.log(rooms); // an array containing all rooms (accross every node)
319 | });
320 | ```
321 |
322 | After:
323 |
324 | ```js
325 | const rooms = await io.of('/').adapter.allRooms();
326 | console.log(rooms); // a Set containing all rooms (across every node)
327 | ```
328 |
329 | - RedisAdapter.clients() is renamed to RedisAdapter.sockets()
330 |
331 | See https://github.com/socketio/socket.io-adapter/commit/130f28a43c5aca924aa2c1a318422d21ba03cdac
332 |
333 | - RedisAdapter.customHook() and RedisAdapter.customRequest() are removed
334 |
335 | Those methods will be replaced by a more intuitive API in a future iteration.
336 |
337 | - support for Node.js 8 is dropped
338 |
339 | See https://github.com/nodejs/Release
340 |
341 |
342 |
343 | ## [5.4.0](https://github.com/socketio/socket.io-redis/compare/5.3.0...5.4.0) (2020-09-02)
344 |
345 |
346 | ### Features
347 |
348 | * update node-redis version to 3.x ([5b3ed58](https://github.com/socketio/socket.io-redis/commit/5b3ed5877acfdb35e4faa2f46f06a8032ff8b574))
349 |
350 |
351 |
352 | ## [5.3.0](https://github.com/socketio/socket.io-redis/compare/5.2.0...5.3.0) (2020-06-04)
353 |
354 |
355 | ### Features
356 |
357 | * add support for Redis Cluster ([7a19075](https://github.com/socketio/socket.io-redis/commit/7a190755c01732d1335199732e7b0eb5a1fb1f9e))
358 |
359 |
360 |
361 | ## [5.2.0](https://github.com/socketio/socket.io-redis/compare/5.1.0...5.2.0) (2017-08-24)
362 |
363 |
364 | ### Features
365 |
366 | * increase default requestsTimeout to 5000 ms ([37e28df](https://github.com/socketio/socket.io-redis/commit/37e28df54b0b8c71b4f8ea1766e56dc63fb26ba2))
367 |
368 |
369 |
370 | ## [5.1.0](https://github.com/socketio/socket.io-redis/compare/5.0.1...5.1.0) (2017-06-04)
371 |
372 | ### Bug Fixes
373 |
374 | * use the requestid from response when deleting requests ([4f08b1a](https://github.com/socketio/socket.io-redis/commit/4f08b1ae7b3b9ee549349f1b95f5e3f3ff69d651))
375 |
376 |
377 | ### Features
378 |
379 | * add support for ArrayBuffer ([b3ad4ad](https://github.com/socketio/socket.io-redis/commit/b3ad4ad28b225f1999d5dd709f2ea6d5674085f6))
380 |
381 |
382 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2016 Guillermo Rauch (@rauchg)
2 |
3 | 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:
4 |
5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6 |
7 | 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 SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Socket.IO Redis adapter
2 |
3 | The `@socket.io/redis-adapter` package allows broadcasting packets between multiple Socket.IO servers.
4 |
5 |
6 |
7 |
8 |
9 |
10 | **Table of contents**
11 |
12 | - [Supported features](#supported-features)
13 | - [Installation](#installation)
14 | - [Compatibility table](#compatibility-table)
15 | - [Usage](#usage)
16 | - [With the `redis` package](#with-the-redis-package)
17 | - [With the `redis` package and a Redis cluster](#with-the-redis-package-and-a-redis-cluster)
18 | - [With the `ioredis` package](#with-the-ioredis-package)
19 | - [With the `ioredis` package and a Redis cluster](#with-the-ioredis-package-and-a-redis-cluster)
20 | - [With Redis sharded Pub/Sub](#with-redis-sharded-pubsub)
21 | - [Options](#options)
22 | - [Default adapter](#default-adapter)
23 | - [Sharded adapter](#sharded-adapter)
24 | - [License](#license)
25 |
26 | ## Supported features
27 |
28 | | Feature | `socket.io` version | Support |
29 | |---------------------------------|---------------------|------------------------------------------------|
30 | | Socket management | `4.0.0` | :white_check_mark: YES (since version `6.1.0`) |
31 | | Inter-server communication | `4.1.0` | :white_check_mark: YES (since version `7.0.0`) |
32 | | Broadcast with acknowledgements | `4.5.0` | :white_check_mark: YES (since version `7.2.0`) |
33 | | Connection state recovery | `4.6.0` | :x: NO |
34 |
35 | ## Installation
36 |
37 | ```
38 | npm install @socket.io/redis-adapter
39 | ```
40 |
41 | ## Compatibility table
42 |
43 | | Redis Adapter version | Socket.IO server version |
44 | |-----------------------|--------------------------|
45 | | 4.x | 1.x |
46 | | 5.x | 2.x |
47 | | 6.0.x | 3.x |
48 | | 6.1.x | 4.x |
49 | | 7.x and above | 4.3.1 and above |
50 |
51 | ## Usage
52 |
53 | ### With the `redis` package
54 |
55 | ```js
56 | import { createClient } from "redis";
57 | import { Server } from "socket.io";
58 | import { createAdapter } from "@socket.io/redis-adapter";
59 |
60 | const pubClient = createClient({ url: "redis://localhost:6379" });
61 | const subClient = pubClient.duplicate();
62 |
63 | await Promise.all([
64 | pubClient.connect(),
65 | subClient.connect()
66 | ]);
67 |
68 | const io = new Server({
69 | adapter: createAdapter(pubClient, subClient)
70 | });
71 |
72 | io.listen(3000);
73 | ```
74 |
75 | ### With the `redis` package and a Redis cluster
76 |
77 | ```js
78 | import { createCluster } from "redis";
79 | import { Server } from "socket.io";
80 | import { createAdapter } from "@socket.io/redis-adapter";
81 |
82 | const pubClient = createCluster({
83 | rootNodes: [
84 | {
85 | url: "redis://localhost:7000",
86 | },
87 | {
88 | url: "redis://localhost:7001",
89 | },
90 | {
91 | url: "redis://localhost:7002",
92 | },
93 | ],
94 | });
95 | const subClient = pubClient.duplicate();
96 |
97 | await Promise.all([
98 | pubClient.connect(),
99 | subClient.connect()
100 | ]);
101 |
102 | const io = new Server({
103 | adapter: createAdapter(pubClient, subClient)
104 | });
105 |
106 | io.listen(3000);
107 | ```
108 |
109 | ### With the `ioredis` package
110 |
111 | ```js
112 | import { Redis } from "ioredis";
113 | import { Server } from "socket.io";
114 | import { createAdapter } from "@socket.io/redis-adapter";
115 |
116 | const pubClient = new Redis();
117 | const subClient = pubClient.duplicate();
118 |
119 | const io = new Server({
120 | adapter: createAdapter(pubClient, subClient)
121 | });
122 |
123 | io.listen(3000);
124 | ```
125 |
126 | ### With the `ioredis` package and a Redis cluster
127 |
128 | ```js
129 | import { Cluster } from "ioredis";
130 | import { Server } from "socket.io";
131 | import { createAdapter } from "@socket.io/redis-adapter";
132 |
133 | const pubClient = new Cluster([
134 | {
135 | host: "localhost",
136 | port: 7000,
137 | },
138 | {
139 | host: "localhost",
140 | port: 7001,
141 | },
142 | {
143 | host: "localhost",
144 | port: 7002,
145 | },
146 | ]);
147 | const subClient = pubClient.duplicate();
148 |
149 | const io = new Server({
150 | adapter: createAdapter(pubClient, subClient)
151 | });
152 |
153 | io.listen(3000);
154 | ```
155 |
156 | ### With Redis sharded Pub/Sub
157 |
158 | Sharded Pub/Sub was introduced in Redis 7.0 in order to help scaling the usage of Pub/Sub in cluster mode.
159 |
160 | Reference: https://redis.io/docs/interact/pubsub/#sharded-pubsub
161 |
162 | A dedicated adapter can be created with the `createShardedAdapter()` method:
163 |
164 | ```js
165 | import { Server } from "socket.io";
166 | import { createClient } from "redis";
167 | import { createShardedAdapter } from "@socket.io/redis-adapter";
168 |
169 | const pubClient = createClient({ host: "localhost", port: 6379 });
170 | const subClient = pubClient.duplicate();
171 |
172 | await Promise.all([
173 | pubClient.connect(),
174 | subClient.connect()
175 | ]);
176 |
177 | const io = new Server({
178 | adapter: createShardedAdapter(pubClient, subClient)
179 | });
180 |
181 | io.listen(3000);
182 | ```
183 |
184 | Minimum requirements:
185 |
186 | - Redis 7.0
187 | - [`redis@4.6.0`](https://github.com/redis/node-redis/commit/3b1bad229674b421b2bc6424155b20d4d3e45bd1)
188 |
189 | Note: it is not currently possible to use the sharded adapter with the `ioredis` package and a Redis cluster ([reference](https://github.com/luin/ioredis/issues/1759)).
190 |
191 | ## Options
192 |
193 | ### Default adapter
194 |
195 | | Name | Description | Default value |
196 | |------------------------------------|-------------------------------------------------------------------------------|---------------|
197 | | `key` | The prefix for the Redis Pub/Sub channels. | `socket.io` |
198 | | `requestsTimeout` | After this timeout the adapter will stop waiting from responses to request. | `5_000` |
199 | | `publishOnSpecificResponseChannel` | Whether to publish a response to the channel specific to the requesting node. | `false` |
200 | | `parser` | The parser to use for encoding and decoding messages sent to Redis. | `-` |
201 |
202 | ### Sharded adapter
203 |
204 | | Name | Description | Default value |
205 | |--------------------|-----------------------------------------------------------------------------------------|---------------|
206 | | `channelPrefix` | The prefix for the Redis Pub/Sub channels. | `socket.io` |
207 | | `subscriptionMode` | The subscription mode impacts the number of Redis Pub/Sub channels used by the adapter. | `dynamic` |
208 |
209 | ## License
210 |
211 | [MIT](LICENSE)
212 |
--------------------------------------------------------------------------------
/assets/adapter.excalidraw:
--------------------------------------------------------------------------------
1 | {
2 | "type": "excalidraw",
3 | "version": 2,
4 | "source": "https://excalidraw.com",
5 | "elements": [
6 | {
7 | "type": "text",
8 | "version": 184,
9 | "versionNonce": 1779359888,
10 | "isDeleted": false,
11 | "id": "5hUB5ALUlsn26W0PzU4fM",
12 | "fillStyle": "hachure",
13 | "strokeWidth": 1,
14 | "strokeStyle": "solid",
15 | "roughness": 1,
16 | "opacity": 100,
17 | "angle": 0,
18 | "x": 531,
19 | "y": -120.5,
20 | "strokeColor": "#000000",
21 | "backgroundColor": "transparent",
22 | "width": 63.95000076293945,
23 | "height": 25,
24 | "seed": 28708370,
25 | "groupIds": [],
26 | "frameId": null,
27 | "roundness": null,
28 | "boundElements": [],
29 | "updated": 1710340018665,
30 | "link": null,
31 | "locked": false,
32 | "fontSize": 20,
33 | "fontFamily": 1,
34 | "text": "socket",
35 | "textAlign": "center",
36 | "verticalAlign": "middle",
37 | "containerId": null,
38 | "originalText": "socket",
39 | "lineHeight": 1.25
40 | },
41 | {
42 | "type": "rectangle",
43 | "version": 199,
44 | "versionNonce": 1125439632,
45 | "isDeleted": false,
46 | "id": "lmQ4o4New7xuXQLwavuSn",
47 | "fillStyle": "hachure",
48 | "strokeWidth": 1,
49 | "strokeStyle": "solid",
50 | "roughness": 1,
51 | "opacity": 100,
52 | "angle": 0,
53 | "x": 461,
54 | "y": -204,
55 | "strokeColor": "#000000",
56 | "backgroundColor": "transparent",
57 | "width": 277,
58 | "height": 311,
59 | "seed": 1594950354,
60 | "groupIds": [],
61 | "frameId": null,
62 | "roundness": null,
63 | "boundElements": [
64 | {
65 | "type": "arrow",
66 | "id": "_wBO22vaQplcoKyBXbWRC"
67 | },
68 | {
69 | "type": "arrow",
70 | "id": "BZVwnsrGk9G-X87ZHkh-6"
71 | }
72 | ],
73 | "updated": 1710339987249,
74 | "link": null,
75 | "locked": false
76 | },
77 | {
78 | "type": "text",
79 | "version": 112,
80 | "versionNonce": 725168240,
81 | "isDeleted": false,
82 | "id": "ZQsZmj4NaTubBHMkVG2dl",
83 | "fillStyle": "hachure",
84 | "strokeWidth": 1,
85 | "strokeStyle": "solid",
86 | "roughness": 1,
87 | "opacity": 100,
88 | "angle": 0,
89 | "x": 480,
90 | "y": -193,
91 | "strokeColor": "#000000",
92 | "backgroundColor": "transparent",
93 | "width": 84.81666564941406,
94 | "height": 26,
95 | "seed": 126533902,
96 | "groupIds": [],
97 | "frameId": null,
98 | "roundness": null,
99 | "boundElements": [],
100 | "updated": 1710340018665,
101 | "link": null,
102 | "locked": false,
103 | "fontSize": 20,
104 | "fontFamily": 1,
105 | "text": "Server A",
106 | "textAlign": "left",
107 | "verticalAlign": "top",
108 | "containerId": null,
109 | "originalText": "Server A",
110 | "lineHeight": 1.3
111 | },
112 | {
113 | "type": "arrow",
114 | "version": 166,
115 | "versionNonce": 1893470352,
116 | "isDeleted": false,
117 | "id": "ABQydsvmkN5ptLyYQaUA3",
118 | "fillStyle": "hachure",
119 | "strokeWidth": 2,
120 | "strokeStyle": "solid",
121 | "roughness": 1,
122 | "opacity": 100,
123 | "angle": 0,
124 | "x": 474.8983868047594,
125 | "y": -102.13129275811838,
126 | "strokeColor": "#000000",
127 | "backgroundColor": "transparent",
128 | "width": 251.33111393617446,
129 | "height": 0.7613046474941143,
130 | "seed": 1466702734,
131 | "groupIds": [],
132 | "frameId": null,
133 | "roundness": {
134 | "type": 2
135 | },
136 | "boundElements": [],
137 | "updated": 1710339987250,
138 | "link": null,
139 | "locked": false,
140 | "startBinding": null,
141 | "endBinding": null,
142 | "lastCommittedPoint": null,
143 | "startArrowhead": null,
144 | "endArrowhead": "arrow",
145 | "points": [
146 | [
147 | 0,
148 | 0
149 | ],
150 | [
151 | -251.33111393617446,
152 | -0.7613046474941143
153 | ]
154 | ]
155 | },
156 | {
157 | "type": "rectangle",
158 | "version": 241,
159 | "versionNonce": 975928976,
160 | "isDeleted": false,
161 | "id": "x54ljUV2PW8AfubZ6fiVJ",
162 | "fillStyle": "hachure",
163 | "strokeWidth": 1,
164 | "strokeStyle": "solid",
165 | "roughness": 1,
166 | "opacity": 100,
167 | "angle": 0,
168 | "x": 73,
169 | "y": -132,
170 | "strokeColor": "#000000",
171 | "backgroundColor": "transparent",
172 | "width": 129,
173 | "height": 56,
174 | "seed": 486293390,
175 | "groupIds": [],
176 | "frameId": null,
177 | "roundness": null,
178 | "boundElements": [],
179 | "updated": 1710339987250,
180 | "link": null,
181 | "locked": false
182 | },
183 | {
184 | "type": "arrow",
185 | "version": 182,
186 | "versionNonce": 1130179728,
187 | "isDeleted": false,
188 | "id": "zdzgdf3hgOYX0SgjEtyIZ",
189 | "fillStyle": "hachure",
190 | "strokeWidth": 2,
191 | "strokeStyle": "solid",
192 | "roughness": 1,
193 | "opacity": 100,
194 | "angle": 0,
195 | "x": 474.24341762810946,
196 | "y": -36.807185424128534,
197 | "strokeColor": "#000000",
198 | "backgroundColor": "transparent",
199 | "width": 247.23231148719788,
200 | "height": 2.2114410393964476,
201 | "seed": 1674715794,
202 | "groupIds": [],
203 | "frameId": null,
204 | "roundness": {
205 | "type": 2
206 | },
207 | "boundElements": [],
208 | "updated": 1710339987250,
209 | "link": null,
210 | "locked": false,
211 | "startBinding": null,
212 | "endBinding": null,
213 | "lastCommittedPoint": null,
214 | "startArrowhead": null,
215 | "endArrowhead": "arrow",
216 | "points": [
217 | [
218 | 0,
219 | 0
220 | ],
221 | [
222 | -247.23231148719788,
223 | 2.2114410393964476
224 | ]
225 | ]
226 | },
227 | {
228 | "type": "text",
229 | "version": 216,
230 | "versionNonce": 612650640,
231 | "isDeleted": false,
232 | "id": "dXknKeuYe3X3K-0Hw9P95",
233 | "fillStyle": "hachure",
234 | "strokeWidth": 1,
235 | "strokeStyle": "solid",
236 | "roughness": 1,
237 | "opacity": 100,
238 | "angle": 0,
239 | "x": 121,
240 | "y": -115,
241 | "strokeColor": "#000000",
242 | "backgroundColor": "transparent",
243 | "width": 40.983333587646484,
244 | "height": 20,
245 | "seed": 1858283854,
246 | "groupIds": [],
247 | "frameId": null,
248 | "roundness": null,
249 | "boundElements": [],
250 | "updated": 1710340018665,
251 | "link": null,
252 | "locked": false,
253 | "fontSize": 16,
254 | "fontFamily": 1,
255 | "text": "client",
256 | "textAlign": "left",
257 | "verticalAlign": "top",
258 | "containerId": null,
259 | "originalText": "client",
260 | "lineHeight": 1.25
261 | },
262 | {
263 | "type": "rectangle",
264 | "version": 278,
265 | "versionNonce": 1162299536,
266 | "isDeleted": false,
267 | "id": "Ce1Lw4MMOtiunstd3FPJv",
268 | "fillStyle": "hachure",
269 | "strokeWidth": 1,
270 | "strokeStyle": "solid",
271 | "roughness": 1,
272 | "opacity": 100,
273 | "angle": 0,
274 | "x": 74.5,
275 | "y": -57,
276 | "strokeColor": "#000000",
277 | "backgroundColor": "transparent",
278 | "width": 129,
279 | "height": 56,
280 | "seed": 568384654,
281 | "groupIds": [],
282 | "frameId": null,
283 | "roundness": null,
284 | "boundElements": [],
285 | "updated": 1710339987250,
286 | "link": null,
287 | "locked": false
288 | },
289 | {
290 | "type": "text",
291 | "version": 282,
292 | "versionNonce": 261726832,
293 | "isDeleted": false,
294 | "id": "rcCUGk-XM0jKzcGaeO0iS",
295 | "fillStyle": "hachure",
296 | "strokeWidth": 1,
297 | "strokeStyle": "solid",
298 | "roughness": 1,
299 | "opacity": 100,
300 | "angle": 0,
301 | "x": 121.5,
302 | "y": -40,
303 | "strokeColor": "#000000",
304 | "backgroundColor": "transparent",
305 | "width": 40.983333587646484,
306 | "height": 20,
307 | "seed": 244546386,
308 | "groupIds": [],
309 | "frameId": null,
310 | "roundness": null,
311 | "boundElements": [],
312 | "updated": 1710340018665,
313 | "link": null,
314 | "locked": false,
315 | "fontSize": 16,
316 | "fontFamily": 1,
317 | "text": "client",
318 | "textAlign": "left",
319 | "verticalAlign": "top",
320 | "containerId": null,
321 | "originalText": "client",
322 | "lineHeight": 1.25
323 | },
324 | {
325 | "type": "rectangle",
326 | "version": 284,
327 | "versionNonce": 1884972176,
328 | "isDeleted": false,
329 | "id": "4iido5zQ7QhoIfnOzWp3h",
330 | "fillStyle": "hachure",
331 | "strokeWidth": 1,
332 | "strokeStyle": "solid",
333 | "roughness": 1,
334 | "opacity": 100,
335 | "angle": 0,
336 | "x": 74.5,
337 | "y": 18,
338 | "strokeColor": "#000000",
339 | "backgroundColor": "transparent",
340 | "width": 129,
341 | "height": 56,
342 | "seed": 1055485070,
343 | "groupIds": [],
344 | "frameId": null,
345 | "roundness": null,
346 | "boundElements": [],
347 | "updated": 1710339987250,
348 | "link": null,
349 | "locked": false
350 | },
351 | {
352 | "type": "text",
353 | "version": 276,
354 | "versionNonce": 169606288,
355 | "isDeleted": false,
356 | "id": "D1E2DkimaDb8hGxIfXKmq",
357 | "fillStyle": "hachure",
358 | "strokeWidth": 1,
359 | "strokeStyle": "solid",
360 | "roughness": 1,
361 | "opacity": 100,
362 | "angle": 0,
363 | "x": 121.5,
364 | "y": 35,
365 | "strokeColor": "#000000",
366 | "backgroundColor": "transparent",
367 | "width": 40.983333587646484,
368 | "height": 20,
369 | "seed": 270265170,
370 | "groupIds": [],
371 | "frameId": null,
372 | "roundness": null,
373 | "boundElements": [],
374 | "updated": 1710340018665,
375 | "link": null,
376 | "locked": false,
377 | "fontSize": 16,
378 | "fontFamily": 1,
379 | "text": "client",
380 | "textAlign": "left",
381 | "verticalAlign": "top",
382 | "containerId": null,
383 | "originalText": "client",
384 | "lineHeight": 1.25
385 | },
386 | {
387 | "type": "rectangle",
388 | "version": 237,
389 | "versionNonce": 1314291856,
390 | "isDeleted": false,
391 | "id": "RRrk3Vsl-pM8Z1r8Fj3Vu",
392 | "fillStyle": "hachure",
393 | "strokeWidth": 1,
394 | "strokeStyle": "solid",
395 | "roughness": 1,
396 | "opacity": 100,
397 | "angle": 0,
398 | "x": 497.5,
399 | "y": -132,
400 | "strokeColor": "#000000",
401 | "backgroundColor": "transparent",
402 | "width": 129,
403 | "height": 56,
404 | "seed": 1013161166,
405 | "groupIds": [],
406 | "frameId": null,
407 | "roundness": null,
408 | "boundElements": [],
409 | "updated": 1710339987250,
410 | "link": null,
411 | "locked": false
412 | },
413 | {
414 | "type": "text",
415 | "version": 244,
416 | "versionNonce": 857461872,
417 | "isDeleted": false,
418 | "id": "8pCtm42TpakWdZ7WNS4VN",
419 | "fillStyle": "hachure",
420 | "strokeWidth": 1,
421 | "strokeStyle": "solid",
422 | "roughness": 1,
423 | "opacity": 100,
424 | "angle": 0,
425 | "x": 530,
426 | "y": -50.5,
427 | "strokeColor": "#000000",
428 | "backgroundColor": "transparent",
429 | "width": 63.95000076293945,
430 | "height": 25,
431 | "seed": 684338382,
432 | "groupIds": [],
433 | "frameId": null,
434 | "roundness": null,
435 | "boundElements": [],
436 | "updated": 1710340018665,
437 | "link": null,
438 | "locked": false,
439 | "fontSize": 20,
440 | "fontFamily": 1,
441 | "text": "socket",
442 | "textAlign": "center",
443 | "verticalAlign": "middle",
444 | "containerId": null,
445 | "originalText": "socket",
446 | "lineHeight": 1.25
447 | },
448 | {
449 | "type": "rectangle",
450 | "version": 295,
451 | "versionNonce": 2029755536,
452 | "isDeleted": false,
453 | "id": "thsI1AfZ_VshmC8wdQoT_",
454 | "fillStyle": "hachure",
455 | "strokeWidth": 1,
456 | "strokeStyle": "solid",
457 | "roughness": 1,
458 | "opacity": 100,
459 | "angle": 0,
460 | "x": 496.5,
461 | "y": -62,
462 | "strokeColor": "#000000",
463 | "backgroundColor": "transparent",
464 | "width": 129,
465 | "height": 56,
466 | "seed": 1104563986,
467 | "groupIds": [],
468 | "frameId": null,
469 | "roundness": null,
470 | "boundElements": [],
471 | "updated": 1710339987250,
472 | "link": null,
473 | "locked": false
474 | },
475 | {
476 | "type": "text",
477 | "version": 243,
478 | "versionNonce": 2089675408,
479 | "isDeleted": false,
480 | "id": "dfFxeVTIg6OH8ny7WuBsb",
481 | "fillStyle": "hachure",
482 | "strokeWidth": 1,
483 | "strokeStyle": "solid",
484 | "roughness": 1,
485 | "opacity": 100,
486 | "angle": 0,
487 | "x": 527,
488 | "y": 26.5,
489 | "strokeColor": "#000000",
490 | "backgroundColor": "transparent",
491 | "width": 63.95000076293945,
492 | "height": 25,
493 | "seed": 1000469902,
494 | "groupIds": [],
495 | "frameId": null,
496 | "roundness": null,
497 | "boundElements": [],
498 | "updated": 1710340018665,
499 | "link": null,
500 | "locked": false,
501 | "fontSize": 20,
502 | "fontFamily": 1,
503 | "text": "socket",
504 | "textAlign": "center",
505 | "verticalAlign": "middle",
506 | "containerId": null,
507 | "originalText": "socket",
508 | "lineHeight": 1.25
509 | },
510 | {
511 | "type": "rectangle",
512 | "version": 296,
513 | "versionNonce": 436844688,
514 | "isDeleted": false,
515 | "id": "Ejm4QTgpRy-0064kg5DDC",
516 | "fillStyle": "hachure",
517 | "strokeWidth": 1,
518 | "strokeStyle": "solid",
519 | "roughness": 1,
520 | "opacity": 100,
521 | "angle": 0,
522 | "x": 493.5,
523 | "y": 15,
524 | "strokeColor": "#000000",
525 | "backgroundColor": "transparent",
526 | "width": 129,
527 | "height": 56,
528 | "seed": 1070363218,
529 | "groupIds": [],
530 | "frameId": null,
531 | "roundness": null,
532 | "boundElements": [],
533 | "updated": 1710339987250,
534 | "link": null,
535 | "locked": false
536 | },
537 | {
538 | "type": "arrow",
539 | "version": 217,
540 | "versionNonce": 1063528080,
541 | "isDeleted": false,
542 | "id": "yn0_EJ_FjGmr2PHYTCPsC",
543 | "fillStyle": "hachure",
544 | "strokeWidth": 2,
545 | "strokeStyle": "solid",
546 | "roughness": 1,
547 | "opacity": 100,
548 | "angle": 0,
549 | "x": 474.61615574359894,
550 | "y": 42.89427948030175,
551 | "strokeColor": "#000000",
552 | "backgroundColor": "transparent",
553 | "width": 247.23231148719788,
554 | "height": 2.2114410393964476,
555 | "seed": 1559186084,
556 | "groupIds": [],
557 | "frameId": null,
558 | "roundness": {
559 | "type": 2
560 | },
561 | "boundElements": [],
562 | "updated": 1710339987250,
563 | "link": null,
564 | "locked": false,
565 | "startBinding": null,
566 | "endBinding": null,
567 | "lastCommittedPoint": null,
568 | "startArrowhead": null,
569 | "endArrowhead": "arrow",
570 | "points": [
571 | [
572 | 0,
573 | 0
574 | ],
575 | [
576 | -247.23231148719788,
577 | 2.2114410393964476
578 | ]
579 | ]
580 | },
581 | {
582 | "type": "text",
583 | "version": 193,
584 | "versionNonce": 1267189360,
585 | "isDeleted": false,
586 | "id": "2KQuRzgUL-iSoMHZQ9zbS",
587 | "fillStyle": "hachure",
588 | "strokeWidth": 1,
589 | "strokeStyle": "solid",
590 | "roughness": 1,
591 | "opacity": 100,
592 | "angle": 0,
593 | "x": 529.5,
594 | "y": 282,
595 | "strokeColor": "#000000",
596 | "backgroundColor": "transparent",
597 | "width": 63.95000076293945,
598 | "height": 25,
599 | "seed": 1479277478,
600 | "groupIds": [],
601 | "frameId": null,
602 | "roundness": null,
603 | "boundElements": [],
604 | "updated": 1710340018665,
605 | "link": null,
606 | "locked": false,
607 | "fontSize": 20,
608 | "fontFamily": 1,
609 | "text": "socket",
610 | "textAlign": "center",
611 | "verticalAlign": "middle",
612 | "containerId": null,
613 | "originalText": "socket",
614 | "lineHeight": 1.25
615 | },
616 | {
617 | "type": "rectangle",
618 | "version": 223,
619 | "versionNonce": 324518544,
620 | "isDeleted": false,
621 | "id": "dJhDWOnAJOszWt_UNEXdt",
622 | "fillStyle": "hachure",
623 | "strokeWidth": 1,
624 | "strokeStyle": "solid",
625 | "roughness": 1,
626 | "opacity": 100,
627 | "angle": 0,
628 | "x": 459.5,
629 | "y": 198.5,
630 | "strokeColor": "#000000",
631 | "backgroundColor": "transparent",
632 | "width": 277,
633 | "height": 280,
634 | "seed": 224360890,
635 | "groupIds": [],
636 | "frameId": null,
637 | "roundness": null,
638 | "boundElements": [
639 | {
640 | "type": "arrow",
641 | "id": "qmYaJfZ9NO1RK7YHGQGo6"
642 | },
643 | {
644 | "type": "arrow",
645 | "id": "x_nMpLlFEV43XGOAM6Gxj"
646 | }
647 | ],
648 | "updated": 1710339987250,
649 | "link": null,
650 | "locked": false
651 | },
652 | {
653 | "type": "text",
654 | "version": 130,
655 | "versionNonce": 1720850576,
656 | "isDeleted": false,
657 | "id": "lyh4RgaTTCZNLUjl519k9",
658 | "fillStyle": "hachure",
659 | "strokeWidth": 1,
660 | "strokeStyle": "solid",
661 | "roughness": 1,
662 | "opacity": 100,
663 | "angle": 0,
664 | "x": 478.5,
665 | "y": 209.5,
666 | "strokeColor": "#000000",
667 | "backgroundColor": "transparent",
668 | "width": 86.23332977294922,
669 | "height": 26,
670 | "seed": 364484326,
671 | "groupIds": [],
672 | "frameId": null,
673 | "roundness": null,
674 | "boundElements": [],
675 | "updated": 1710340018665,
676 | "link": null,
677 | "locked": false,
678 | "fontSize": 20,
679 | "fontFamily": 1,
680 | "text": "Server B",
681 | "textAlign": "left",
682 | "verticalAlign": "top",
683 | "containerId": null,
684 | "originalText": "Server B",
685 | "lineHeight": 1.3
686 | },
687 | {
688 | "type": "arrow",
689 | "version": 204,
690 | "versionNonce": 1532623504,
691 | "isDeleted": false,
692 | "id": "x7ujWlTTvv0aN7XIFTWjr",
693 | "fillStyle": "hachure",
694 | "strokeWidth": 2,
695 | "strokeStyle": "solid",
696 | "roughness": 1,
697 | "opacity": 100,
698 | "angle": 0,
699 | "x": 479.3983868047594,
700 | "y": 301.3687072418816,
701 | "strokeColor": "#000000",
702 | "backgroundColor": "transparent",
703 | "width": 251.33111393617446,
704 | "height": 0.7613046474941143,
705 | "seed": 1836855930,
706 | "groupIds": [],
707 | "frameId": null,
708 | "roundness": {
709 | "type": 2
710 | },
711 | "boundElements": [],
712 | "updated": 1710339987250,
713 | "link": null,
714 | "locked": false,
715 | "startBinding": null,
716 | "endBinding": null,
717 | "lastCommittedPoint": null,
718 | "startArrowhead": null,
719 | "endArrowhead": "arrow",
720 | "points": [
721 | [
722 | 0,
723 | 0
724 | ],
725 | [
726 | -251.33111393617446,
727 | -0.7613046474941143
728 | ]
729 | ]
730 | },
731 | {
732 | "type": "rectangle",
733 | "version": 211,
734 | "versionNonce": 828123280,
735 | "isDeleted": false,
736 | "id": "cqdTPTcZefvtqeNEAMTBe",
737 | "fillStyle": "hachure",
738 | "strokeWidth": 1,
739 | "strokeStyle": "solid",
740 | "roughness": 1,
741 | "opacity": 100,
742 | "angle": 0,
743 | "x": 77.5,
744 | "y": 271.5,
745 | "strokeColor": "#000000",
746 | "backgroundColor": "transparent",
747 | "width": 129,
748 | "height": 56,
749 | "seed": 1567738406,
750 | "groupIds": [],
751 | "frameId": null,
752 | "roundness": null,
753 | "boundElements": [],
754 | "updated": 1710339987250,
755 | "link": null,
756 | "locked": false
757 | },
758 | {
759 | "type": "arrow",
760 | "version": 220,
761 | "versionNonce": 585668240,
762 | "isDeleted": false,
763 | "id": "59kripFevaDD2Mo2bkYk-",
764 | "fillStyle": "hachure",
765 | "strokeWidth": 2,
766 | "strokeStyle": "solid",
767 | "roughness": 1,
768 | "opacity": 100,
769 | "angle": 0,
770 | "x": 478.74341762810946,
771 | "y": 366.69281457587147,
772 | "strokeColor": "#000000",
773 | "backgroundColor": "transparent",
774 | "width": 247.23231148719788,
775 | "height": 2.2114410393964476,
776 | "seed": 1124324154,
777 | "groupIds": [],
778 | "frameId": null,
779 | "roundness": {
780 | "type": 2
781 | },
782 | "boundElements": [],
783 | "updated": 1710339987250,
784 | "link": null,
785 | "locked": false,
786 | "startBinding": null,
787 | "endBinding": null,
788 | "lastCommittedPoint": null,
789 | "startArrowhead": null,
790 | "endArrowhead": "arrow",
791 | "points": [
792 | [
793 | 0,
794 | 0
795 | ],
796 | [
797 | -247.23231148719788,
798 | 2.2114410393964476
799 | ]
800 | ]
801 | },
802 | {
803 | "type": "text",
804 | "version": 189,
805 | "versionNonce": 363165808,
806 | "isDeleted": false,
807 | "id": "U0x2FIFxg4BZgOIK6sVnW",
808 | "fillStyle": "hachure",
809 | "strokeWidth": 1,
810 | "strokeStyle": "solid",
811 | "roughness": 1,
812 | "opacity": 100,
813 | "angle": 0,
814 | "x": 125.5,
815 | "y": 288.5,
816 | "strokeColor": "#000000",
817 | "backgroundColor": "transparent",
818 | "width": 40.983333587646484,
819 | "height": 20,
820 | "seed": 1044485478,
821 | "groupIds": [],
822 | "frameId": null,
823 | "roundness": null,
824 | "boundElements": [],
825 | "updated": 1710340018665,
826 | "link": null,
827 | "locked": false,
828 | "fontSize": 16,
829 | "fontFamily": 1,
830 | "text": "client",
831 | "textAlign": "left",
832 | "verticalAlign": "top",
833 | "containerId": null,
834 | "originalText": "client",
835 | "lineHeight": 1.25
836 | },
837 | {
838 | "type": "rectangle",
839 | "version": 231,
840 | "versionNonce": 1417687696,
841 | "isDeleted": false,
842 | "id": "NU9potS0F6f8sxY5IT0Lt",
843 | "fillStyle": "hachure",
844 | "strokeWidth": 1,
845 | "strokeStyle": "solid",
846 | "roughness": 1,
847 | "opacity": 100,
848 | "angle": 0,
849 | "x": 79,
850 | "y": 346.5,
851 | "strokeColor": "#000000",
852 | "backgroundColor": "transparent",
853 | "width": 129,
854 | "height": 56,
855 | "seed": 1884904442,
856 | "groupIds": [],
857 | "frameId": null,
858 | "roundness": null,
859 | "boundElements": [],
860 | "updated": 1710339987250,
861 | "link": null,
862 | "locked": false
863 | },
864 | {
865 | "type": "text",
866 | "version": 210,
867 | "versionNonce": 467699344,
868 | "isDeleted": false,
869 | "id": "IpJJ20xja0yqXQC_netfw",
870 | "fillStyle": "hachure",
871 | "strokeWidth": 1,
872 | "strokeStyle": "solid",
873 | "roughness": 1,
874 | "opacity": 100,
875 | "angle": 0,
876 | "x": 126,
877 | "y": 363.5,
878 | "strokeColor": "#000000",
879 | "backgroundColor": "transparent",
880 | "width": 40.983333587646484,
881 | "height": 20,
882 | "seed": 1635121318,
883 | "groupIds": [],
884 | "frameId": null,
885 | "roundness": null,
886 | "boundElements": [],
887 | "updated": 1710340018665,
888 | "link": null,
889 | "locked": false,
890 | "fontSize": 16,
891 | "fontFamily": 1,
892 | "text": "client",
893 | "textAlign": "left",
894 | "verticalAlign": "top",
895 | "containerId": null,
896 | "originalText": "client",
897 | "lineHeight": 1.25
898 | },
899 | {
900 | "type": "rectangle",
901 | "version": 250,
902 | "versionNonce": 1105251984,
903 | "isDeleted": false,
904 | "id": "scSxnujNYgELyMUDbnTNS",
905 | "fillStyle": "hachure",
906 | "strokeWidth": 1,
907 | "strokeStyle": "solid",
908 | "roughness": 1,
909 | "opacity": 100,
910 | "angle": 0,
911 | "x": 496,
912 | "y": 270.5,
913 | "strokeColor": "#000000",
914 | "backgroundColor": "transparent",
915 | "width": 129,
916 | "height": 56,
917 | "seed": 303703418,
918 | "groupIds": [],
919 | "frameId": null,
920 | "roundness": null,
921 | "boundElements": [],
922 | "updated": 1710339987250,
923 | "link": null,
924 | "locked": false
925 | },
926 | {
927 | "type": "text",
928 | "version": 234,
929 | "versionNonce": 1966926448,
930 | "isDeleted": false,
931 | "id": "Lyv2NwV0SfYm5kvp9sJEn",
932 | "fillStyle": "hachure",
933 | "strokeWidth": 1,
934 | "strokeStyle": "solid",
935 | "roughness": 1,
936 | "opacity": 100,
937 | "angle": 0,
938 | "x": 528.5,
939 | "y": 352,
940 | "strokeColor": "#000000",
941 | "backgroundColor": "transparent",
942 | "width": 63.95000076293945,
943 | "height": 25,
944 | "seed": 1344309030,
945 | "groupIds": [],
946 | "frameId": null,
947 | "roundness": null,
948 | "boundElements": [],
949 | "updated": 1710340018666,
950 | "link": null,
951 | "locked": false,
952 | "fontSize": 20,
953 | "fontFamily": 1,
954 | "text": "socket",
955 | "textAlign": "center",
956 | "verticalAlign": "middle",
957 | "containerId": null,
958 | "originalText": "socket",
959 | "lineHeight": 1.25
960 | },
961 | {
962 | "type": "rectangle",
963 | "version": 291,
964 | "versionNonce": 1288333968,
965 | "isDeleted": false,
966 | "id": "e3D2rl_rbVQwQUKshOG8E",
967 | "fillStyle": "hachure",
968 | "strokeWidth": 1,
969 | "strokeStyle": "solid",
970 | "roughness": 1,
971 | "opacity": 100,
972 | "angle": 0,
973 | "x": 495,
974 | "y": 340.5,
975 | "strokeColor": "#000000",
976 | "backgroundColor": "transparent",
977 | "width": 129,
978 | "height": 56,
979 | "seed": 627795514,
980 | "groupIds": [],
981 | "frameId": null,
982 | "roundness": null,
983 | "boundElements": [],
984 | "updated": 1710339987250,
985 | "link": null,
986 | "locked": false
987 | },
988 | {
989 | "type": "diamond",
990 | "version": 272,
991 | "versionNonce": 2067699856,
992 | "isDeleted": false,
993 | "id": "k0pJTVL4F3HHsfRPlE-gO",
994 | "fillStyle": "hachure",
995 | "strokeWidth": 2,
996 | "strokeStyle": "solid",
997 | "roughness": 0,
998 | "opacity": 100,
999 | "angle": 0,
1000 | "x": 786,
1001 | "y": -58,
1002 | "strokeColor": "#000000",
1003 | "backgroundColor": "transparent",
1004 | "width": 46,
1005 | "height": 46,
1006 | "seed": 1260350118,
1007 | "groupIds": [],
1008 | "frameId": null,
1009 | "roundness": null,
1010 | "boundElements": [
1011 | {
1012 | "type": "arrow",
1013 | "id": "Sp9AvxDh8gwRvSC53VFKe"
1014 | }
1015 | ],
1016 | "updated": 1710339987250,
1017 | "link": null,
1018 | "locked": false
1019 | },
1020 | {
1021 | "type": "text",
1022 | "version": 285,
1023 | "versionNonce": 1550342288,
1024 | "isDeleted": false,
1025 | "id": "DiLMkDsU2SrPef3STL9fw",
1026 | "fillStyle": "hachure",
1027 | "strokeWidth": 1,
1028 | "strokeStyle": "dashed",
1029 | "roughness": 0,
1030 | "opacity": 100,
1031 | "angle": 0,
1032 | "x": 765.5583343505859,
1033 | "y": -97.5,
1034 | "strokeColor": "#000000",
1035 | "backgroundColor": "transparent",
1036 | "width": 139.88333129882812,
1037 | "height": 26,
1038 | "seed": 1810644198,
1039 | "groupIds": [],
1040 | "frameId": null,
1041 | "roundness": null,
1042 | "boundElements": [],
1043 | "updated": 1710339990684,
1044 | "link": null,
1045 | "locked": false,
1046 | "fontSize": 20,
1047 | "fontFamily": 1,
1048 | "text": "Redis adapter",
1049 | "textAlign": "center",
1050 | "verticalAlign": "top",
1051 | "containerId": null,
1052 | "originalText": "Redis adapter",
1053 | "lineHeight": 1.3
1054 | },
1055 | {
1056 | "type": "arrow",
1057 | "version": 57,
1058 | "versionNonce": 1020103280,
1059 | "isDeleted": false,
1060 | "id": "Sp9AvxDh8gwRvSC53VFKe",
1061 | "fillStyle": "hachure",
1062 | "strokeWidth": 2,
1063 | "strokeStyle": "solid",
1064 | "roughness": 0,
1065 | "opacity": 100,
1066 | "angle": 0,
1067 | "x": 766,
1068 | "y": -35,
1069 | "strokeColor": "#000000",
1070 | "backgroundColor": "transparent",
1071 | "width": 109,
1072 | "height": 1,
1073 | "seed": 714162918,
1074 | "groupIds": [],
1075 | "frameId": null,
1076 | "roundness": {
1077 | "type": 2
1078 | },
1079 | "boundElements": [],
1080 | "updated": 1710339987276,
1081 | "link": null,
1082 | "locked": false,
1083 | "startBinding": {
1084 | "elementId": "k0pJTVL4F3HHsfRPlE-gO",
1085 | "focus": -0.01715197447147986,
1086 | "gap": 14.142135623730947
1087 | },
1088 | "endBinding": null,
1089 | "lastCommittedPoint": null,
1090 | "startArrowhead": null,
1091 | "endArrowhead": "arrow",
1092 | "points": [
1093 | [
1094 | 0,
1095 | 0
1096 | ],
1097 | [
1098 | -109,
1099 | -1
1100 | ]
1101 | ]
1102 | },
1103 | {
1104 | "type": "arrow",
1105 | "version": 73,
1106 | "versionNonce": 1637006448,
1107 | "isDeleted": false,
1108 | "id": "_wBO22vaQplcoKyBXbWRC",
1109 | "fillStyle": "hachure",
1110 | "strokeWidth": 2,
1111 | "strokeStyle": "solid",
1112 | "roughness": 0,
1113 | "opacity": 100,
1114 | "angle": 0,
1115 | "x": 763,
1116 | "y": -41,
1117 | "strokeColor": "#000000",
1118 | "backgroundColor": "transparent",
1119 | "width": 105,
1120 | "height": 57,
1121 | "seed": 1243541542,
1122 | "groupIds": [],
1123 | "frameId": null,
1124 | "roundness": {
1125 | "type": 2
1126 | },
1127 | "boundElements": [],
1128 | "updated": 1710339987276,
1129 | "link": null,
1130 | "locked": false,
1131 | "startBinding": {
1132 | "elementId": "lmQ4o4New7xuXQLwavuSn",
1133 | "focus": 0.35224176368590543,
1134 | "gap": 25
1135 | },
1136 | "endBinding": null,
1137 | "lastCommittedPoint": null,
1138 | "startArrowhead": null,
1139 | "endArrowhead": "arrow",
1140 | "points": [
1141 | [
1142 | 0,
1143 | 0
1144 | ],
1145 | [
1146 | -105,
1147 | -57
1148 | ]
1149 | ]
1150 | },
1151 | {
1152 | "type": "arrow",
1153 | "version": 88,
1154 | "versionNonce": 1524739696,
1155 | "isDeleted": false,
1156 | "id": "BZVwnsrGk9G-X87ZHkh-6",
1157 | "fillStyle": "hachure",
1158 | "strokeWidth": 2,
1159 | "strokeStyle": "solid",
1160 | "roughness": 0,
1161 | "opacity": 100,
1162 | "angle": 0,
1163 | "x": 765,
1164 | "y": -28,
1165 | "strokeColor": "#000000",
1166 | "backgroundColor": "transparent",
1167 | "width": 95,
1168 | "height": 62,
1169 | "seed": 1890534970,
1170 | "groupIds": [],
1171 | "frameId": null,
1172 | "roundness": {
1173 | "type": 2
1174 | },
1175 | "boundElements": [],
1176 | "updated": 1710339987276,
1177 | "link": null,
1178 | "locked": false,
1179 | "startBinding": {
1180 | "elementId": "lmQ4o4New7xuXQLwavuSn",
1181 | "focus": -0.522635330379503,
1182 | "gap": 27
1183 | },
1184 | "endBinding": null,
1185 | "lastCommittedPoint": null,
1186 | "startArrowhead": null,
1187 | "endArrowhead": "arrow",
1188 | "points": [
1189 | [
1190 | 0,
1191 | 0
1192 | ],
1193 | [
1194 | -95,
1195 | 62
1196 | ]
1197 | ]
1198 | },
1199 | {
1200 | "type": "diamond",
1201 | "version": 378,
1202 | "versionNonce": 1094118544,
1203 | "isDeleted": false,
1204 | "id": "vJwd2LS9grrvUFlbCugEG",
1205 | "fillStyle": "hachure",
1206 | "strokeWidth": 2,
1207 | "strokeStyle": "solid",
1208 | "roughness": 0,
1209 | "opacity": 100,
1210 | "angle": 0,
1211 | "x": 786.25,
1212 | "y": 343,
1213 | "strokeColor": "#000000",
1214 | "backgroundColor": "transparent",
1215 | "width": 46,
1216 | "height": 46,
1217 | "seed": 1072510330,
1218 | "groupIds": [],
1219 | "frameId": null,
1220 | "roundness": null,
1221 | "boundElements": [
1222 | {
1223 | "type": "arrow",
1224 | "id": "x_nMpLlFEV43XGOAM6Gxj"
1225 | }
1226 | ],
1227 | "updated": 1710339987250,
1228 | "link": null,
1229 | "locked": false
1230 | },
1231 | {
1232 | "type": "arrow",
1233 | "version": 277,
1234 | "versionNonce": 1904369776,
1235 | "isDeleted": false,
1236 | "id": "x_nMpLlFEV43XGOAM6Gxj",
1237 | "fillStyle": "hachure",
1238 | "strokeWidth": 2,
1239 | "strokeStyle": "solid",
1240 | "roughness": 0,
1241 | "opacity": 100,
1242 | "angle": 0,
1243 | "x": 760.25,
1244 | "y": 365,
1245 | "strokeColor": "#000000",
1246 | "backgroundColor": "transparent",
1247 | "width": 109,
1248 | "height": 1,
1249 | "seed": 1180464698,
1250 | "groupIds": [],
1251 | "frameId": null,
1252 | "roundness": {
1253 | "type": 2
1254 | },
1255 | "boundElements": [],
1256 | "updated": 1710339987277,
1257 | "link": null,
1258 | "locked": false,
1259 | "startBinding": {
1260 | "elementId": "dJhDWOnAJOszWt_UNEXdt",
1261 | "focus": -0.17704646556482773,
1262 | "gap": 23.75
1263 | },
1264 | "endBinding": null,
1265 | "lastCommittedPoint": null,
1266 | "startArrowhead": null,
1267 | "endArrowhead": "arrow",
1268 | "points": [
1269 | [
1270 | 0,
1271 | 0
1272 | ],
1273 | [
1274 | -109,
1275 | -1
1276 | ]
1277 | ]
1278 | },
1279 | {
1280 | "type": "arrow",
1281 | "version": 268,
1282 | "versionNonce": 356307568,
1283 | "isDeleted": false,
1284 | "id": "qmYaJfZ9NO1RK7YHGQGo6",
1285 | "fillStyle": "hachure",
1286 | "strokeWidth": 2,
1287 | "strokeStyle": "solid",
1288 | "roughness": 0,
1289 | "opacity": 100,
1290 | "angle": 0,
1291 | "x": 756.9214748277186,
1292 | "y": 355.7229508196721,
1293 | "strokeColor": "#000000",
1294 | "backgroundColor": "transparent",
1295 | "width": 104.67147482771861,
1296 | "height": 53.72295081967212,
1297 | "seed": 880321126,
1298 | "groupIds": [],
1299 | "frameId": null,
1300 | "roundness": {
1301 | "type": 2
1302 | },
1303 | "boundElements": [],
1304 | "updated": 1710339987277,
1305 | "link": null,
1306 | "locked": false,
1307 | "startBinding": {
1308 | "elementId": "dJhDWOnAJOszWt_UNEXdt",
1309 | "focus": 0.304824173970933,
1310 | "gap": 20.421474827718612
1311 | },
1312 | "endBinding": null,
1313 | "lastCommittedPoint": null,
1314 | "startArrowhead": null,
1315 | "endArrowhead": "arrow",
1316 | "points": [
1317 | [
1318 | 0,
1319 | 0
1320 | ],
1321 | [
1322 | -104.67147482771861,
1323 | -53.72295081967212
1324 | ]
1325 | ]
1326 | },
1327 | {
1328 | "type": "ellipse",
1329 | "version": 135,
1330 | "versionNonce": 2074179216,
1331 | "isDeleted": false,
1332 | "id": "EQmjbilyrf3OcSwGbMZrg",
1333 | "fillStyle": "hachure",
1334 | "strokeWidth": 2,
1335 | "strokeStyle": "solid",
1336 | "roughness": 0,
1337 | "opacity": 100,
1338 | "angle": 0,
1339 | "x": 841,
1340 | "y": 94,
1341 | "strokeColor": "#000000",
1342 | "backgroundColor": "transparent",
1343 | "width": 150.00000000000003,
1344 | "height": 93.00000000000001,
1345 | "seed": 1885795942,
1346 | "groupIds": [],
1347 | "frameId": null,
1348 | "roundness": null,
1349 | "boundElements": [
1350 | {
1351 | "type": "arrow",
1352 | "id": "xDobZ6graJnZZP8g59wJ4"
1353 | },
1354 | {
1355 | "type": "arrow",
1356 | "id": "eU1gfEXnHSjxc-pEgv43A"
1357 | }
1358 | ],
1359 | "updated": 1710339987250,
1360 | "link": null,
1361 | "locked": false
1362 | },
1363 | {
1364 | "type": "text",
1365 | "version": 64,
1366 | "versionNonce": 857393296,
1367 | "isDeleted": false,
1368 | "id": "wV6Y3XyIP5TbX50EF6xs6",
1369 | "fillStyle": "hachure",
1370 | "strokeWidth": 2,
1371 | "strokeStyle": "solid",
1372 | "roughness": 0,
1373 | "opacity": 100,
1374 | "angle": 0,
1375 | "x": 892.0666675567627,
1376 | "y": 129.5,
1377 | "strokeColor": "#000000",
1378 | "backgroundColor": "transparent",
1379 | "width": 51.13333511352539,
1380 | "height": 26,
1381 | "seed": 1433614630,
1382 | "groupIds": [],
1383 | "frameId": null,
1384 | "roundness": null,
1385 | "boundElements": [],
1386 | "updated": 1710339993804,
1387 | "link": null,
1388 | "locked": false,
1389 | "fontSize": 20,
1390 | "fontFamily": 1,
1391 | "text": "Redis",
1392 | "textAlign": "center",
1393 | "verticalAlign": "middle",
1394 | "containerId": null,
1395 | "originalText": "Redis",
1396 | "lineHeight": 1.3
1397 | },
1398 | {
1399 | "type": "arrow",
1400 | "version": 132,
1401 | "versionNonce": 2094597232,
1402 | "isDeleted": false,
1403 | "id": "eU1gfEXnHSjxc-pEgv43A",
1404 | "fillStyle": "hachure",
1405 | "strokeWidth": 2,
1406 | "strokeStyle": "solid",
1407 | "roughness": 0,
1408 | "opacity": 100,
1409 | "angle": 0,
1410 | "x": 831,
1411 | "y": -9,
1412 | "strokeColor": "#000000",
1413 | "backgroundColor": "transparent",
1414 | "width": 47.87517320626739,
1415 | "height": 85.84605939285643,
1416 | "seed": 1145880934,
1417 | "groupIds": [],
1418 | "frameId": null,
1419 | "roundness": {
1420 | "type": 2
1421 | },
1422 | "boundElements": [],
1423 | "updated": 1710339987278,
1424 | "link": null,
1425 | "locked": false,
1426 | "startBinding": null,
1427 | "endBinding": {
1428 | "elementId": "EQmjbilyrf3OcSwGbMZrg",
1429 | "focus": -0.02048842961361912,
1430 | "gap": 22.17434859207502
1431 | },
1432 | "lastCommittedPoint": null,
1433 | "startArrowhead": null,
1434 | "endArrowhead": "arrow",
1435 | "points": [
1436 | [
1437 | 0,
1438 | 0
1439 | ],
1440 | [
1441 | 47.87517320626739,
1442 | 85.84605939285643
1443 | ]
1444 | ]
1445 | },
1446 | {
1447 | "type": "arrow",
1448 | "version": 197,
1449 | "versionNonce": 1554695312,
1450 | "isDeleted": false,
1451 | "id": "xDobZ6graJnZZP8g59wJ4",
1452 | "fillStyle": "hachure",
1453 | "strokeWidth": 2,
1454 | "strokeStyle": "solid",
1455 | "roughness": 0,
1456 | "opacity": 100,
1457 | "angle": 0,
1458 | "x": 885.1947399534047,
1459 | "y": 201.03676246231026,
1460 | "strokeColor": "#000000",
1461 | "backgroundColor": "transparent",
1462 | "width": 29.812198779418623,
1463 | "height": 92.55583013028235,
1464 | "seed": 1443544058,
1465 | "groupIds": [],
1466 | "frameId": null,
1467 | "roundness": {
1468 | "type": 2
1469 | },
1470 | "boundElements": [],
1471 | "updated": 1710339997274,
1472 | "link": null,
1473 | "locked": false,
1474 | "startBinding": {
1475 | "elementId": "EQmjbilyrf3OcSwGbMZrg",
1476 | "focus": 0.14442451001935527,
1477 | "gap": 17.685613369250504
1478 | },
1479 | "endBinding": {
1480 | "elementId": "g_nwmfFr4gmfrn6naI6-1",
1481 | "focus": 0.08950820540708966,
1482 | "gap": 14.15740740740739
1483 | },
1484 | "lastCommittedPoint": null,
1485 | "startArrowhead": null,
1486 | "endArrowhead": "arrow",
1487 | "points": [
1488 | [
1489 | 0,
1490 | 0
1491 | ],
1492 | [
1493 | -29.812198779418623,
1494 | 92.55583013028235
1495 | ]
1496 | ]
1497 | },
1498 | {
1499 | "type": "text",
1500 | "version": 339,
1501 | "versionNonce": 1211082896,
1502 | "isDeleted": false,
1503 | "id": "g_nwmfFr4gmfrn6naI6-1",
1504 | "fillStyle": "hachure",
1505 | "strokeWidth": 1,
1506 | "strokeStyle": "dashed",
1507 | "roughness": 0,
1508 | "opacity": 100,
1509 | "angle": 0,
1510 | "x": 770.0583343505859,
1511 | "y": 307.75,
1512 | "strokeColor": "#000000",
1513 | "backgroundColor": "transparent",
1514 | "width": 139.88333129882812,
1515 | "height": 26,
1516 | "seed": 1287799958,
1517 | "groupIds": [],
1518 | "frameId": null,
1519 | "roundness": null,
1520 | "boundElements": [
1521 | {
1522 | "type": "arrow",
1523 | "id": "xDobZ6graJnZZP8g59wJ4"
1524 | }
1525 | ],
1526 | "updated": 1710339997271,
1527 | "link": null,
1528 | "locked": false,
1529 | "fontSize": 20,
1530 | "fontFamily": 1,
1531 | "text": "Redis adapter",
1532 | "textAlign": "center",
1533 | "verticalAlign": "top",
1534 | "containerId": null,
1535 | "originalText": "Redis adapter",
1536 | "lineHeight": 1.3
1537 | }
1538 | ],
1539 | "appState": {
1540 | "gridSize": null,
1541 | "viewBackgroundColor": "#ffffff"
1542 | },
1543 | "files": {}
1544 | }
--------------------------------------------------------------------------------
/assets/adapter.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/socketio/socket.io-redis-adapter/cdb55353f83c78cabe9788683e4dd93ac4cd50c9/assets/adapter.png
--------------------------------------------------------------------------------
/assets/adapter_dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/socketio/socket.io-redis-adapter/cdb55353f83c78cabe9788683e4dd93ac4cd50c9/assets/adapter_dark.png
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | redis:
3 | image: redis:7
4 | ports:
5 | - "6379:6379"
6 |
7 | redis-cluster:
8 | image: grokzen/redis-cluster:7.0.10
9 | ports:
10 | - "7000-7005:7000-7005"
11 |
--------------------------------------------------------------------------------
/lib/index.ts:
--------------------------------------------------------------------------------
1 | import uid2 = require("uid2");
2 | import msgpack = require("notepack.io");
3 | import { Adapter, BroadcastOptions, Room } from "socket.io-adapter";
4 | import { PUBSUB } from "./util";
5 |
6 | const debug = require("debug")("socket.io-redis");
7 |
8 | module.exports = exports = createAdapter;
9 |
10 | /**
11 | * Request types, for messages between nodes
12 | */
13 |
14 | enum RequestType {
15 | SOCKETS = 0,
16 | ALL_ROOMS = 1,
17 | REMOTE_JOIN = 2,
18 | REMOTE_LEAVE = 3,
19 | REMOTE_DISCONNECT = 4,
20 | REMOTE_FETCH = 5,
21 | SERVER_SIDE_EMIT = 6,
22 | BROADCAST,
23 | BROADCAST_CLIENT_COUNT,
24 | BROADCAST_ACK,
25 | }
26 |
27 | interface Request {
28 | type: RequestType;
29 | resolve: Function;
30 | timeout: NodeJS.Timeout;
31 | numSub?: number;
32 | msgCount?: number;
33 | [other: string]: any;
34 | }
35 |
36 | interface AckRequest {
37 | clientCountCallback: (clientCount: number) => void;
38 | ack: (...args: any[]) => void;
39 | }
40 |
41 | interface Parser {
42 | decode: (msg: any) => any;
43 | encode: (msg: any) => any;
44 | }
45 |
46 | const isNumeric = (str) => !isNaN(str) && !isNaN(parseFloat(str));
47 |
48 | export interface RedisAdapterOptions {
49 | /**
50 | * the name of the key to pub/sub events on as prefix
51 | * @default socket.io
52 | */
53 | key: string;
54 | /**
55 | * after this timeout the adapter will stop waiting from responses to request
56 | * @default 5000
57 | */
58 | requestsTimeout: number;
59 | /**
60 | * Whether to publish a response to the channel specific to the requesting node.
61 | *
62 | * - if true, the response will be published to `${key}-request#${nsp}#${uid}#`
63 | * - if false, the response will be published to `${key}-request#${nsp}#`
64 | *
65 | * This option currently defaults to false for backward compatibility, but will be set to true in the next major
66 | * release.
67 | *
68 | * @default false
69 | */
70 | publishOnSpecificResponseChannel: boolean;
71 | /**
72 | * The parser to use for encoding and decoding messages sent to Redis.
73 | * This option defaults to using `notepack.io`, a MessagePack implementation.
74 | */
75 | parser: Parser;
76 | }
77 |
78 | /**
79 | * Returns a function that will create a RedisAdapter instance.
80 | *
81 | * @param pubClient - a Redis client that will be used to publish messages
82 | * @param subClient - a Redis client that will be used to receive messages (put in subscribed state)
83 | * @param opts - additional options
84 | *
85 | * @public
86 | */
87 | export function createAdapter(
88 | pubClient: any,
89 | subClient: any,
90 | opts?: Partial
91 | ) {
92 | return function (nsp) {
93 | return new RedisAdapter(nsp, pubClient, subClient, opts);
94 | };
95 | }
96 |
97 | export class RedisAdapter extends Adapter {
98 | public readonly uid;
99 | public readonly requestsTimeout: number;
100 | public readonly publishOnSpecificResponseChannel: boolean;
101 | public readonly parser: Parser;
102 |
103 | private readonly channel: string;
104 | private readonly requestChannel: string;
105 | private readonly responseChannel: string;
106 | private readonly specificResponseChannel: string;
107 | private requests: Map = new Map();
108 | private ackRequests: Map = new Map();
109 | private redisListeners: Map = new Map();
110 | private readonly friendlyErrorHandler: () => void;
111 |
112 | /**
113 | * Adapter constructor.
114 | *
115 | * @param nsp - the namespace
116 | * @param pubClient - a Redis client that will be used to publish messages
117 | * @param subClient - a Redis client that will be used to receive messages (put in subscribed state)
118 | * @param opts - additional options
119 | *
120 | * @public
121 | */
122 | constructor(
123 | nsp: any,
124 | readonly pubClient: any,
125 | readonly subClient: any,
126 | opts: Partial = {}
127 | ) {
128 | super(nsp);
129 |
130 | this.uid = uid2(6);
131 | this.requestsTimeout = opts.requestsTimeout || 5000;
132 | this.publishOnSpecificResponseChannel =
133 | !!opts.publishOnSpecificResponseChannel;
134 | this.parser = opts.parser || msgpack;
135 |
136 | const prefix = opts.key || "socket.io";
137 |
138 | this.channel = prefix + "#" + nsp.name + "#";
139 | this.requestChannel = prefix + "-request#" + this.nsp.name + "#";
140 | this.responseChannel = prefix + "-response#" + this.nsp.name + "#";
141 | this.specificResponseChannel = this.responseChannel + this.uid + "#";
142 |
143 | const isRedisV4 = typeof this.pubClient.pSubscribe === "function";
144 | if (isRedisV4) {
145 | this.redisListeners.set("psub", (msg, channel) => {
146 | this.onmessage(null, channel, msg);
147 | });
148 |
149 | this.redisListeners.set("sub", (msg, channel) => {
150 | this.onrequest(channel, msg);
151 | });
152 |
153 | this.subClient.pSubscribe(
154 | this.channel + "*",
155 | this.redisListeners.get("psub"),
156 | true
157 | );
158 | this.subClient.subscribe(
159 | [
160 | this.requestChannel,
161 | this.responseChannel,
162 | this.specificResponseChannel,
163 | ],
164 | this.redisListeners.get("sub"),
165 | true
166 | );
167 | } else {
168 | this.redisListeners.set("pmessageBuffer", this.onmessage.bind(this));
169 | this.redisListeners.set("messageBuffer", this.onrequest.bind(this));
170 |
171 | this.subClient.psubscribe(this.channel + "*");
172 | this.subClient.on(
173 | "pmessageBuffer",
174 | this.redisListeners.get("pmessageBuffer")
175 | );
176 |
177 | this.subClient.subscribe([
178 | this.requestChannel,
179 | this.responseChannel,
180 | this.specificResponseChannel,
181 | ]);
182 | this.subClient.on(
183 | "messageBuffer",
184 | this.redisListeners.get("messageBuffer")
185 | );
186 | }
187 |
188 | this.friendlyErrorHandler = function () {
189 | if (this.listenerCount("error") === 1) {
190 | console.warn("missing 'error' handler on this Redis client");
191 | }
192 | };
193 | this.pubClient.on("error", this.friendlyErrorHandler);
194 | this.subClient.on("error", this.friendlyErrorHandler);
195 | }
196 |
197 | /**
198 | * Called with a subscription message
199 | *
200 | * @private
201 | */
202 | private onmessage(pattern, channel, msg) {
203 | channel = channel.toString();
204 |
205 | const channelMatches = channel.startsWith(this.channel);
206 | if (!channelMatches) {
207 | return debug("ignore different channel");
208 | }
209 |
210 | const room = channel.slice(this.channel.length, -1);
211 | if (room !== "" && !this.hasRoom(room)) {
212 | return debug("ignore unknown room %s", room);
213 | }
214 |
215 | const args = this.parser.decode(msg);
216 |
217 | const [uid, packet, opts] = args;
218 | if (this.uid === uid) return debug("ignore same uid");
219 |
220 | if (packet && packet.nsp === undefined) {
221 | packet.nsp = "/";
222 | }
223 |
224 | if (!packet || packet.nsp !== this.nsp.name) {
225 | return debug("ignore different namespace");
226 | }
227 | opts.rooms = new Set(opts.rooms);
228 | opts.except = new Set(opts.except);
229 |
230 | super.broadcast(packet, opts);
231 | }
232 |
233 | private hasRoom(room): boolean {
234 | // @ts-ignore
235 | const hasNumericRoom = isNumeric(room) && this.rooms.has(parseFloat(room));
236 | return hasNumericRoom || this.rooms.has(room);
237 | }
238 |
239 | /**
240 | * Called on request from another node
241 | *
242 | * @private
243 | */
244 | private async onrequest(channel, msg) {
245 | channel = channel.toString();
246 |
247 | if (channel.startsWith(this.responseChannel)) {
248 | return this.onresponse(channel, msg);
249 | } else if (!channel.startsWith(this.requestChannel)) {
250 | return debug("ignore different channel");
251 | }
252 |
253 | let request;
254 |
255 | try {
256 | // if the buffer starts with a "{" character
257 | if (msg[0] === 0x7b) {
258 | request = JSON.parse(msg.toString());
259 | } else {
260 | request = this.parser.decode(msg);
261 | }
262 | } catch (err) {
263 | debug("ignoring malformed request");
264 | return;
265 | }
266 |
267 | debug("received request %j", request);
268 |
269 | let response, socket;
270 |
271 | switch (request.type) {
272 | case RequestType.SOCKETS:
273 | if (this.requests.has(request.requestId)) {
274 | return;
275 | }
276 |
277 | const sockets = await super.sockets(new Set(request.rooms));
278 |
279 | response = JSON.stringify({
280 | requestId: request.requestId,
281 | sockets: [...sockets],
282 | });
283 |
284 | this.publishResponse(request, response);
285 | break;
286 |
287 | case RequestType.ALL_ROOMS:
288 | if (this.requests.has(request.requestId)) {
289 | return;
290 | }
291 |
292 | response = JSON.stringify({
293 | requestId: request.requestId,
294 | rooms: [...this.rooms.keys()],
295 | });
296 |
297 | this.publishResponse(request, response);
298 | break;
299 |
300 | case RequestType.REMOTE_JOIN:
301 | if (request.opts) {
302 | const opts = {
303 | rooms: new Set(request.opts.rooms),
304 | except: new Set(request.opts.except),
305 | };
306 | return super.addSockets(opts, request.rooms);
307 | }
308 |
309 | socket = this.nsp.sockets.get(request.sid);
310 | if (!socket) {
311 | return;
312 | }
313 |
314 | socket.join(request.room);
315 |
316 | response = JSON.stringify({
317 | requestId: request.requestId,
318 | });
319 |
320 | this.publishResponse(request, response);
321 | break;
322 |
323 | case RequestType.REMOTE_LEAVE:
324 | if (request.opts) {
325 | const opts = {
326 | rooms: new Set(request.opts.rooms),
327 | except: new Set(request.opts.except),
328 | };
329 | return super.delSockets(opts, request.rooms);
330 | }
331 |
332 | socket = this.nsp.sockets.get(request.sid);
333 | if (!socket) {
334 | return;
335 | }
336 |
337 | socket.leave(request.room);
338 |
339 | response = JSON.stringify({
340 | requestId: request.requestId,
341 | });
342 |
343 | this.publishResponse(request, response);
344 | break;
345 |
346 | case RequestType.REMOTE_DISCONNECT:
347 | if (request.opts) {
348 | const opts = {
349 | rooms: new Set(request.opts.rooms),
350 | except: new Set(request.opts.except),
351 | };
352 | return super.disconnectSockets(opts, request.close);
353 | }
354 |
355 | socket = this.nsp.sockets.get(request.sid);
356 | if (!socket) {
357 | return;
358 | }
359 |
360 | socket.disconnect(request.close);
361 |
362 | response = JSON.stringify({
363 | requestId: request.requestId,
364 | });
365 |
366 | this.publishResponse(request, response);
367 | break;
368 |
369 | case RequestType.REMOTE_FETCH:
370 | if (this.requests.has(request.requestId)) {
371 | return;
372 | }
373 |
374 | const opts = {
375 | rooms: new Set(request.opts.rooms),
376 | except: new Set(request.opts.except),
377 | };
378 | const localSockets = await super.fetchSockets(opts);
379 |
380 | response = JSON.stringify({
381 | requestId: request.requestId,
382 | sockets: localSockets.map((socket) => {
383 | // remove sessionStore from handshake, as it may contain circular references
384 | const { sessionStore, ...handshake } = socket.handshake;
385 | return {
386 | id: socket.id,
387 | handshake,
388 | rooms: [...socket.rooms],
389 | data: socket.data,
390 | };
391 | }),
392 | });
393 |
394 | this.publishResponse(request, response);
395 | break;
396 |
397 | case RequestType.SERVER_SIDE_EMIT:
398 | if (request.uid === this.uid) {
399 | debug("ignore same uid");
400 | return;
401 | }
402 | const withAck = request.requestId !== undefined;
403 | if (!withAck) {
404 | this.nsp._onServerSideEmit(request.data);
405 | return;
406 | }
407 | let called = false;
408 | const callback = (arg) => {
409 | // only one argument is expected
410 | if (called) {
411 | return;
412 | }
413 | called = true;
414 | debug("calling acknowledgement with %j", arg);
415 | this.pubClient.publish(
416 | this.responseChannel,
417 | JSON.stringify({
418 | type: RequestType.SERVER_SIDE_EMIT,
419 | requestId: request.requestId,
420 | data: arg,
421 | })
422 | );
423 | };
424 | request.data.push(callback);
425 | this.nsp._onServerSideEmit(request.data);
426 | break;
427 |
428 | case RequestType.BROADCAST: {
429 | if (this.ackRequests.has(request.requestId)) {
430 | // ignore self
431 | return;
432 | }
433 |
434 | const opts = {
435 | rooms: new Set(request.opts.rooms),
436 | except: new Set(request.opts.except),
437 | };
438 |
439 | super.broadcastWithAck(
440 | request.packet,
441 | opts,
442 | (clientCount) => {
443 | debug("waiting for %d client acknowledgements", clientCount);
444 | this.publishResponse(
445 | request,
446 | JSON.stringify({
447 | type: RequestType.BROADCAST_CLIENT_COUNT,
448 | requestId: request.requestId,
449 | clientCount,
450 | })
451 | );
452 | },
453 | (arg) => {
454 | debug("received acknowledgement with value %j", arg);
455 |
456 | this.publishResponse(
457 | request,
458 | this.parser.encode({
459 | type: RequestType.BROADCAST_ACK,
460 | requestId: request.requestId,
461 | packet: arg,
462 | })
463 | );
464 | }
465 | );
466 | break;
467 | }
468 |
469 | default:
470 | debug("ignoring unknown request type: %s", request.type);
471 | }
472 | }
473 |
474 | /**
475 | * Send the response to the requesting node
476 | * @param request
477 | * @param response
478 | * @private
479 | */
480 | private publishResponse(request, response) {
481 | const responseChannel = this.publishOnSpecificResponseChannel
482 | ? `${this.responseChannel}${request.uid}#`
483 | : this.responseChannel;
484 | debug("publishing response to channel %s", responseChannel);
485 | this.pubClient.publish(responseChannel, response);
486 | }
487 |
488 | /**
489 | * Called on response from another node
490 | *
491 | * @private
492 | */
493 | private onresponse(channel, msg) {
494 | let response;
495 |
496 | try {
497 | // if the buffer starts with a "{" character
498 | if (msg[0] === 0x7b) {
499 | response = JSON.parse(msg.toString());
500 | } else {
501 | response = this.parser.decode(msg);
502 | }
503 | } catch (err) {
504 | debug("ignoring malformed response");
505 | return;
506 | }
507 |
508 | const requestId = response.requestId;
509 |
510 | if (this.ackRequests.has(requestId)) {
511 | const ackRequest = this.ackRequests.get(requestId);
512 |
513 | switch (response.type) {
514 | case RequestType.BROADCAST_CLIENT_COUNT: {
515 | ackRequest?.clientCountCallback(response.clientCount);
516 | break;
517 | }
518 |
519 | case RequestType.BROADCAST_ACK: {
520 | ackRequest?.ack(response.packet);
521 | break;
522 | }
523 | }
524 | return;
525 | }
526 |
527 | if (
528 | !requestId ||
529 | !(this.requests.has(requestId) || this.ackRequests.has(requestId))
530 | ) {
531 | debug("ignoring unknown request");
532 | return;
533 | }
534 |
535 | debug("received response %j", response);
536 |
537 | const request = this.requests.get(requestId);
538 |
539 | switch (request.type) {
540 | case RequestType.SOCKETS:
541 | case RequestType.REMOTE_FETCH:
542 | request.msgCount++;
543 |
544 | // ignore if response does not contain 'sockets' key
545 | if (!response.sockets || !Array.isArray(response.sockets)) return;
546 |
547 | if (request.type === RequestType.SOCKETS) {
548 | response.sockets.forEach((s) => request.sockets.add(s));
549 | } else {
550 | response.sockets.forEach((s) => request.sockets.push(s));
551 | }
552 |
553 | if (request.msgCount === request.numSub) {
554 | clearTimeout(request.timeout);
555 | if (request.resolve) {
556 | request.resolve(request.sockets);
557 | }
558 | this.requests.delete(requestId);
559 | }
560 | break;
561 |
562 | case RequestType.ALL_ROOMS:
563 | request.msgCount++;
564 |
565 | // ignore if response does not contain 'rooms' key
566 | if (!response.rooms || !Array.isArray(response.rooms)) return;
567 |
568 | response.rooms.forEach((s) => request.rooms.add(s));
569 |
570 | if (request.msgCount === request.numSub) {
571 | clearTimeout(request.timeout);
572 | if (request.resolve) {
573 | request.resolve(request.rooms);
574 | }
575 | this.requests.delete(requestId);
576 | }
577 | break;
578 |
579 | case RequestType.REMOTE_JOIN:
580 | case RequestType.REMOTE_LEAVE:
581 | case RequestType.REMOTE_DISCONNECT:
582 | clearTimeout(request.timeout);
583 | if (request.resolve) {
584 | request.resolve();
585 | }
586 | this.requests.delete(requestId);
587 | break;
588 |
589 | case RequestType.SERVER_SIDE_EMIT:
590 | request.responses.push(response.data);
591 |
592 | debug(
593 | "serverSideEmit: got %d responses out of %d",
594 | request.responses.length,
595 | request.numSub
596 | );
597 | if (request.responses.length === request.numSub) {
598 | clearTimeout(request.timeout);
599 | if (request.resolve) {
600 | request.resolve(null, request.responses);
601 | }
602 | this.requests.delete(requestId);
603 | }
604 | break;
605 |
606 | default:
607 | debug("ignoring unknown request type: %s", request.type);
608 | }
609 | }
610 |
611 | /**
612 | * Broadcasts a packet.
613 | *
614 | * @param {Object} packet - packet to emit
615 | * @param {Object} opts - options
616 | *
617 | * @public
618 | */
619 | public broadcast(packet: any, opts: BroadcastOptions) {
620 | packet.nsp = this.nsp.name;
621 |
622 | const onlyLocal = opts && opts.flags && opts.flags.local;
623 |
624 | if (!onlyLocal) {
625 | const rawOpts = {
626 | rooms: [...opts.rooms],
627 | except: [...new Set(opts.except)],
628 | flags: opts.flags,
629 | };
630 | const msg = this.parser.encode([this.uid, packet, rawOpts]);
631 | let channel = this.channel;
632 | if (opts.rooms && opts.rooms.size === 1) {
633 | channel += opts.rooms.keys().next().value + "#";
634 | }
635 | debug("publishing message to channel %s", channel);
636 | this.pubClient.publish(channel, msg);
637 | }
638 | super.broadcast(packet, opts);
639 | }
640 |
641 | public broadcastWithAck(
642 | packet: any,
643 | opts: BroadcastOptions,
644 | clientCountCallback: (clientCount: number) => void,
645 | ack: (...args: any[]) => void
646 | ) {
647 | packet.nsp = this.nsp.name;
648 |
649 | const onlyLocal = opts?.flags?.local;
650 |
651 | if (!onlyLocal) {
652 | const requestId = uid2(6);
653 |
654 | const rawOpts = {
655 | rooms: [...opts.rooms],
656 | except: [...new Set(opts.except)],
657 | flags: opts.flags,
658 | };
659 |
660 | const request = this.parser.encode({
661 | uid: this.uid,
662 | requestId,
663 | type: RequestType.BROADCAST,
664 | packet,
665 | opts: rawOpts,
666 | });
667 |
668 | this.pubClient.publish(this.requestChannel, request);
669 |
670 | this.ackRequests.set(requestId, {
671 | clientCountCallback,
672 | ack,
673 | });
674 |
675 | // we have no way to know at this level whether the server has received an acknowledgement from each client, so we
676 | // will simply clean up the ackRequests map after the given delay
677 | setTimeout(() => {
678 | this.ackRequests.delete(requestId);
679 | }, opts.flags!.timeout);
680 | }
681 |
682 | super.broadcastWithAck(packet, opts, clientCountCallback, ack);
683 | }
684 |
685 | /**
686 | * Gets the list of all rooms (across every node)
687 | *
688 | * @public
689 | */
690 | public async allRooms(): Promise> {
691 | const localRooms = new Set(this.rooms.keys());
692 | const numSub = await this.serverCount();
693 | debug('waiting for %d responses to "allRooms" request', numSub);
694 |
695 | if (numSub <= 1) {
696 | return localRooms;
697 | }
698 |
699 | const requestId = uid2(6);
700 | const request = JSON.stringify({
701 | uid: this.uid,
702 | requestId,
703 | type: RequestType.ALL_ROOMS,
704 | });
705 |
706 | return new Promise((resolve, reject) => {
707 | const timeout = setTimeout(() => {
708 | if (this.requests.has(requestId)) {
709 | reject(
710 | new Error("timeout reached while waiting for allRooms response")
711 | );
712 | this.requests.delete(requestId);
713 | }
714 | }, this.requestsTimeout);
715 |
716 | this.requests.set(requestId, {
717 | type: RequestType.ALL_ROOMS,
718 | numSub,
719 | resolve,
720 | timeout,
721 | msgCount: 1,
722 | rooms: localRooms,
723 | });
724 |
725 | this.pubClient.publish(this.requestChannel, request);
726 | });
727 | }
728 |
729 | public async fetchSockets(opts: BroadcastOptions): Promise {
730 | const localSockets = await super.fetchSockets(opts);
731 |
732 | if (opts.flags?.local) {
733 | return localSockets;
734 | }
735 |
736 | const numSub = await this.serverCount();
737 | debug('waiting for %d responses to "fetchSockets" request', numSub);
738 |
739 | if (numSub <= 1) {
740 | return localSockets;
741 | }
742 |
743 | const requestId = uid2(6);
744 |
745 | const request = JSON.stringify({
746 | uid: this.uid,
747 | requestId,
748 | type: RequestType.REMOTE_FETCH,
749 | opts: {
750 | rooms: [...opts.rooms],
751 | except: [...opts.except],
752 | },
753 | });
754 |
755 | return new Promise((resolve, reject) => {
756 | const timeout = setTimeout(() => {
757 | if (this.requests.has(requestId)) {
758 | reject(
759 | new Error("timeout reached while waiting for fetchSockets response")
760 | );
761 | this.requests.delete(requestId);
762 | }
763 | }, this.requestsTimeout);
764 |
765 | this.requests.set(requestId, {
766 | type: RequestType.REMOTE_FETCH,
767 | numSub,
768 | resolve,
769 | timeout,
770 | msgCount: 1,
771 | sockets: localSockets,
772 | });
773 |
774 | this.pubClient.publish(this.requestChannel, request);
775 | });
776 | }
777 |
778 | public addSockets(opts: BroadcastOptions, rooms: Room[]) {
779 | if (opts.flags?.local) {
780 | return super.addSockets(opts, rooms);
781 | }
782 |
783 | const request = JSON.stringify({
784 | uid: this.uid,
785 | type: RequestType.REMOTE_JOIN,
786 | opts: {
787 | rooms: [...opts.rooms],
788 | except: [...opts.except],
789 | },
790 | rooms: [...rooms],
791 | });
792 |
793 | this.pubClient.publish(this.requestChannel, request);
794 | }
795 |
796 | public delSockets(opts: BroadcastOptions, rooms: Room[]) {
797 | if (opts.flags?.local) {
798 | return super.delSockets(opts, rooms);
799 | }
800 |
801 | const request = JSON.stringify({
802 | uid: this.uid,
803 | type: RequestType.REMOTE_LEAVE,
804 | opts: {
805 | rooms: [...opts.rooms],
806 | except: [...opts.except],
807 | },
808 | rooms: [...rooms],
809 | });
810 |
811 | this.pubClient.publish(this.requestChannel, request);
812 | }
813 |
814 | public disconnectSockets(opts: BroadcastOptions, close: boolean) {
815 | if (opts.flags?.local) {
816 | return super.disconnectSockets(opts, close);
817 | }
818 |
819 | const request = JSON.stringify({
820 | uid: this.uid,
821 | type: RequestType.REMOTE_DISCONNECT,
822 | opts: {
823 | rooms: [...opts.rooms],
824 | except: [...opts.except],
825 | },
826 | close,
827 | });
828 |
829 | this.pubClient.publish(this.requestChannel, request);
830 | }
831 |
832 | public serverSideEmit(packet: any[]): void {
833 | const withAck = typeof packet[packet.length - 1] === "function";
834 |
835 | if (withAck) {
836 | this.serverSideEmitWithAck(packet).catch(() => {
837 | // ignore errors
838 | });
839 | return;
840 | }
841 |
842 | const request = JSON.stringify({
843 | uid: this.uid,
844 | type: RequestType.SERVER_SIDE_EMIT,
845 | data: packet,
846 | });
847 |
848 | this.pubClient.publish(this.requestChannel, request);
849 | }
850 |
851 | private async serverSideEmitWithAck(packet: any[]) {
852 | const ack = packet.pop();
853 | const numSub = (await this.serverCount()) - 1; // ignore self
854 |
855 | debug('waiting for %d responses to "serverSideEmit" request', numSub);
856 |
857 | if (numSub <= 0) {
858 | return ack(null, []);
859 | }
860 |
861 | const requestId = uid2(6);
862 | const request = JSON.stringify({
863 | uid: this.uid,
864 | requestId, // the presence of this attribute defines whether an acknowledgement is needed
865 | type: RequestType.SERVER_SIDE_EMIT,
866 | data: packet,
867 | });
868 |
869 | const timeout = setTimeout(() => {
870 | const storedRequest = this.requests.get(requestId);
871 | if (storedRequest) {
872 | ack(
873 | new Error(
874 | `timeout reached: only ${storedRequest.responses.length} responses received out of ${storedRequest.numSub}`
875 | ),
876 | storedRequest.responses
877 | );
878 | this.requests.delete(requestId);
879 | }
880 | }, this.requestsTimeout);
881 |
882 | this.requests.set(requestId, {
883 | type: RequestType.SERVER_SIDE_EMIT,
884 | numSub,
885 | timeout,
886 | resolve: ack,
887 | responses: [],
888 | });
889 |
890 | this.pubClient.publish(this.requestChannel, request);
891 | }
892 |
893 | override serverCount(): Promise {
894 | return PUBSUB(this.pubClient, "NUMSUB", this.requestChannel);
895 | }
896 |
897 | close(): Promise | void {
898 | const isRedisV4 = typeof this.pubClient.pSubscribe === "function";
899 | if (isRedisV4) {
900 | this.subClient.pUnsubscribe(
901 | this.channel + "*",
902 | this.redisListeners.get("psub"),
903 | true
904 | );
905 |
906 | // There is a bug in redis v4 when unsubscribing multiple channels at once, so we'll unsub one at a time.
907 | // See https://github.com/redis/node-redis/issues/2052
908 | this.subClient.unsubscribe(
909 | this.requestChannel,
910 | this.redisListeners.get("sub"),
911 | true
912 | );
913 | this.subClient.unsubscribe(
914 | this.responseChannel,
915 | this.redisListeners.get("sub"),
916 | true
917 | );
918 | this.subClient.unsubscribe(
919 | this.specificResponseChannel,
920 | this.redisListeners.get("sub"),
921 | true
922 | );
923 | } else {
924 | this.subClient.punsubscribe(this.channel + "*");
925 | this.subClient.off(
926 | "pmessageBuffer",
927 | this.redisListeners.get("pmessageBuffer")
928 | );
929 |
930 | this.subClient.unsubscribe([
931 | this.requestChannel,
932 | this.responseChannel,
933 | this.specificResponseChannel,
934 | ]);
935 | this.subClient.off(
936 | "messageBuffer",
937 | this.redisListeners.get("messageBuffer")
938 | );
939 | }
940 |
941 | this.pubClient.off("error", this.friendlyErrorHandler);
942 | this.subClient.off("error", this.friendlyErrorHandler);
943 | }
944 | }
945 |
946 | export { createShardedAdapter } from "./sharded-adapter";
947 |
--------------------------------------------------------------------------------
/lib/sharded-adapter.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ClusterAdapter,
3 | ClusterMessage,
4 | ClusterResponse,
5 | MessageType,
6 | Offset,
7 | } from "socket.io-adapter";
8 | import { decode, encode } from "notepack.io";
9 | import { hasBinary, PUBSUB, SPUBLISH, SSUBSCRIBE, SUNSUBSCRIBE } from "./util";
10 | import debugModule from "debug";
11 |
12 | const debug = debugModule("socket.io-redis");
13 |
14 | function looksLikeASocketId(room: any) {
15 | return typeof room === "string" && room.length === 20;
16 | }
17 |
18 | export interface ShardedRedisAdapterOptions {
19 | /**
20 | * The prefix for the Redis Pub/Sub channels.
21 | *
22 | * @default "socket.io"
23 | */
24 | channelPrefix?: string;
25 | /**
26 | * The subscription mode impacts the number of Redis Pub/Sub channels:
27 | *
28 | * - "static": 2 channels per namespace
29 | *
30 | * Useful when used with dynamic namespaces.
31 | *
32 | * - "dynamic": (2 + 1 per public room) channels per namespace
33 | *
34 | * The default value, useful when some rooms have a low number of clients (so only a few Socket.IO servers are notified).
35 | *
36 | * Only public rooms (i.e. not related to a particular Socket ID) are taken in account, because:
37 | * - a lot of connected clients would mean a lot of subscription/unsubscription
38 | * - the Socket ID attribute is ephemeral
39 | *
40 | * - "dynamic-private"
41 | *
42 | * Like "dynamic" but creates separate channels for private rooms as well. Useful when there is lots of 1:1 communication
43 | * via socket.emit() calls.
44 | *
45 | * @default "dynamic"
46 | */
47 | subscriptionMode?: "static" | "dynamic" | "dynamic-private";
48 | }
49 |
50 | /**
51 | * Create a new Adapter based on Redis sharded Pub/Sub introduced in Redis 7.0.
52 | *
53 | * @see https://redis.io/docs/manual/pubsub/#sharded-pubsub
54 | *
55 | * @param pubClient - the Redis client used to publish (from the `redis` package)
56 | * @param subClient - the Redis client used to subscribe (from the `redis` package)
57 | * @param opts - some additional options
58 | */
59 | export function createShardedAdapter(
60 | pubClient: any,
61 | subClient: any,
62 | opts?: ShardedRedisAdapterOptions
63 | ) {
64 | return function (nsp) {
65 | return new ShardedRedisAdapter(nsp, pubClient, subClient, opts);
66 | };
67 | }
68 |
69 | class ShardedRedisAdapter extends ClusterAdapter {
70 | private readonly pubClient: any;
71 | private readonly subClient: any;
72 | private readonly opts: Required;
73 | private readonly channel: string;
74 | private readonly responseChannel: string;
75 |
76 | constructor(nsp, pubClient, subClient, opts: ShardedRedisAdapterOptions) {
77 | super(nsp);
78 | this.pubClient = pubClient;
79 | this.subClient = subClient;
80 | this.opts = Object.assign(
81 | {
82 | channelPrefix: "socket.io",
83 | subscriptionMode: "dynamic",
84 | },
85 | opts
86 | );
87 |
88 | this.channel = `${this.opts.channelPrefix}#${nsp.name}#`;
89 | this.responseChannel = `${this.opts.channelPrefix}#${nsp.name}#${this.uid}#`;
90 |
91 | const handler = (message, channel) => this.onRawMessage(message, channel);
92 |
93 | SSUBSCRIBE(this.subClient, this.channel, handler);
94 | SSUBSCRIBE(this.subClient, this.responseChannel, handler);
95 |
96 | if (
97 | this.opts.subscriptionMode === "dynamic" ||
98 | this.opts.subscriptionMode === "dynamic-private"
99 | ) {
100 | this.on("create-room", (room) => {
101 | if (this.shouldUseASeparateNamespace(room)) {
102 | SSUBSCRIBE(this.subClient, this.dynamicChannel(room), handler);
103 | }
104 | });
105 |
106 | this.on("delete-room", (room) => {
107 | if (this.shouldUseASeparateNamespace(room)) {
108 | SUNSUBSCRIBE(this.subClient, this.dynamicChannel(room));
109 | }
110 | });
111 | }
112 | }
113 |
114 | override close(): Promise | void {
115 | const channels = [this.channel, this.responseChannel];
116 |
117 | if (
118 | this.opts.subscriptionMode === "dynamic" ||
119 | this.opts.subscriptionMode === "dynamic-private"
120 | ) {
121 | this.rooms.forEach((_sids, room) => {
122 | if (this.shouldUseASeparateNamespace(room)) {
123 | channels.push(this.dynamicChannel(room));
124 | }
125 | });
126 | }
127 |
128 | return Promise.all(
129 | channels.map((channel) => SUNSUBSCRIBE(this.subClient, channel))
130 | ).then();
131 | }
132 |
133 | override doPublish(message: ClusterMessage): Promise {
134 | const channel = this.computeChannel(message);
135 | debug("publishing message of type %s to %s", message.type, channel);
136 |
137 | return SPUBLISH(this.pubClient, channel, this.encode(message)).then(
138 | () => ""
139 | );
140 | }
141 |
142 | private computeChannel(message) {
143 | // broadcast with ack can not use a dynamic channel, because the serverCount() method return the number of all
144 | // servers, not only the ones where the given room exists
145 | const useDynamicChannel =
146 | message.type === MessageType.BROADCAST &&
147 | message.data.requestId === undefined &&
148 | message.data.opts.rooms.length === 1 &&
149 | ((this.opts.subscriptionMode === "dynamic" &&
150 | !looksLikeASocketId(message.data.opts.rooms[0])) ||
151 | this.opts.subscriptionMode === "dynamic-private");
152 |
153 | if (useDynamicChannel) {
154 | return this.dynamicChannel(message.data.opts.rooms[0]);
155 | } else {
156 | return this.channel;
157 | }
158 | }
159 |
160 | private dynamicChannel(room) {
161 | return this.channel + room + "#";
162 | }
163 |
164 | override doPublishResponse(
165 | requesterUid: string,
166 | response: ClusterResponse
167 | ): Promise {
168 | debug("publishing response of type %s to %s", response.type, requesterUid);
169 |
170 | return SPUBLISH(
171 | this.pubClient,
172 | `${this.channel}${requesterUid}#`,
173 | this.encode(response)
174 | ).then();
175 | }
176 |
177 | private encode(message: ClusterMessage | ClusterResponse) {
178 | const mayContainBinary = [
179 | MessageType.BROADCAST,
180 | MessageType.BROADCAST_ACK,
181 | MessageType.FETCH_SOCKETS_RESPONSE,
182 | MessageType.SERVER_SIDE_EMIT,
183 | MessageType.SERVER_SIDE_EMIT_RESPONSE,
184 | ].includes(message.type);
185 |
186 | // @ts-ignore
187 | if (mayContainBinary && hasBinary(message.data)) {
188 | return encode(message);
189 | } else {
190 | return JSON.stringify(message);
191 | }
192 | }
193 |
194 | private onRawMessage(rawMessage: Buffer, channel: Buffer) {
195 | let message;
196 | try {
197 | if (rawMessage[0] === 0x7b) {
198 | message = JSON.parse(rawMessage.toString());
199 | } else {
200 | message = decode(rawMessage);
201 | }
202 | } catch (e) {
203 | return debug("invalid format: %s", e.message);
204 | }
205 |
206 | if (channel.toString() === this.responseChannel) {
207 | this.onResponse(message);
208 | } else {
209 | this.onMessage(message);
210 | }
211 | }
212 |
213 | override serverCount(): Promise {
214 | return PUBSUB(this.pubClient, "SHARDNUMSUB", this.channel);
215 | }
216 |
217 | private shouldUseASeparateNamespace(room: string): boolean {
218 | const isPublicRoom = !this.sids.has(room);
219 |
220 | return (
221 | (this.opts.subscriptionMode === "dynamic" && isPublicRoom) ||
222 | this.opts.subscriptionMode === "dynamic-private"
223 | );
224 | }
225 | }
226 |
--------------------------------------------------------------------------------
/lib/util.ts:
--------------------------------------------------------------------------------
1 | export function hasBinary(obj: any, toJSON?: boolean): boolean {
2 | if (!obj || typeof obj !== "object") {
3 | return false;
4 | }
5 |
6 | if (obj instanceof ArrayBuffer || ArrayBuffer.isView(obj)) {
7 | return true;
8 | }
9 |
10 | if (Array.isArray(obj)) {
11 | for (let i = 0, l = obj.length; i < l; i++) {
12 | if (hasBinary(obj[i])) {
13 | return true;
14 | }
15 | }
16 | return false;
17 | }
18 |
19 | for (const key in obj) {
20 | if (Object.prototype.hasOwnProperty.call(obj, key) && hasBinary(obj[key])) {
21 | return true;
22 | }
23 | }
24 |
25 | if (obj.toJSON && typeof obj.toJSON === "function" && !toJSON) {
26 | return hasBinary(obj.toJSON(), true);
27 | }
28 |
29 | return false;
30 | }
31 |
32 | export function parseNumSubResponse(res) {
33 | return parseInt(res[1], 10);
34 | }
35 |
36 | export function sumValues(values) {
37 | return values.reduce((acc, val) => {
38 | return acc + val;
39 | }, 0);
40 | }
41 |
42 | const RETURN_BUFFERS = true;
43 |
44 | /**
45 | * Whether the client comes from the `redis` package
46 | *
47 | * @param redisClient
48 | *
49 | * @see https://github.com/redis/node-redis
50 | */
51 | function isRedisV4Client(redisClient: any) {
52 | return typeof redisClient.sSubscribe === "function";
53 | }
54 |
55 | const kHandlers = Symbol("handlers");
56 |
57 | export function SSUBSCRIBE(
58 | redisClient: any,
59 | channel: string,
60 | handler: (rawMessage: Buffer, channel: Buffer) => void
61 | ) {
62 | if (isRedisV4Client(redisClient)) {
63 | redisClient.sSubscribe(channel, handler, RETURN_BUFFERS);
64 | } else {
65 | if (!redisClient[kHandlers]) {
66 | redisClient[kHandlers] = new Map();
67 | redisClient.on("smessageBuffer", (rawChannel, message) => {
68 | redisClient[kHandlers].get(rawChannel.toString())?.(
69 | message,
70 | rawChannel
71 | );
72 | });
73 | }
74 | redisClient[kHandlers].set(channel, handler);
75 | redisClient.ssubscribe(channel);
76 | }
77 | }
78 |
79 | export function SUNSUBSCRIBE(redisClient: any, channel: string | string[]) {
80 | if (isRedisV4Client(redisClient)) {
81 | redisClient.sUnsubscribe(channel);
82 | } else {
83 | redisClient.sunsubscribe(channel);
84 | if (Array.isArray(channel)) {
85 | channel.forEach((c) => redisClient[kHandlers].delete(c));
86 | } else {
87 | redisClient[kHandlers].delete(channel);
88 | }
89 | }
90 | }
91 |
92 | /**
93 | * @see https://redis.io/commands/spublish/
94 | */
95 | export function SPUBLISH(
96 | redisClient: any,
97 | channel: string,
98 | payload: string | Uint8Array
99 | ) {
100 | if (isRedisV4Client(redisClient)) {
101 | return redisClient.sPublish(channel, payload);
102 | } else {
103 | return redisClient.spublish(channel, payload);
104 | }
105 | }
106 |
107 | export function PUBSUB(redisClient: any, arg: string, channel: string) {
108 | if (redisClient.constructor.name === "Cluster" || redisClient.isCluster) {
109 | // ioredis cluster
110 | return Promise.all(
111 | redisClient.nodes().map((node) => {
112 | return node
113 | .send_command("PUBSUB", [arg, channel])
114 | .then(parseNumSubResponse);
115 | })
116 | ).then(sumValues);
117 | } else if (isRedisV4Client(redisClient)) {
118 | const isCluster = Array.isArray(redisClient.masters);
119 | if (isCluster) {
120 | // redis@4 cluster
121 | const nodes = redisClient.masters;
122 | return Promise.all(
123 | nodes.map((node) => {
124 | return node.client
125 | .sendCommand(["PUBSUB", arg, channel])
126 | .then(parseNumSubResponse);
127 | })
128 | ).then(sumValues);
129 | } else {
130 | // redis@4 standalone
131 | return redisClient
132 | .sendCommand(["PUBSUB", arg, channel])
133 | .then(parseNumSubResponse);
134 | }
135 | } else {
136 | // ioredis / redis@3 standalone
137 | return new Promise((resolve, reject) => {
138 | redisClient.send_command("PUBSUB", [arg, channel], (err, numSub) => {
139 | if (err) return reject(err);
140 | resolve(parseNumSubResponse(numSub));
141 | });
142 | });
143 | }
144 | }
145 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@socket.io/redis-adapter",
3 | "version": "8.3.0",
4 | "description": "The Socket.IO Redis adapter, allowing to broadcast events between several Socket.IO servers",
5 | "license": "MIT",
6 | "repository": {
7 | "type": "git",
8 | "url": "git@github.com:socketio/socket.io-redis-adapter.git"
9 | },
10 | "files": [
11 | "dist/"
12 | ],
13 | "main": "./dist/index.js",
14 | "types": "./dist/index.d.ts",
15 | "scripts": {
16 | "compile": "rimraf ./dist && tsc",
17 | "test": "npm run format:check && tsc && nyc mocha --bail --require ts-node/register test/test-runner.ts",
18 | "format:check": "prettier --parser typescript --check 'lib/**/*.ts' 'test/**/*.ts'",
19 | "format:fix": "prettier --parser typescript --write 'lib/**/*.ts' 'test/**/*.ts'",
20 | "prepack": "npm run compile"
21 | },
22 | "dependencies": {
23 | "debug": "~4.3.1",
24 | "notepack.io": "~3.0.1",
25 | "uid2": "1.0.0"
26 | },
27 | "peerDependencies": {
28 | "socket.io-adapter": "^2.5.4"
29 | },
30 | "devDependencies": {
31 | "@types/expect.js": "^0.3.29",
32 | "@types/mocha": "^8.2.1",
33 | "@types/node": "^14.14.7",
34 | "expect.js": "0.3.1",
35 | "ioredis": "^5.3.2",
36 | "mocha": "^10.1.0",
37 | "nyc": "^15.1.0",
38 | "prettier": "^2.8.7",
39 | "redis": "^4.6.6",
40 | "redis-v3": "npm:redis@^3.1.2",
41 | "rimraf": "^5.0.5",
42 | "socket.io": "^4.6.1",
43 | "socket.io-client": "^4.1.1",
44 | "ts-node": "^10.9.1",
45 | "typescript": "^4.9.5"
46 | },
47 | "engines": {
48 | "node": ">=10.0.0"
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/test/custom-parser.ts:
--------------------------------------------------------------------------------
1 | import type { Server } from "socket.io";
2 | import type { Socket as ClientSocket } from "socket.io-client";
3 | import { setup, times } from "./util";
4 | import expect = require("expect.js");
5 | import { createClient } from "redis";
6 | import { createAdapter } from "../lib";
7 |
8 | describe("custom parser", () => {
9 | let servers: Server[];
10 | let clientSockets: ClientSocket[];
11 | let cleanup: () => void;
12 |
13 | beforeEach(async () => {
14 | const testContext = await setup(async () => {
15 | const pubClient = createClient();
16 | const subClient = pubClient.duplicate();
17 |
18 | await Promise.all([pubClient.connect(), subClient.connect()]);
19 |
20 | return [
21 | createAdapter(pubClient, subClient, {
22 | parser: {
23 | decode(msg) {
24 | return JSON.parse(msg);
25 | },
26 | encode(msg) {
27 | return JSON.stringify(msg);
28 | },
29 | },
30 | }),
31 | () => {
32 | pubClient.disconnect();
33 | subClient.disconnect();
34 | },
35 | ];
36 | });
37 | servers = testContext.servers;
38 | clientSockets = testContext.clientSockets;
39 | cleanup = testContext.cleanup;
40 | });
41 |
42 | afterEach(() => cleanup());
43 |
44 | it("broadcasts", (done) => {
45 | const partialDone = times(3, done);
46 |
47 | clientSockets.forEach((clientSocket) => {
48 | clientSocket.on("test", (arg1, arg2, arg3) => {
49 | expect(arg1).to.eql(1);
50 | expect(arg2).to.eql("2");
51 | expect(arg3).to.eql([3]);
52 | partialDone();
53 | });
54 | });
55 |
56 | servers[0].emit("test", 1, "2", [3]);
57 | });
58 | });
59 |
--------------------------------------------------------------------------------
/test/index.ts:
--------------------------------------------------------------------------------
1 | import type { Server, Socket as ServerSocket } from "socket.io";
2 | import type { Socket as ClientSocket } from "socket.io-client";
3 | import expect = require("expect.js");
4 | import { times, sleep, shouldNotHappen, setup } from "./util";
5 |
6 | /**
7 | * Default test suite for all adapters
8 | *
9 | * @see https://github.com/socketio/socket.io-redis-adapter
10 | * @see https://github.com/socketio/socket.io-mongo-adapter
11 | * @see https://github.com/socketio/socket.io-postgres-adapter
12 | */
13 | export function testSuite(createAdapter: any) {
14 | describe("common", () => {
15 | let servers: Server[];
16 | let serverSockets: ServerSocket[];
17 | let clientSockets: ClientSocket[];
18 | let cleanup: () => void;
19 |
20 | beforeEach(async () => {
21 | const testContext = await setup(createAdapter);
22 | servers = testContext.servers;
23 | serverSockets = testContext.serverSockets;
24 | clientSockets = testContext.clientSockets;
25 | cleanup = testContext.cleanup;
26 | });
27 |
28 | afterEach(() => cleanup());
29 |
30 | describe("broadcast", function () {
31 | it("broadcasts to all clients", (done) => {
32 | const partialDone = times(3, done);
33 |
34 | clientSockets.forEach((clientSocket) => {
35 | clientSocket.on("test", (arg1, arg2, arg3) => {
36 | expect(arg1).to.eql(1);
37 | expect(arg2).to.eql("2");
38 | expect(Buffer.isBuffer(arg3)).to.be(true);
39 | partialDone();
40 | });
41 | });
42 |
43 | servers[0].emit("test", 1, "2", Buffer.from([3, 4]));
44 | });
45 |
46 | it("broadcasts to all clients in a namespace", (done) => {
47 | const partialDone = times(3, done);
48 |
49 | servers.forEach((server) => server.of("/custom"));
50 |
51 | const onConnect = times(3, async () => {
52 | await sleep(200);
53 |
54 | servers[0].of("/custom").emit("test");
55 | });
56 |
57 | clientSockets.forEach((clientSocket) => {
58 | const socket = clientSocket.io.socket("/custom");
59 | socket.on("connect", onConnect);
60 | socket.on("test", () => {
61 | socket.disconnect();
62 | partialDone();
63 | });
64 | });
65 | });
66 |
67 | it("broadcasts to all clients in a room", (done) => {
68 | serverSockets[1].join("room1");
69 |
70 | clientSockets[0].on("test", shouldNotHappen(done));
71 | clientSockets[1].on("test", () => done());
72 | clientSockets[2].on("test", shouldNotHappen(done));
73 |
74 | // delay is needed for the sharded adapter in dynamic mode
75 | setTimeout(() => servers[0].to("room1").emit("test"), 50);
76 | });
77 |
78 | it("broadcasts to all clients except in room", (done) => {
79 | const partialDone = times(2, done);
80 | serverSockets[1].join("room1");
81 |
82 | clientSockets[0].on("test", () => partialDone());
83 | clientSockets[1].on("test", shouldNotHappen(done));
84 | clientSockets[2].on("test", () => partialDone());
85 |
86 | servers[0].of("/").except("room1").emit("test");
87 | });
88 |
89 | it("broadcasts to all clients once", (done) => {
90 | const partialDone = times(2, done);
91 | serverSockets[0].join(["room1", "room2"]);
92 | serverSockets[1].join(["room1", "room2", "room3"]);
93 | serverSockets[2].join("room1");
94 |
95 | clientSockets[0].on("test", () => partialDone());
96 | clientSockets[1].on("test", shouldNotHappen(done));
97 | clientSockets[2].on("test", () => partialDone());
98 |
99 | servers[0].of("/").to("room1").to("room2").except("room3").emit("test");
100 | });
101 |
102 | it("broadcasts to local clients only", (done) => {
103 | clientSockets[0].on("test", () => done());
104 | clientSockets[1].on("test", shouldNotHappen(done));
105 | clientSockets[2].on("test", shouldNotHappen(done));
106 |
107 | servers[0].local.emit("test");
108 | });
109 |
110 | it("broadcasts to a single client", (done) => {
111 | clientSockets[0].on("test", shouldNotHappen(done));
112 | clientSockets[1].on("test", () => done());
113 | clientSockets[2].on("test", shouldNotHappen(done));
114 |
115 | servers[0].to(serverSockets[1].id).emit("test");
116 | });
117 |
118 | it("broadcasts with multiple acknowledgements", (done) => {
119 | clientSockets[0].on("test", (cb) => cb(1));
120 | clientSockets[1].on("test", (cb) => cb(2));
121 | clientSockets[2].on("test", (cb) => cb(3));
122 |
123 | servers[0].timeout(500).emit("test", (err: Error, responses: any[]) => {
124 | expect(err).to.be(null);
125 | expect(responses).to.contain(1);
126 | expect(responses).to.contain(2);
127 | expect(responses).to.contain(3);
128 |
129 | setTimeout(() => {
130 | // @ts-ignore
131 | expect(servers[0].of("/").adapter.ackRequests.size).to.eql(0);
132 |
133 | done();
134 | }, 500);
135 | });
136 | });
137 |
138 | it("broadcasts with multiple acknowledgements (binary content)", (done) => {
139 | clientSockets[0].on("test", (cb) => cb(Buffer.from([1])));
140 | clientSockets[1].on("test", (cb) => cb(Buffer.from([2])));
141 | clientSockets[2].on("test", (cb) => cb(Buffer.from([3])));
142 |
143 | servers[0].timeout(500).emit("test", (err: Error, responses: any[]) => {
144 | expect(err).to.be(null);
145 | responses.forEach((response) => {
146 | expect(Buffer.isBuffer(response)).to.be(true);
147 | });
148 |
149 | done();
150 | });
151 | });
152 |
153 | it("broadcasts with multiple acknowledgements (no client)", (done) => {
154 | servers[0]
155 | .to("abc")
156 | .timeout(500)
157 | .emit("test", (err: Error, responses: any[]) => {
158 | expect(err).to.be(null);
159 | expect(responses).to.eql([]);
160 |
161 | done();
162 | });
163 | });
164 |
165 | it("broadcasts with multiple acknowledgements (timeout)", (done) => {
166 | clientSockets[0].on("test", (cb) => cb(1));
167 | clientSockets[1].on("test", (cb) => cb(2));
168 | clientSockets[2].on("test", (_cb) => {
169 | // do nothing
170 | });
171 |
172 | servers[0].timeout(500).emit("test", (err: Error, responses: any[]) => {
173 | expect(err).to.be.an(Error);
174 | expect(responses).to.contain(1);
175 | expect(responses).to.contain(2);
176 |
177 | done();
178 | });
179 | });
180 | });
181 |
182 | describe("socketsJoin", () => {
183 | it("makes all socket instances join the specified room", async () => {
184 | servers[0].socketsJoin("room1");
185 |
186 | await sleep(200);
187 |
188 | expect(serverSockets[0].rooms.has("room1")).to.be(true);
189 | expect(serverSockets[1].rooms.has("room1")).to.be(true);
190 | expect(serverSockets[2].rooms.has("room1")).to.be(true);
191 | });
192 |
193 | it("makes the matching socket instances join the specified room", async () => {
194 | serverSockets[0].join("room1");
195 | serverSockets[2].join("room1");
196 |
197 | servers[0].in("room1").socketsJoin("room2");
198 |
199 | await sleep(200);
200 |
201 | expect(serverSockets[0].rooms.has("room2")).to.be(true);
202 | expect(serverSockets[1].rooms.has("room2")).to.be(false);
203 | expect(serverSockets[2].rooms.has("room2")).to.be(true);
204 | });
205 |
206 | it("makes the given socket instance join the specified room", async () => {
207 | servers[0].in(serverSockets[1].id).socketsJoin("room3");
208 |
209 | await sleep(200);
210 |
211 | expect(serverSockets[0].rooms.has("room3")).to.be(false);
212 | expect(serverSockets[1].rooms.has("room3")).to.be(true);
213 | expect(serverSockets[2].rooms.has("room3")).to.be(false);
214 | });
215 | });
216 |
217 | describe("socketsLeave", () => {
218 | it("makes all socket instances leave the specified room", async () => {
219 | serverSockets[0].join("room1");
220 | serverSockets[2].join("room1");
221 |
222 | servers[0].socketsLeave("room1");
223 |
224 | await sleep(200);
225 |
226 | expect(serverSockets[0].rooms.has("room1")).to.be(false);
227 | expect(serverSockets[1].rooms.has("room1")).to.be(false);
228 | expect(serverSockets[2].rooms.has("room1")).to.be(false);
229 | });
230 |
231 | it("makes the matching socket instances leave the specified room", async () => {
232 | serverSockets[0].join(["room1", "room2"]);
233 | serverSockets[1].join(["room1", "room2"]);
234 | serverSockets[2].join(["room2"]);
235 |
236 | servers[0].in("room1").socketsLeave("room2");
237 |
238 | await sleep(200);
239 |
240 | expect(serverSockets[0].rooms.has("room2")).to.be(false);
241 | expect(serverSockets[1].rooms.has("room2")).to.be(false);
242 | expect(serverSockets[2].rooms.has("room2")).to.be(true);
243 | });
244 |
245 | it("makes the given socket instance leave the specified room", async () => {
246 | serverSockets[0].join("room3");
247 | serverSockets[1].join("room3");
248 | serverSockets[2].join("room3");
249 |
250 | servers[0].in(serverSockets[1].id).socketsLeave("room3");
251 |
252 | await sleep(200);
253 |
254 | expect(serverSockets[0].rooms.has("room3")).to.be(true);
255 | expect(serverSockets[1].rooms.has("room3")).to.be(false);
256 | expect(serverSockets[2].rooms.has("room3")).to.be(true);
257 | });
258 | });
259 |
260 | describe("disconnectSockets", () => {
261 | it("makes all socket instances disconnect", (done) => {
262 | const partialDone = times(3, done);
263 |
264 | clientSockets.forEach((clientSocket) => {
265 | clientSocket.on("disconnect", (reason) => {
266 | expect(reason).to.eql("io server disconnect");
267 | partialDone();
268 | });
269 | });
270 |
271 | servers[0].disconnectSockets();
272 | });
273 | });
274 |
275 | describe("fetchSockets", () => {
276 | it("returns all socket instances", async () => {
277 | const sockets = await servers[0].fetchSockets();
278 |
279 | expect(sockets).to.be.an(Array);
280 | expect(sockets).to.have.length(3);
281 | // @ts-ignore
282 | expect(servers[0].of("/").adapter.requests.size).to.eql(0); // clean up
283 | });
284 |
285 | it("returns a single socket instance", async () => {
286 | serverSockets[1].data = "test" as any;
287 |
288 | const [remoteSocket] = await servers[0]
289 | .in(serverSockets[1].id)
290 | .fetchSockets();
291 |
292 | expect(remoteSocket.handshake).to.eql(serverSockets[1].handshake);
293 | expect(remoteSocket.data).to.eql("test");
294 | expect(remoteSocket.rooms.size).to.eql(1);
295 | });
296 |
297 | it("returns only local socket instances", async () => {
298 | const sockets = await servers[0].local.fetchSockets();
299 |
300 | expect(sockets).to.have.length(1);
301 | });
302 | });
303 |
304 | describe("serverSideEmit", () => {
305 | it("sends an event to other server instances", (done) => {
306 | const partialDone = times(2, done);
307 |
308 | servers[0].serverSideEmit("hello", "world", 1, "2");
309 |
310 | servers[0].on("hello", shouldNotHappen(done));
311 |
312 | servers[1].on("hello", (arg1, arg2, arg3) => {
313 | expect(arg1).to.eql("world");
314 | expect(arg2).to.eql(1);
315 | expect(arg3).to.eql("2");
316 | partialDone();
317 | });
318 |
319 | servers[2].of("/").on("hello", () => partialDone());
320 | });
321 |
322 | it("sends an event and receives a response from the other server instances", (done) => {
323 | servers[0].serverSideEmit("hello", (err: Error, response: any) => {
324 | expect(err).to.be(null);
325 | expect(response).to.be.an(Array);
326 | expect(response).to.contain(2);
327 | expect(response).to.contain("3");
328 | done();
329 | });
330 |
331 | servers[0].on("hello", shouldNotHappen(done));
332 | servers[1].on("hello", (cb) => cb(2));
333 | servers[2].on("hello", (cb) => cb("3"));
334 | });
335 |
336 | it("sends an event but timeout if one server does not respond", function (done) {
337 | // TODO the serverSideEmit() method currently ignores the timeout() flag
338 | this.timeout(6000);
339 |
340 | servers[0].serverSideEmit("hello", (err: Error, response: any) => {
341 | expect(err.message).to.be(
342 | "timeout reached: only 1 responses received out of 2"
343 | );
344 | expect(response).to.be.an(Array);
345 | expect(response).to.contain(2);
346 | done();
347 | });
348 |
349 | servers[0].on("hello", shouldNotHappen(done));
350 | servers[1].on("hello", (cb) => cb(2));
351 | servers[2].on("hello", () => {
352 | // do nothing
353 | });
354 | });
355 | });
356 | });
357 | }
358 |
--------------------------------------------------------------------------------
/test/specifics.ts:
--------------------------------------------------------------------------------
1 | import type { Server, Socket as ServerSocket } from "socket.io";
2 | import type { Socket as ClientSocket } from "socket.io-client";
3 | import expect = require("expect.js");
4 | import { shouldNotHappen, setup } from "./util";
5 | import type { RedisAdapter } from "../lib";
6 |
7 | export function testSuite(
8 | createAdapter: any,
9 | redisPackage: string,
10 | sharded: boolean
11 | ) {
12 | describe("specifics", () => {
13 | let servers: Server[];
14 | let serverSockets: ServerSocket[];
15 | let clientSockets: ClientSocket[];
16 | let cleanup: () => void;
17 |
18 | beforeEach(async () => {
19 | const testContext = await setup(createAdapter);
20 | servers = testContext.servers;
21 | serverSockets = testContext.serverSockets;
22 | clientSockets = testContext.clientSockets;
23 | cleanup = testContext.cleanup;
24 | });
25 |
26 | afterEach(() => cleanup());
27 |
28 | describe("broadcast", function () {
29 | it("broadcasts to a numeric room", function (done) {
30 | if (sharded) {
31 | return this.skip();
32 | }
33 | // @ts-ignore
34 | serverSockets[0].join(123);
35 |
36 | clientSockets[0].on("test", () => done());
37 | clientSockets[1].on("test", shouldNotHappen(done));
38 | clientSockets[2].on("test", shouldNotHappen(done));
39 |
40 | // @ts-ignore
41 | servers[1].to(123).emit("test");
42 | });
43 | });
44 |
45 | // TODO handle Redis cluster
46 | it.skip("unsubscribes when close is called", async function () {
47 | if (sharded) {
48 | return this.skip();
49 | }
50 | const parseInfo = (rawInfo: string) => {
51 | const info = {};
52 |
53 | rawInfo.split("\r\n").forEach((line) => {
54 | if (line.length > 0 && !line.startsWith("#")) {
55 | const fieldVal = line.split(":");
56 | info[fieldVal[0]] = fieldVal[1];
57 | }
58 | });
59 |
60 | return info;
61 | };
62 |
63 | const getInfo = async (): Promise => {
64 | if (process.env.REDIS_CLIENT === undefined) {
65 | return parseInfo(
66 | await (
67 | servers[2].of("/").adapter as RedisAdapter
68 | ).pubClient.sendCommand(["info"])
69 | );
70 | } else if (process.env.REDIS_CLIENT === "ioredis") {
71 | // @ts-ignore
72 | return parseInfo(
73 | await (servers[2].of("/").adapter as RedisAdapter).pubClient.call(
74 | "info"
75 | )
76 | );
77 | } else {
78 | return await new Promise((resolve, reject) => {
79 | (servers[2].of("/").adapter as RedisAdapter).pubClient.sendCommand(
80 | "info",
81 | [],
82 | (err, result) => {
83 | if (err) {
84 | reject(err);
85 | }
86 | resolve(parseInfo(result));
87 | }
88 | );
89 | });
90 | }
91 | };
92 |
93 | return new Promise(async (resolve, reject) => {
94 | // Give it a moment to subscribe to all the channels
95 | setTimeout(async () => {
96 | try {
97 | const info = await getInfo();
98 |
99 | // Depending on the version of redis this may be 3 (redis < v5) or 1 (redis > v4)
100 | // Older versions subscribed multiple times on the same pattern. Newer versions only sub once.
101 | expect(info.pubsub_patterns).to.be.greaterThan(0);
102 | expect(info.pubsub_channels).to.eql(5); // 2 shared (request/response) + 3 unique for each namespace
103 |
104 | servers[0].of("/").adapter.close();
105 | servers[1].of("/").adapter.close();
106 | servers[2].of("/").adapter.close();
107 |
108 | // Give it a moment to unsubscribe
109 | setTimeout(async () => {
110 | try {
111 | const info = await getInfo();
112 |
113 | expect(info.pubsub_patterns).to.eql(0); // All patterns subscriptions should be unsubscribed
114 | expect(info.pubsub_channels).to.eql(0); // All subscriptions should be unsubscribed
115 | resolve();
116 | } catch (error) {
117 | reject(error);
118 | }
119 | }, 100);
120 | } catch (error) {
121 | reject(error);
122 | }
123 | }, 100);
124 | });
125 | });
126 |
127 | if (redisPackage === "redis@4") {
128 | // redis@4
129 | it("ignores messages from unknown channels", (done) => {
130 | (servers[0].of("/").adapter as RedisAdapter).subClient
131 | .PSUBSCRIBE("f?o", () => {
132 | setTimeout(done, 50);
133 | })
134 | .then(() => {
135 | (servers[2].of("/").adapter as RedisAdapter).pubClient.publish(
136 | "foo",
137 | "bar"
138 | );
139 | });
140 | });
141 |
142 | it("ignores messages from unknown channels (2)", (done) => {
143 | (servers[0].of("/").adapter as RedisAdapter).subClient
144 | .PSUBSCRIBE("woot", () => {
145 | setTimeout(done, 50);
146 | })
147 | .then(() => {
148 | (servers[2].of("/").adapter as RedisAdapter).pubClient.publish(
149 | "woot",
150 | "toow"
151 | );
152 | });
153 | });
154 | } else {
155 | // redis@3 and ioredis
156 | it("ignores messages from unknown channels", (done) => {
157 | (servers[0].of("/").adapter as RedisAdapter).subClient.psubscribe(
158 | "f?o",
159 | () => {
160 | (servers[2].of("/").adapter as RedisAdapter).pubClient.publish(
161 | "foo",
162 | "bar"
163 | );
164 | }
165 | );
166 |
167 | (servers[0].of("/").adapter as RedisAdapter).subClient.on(
168 | "pmessageBuffer",
169 | () => {
170 | setTimeout(done, 50);
171 | }
172 | );
173 | });
174 |
175 | it("ignores messages from unknown channels (2)", (done) => {
176 | (servers[0].of("/").adapter as RedisAdapter).subClient.subscribe(
177 | "woot",
178 | () => {
179 | (servers[2].of("/").adapter as RedisAdapter).pubClient.publish(
180 | "woot",
181 | "toow"
182 | );
183 | }
184 | );
185 |
186 | (servers[0].of("/").adapter as RedisAdapter).subClient.on(
187 | "messageBuffer",
188 | () => {
189 | setTimeout(done, 50);
190 | }
191 | );
192 | });
193 | }
194 |
195 | describe("allRooms", () => {
196 | afterEach(() => {
197 | // @ts-ignore
198 | expect(servers[0].of("/").adapter.requests.size).to.eql(0);
199 | });
200 |
201 | it("returns all rooms across several nodes", async function () {
202 | if (sharded) {
203 | return this.skip();
204 | }
205 | serverSockets[0].join("woot1");
206 |
207 | const rooms = await (
208 | servers[0].of("/").adapter as RedisAdapter
209 | ).allRooms();
210 |
211 | expect(rooms).to.be.a(Set);
212 | expect(rooms.size).to.eql(4);
213 | expect(rooms.has(serverSockets[0].id)).to.be(true);
214 | expect(rooms.has(serverSockets[1].id)).to.be(true);
215 | expect(rooms.has(serverSockets[2].id)).to.be(true);
216 | expect(rooms.has("woot1")).to.be(true);
217 | });
218 | });
219 | });
220 | }
221 |
--------------------------------------------------------------------------------
/test/test-runner.ts:
--------------------------------------------------------------------------------
1 | import { testSuite as commonTestSuite } from "./index";
2 | import { testSuite as specificsTestSuite } from "./specifics";
3 | import { createAdapter, createShardedAdapter } from "../lib";
4 | import { createClient, createCluster } from "redis";
5 | import { Redis, Cluster } from "ioredis";
6 | import { createClient as createClientV3 } from "redis-v3";
7 |
8 | const clusterNodes = [
9 | {
10 | url: "redis://localhost:7000",
11 | host: "localhost",
12 | port: 7000,
13 | },
14 | {
15 | url: "redis://localhost:7001",
16 | host: "localhost",
17 | port: 7001,
18 | },
19 | {
20 | url: "redis://localhost:7002",
21 | host: "localhost",
22 | port: 7002,
23 | },
24 | {
25 | url: "redis://localhost:7003",
26 | host: "localhost",
27 | port: 7003,
28 | },
29 | {
30 | url: "redis://localhost:7004",
31 | host: "localhost",
32 | port: 7004,
33 | },
34 | {
35 | url: "redis://localhost:7005",
36 | host: "localhost",
37 | port: 7005,
38 | },
39 | ];
40 |
41 | function testSuite(
42 | createAdapter: any,
43 | redisPackage: string = "redis@4",
44 | sharded = false
45 | ) {
46 | commonTestSuite(createAdapter);
47 | specificsTestSuite(createAdapter, redisPackage, sharded);
48 | }
49 |
50 | describe("@socket.io/redis-adapter", () => {
51 | describe("redis@4 standalone", () =>
52 | testSuite(async () => {
53 | const pubClient = createClient();
54 | const subClient = pubClient.duplicate();
55 |
56 | await Promise.all([pubClient.connect(), subClient.connect()]);
57 |
58 | return [
59 | createAdapter(pubClient, subClient, {
60 | requestsTimeout: 1000,
61 | }),
62 | () => {
63 | pubClient.disconnect();
64 | subClient.disconnect();
65 | },
66 | ];
67 | }));
68 |
69 | describe("redis@4 standalone (specific response channel)", () =>
70 | testSuite(async () => {
71 | const pubClient = createClient();
72 | const subClient = pubClient.duplicate();
73 |
74 | await Promise.all([pubClient.connect(), subClient.connect()]);
75 |
76 | return [
77 | createAdapter(pubClient, subClient, {
78 | requestsTimeout: 1000,
79 | publishOnSpecificResponseChannel: true,
80 | }),
81 | () => {
82 | pubClient.disconnect();
83 | subClient.disconnect();
84 | },
85 | ];
86 | }));
87 |
88 | describe("redis@4 cluster", () =>
89 | testSuite(async () => {
90 | const pubClient = createCluster({
91 | rootNodes: clusterNodes,
92 | });
93 | const subClient = pubClient.duplicate();
94 |
95 | await Promise.all([pubClient.connect(), subClient.connect()]);
96 |
97 | return [
98 | createAdapter(pubClient, subClient, {
99 | requestsTimeout: 1000,
100 | }),
101 | () => {
102 | pubClient.disconnect();
103 | subClient.disconnect();
104 | },
105 | ];
106 | }));
107 |
108 | describe("redis@3 standalone", () =>
109 | testSuite(async () => {
110 | const pubClient = createClientV3();
111 | const subClient = pubClient.duplicate();
112 |
113 | return [
114 | createAdapter(pubClient, subClient, {
115 | requestsTimeout: 1000,
116 | }),
117 | () => {
118 | pubClient.quit();
119 | subClient.quit();
120 | },
121 | ];
122 | }, "redis@3"));
123 |
124 | describe("ioredis standalone", () =>
125 | testSuite(async () => {
126 | const pubClient = new Redis();
127 | const subClient = pubClient.duplicate();
128 |
129 | return [
130 | createAdapter(pubClient, subClient, {
131 | requestsTimeout: 1000,
132 | }),
133 | () => {
134 | pubClient.disconnect();
135 | subClient.disconnect();
136 | },
137 | ];
138 | }, "ioredis"));
139 |
140 | describe("ioredis cluster", () =>
141 | testSuite(async () => {
142 | const pubClient = new Cluster(clusterNodes);
143 | const subClient = pubClient.duplicate();
144 |
145 | return [
146 | createAdapter(pubClient, subClient, {
147 | requestsTimeout: 1000,
148 | }),
149 | () => {
150 | pubClient.disconnect();
151 | subClient.disconnect();
152 | },
153 | ];
154 | }, "ioredis"));
155 |
156 | describe("[sharded] redis@4 standalone (dynamic subscription mode)", () =>
157 | testSuite(
158 | async () => {
159 | const pubClient = createClient();
160 | const subClient = pubClient.duplicate();
161 |
162 | await Promise.all([pubClient.connect(), subClient.connect()]);
163 |
164 | return [
165 | createShardedAdapter(pubClient, subClient, {
166 | subscriptionMode: "dynamic",
167 | }),
168 | () => {
169 | pubClient.disconnect();
170 | subClient.disconnect();
171 | },
172 | ];
173 | },
174 | "redis@4",
175 | true
176 | ));
177 |
178 | describe("[sharded] redis@4 standalone (dynamic subscription mode & dynamic private channels)", () =>
179 | testSuite(
180 | async () => {
181 | const pubClient = createClient();
182 | const subClient = pubClient.duplicate();
183 |
184 | await Promise.all([pubClient.connect(), subClient.connect()]);
185 |
186 | return [
187 | createShardedAdapter(pubClient, subClient, {
188 | subscriptionMode: "dynamic-private",
189 | }),
190 | () => {
191 | pubClient.disconnect();
192 | subClient.disconnect();
193 | },
194 | ];
195 | },
196 | "redis@4",
197 | true
198 | ));
199 |
200 | describe("[sharded] redis@4 standalone (static subscription mode)", () =>
201 | testSuite(
202 | async () => {
203 | const pubClient = createClient();
204 | const subClient = pubClient.duplicate();
205 |
206 | await Promise.all([pubClient.connect(), subClient.connect()]);
207 |
208 | return [
209 | createShardedAdapter(pubClient, subClient, {
210 | subscriptionMode: "static",
211 | }),
212 | () => {
213 | pubClient.disconnect();
214 | subClient.disconnect();
215 | },
216 | ];
217 | },
218 | "redis@4",
219 | true
220 | ));
221 |
222 | describe("[sharded] redis@4 cluster", () =>
223 | testSuite(
224 | async () => {
225 | const pubClient = createCluster({
226 | rootNodes: clusterNodes,
227 | });
228 | const subClient = pubClient.duplicate();
229 |
230 | await Promise.all([pubClient.connect(), subClient.connect()]);
231 |
232 | return [
233 | createShardedAdapter(pubClient, subClient),
234 | () => {
235 | pubClient.disconnect();
236 | subClient.disconnect();
237 | },
238 | ];
239 | },
240 | "redis@4",
241 | true
242 | ));
243 |
244 | describe("[sharded] ioredis standalone", () =>
245 | testSuite(
246 | async () => {
247 | const pubClient = new Redis();
248 | const subClient = pubClient.duplicate();
249 |
250 | return [
251 | createShardedAdapter(pubClient, subClient),
252 | () => {
253 | pubClient.disconnect();
254 | subClient.disconnect();
255 | },
256 | ];
257 | },
258 | "ioredis",
259 | true
260 | ));
261 |
262 | // FIXME see https://github.com/luin/ioredis/issues/1759
263 | describe.skip("[sharded] ioredis cluster", () =>
264 | testSuite(
265 | async () => {
266 | const pubClient = new Cluster(clusterNodes);
267 | const subClient = pubClient.duplicate();
268 |
269 | return [
270 | createShardedAdapter(pubClient, subClient),
271 | () => {
272 | pubClient.disconnect();
273 | subClient.disconnect();
274 | },
275 | ];
276 | },
277 | "ioredis",
278 | true
279 | ));
280 |
281 | import("./custom-parser");
282 | });
283 |
--------------------------------------------------------------------------------
/test/util.ts:
--------------------------------------------------------------------------------
1 | import { createServer } from "http";
2 | import { AddressInfo } from "net";
3 | import { Server, Socket as ServerSocket } from "socket.io";
4 | import { io as ioc, Socket as ClientSocket } from "socket.io-client";
5 |
6 | export function times(count: number, fn: () => void) {
7 | let i = 0;
8 | return () => {
9 | i++;
10 | if (i === count) {
11 | fn();
12 | } else if (i > count) {
13 | throw new Error(`too many calls: ${i} instead of ${count}`);
14 | }
15 | };
16 | }
17 |
18 | export function sleep(duration: number) {
19 | return new Promise((resolve) => setTimeout(resolve, duration));
20 | }
21 |
22 | export function shouldNotHappen(done) {
23 | return () => done(new Error("should not happen"));
24 | }
25 |
26 | const NODES_COUNT = 3;
27 |
28 | interface TestContext {
29 | servers: Server[];
30 | serverSockets: ServerSocket[];
31 | clientSockets: ClientSocket[];
32 | cleanup: () => void;
33 | }
34 |
35 | export function setup(createAdapter: any) {
36 | const servers = [];
37 | const serverSockets = [];
38 | const clientSockets = [];
39 | const redisCleanupFunctions = [];
40 |
41 | return new Promise(async (resolve) => {
42 | for (let i = 1; i <= NODES_COUNT; i++) {
43 | const [adapter, redisCleanup] = await createAdapter();
44 |
45 | const httpServer = createServer();
46 | const io = new Server(httpServer, {
47 | adapter,
48 | });
49 | httpServer.listen(() => {
50 | const port = (httpServer.address() as AddressInfo).port;
51 | const clientSocket = ioc(`http://localhost:${port}`);
52 |
53 | io.on("connection", async (socket) => {
54 | clientSockets.push(clientSocket);
55 | serverSockets.push(socket);
56 | servers.push(io);
57 | redisCleanupFunctions.push(redisCleanup);
58 | if (servers.length === NODES_COUNT) {
59 | await sleep(200);
60 |
61 | resolve({
62 | servers,
63 | serverSockets,
64 | clientSockets,
65 | cleanup: () => {
66 | servers.forEach((server) => server.close());
67 | clientSockets.forEach((socket) => socket.disconnect());
68 | redisCleanupFunctions.forEach((fn) => fn());
69 | },
70 | });
71 | }
72 | });
73 | });
74 | }
75 | });
76 | }
77 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "outDir": "./dist",
4 | "allowJs": false,
5 | "target": "es2017",
6 | "module": "commonjs",
7 | "declaration": true
8 | },
9 | "include": [
10 | "./lib/**/*"
11 | ]
12 | }
13 |
--------------------------------------------------------------------------------