├── .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 | Diagram of Socket.IO packets forwarded through Redis 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 | --------------------------------------------------------------------------------