├── .github └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── assets ├── adapter.excalidraw └── adapter.png ├── lib └── index.ts ├── package-lock.json ├── package.json ├── test ├── index.ts ├── util.ts └── worker.js └── 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 | jobs: 10 | test-node: 11 | runs-on: ubuntu-latest 12 | timeout-minutes: 10 13 | 14 | strategy: 15 | matrix: 16 | node-version: 17 | - 12 18 | - 20 19 | 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@v3 23 | 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v3 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | 29 | - name: Install dependencies 30 | run: npm ci 31 | 32 | - name: Run tests 33 | run: npm test 34 | -------------------------------------------------------------------------------- /.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 | - [0.2.2](#022-2023-03-24) (Mar 2023) 4 | - [0.2.1](#021-2022-10-13) (Oct 2022) 5 | - [0.2.0](#020-2022-04-28) (Apr 2022) 6 | - [0.1.0](#010-2021-06-22) (Jun 2021) 7 | 8 | 9 | 10 | # Release notes 11 | 12 | ## [0.2.2](https://github.com/socketio/socket.io-cluster-adapter/compare/0.2.1...0.2.2) (2023-03-24) 13 | 14 | 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 [15fd56e](https://github.com/socketio/socket.io-cluster-adapter/commit/15fd56e78d52aa65c5fbf412dec57ab4bdaee7cc)). 15 | 16 | Support for connection state recovery (see [here](https://github.com/socketio/socket.io/releases/4.6.0)) will be added in the next release. 17 | 18 | 19 | 20 | ## [0.2.1](https://github.com/socketio/socket.io-cluster-adapter/compare/0.2.0...0.2.1) (2022-10-13) 21 | 22 | 23 | ### Bug Fixes 24 | 25 | * properly handle ERR_IPC_CHANNEL_CLOSED errors ([#6](https://github.com/socketio/socket.io-cluster-adapter/issues/6)) ([be0a0e3](https://github.com/socketio/socket.io-cluster-adapter/commit/be0a0e3217bd7100d569e5624194612bcc8b96ff)) 26 | 27 | 28 | 29 | ## [0.2.0](https://github.com/socketio/socket.io-cluster-adapter/compare/0.1.0...0.2.0) (2022-04-28) 30 | 31 | 32 | ### Features 33 | 34 | * broadcast and expect multiple acks ([055b784](https://github.com/socketio/socket.io-cluster-adapter/commit/055b7840d8cf88173d8299041ef3fafa9791c97a)) 35 | 36 | This feature was added in `socket.io@4.5.0`: 37 | 38 | ```js 39 | io.timeout(1000).emit("some-event", (err, responses) => { 40 | // ... 41 | }); 42 | ``` 43 | 44 | Thanks to this change, it will now work within a Node.js cluster. 45 | 46 | 47 | 48 | ## 0.1.0 (2021-06-22) 49 | 50 | Initial commit 51 | 52 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 Damien Arrachequesne (@darrachequesne) 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. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Socket.IO cluster adapter 2 | 3 | The `@socket.io/cluster-adapter` package allows broadcasting packets between multiple Socket.IO servers. 4 | 5 | ![Adapter diagram](./assets/adapter.png) 6 | 7 | It can be used in conjunction with [`@socket.io/sticky`](https://github.com/socketio/socket.io-sticky) to broadcast packets between the workers of the same Node.js [cluster](https://nodejs.org/api/cluster.html). 8 | 9 | Supported features: 10 | 11 | - [broadcasting](https://socket.io/docs/v4/broadcasting-events/) 12 | - [utility methods](https://socket.io/docs/v4/server-instance/#Utility-methods) 13 | - [`socketsJoin`](https://socket.io/docs/v4/server-instance/#socketsJoin) 14 | - [`socketsLeave`](https://socket.io/docs/v4/server-instance/#socketsLeave) 15 | - [`disconnectSockets`](https://socket.io/docs/v4/server-instance/#disconnectSockets) 16 | - [`fetchSockets`](https://socket.io/docs/v4/server-instance/#fetchSockets) 17 | - [`serverSideEmit`](https://socket.io/docs/v4/server-instance/#serverSideEmit) 18 | 19 | Related packages: 20 | 21 | - Postgres adapter: https://github.com/socketio/socket.io-postgres-adapter/ 22 | - Redis adapter: https://github.com/socketio/socket.io-redis-adapter/ 23 | - MongoDB adapter: https://github.com/socketio/socket.io-mongo-adapter/ 24 | 25 | **Table of contents** 26 | 27 | - [Installation](#installation) 28 | - [Usage](#usage) 29 | - [License](#license) 30 | 31 | ## Installation 32 | 33 | ``` 34 | npm install @socket.io/cluster-adapter 35 | ``` 36 | 37 | ## Usage 38 | 39 | ```js 40 | const cluster = require("cluster"); 41 | const http = require("http"); 42 | const { Server } = require("socket.io"); 43 | const numCPUs = require("os").cpus().length; 44 | const { setupMaster, setupWorker } = require("@socket.io/sticky"); 45 | const { createAdapter, setupPrimary } = require("@socket.io/cluster-adapter"); 46 | 47 | if (cluster.isMaster) { 48 | console.log(`Master ${process.pid} is running`); 49 | 50 | const httpServer = http.createServer(); 51 | 52 | // setup sticky sessions 53 | setupMaster(httpServer, { 54 | loadBalancingMethod: "least-connection", 55 | }); 56 | 57 | // setup connections between the workers 58 | setupPrimary(); 59 | 60 | // needed for packets containing buffers (you can ignore it if you only send plaintext objects) 61 | // Node.js < 16.0.0 62 | cluster.setupMaster({ 63 | serialization: "advanced", 64 | }); 65 | // Node.js > 16.0.0 66 | // cluster.setupPrimary({ 67 | // serialization: "advanced", 68 | // }); 69 | 70 | httpServer.listen(3000); 71 | 72 | for (let i = 0; i < numCPUs; i++) { 73 | cluster.fork(); 74 | } 75 | 76 | cluster.on("exit", (worker) => { 77 | console.log(`Worker ${worker.process.pid} died`); 78 | cluster.fork(); 79 | }); 80 | } else { 81 | console.log(`Worker ${process.pid} started`); 82 | 83 | const httpServer = http.createServer(); 84 | const io = new Server(httpServer); 85 | 86 | // use the cluster adapter 87 | io.adapter(createAdapter()); 88 | 89 | // setup connection with the primary process 90 | setupWorker(io); 91 | 92 | io.on("connection", (socket) => { 93 | /* ... */ 94 | }); 95 | } 96 | ``` 97 | 98 | ## License 99 | 100 | [MIT](LICENSE) 101 | -------------------------------------------------------------------------------- /assets/adapter.excalidraw: -------------------------------------------------------------------------------- 1 | { 2 | "type": "excalidraw", 3 | "version": 2, 4 | "source": "https://excalidraw.com", 5 | "elements": [ 6 | { 7 | "type": "text", 8 | "version": 345, 9 | "versionNonce": 1782313961, 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": 777, 19 | "y": -89.5, 20 | "strokeColor": "#000000", 21 | "backgroundColor": "transparent", 22 | "width": 78, 23 | "height": 26, 24 | "seed": 28708370, 25 | "groupIds": [], 26 | "strokeSharpness": "sharp", 27 | "boundElementIds": [ 28 | "_wBO22vaQplcoKyBXbWRC" 29 | ], 30 | "fontSize": 20, 31 | "fontFamily": 1, 32 | "text": "worker 1", 33 | "baseline": 18, 34 | "textAlign": "center", 35 | "verticalAlign": "middle" 36 | }, 37 | { 38 | "type": "rectangle", 39 | "version": 230, 40 | "versionNonce": 1587305255, 41 | "isDeleted": false, 42 | "id": "lmQ4o4New7xuXQLwavuSn", 43 | "fillStyle": "hachure", 44 | "strokeWidth": 1, 45 | "strokeStyle": "solid", 46 | "roughness": 1, 47 | "opacity": 100, 48 | "angle": 0, 49 | "x": 725, 50 | "y": -169, 51 | "strokeColor": "#000000", 52 | "backgroundColor": "transparent", 53 | "width": 345.00000000000006, 54 | "height": 311, 55 | "seed": 1594950354, 56 | "groupIds": [], 57 | "strokeSharpness": "sharp", 58 | "boundElementIds": [ 59 | "_wBO22vaQplcoKyBXbWRC", 60 | "BZVwnsrGk9G-X87ZHkh-6", 61 | "eU1gfEXnHSjxc-pEgv43A", 62 | "XZpY0rnxgeDlxu5b8fgRQ", 63 | "4mjxZzapHnLuRx7KU2JeH", 64 | "mV8ZNfAcYrxGLJ7b9a_kn" 65 | ] 66 | }, 67 | { 68 | "type": "text", 69 | "version": 152, 70 | "versionNonce": 742887113, 71 | "isDeleted": false, 72 | "id": "ZQsZmj4NaTubBHMkVG2dl", 73 | "fillStyle": "hachure", 74 | "strokeWidth": 1, 75 | "strokeStyle": "solid", 76 | "roughness": 1, 77 | "opacity": 100, 78 | "angle": 0, 79 | "x": 745, 80 | "y": -159, 81 | "strokeColor": "#000000", 82 | "backgroundColor": "transparent", 83 | "width": 43, 84 | "height": 26, 85 | "seed": 126533902, 86 | "groupIds": [], 87 | "strokeSharpness": "sharp", 88 | "boundElementIds": [], 89 | "fontSize": 20, 90 | "fontFamily": 1, 91 | "text": "Host", 92 | "baseline": 18, 93 | "textAlign": "left", 94 | "verticalAlign": "top" 95 | }, 96 | { 97 | "type": "rectangle", 98 | "version": 334, 99 | "versionNonce": 1221877319, 100 | "isDeleted": false, 101 | "id": "RRrk3Vsl-pM8Z1r8Fj3Vu", 102 | "fillStyle": "hachure", 103 | "strokeWidth": 1, 104 | "strokeStyle": "solid", 105 | "roughness": 1, 106 | "opacity": 100, 107 | "angle": 0, 108 | "x": 749.5, 109 | "y": -105, 110 | "strokeColor": "#000000", 111 | "backgroundColor": "transparent", 112 | "width": 129, 113 | "height": 56, 114 | "seed": 1013161166, 115 | "groupIds": [], 116 | "strokeSharpness": "sharp", 117 | "boundElementIds": [ 118 | "use4Bp2hbb77Fq5njtwBi", 119 | "U7UCkn3nVHlWGYetjII_Z" 120 | ] 121 | }, 122 | { 123 | "type": "text", 124 | "version": 386, 125 | "versionNonce": 601673129, 126 | "isDeleted": false, 127 | "id": "qfQdcJHnwYnCMtLCV51X8", 128 | "fillStyle": "hachure", 129 | "strokeWidth": 1, 130 | "strokeStyle": "solid", 131 | "roughness": 1, 132 | "opacity": 100, 133 | "angle": 0, 134 | "x": 773, 135 | "y": -18.5, 136 | "strokeColor": "#000000", 137 | "backgroundColor": "transparent", 138 | "width": 90, 139 | "height": 26, 140 | "seed": 1535426147, 141 | "groupIds": [], 142 | "strokeSharpness": "sharp", 143 | "boundElementIds": [ 144 | "_wBO22vaQplcoKyBXbWRC", 145 | "2DIFacJXJtC5QIuMuo3pK" 146 | ], 147 | "fontSize": 20, 148 | "fontFamily": 1, 149 | "text": "worker 2", 150 | "baseline": 18, 151 | "textAlign": "center", 152 | "verticalAlign": "middle" 153 | }, 154 | { 155 | "type": "rectangle", 156 | "version": 330, 157 | "versionNonce": 794754407, 158 | "isDeleted": false, 159 | "id": "IRd1nPQbv0PQdJQn_yLOs", 160 | "fillStyle": "hachure", 161 | "strokeWidth": 1, 162 | "strokeStyle": "solid", 163 | "roughness": 1, 164 | "opacity": 100, 165 | "angle": 0, 166 | "x": 749.5, 167 | "y": -36, 168 | "strokeColor": "#000000", 169 | "backgroundColor": "transparent", 170 | "width": 129, 171 | "height": 56, 172 | "seed": 452398413, 173 | "groupIds": [], 174 | "strokeSharpness": "sharp", 175 | "boundElementIds": [ 176 | "2DIFacJXJtC5QIuMuo3pK", 177 | "NhqDM6wVMhgbRvXrQJQge", 178 | "C1IueNJdiTSkqUxSTEvKe" 179 | ] 180 | }, 181 | { 182 | "type": "text", 183 | "version": 374, 184 | "versionNonce": 942092425, 185 | "isDeleted": false, 186 | "id": "ENOSqQ4visNbCN7ZMZwxP", 187 | "fillStyle": "hachure", 188 | "strokeWidth": 1, 189 | "strokeStyle": "solid", 190 | "roughness": 1, 191 | "opacity": 100, 192 | "angle": 0, 193 | "x": 770.5, 194 | "y": 48.5, 195 | "strokeColor": "#000000", 196 | "backgroundColor": "transparent", 197 | "width": 89, 198 | "height": 26, 199 | "seed": 1916984429, 200 | "groupIds": [], 201 | "strokeSharpness": "sharp", 202 | "boundElementIds": [ 203 | "_wBO22vaQplcoKyBXbWRC" 204 | ], 205 | "fontSize": 20, 206 | "fontFamily": 1, 207 | "text": "worker 3", 208 | "baseline": 18, 209 | "textAlign": "center", 210 | "verticalAlign": "middle" 211 | }, 212 | { 213 | "type": "rectangle", 214 | "version": 314, 215 | "versionNonce": 629717127, 216 | "isDeleted": false, 217 | "id": "IqdB8EO7s50UY1EU9TVVP", 218 | "fillStyle": "hachure", 219 | "strokeWidth": 1, 220 | "strokeStyle": "solid", 221 | "roughness": 1, 222 | "opacity": 100, 223 | "angle": 0, 224 | "x": 750.5, 225 | "y": 30, 226 | "strokeColor": "#000000", 227 | "backgroundColor": "transparent", 228 | "width": 129, 229 | "height": 56, 230 | "seed": 1832463587, 231 | "groupIds": [], 232 | "strokeSharpness": "sharp", 233 | "boundElementIds": [ 234 | "NhqDM6wVMhgbRvXrQJQge" 235 | ] 236 | }, 237 | { 238 | "type": "rectangle", 239 | "version": 115, 240 | "versionNonce": 997860361, 241 | "isDeleted": false, 242 | "id": "9grXh8d6z3-WENQLWSBP6", 243 | "fillStyle": "hachure", 244 | "strokeWidth": 1, 245 | "strokeStyle": "solid", 246 | "roughness": 1, 247 | "opacity": 100, 248 | "angle": 0, 249 | "x": 338, 250 | "y": -95, 251 | "strokeColor": "#000000", 252 | "backgroundColor": "transparent", 253 | "width": 140, 254 | "height": 49, 255 | "seed": 1667334019, 256 | "groupIds": [], 257 | "strokeSharpness": "sharp", 258 | "boundElementIds": [] 259 | }, 260 | { 261 | "type": "text", 262 | "version": 95, 263 | "versionNonce": 1806744839, 264 | "isDeleted": false, 265 | "id": "uw4DcwoucyYZxQuvW-XdC", 266 | "fillStyle": "hachure", 267 | "strokeWidth": 1, 268 | "strokeStyle": "solid", 269 | "roughness": 1, 270 | "opacity": 100, 271 | "angle": 0, 272 | "x": 383, 273 | "y": -83.5, 274 | "strokeColor": "#000000", 275 | "backgroundColor": "transparent", 276 | "width": 50, 277 | "height": 26, 278 | "seed": 1216901411, 279 | "groupIds": [], 280 | "strokeSharpness": "sharp", 281 | "boundElementIds": [], 282 | "fontSize": 20, 283 | "fontFamily": 1, 284 | "text": "client", 285 | "baseline": 18, 286 | "textAlign": "center", 287 | "verticalAlign": "middle" 288 | }, 289 | { 290 | "id": "ZR4mzBF0WDviz0UhZ4jZM", 291 | "type": "diamond", 292 | "x": 891.5, 293 | "y": -87, 294 | "width": 35, 295 | "height": 18, 296 | "angle": 0, 297 | "strokeColor": "#000000", 298 | "backgroundColor": "transparent", 299 | "fillStyle": "hachure", 300 | "strokeWidth": 2, 301 | "strokeStyle": "solid", 302 | "roughness": 1, 303 | "opacity": 100, 304 | "groupIds": [], 305 | "strokeSharpness": "sharp", 306 | "seed": 1951590473, 307 | "version": 87, 308 | "versionNonce": 232917705, 309 | "isDeleted": false, 310 | "boundElementIds": [ 311 | "LgoBfF5uxSyzlQnWpzp21", 312 | "VzzoutsEZMxQbMloZdWpR", 313 | "OEzsLqAW3F-2LE0bnPrtx" 314 | ] 315 | }, 316 | { 317 | "id": "tpect-oo26kMVbG_xdqoO", 318 | "type": "text", 319 | "x": 895.5, 320 | "y": -130, 321 | "width": 157, 322 | "height": 26, 323 | "angle": 0, 324 | "strokeColor": "#000000", 325 | "backgroundColor": "transparent", 326 | "fillStyle": "hachure", 327 | "strokeWidth": 2, 328 | "strokeStyle": "solid", 329 | "roughness": 1, 330 | "opacity": 100, 331 | "groupIds": [], 332 | "strokeSharpness": "sharp", 333 | "seed": 294816809, 334 | "version": 104, 335 | "versionNonce": 2120602793, 336 | "isDeleted": false, 337 | "boundElementIds": null, 338 | "text": "cluster adapter", 339 | "fontSize": 20, 340 | "fontFamily": 1, 341 | "textAlign": "left", 342 | "verticalAlign": "top", 343 | "baseline": 18 344 | }, 345 | { 346 | "type": "diamond", 347 | "version": 113, 348 | "versionNonce": 1690814889, 349 | "isDeleted": false, 350 | "id": "ScoFMjrxukGD1efHfKiFH", 351 | "fillStyle": "hachure", 352 | "strokeWidth": 2, 353 | "strokeStyle": "solid", 354 | "roughness": 1, 355 | "opacity": 100, 356 | "angle": 0, 357 | "x": 894.5, 358 | "y": -20, 359 | "strokeColor": "#000000", 360 | "backgroundColor": "transparent", 361 | "width": 35, 362 | "height": 18, 363 | "seed": 696687431, 364 | "groupIds": [], 365 | "strokeSharpness": "sharp", 366 | "boundElementIds": [ 367 | "VzzoutsEZMxQbMloZdWpR" 368 | ] 369 | }, 370 | { 371 | "type": "diamond", 372 | "version": 158, 373 | "versionNonce": 1317846759, 374 | "isDeleted": false, 375 | "id": "wj2HZ2scg4U6UtpfK8x3e", 376 | "fillStyle": "hachure", 377 | "strokeWidth": 2, 378 | "strokeStyle": "solid", 379 | "roughness": 1, 380 | "opacity": 100, 381 | "angle": 0, 382 | "x": 893.5, 383 | "y": 46, 384 | "strokeColor": "#000000", 385 | "backgroundColor": "transparent", 386 | "width": 35, 387 | "height": 18, 388 | "seed": 1469547015, 389 | "groupIds": [], 390 | "strokeSharpness": "sharp", 391 | "boundElementIds": [ 392 | "OEzsLqAW3F-2LE0bnPrtx" 393 | ] 394 | }, 395 | { 396 | "id": "U7UCkn3nVHlWGYetjII_Z", 397 | "type": "arrow", 398 | "x": 734.5, 399 | "y": -74, 400 | "width": 238, 401 | "height": 4, 402 | "angle": 0, 403 | "strokeColor": "#000000", 404 | "backgroundColor": "transparent", 405 | "fillStyle": "hachure", 406 | "strokeWidth": 1, 407 | "strokeStyle": "solid", 408 | "roughness": 1, 409 | "opacity": 100, 410 | "groupIds": [], 411 | "strokeSharpness": "round", 412 | "seed": 1537057031, 413 | "version": 61, 414 | "versionNonce": 1166788937, 415 | "isDeleted": false, 416 | "boundElementIds": null, 417 | "points": [ 418 | [ 419 | 0, 420 | 0 421 | ], 422 | [ 423 | -238, 424 | 4 425 | ] 426 | ], 427 | "lastCommittedPoint": null, 428 | "startBinding": { 429 | "elementId": "RRrk3Vsl-pM8Z1r8Fj3Vu", 430 | "focus": -0.05720889916209189, 431 | "gap": 15 432 | }, 433 | "endBinding": null, 434 | "startArrowhead": null, 435 | "endArrowhead": "arrow" 436 | }, 437 | { 438 | "type": "rectangle", 439 | "version": 163, 440 | "versionNonce": 15141831, 441 | "isDeleted": false, 442 | "id": "mv6FEWy7Oux1XBpOeYKlJ", 443 | "fillStyle": "hachure", 444 | "strokeWidth": 1, 445 | "strokeStyle": "solid", 446 | "roughness": 1, 447 | "opacity": 100, 448 | "angle": 0, 449 | "x": 337.25, 450 | "y": -26.5, 451 | "strokeColor": "#000000", 452 | "backgroundColor": "transparent", 453 | "width": 140, 454 | "height": 49, 455 | "seed": 1927924585, 456 | "groupIds": [], 457 | "strokeSharpness": "sharp", 458 | "boundElementIds": [] 459 | }, 460 | { 461 | "type": "text", 462 | "version": 143, 463 | "versionNonce": 766883881, 464 | "isDeleted": false, 465 | "id": "sBk2P5AAiZa36HbnmeRLi", 466 | "fillStyle": "hachure", 467 | "strokeWidth": 1, 468 | "strokeStyle": "solid", 469 | "roughness": 1, 470 | "opacity": 100, 471 | "angle": 0, 472 | "x": 382.25, 473 | "y": -15, 474 | "strokeColor": "#000000", 475 | "backgroundColor": "transparent", 476 | "width": 50, 477 | "height": 26, 478 | "seed": 1643756455, 479 | "groupIds": [], 480 | "strokeSharpness": "sharp", 481 | "boundElementIds": [], 482 | "fontSize": 20, 483 | "fontFamily": 1, 484 | "text": "client", 485 | "baseline": 18, 486 | "textAlign": "center", 487 | "verticalAlign": "middle" 488 | }, 489 | { 490 | "type": "arrow", 491 | "version": 111, 492 | "versionNonce": 2076179175, 493 | "isDeleted": false, 494 | "id": "C1IueNJdiTSkqUxSTEvKe", 495 | "fillStyle": "hachure", 496 | "strokeWidth": 1, 497 | "strokeStyle": "solid", 498 | "roughness": 1, 499 | "opacity": 100, 500 | "angle": 0, 501 | "x": 733.75, 502 | "y": -5.5, 503 | "strokeColor": "#000000", 504 | "backgroundColor": "transparent", 505 | "width": 238, 506 | "height": 4, 507 | "seed": 542013001, 508 | "groupIds": [], 509 | "strokeSharpness": "round", 510 | "boundElementIds": [], 511 | "startBinding": { 512 | "elementId": "IRd1nPQbv0PQdJQn_yLOs", 513 | "focus": -0.03958393527882115, 514 | "gap": 15.75 515 | }, 516 | "endBinding": null, 517 | "lastCommittedPoint": null, 518 | "startArrowhead": null, 519 | "endArrowhead": "arrow", 520 | "points": [ 521 | [ 522 | 0, 523 | 0 524 | ], 525 | [ 526 | -238, 527 | 4 528 | ] 529 | ] 530 | }, 531 | { 532 | "type": "rectangle", 533 | "version": 138, 534 | "versionNonce": 478071561, 535 | "isDeleted": false, 536 | "id": "6r_-m_ehoDx3FlKI9YqbJ", 537 | "fillStyle": "hachure", 538 | "strokeWidth": 1, 539 | "strokeStyle": "solid", 540 | "roughness": 1, 541 | "opacity": 100, 542 | "angle": 0, 543 | "x": 337.25, 544 | "y": 40.5, 545 | "strokeColor": "#000000", 546 | "backgroundColor": "transparent", 547 | "width": 140, 548 | "height": 49, 549 | "seed": 704870919, 550 | "groupIds": [], 551 | "strokeSharpness": "sharp", 552 | "boundElementIds": [] 553 | }, 554 | { 555 | "type": "text", 556 | "version": 118, 557 | "versionNonce": 133039623, 558 | "isDeleted": false, 559 | "id": "EEhePeT66oko7kRekPQGo", 560 | "fillStyle": "hachure", 561 | "strokeWidth": 1, 562 | "strokeStyle": "solid", 563 | "roughness": 1, 564 | "opacity": 100, 565 | "angle": 0, 566 | "x": 382.25, 567 | "y": 52, 568 | "strokeColor": "#000000", 569 | "backgroundColor": "transparent", 570 | "width": 50, 571 | "height": 26, 572 | "seed": 93212137, 573 | "groupIds": [], 574 | "strokeSharpness": "sharp", 575 | "boundElementIds": [], 576 | "fontSize": 20, 577 | "fontFamily": 1, 578 | "text": "client", 579 | "baseline": 18, 580 | "textAlign": "center", 581 | "verticalAlign": "middle" 582 | }, 583 | { 584 | "type": "arrow", 585 | "version": 86, 586 | "versionNonce": 979534313, 587 | "isDeleted": false, 588 | "id": "etOb8BtJX8fUgxXsPOX2F", 589 | "fillStyle": "hachure", 590 | "strokeWidth": 1, 591 | "strokeStyle": "solid", 592 | "roughness": 1, 593 | "opacity": 100, 594 | "angle": 0, 595 | "x": 733.75, 596 | "y": 61.5, 597 | "strokeColor": "#000000", 598 | "backgroundColor": "transparent", 599 | "width": 238, 600 | "height": 4, 601 | "seed": 464822567, 602 | "groupIds": [], 603 | "strokeSharpness": "round", 604 | "boundElementIds": [], 605 | "startBinding": null, 606 | "endBinding": null, 607 | "lastCommittedPoint": null, 608 | "startArrowhead": null, 609 | "endArrowhead": "arrow", 610 | "points": [ 611 | [ 612 | 0, 613 | 0 614 | ], 615 | [ 616 | -238, 617 | 4 618 | ] 619 | ] 620 | }, 621 | { 622 | "type": "rectangle", 623 | "version": 184, 624 | "versionNonce": 1953344807, 625 | "isDeleted": false, 626 | "id": "G6-PJf8TRngnFyuC3A0QC", 627 | "fillStyle": "hachure", 628 | "strokeWidth": 1, 629 | "strokeStyle": "solid", 630 | "roughness": 1, 631 | "opacity": 100, 632 | "angle": 0, 633 | "x": 337.25, 634 | "y": 105.5, 635 | "strokeColor": "#000000", 636 | "backgroundColor": "transparent", 637 | "width": 140, 638 | "height": 49, 639 | "seed": 1627945223, 640 | "groupIds": [], 641 | "strokeSharpness": "sharp", 642 | "boundElementIds": [ 643 | "JFhaNprFAz7gl6FY48Aps" 644 | ] 645 | }, 646 | { 647 | "type": "text", 648 | "version": 163, 649 | "versionNonce": 342645961, 650 | "isDeleted": false, 651 | "id": "UEv3oUzBr56Mj6Mz7hG-B", 652 | "fillStyle": "hachure", 653 | "strokeWidth": 1, 654 | "strokeStyle": "solid", 655 | "roughness": 1, 656 | "opacity": 100, 657 | "angle": 0, 658 | "x": 382.25, 659 | "y": 117, 660 | "strokeColor": "#000000", 661 | "backgroundColor": "transparent", 662 | "width": 50, 663 | "height": 26, 664 | "seed": 1822211817, 665 | "groupIds": [], 666 | "strokeSharpness": "sharp", 667 | "boundElementIds": [], 668 | "fontSize": 20, 669 | "fontFamily": 1, 670 | "text": "client", 671 | "baseline": 18, 672 | "textAlign": "center", 673 | "verticalAlign": "middle" 674 | }, 675 | { 676 | "type": "arrow", 677 | "version": 135, 678 | "versionNonce": 2091610183, 679 | "isDeleted": false, 680 | "id": "JFhaNprFAz7gl6FY48Aps", 681 | "fillStyle": "hachure", 682 | "strokeWidth": 1, 683 | "strokeStyle": "solid", 684 | "roughness": 1, 685 | "opacity": 100, 686 | "angle": 0, 687 | "x": 731.1770639400929, 688 | "y": 74.303452225402, 689 | "strokeColor": "#000000", 690 | "backgroundColor": "transparent", 691 | "width": 240.42706394009292, 692 | "height": 54.196547774598, 693 | "seed": 1203879975, 694 | "groupIds": [], 695 | "strokeSharpness": "round", 696 | "boundElementIds": [], 697 | "startBinding": null, 698 | "endBinding": { 699 | "elementId": "G6-PJf8TRngnFyuC3A0QC", 700 | "focus": 0.4300574064368146, 701 | "gap": 13.5 702 | }, 703 | "lastCommittedPoint": null, 704 | "startArrowhead": null, 705 | "endArrowhead": "arrow", 706 | "points": [ 707 | [ 708 | 0, 709 | 0 710 | ], 711 | [ 712 | -240.42706394009292, 713 | 54.196547774598 714 | ] 715 | ] 716 | }, 717 | { 718 | "id": "VzzoutsEZMxQbMloZdWpR", 719 | "type": "arrow", 720 | "x": 929.5, 721 | "y": -70, 722 | "width": 17, 723 | "height": 53, 724 | "angle": 0, 725 | "strokeColor": "#000000", 726 | "backgroundColor": "transparent", 727 | "fillStyle": "hachure", 728 | "strokeWidth": 1, 729 | "strokeStyle": "solid", 730 | "roughness": 1, 731 | "opacity": 100, 732 | "groupIds": [], 733 | "strokeSharpness": "round", 734 | "seed": 1957283401, 735 | "version": 330, 736 | "versionNonce": 1093640775, 737 | "isDeleted": false, 738 | "boundElementIds": null, 739 | "points": [ 740 | [ 741 | 0, 742 | 0 743 | ], 744 | [ 745 | 16, 746 | 23 747 | ], 748 | [ 749 | -1, 750 | 53 751 | ] 752 | ], 753 | "lastCommittedPoint": null, 754 | "startBinding": { 755 | "elementId": "ZR4mzBF0WDviz0UhZ4jZM", 756 | "focus": -0.853416149068323, 757 | "gap": 8.48634645640746 758 | }, 759 | "endBinding": { 760 | "elementId": "ScoFMjrxukGD1efHfKiFH", 761 | "focus": 0.7485714285714284, 762 | "gap": 4.878378801288122 763 | }, 764 | "startArrowhead": null, 765 | "endArrowhead": "arrow" 766 | }, 767 | { 768 | "type": "arrow", 769 | "version": 440, 770 | "versionNonce": 2038871081, 771 | "isDeleted": false, 772 | "id": "OEzsLqAW3F-2LE0bnPrtx", 773 | "fillStyle": "hachure", 774 | "strokeWidth": 1, 775 | "strokeStyle": "solid", 776 | "roughness": 1, 777 | "opacity": 100, 778 | "angle": 0, 779 | "x": 930.5, 780 | "y": -71, 781 | "strokeColor": "#000000", 782 | "backgroundColor": "transparent", 783 | "width": 42, 784 | "height": 116, 785 | "seed": 1990607913, 786 | "groupIds": [], 787 | "strokeSharpness": "round", 788 | "boundElementIds": [], 789 | "startBinding": { 790 | "elementId": "ZR4mzBF0WDviz0UhZ4jZM", 791 | "focus": -0.8894409937888198, 792 | "gap": 8.054406666710076 793 | }, 794 | "endBinding": { 795 | "elementId": "wj2HZ2scg4U6UtpfK8x3e", 796 | "focus": 0.6, 797 | "gap": 8.435530010560711 798 | }, 799 | "lastCommittedPoint": null, 800 | "startArrowhead": null, 801 | "endArrowhead": "arrow", 802 | "points": [ 803 | [ 804 | 0, 805 | 0 806 | ], 807 | [ 808 | 39, 809 | 46 810 | ], 811 | [ 812 | -3, 813 | 116 814 | ] 815 | ] 816 | } 817 | ], 818 | "appState": { 819 | "gridSize": null, 820 | "viewBackgroundColor": "#ffffff" 821 | } 822 | } -------------------------------------------------------------------------------- /assets/adapter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/socketio/socket.io-cluster-adapter/56a53bceb9a0f3032c68e9a9ae63643423890cb1/assets/adapter.png -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | import cluster = require("cluster"); 2 | import { Adapter, BroadcastOptions, Room } from "socket.io-adapter"; 3 | import { randomBytes } from "crypto"; 4 | 5 | const randomId = () => randomBytes(8).toString("hex"); 6 | const debug = require("debug")("socket.io-cluster-adapter"); 7 | 8 | const MESSAGE_SOURCE = "_sio_adapter"; 9 | const hasOwnProperty = Object.prototype.hasOwnProperty; 10 | 11 | /** 12 | * Event types, for messages between nodes 13 | */ 14 | 15 | enum EventType { 16 | WORKER_INIT = 1, 17 | WORKER_PING, 18 | WORKER_EXIT, 19 | BROADCAST, 20 | SOCKETS_JOIN, 21 | SOCKETS_LEAVE, 22 | DISCONNECT_SOCKETS, 23 | FETCH_SOCKETS, 24 | FETCH_SOCKETS_RESPONSE, 25 | SERVER_SIDE_EMIT, 26 | SERVER_SIDE_EMIT_RESPONSE, 27 | BROADCAST_CLIENT_COUNT, 28 | BROADCAST_ACK, 29 | } 30 | 31 | interface Request { 32 | type: EventType; 33 | resolve: Function; 34 | timeout: NodeJS.Timeout; 35 | expected: number; 36 | current: number; 37 | responses: any[]; 38 | } 39 | 40 | interface AckRequest { 41 | type: EventType.BROADCAST; 42 | clientCountCallback: (clientCount: number) => void; 43 | ack: (...args: any[]) => void; 44 | } 45 | 46 | export interface ClusterAdapterOptions { 47 | /** 48 | * after this timeout the adapter will stop waiting from responses to request 49 | * @default 5000 50 | */ 51 | requestsTimeout: number; 52 | } 53 | 54 | function ignoreError() {} 55 | 56 | /** 57 | * Returns a function that will create a ClusterAdapter instance. 58 | * 59 | * @param opts - additional options 60 | * 61 | * @public 62 | */ 63 | export function createAdapter(opts: Partial = {}) { 64 | return function (nsp) { 65 | return new ClusterAdapter(nsp, opts); 66 | }; 67 | } 68 | 69 | export class ClusterAdapter extends Adapter { 70 | public requestsTimeout: number; 71 | 72 | private workerIds: Set = new Set(); 73 | private requests: Map = new Map(); 74 | private ackRequests: Map = new Map(); 75 | 76 | /** 77 | * Adapter constructor. 78 | * 79 | * @param nsp - the namespace 80 | * @param opts - additional options 81 | * 82 | * @public 83 | */ 84 | constructor(nsp: any, opts: Partial = {}) { 85 | super(nsp); 86 | this.requestsTimeout = opts.requestsTimeout || 5000; 87 | 88 | this.publish({ 89 | type: EventType.WORKER_INIT, 90 | data: cluster.worker.id, 91 | }); 92 | 93 | process.on("message", this.onMessage.bind(this)); 94 | } 95 | 96 | public async onMessage(message: any) { 97 | const isValidSource = message?.source === MESSAGE_SOURCE; 98 | if (!isValidSource) { 99 | return; 100 | } 101 | 102 | if (message.type === EventType.WORKER_EXIT) { 103 | this.workerIds.delete(message.data); 104 | debug("workers count is now %d", this.workerIds.size); 105 | return; 106 | } 107 | 108 | if (message.nsp !== this.nsp.name) { 109 | debug("ignore other namespace"); 110 | return; 111 | } 112 | 113 | switch (message.type) { 114 | case EventType.WORKER_INIT: 115 | this.workerIds.add(message.data); 116 | debug("workers count is now %d", this.workerIds.size); 117 | this.publish({ 118 | type: EventType.WORKER_PING, 119 | data: cluster.worker.id, 120 | }); 121 | break; 122 | case EventType.WORKER_PING: 123 | this.workerIds.add(message.data); 124 | debug("workers count is now %d", this.workerIds.size); 125 | break; 126 | case EventType.BROADCAST: { 127 | debug("broadcast with opts %j", message.data.opts); 128 | 129 | const withAck = message.data.requestId !== undefined; 130 | if (withAck) { 131 | super.broadcastWithAck( 132 | message.data.packet, 133 | ClusterAdapter.deserializeOptions(message.data.opts), 134 | (clientCount) => { 135 | debug("waiting for %d client acknowledgements", clientCount); 136 | this.publish({ 137 | type: EventType.BROADCAST_CLIENT_COUNT, 138 | data: { 139 | requestId: message.data.requestId, 140 | clientCount, 141 | }, 142 | }); 143 | }, 144 | (arg) => { 145 | debug("received acknowledgement with value %j", arg); 146 | this.publish({ 147 | type: EventType.BROADCAST_ACK, 148 | data: { 149 | requestId: message.data.requestId, 150 | packet: arg, 151 | }, 152 | }); 153 | } 154 | ); 155 | } else { 156 | super.broadcast( 157 | message.data.packet, 158 | ClusterAdapter.deserializeOptions(message.data.opts) 159 | ); 160 | } 161 | break; 162 | } 163 | 164 | case EventType.BROADCAST_CLIENT_COUNT: { 165 | const request = this.ackRequests.get(message.data.requestId); 166 | request?.clientCountCallback(message.data.clientCount); 167 | break; 168 | } 169 | 170 | case EventType.BROADCAST_ACK: { 171 | const request = this.ackRequests.get(message.data.requestId); 172 | request?.ack(message.data.packet); 173 | break; 174 | } 175 | 176 | case EventType.SOCKETS_JOIN: { 177 | debug("calling addSockets with opts %j", message.data.opts); 178 | super.addSockets( 179 | ClusterAdapter.deserializeOptions(message.data.opts), 180 | message.data.rooms 181 | ); 182 | break; 183 | } 184 | case EventType.SOCKETS_LEAVE: { 185 | debug("calling delSockets with opts %j", message.data.opts); 186 | super.delSockets( 187 | ClusterAdapter.deserializeOptions(message.data.opts), 188 | message.data.rooms 189 | ); 190 | break; 191 | } 192 | case EventType.DISCONNECT_SOCKETS: { 193 | debug("calling disconnectSockets with opts %j", message.data.opts); 194 | super.disconnectSockets( 195 | ClusterAdapter.deserializeOptions(message.data.opts), 196 | message.data.close 197 | ); 198 | break; 199 | } 200 | case EventType.FETCH_SOCKETS: { 201 | debug("calling fetchSockets with opts %j", message.data.opts); 202 | const localSockets = await super.fetchSockets( 203 | ClusterAdapter.deserializeOptions(message.data.opts) 204 | ); 205 | 206 | this.publish({ 207 | type: EventType.FETCH_SOCKETS_RESPONSE, 208 | data: { 209 | requestId: message.data.requestId, 210 | workerId: message.data.workerId, 211 | sockets: localSockets.map((socket) => ({ 212 | id: socket.id, 213 | handshake: socket.handshake, 214 | rooms: [...socket.rooms], 215 | data: socket.data, 216 | })), 217 | }, 218 | }); 219 | break; 220 | } 221 | case EventType.FETCH_SOCKETS_RESPONSE: { 222 | const request = this.requests.get(message.data.requestId); 223 | 224 | if (!request) { 225 | return; 226 | } 227 | 228 | request.current++; 229 | message.data.sockets.forEach((socket: any) => 230 | request.responses.push(socket) 231 | ); 232 | 233 | if (request.current === request.expected) { 234 | clearTimeout(request.timeout); 235 | request.resolve(request.responses); 236 | this.requests.delete(message.data.requestId); 237 | } 238 | break; 239 | } 240 | case EventType.SERVER_SIDE_EMIT: { 241 | const packet = message.data.packet; 242 | const withAck = message.data.requestId !== undefined; 243 | if (!withAck) { 244 | this.nsp._onServerSideEmit(packet); 245 | return; 246 | } 247 | let called = false; 248 | const callback = (arg: any) => { 249 | // only one argument is expected 250 | if (called) { 251 | return; 252 | } 253 | called = true; 254 | debug("calling acknowledgement with %j", arg); 255 | this.publish({ 256 | type: EventType.SERVER_SIDE_EMIT_RESPONSE, 257 | data: { 258 | requestId: message.data.requestId, 259 | workerId: message.data.workerId, 260 | packet: arg, 261 | }, 262 | }); 263 | }; 264 | 265 | packet.push(callback); 266 | this.nsp._onServerSideEmit(packet); 267 | break; 268 | } 269 | case EventType.SERVER_SIDE_EMIT_RESPONSE: { 270 | const request = this.requests.get(message.data.requestId); 271 | 272 | if (!request) { 273 | return; 274 | } 275 | 276 | request.current++; 277 | request.responses.push(message.data.packet); 278 | 279 | if (request.current === request.expected) { 280 | clearTimeout(request.timeout); 281 | request.resolve(null, request.responses); 282 | this.requests.delete(message.data.requestId); 283 | } 284 | } 285 | } 286 | } 287 | 288 | private async publish(message: any) { 289 | // to be able to ignore unrelated messages on the cluster message bus 290 | message.source = MESSAGE_SOURCE; 291 | // to be able to ignore messages from other namespaces 292 | message.nsp = this.nsp.name; 293 | 294 | debug( 295 | "publish event of type %s for namespace %s", 296 | message.type, 297 | message.nsp 298 | ); 299 | 300 | process.send(message, null, { swallowErrors: true }, ignoreError); 301 | } 302 | 303 | /** 304 | * Transform ES6 Set into plain arrays. 305 | * 306 | * Note: we manually serialize ES6 Sets so that using `serialization: "advanced"` is not needed when using plaintext 307 | * packets (reference: https://nodejs.org/api/child_process.html#child_process_advanced_serialization) 308 | */ 309 | private static serializeOptions(opts: BroadcastOptions) { 310 | return { 311 | rooms: [...opts.rooms], 312 | except: opts.except ? [...opts.except] : [], 313 | flags: opts.flags, 314 | }; 315 | } 316 | 317 | private static deserializeOptions(opts: any): BroadcastOptions { 318 | return { 319 | rooms: new Set(opts.rooms), 320 | except: new Set(opts.except), 321 | flags: opts.flags, 322 | }; 323 | } 324 | 325 | public broadcast(packet: any, opts: BroadcastOptions) { 326 | const onlyLocal = opts?.flags?.local; 327 | if (!onlyLocal) { 328 | this.publish({ 329 | type: EventType.BROADCAST, 330 | data: { 331 | packet, 332 | opts: ClusterAdapter.serializeOptions(opts), 333 | }, 334 | }); 335 | } 336 | 337 | // packets with binary contents are modified by the broadcast method, hence the nextTick() 338 | process.nextTick(() => { 339 | super.broadcast(packet, opts); 340 | }); 341 | } 342 | 343 | public broadcastWithAck( 344 | packet: any, 345 | opts: BroadcastOptions, 346 | clientCountCallback: (clientCount: number) => void, 347 | ack: (...args: any[]) => void 348 | ) { 349 | const onlyLocal = opts?.flags?.local; 350 | if (!onlyLocal) { 351 | const requestId = randomId(); 352 | 353 | this.publish({ 354 | type: EventType.BROADCAST, 355 | data: { 356 | packet, 357 | requestId, 358 | opts: ClusterAdapter.serializeOptions(opts), 359 | }, 360 | }); 361 | 362 | this.ackRequests.set(requestId, { 363 | type: EventType.BROADCAST, 364 | clientCountCallback, 365 | ack, 366 | }); 367 | 368 | // we have no way to know at this level whether the server has received an acknowledgement from each client, so we 369 | // will simply clean up the ackRequests map after the given delay 370 | setTimeout(() => { 371 | this.ackRequests.delete(requestId); 372 | }, opts.flags!.timeout); 373 | } 374 | 375 | // packets with binary contents are modified by the broadcast method, hence the nextTick() 376 | process.nextTick(() => { 377 | super.broadcastWithAck(packet, opts, clientCountCallback, ack); 378 | }); 379 | } 380 | 381 | public serverCount(): Promise { 382 | return Promise.resolve(1 + this.workerIds.size); 383 | } 384 | 385 | addSockets(opts: BroadcastOptions, rooms: Room[]) { 386 | super.addSockets(opts, rooms); 387 | 388 | const onlyLocal = opts.flags?.local; 389 | if (onlyLocal) { 390 | return; 391 | } 392 | 393 | this.publish({ 394 | type: EventType.SOCKETS_JOIN, 395 | data: { 396 | opts: ClusterAdapter.serializeOptions(opts), 397 | rooms, 398 | }, 399 | }); 400 | } 401 | 402 | delSockets(opts: BroadcastOptions, rooms: Room[]) { 403 | super.delSockets(opts, rooms); 404 | 405 | const onlyLocal = opts.flags?.local; 406 | if (onlyLocal) { 407 | return; 408 | } 409 | 410 | this.publish({ 411 | type: EventType.SOCKETS_LEAVE, 412 | data: { 413 | opts: ClusterAdapter.serializeOptions(opts), 414 | rooms, 415 | }, 416 | }); 417 | } 418 | 419 | disconnectSockets(opts: BroadcastOptions, close: boolean) { 420 | super.disconnectSockets(opts, close); 421 | 422 | const onlyLocal = opts.flags?.local; 423 | if (onlyLocal) { 424 | return; 425 | } 426 | 427 | this.publish({ 428 | type: EventType.DISCONNECT_SOCKETS, 429 | data: { 430 | opts: ClusterAdapter.serializeOptions(opts), 431 | close, 432 | }, 433 | }); 434 | } 435 | 436 | private getExpectedResponseCount() { 437 | return this.workerIds.size; 438 | } 439 | 440 | async fetchSockets(opts: BroadcastOptions): Promise { 441 | const localSockets = await super.fetchSockets(opts); 442 | const expectedResponseCount = this.getExpectedResponseCount(); 443 | 444 | if (opts.flags?.local || expectedResponseCount === 0) { 445 | return localSockets; 446 | } 447 | 448 | const requestId = randomId(); 449 | 450 | return new Promise((resolve, reject) => { 451 | const timeout = setTimeout(() => { 452 | const storedRequest = this.requests.get(requestId); 453 | if (storedRequest) { 454 | reject( 455 | new Error( 456 | `timeout reached: only ${storedRequest.current} responses received out of ${storedRequest.expected}` 457 | ) 458 | ); 459 | this.requests.delete(requestId); 460 | } 461 | }, this.requestsTimeout); 462 | 463 | const storedRequest = { 464 | type: EventType.FETCH_SOCKETS, 465 | resolve, 466 | timeout, 467 | current: 0, 468 | expected: expectedResponseCount, 469 | responses: localSockets, 470 | }; 471 | this.requests.set(requestId, storedRequest); 472 | 473 | this.publish({ 474 | type: EventType.FETCH_SOCKETS, 475 | data: { 476 | requestId, 477 | workerId: cluster.worker.id, 478 | opts: ClusterAdapter.serializeOptions(opts), 479 | }, 480 | }); 481 | }); 482 | } 483 | 484 | public serverSideEmit(packet: any[]): void { 485 | const withAck = typeof packet[packet.length - 1] === "function"; 486 | 487 | if (withAck) { 488 | this.serverSideEmitWithAck(packet).catch(() => { 489 | // ignore errors 490 | }); 491 | return; 492 | } 493 | 494 | this.publish({ 495 | type: EventType.SERVER_SIDE_EMIT, 496 | data: { 497 | packet, 498 | }, 499 | }); 500 | } 501 | 502 | private async serverSideEmitWithAck(packet: any[]) { 503 | const ack = packet.pop(); 504 | const expectedResponseCount = this.getExpectedResponseCount(); 505 | 506 | debug( 507 | 'waiting for %d responses to "serverSideEmit" request', 508 | expectedResponseCount 509 | ); 510 | 511 | if (expectedResponseCount <= 0) { 512 | return ack(null, []); 513 | } 514 | 515 | const requestId = randomId(); 516 | 517 | const timeout = setTimeout(() => { 518 | const storedRequest = this.requests.get(requestId); 519 | if (storedRequest) { 520 | ack( 521 | new Error( 522 | `timeout reached: only ${storedRequest.current} responses received out of ${storedRequest.expected}` 523 | ), 524 | storedRequest.responses 525 | ); 526 | this.requests.delete(requestId); 527 | } 528 | }, this.requestsTimeout); 529 | 530 | const storedRequest = { 531 | type: EventType.FETCH_SOCKETS, 532 | resolve: ack, 533 | timeout, 534 | current: 0, 535 | expected: expectedResponseCount, 536 | responses: [], 537 | }; 538 | this.requests.set(requestId, storedRequest); 539 | 540 | this.publish({ 541 | type: EventType.SERVER_SIDE_EMIT, 542 | data: { 543 | requestId, // the presence of this attribute defines whether an acknowledgement is needed 544 | workerId: cluster.worker.id, 545 | packet, 546 | }, 547 | }); 548 | } 549 | } 550 | 551 | export function setupPrimary() { 552 | cluster.on("message", (worker, message) => { 553 | const isValidSource = message?.source === MESSAGE_SOURCE; 554 | if (!isValidSource) { 555 | return; 556 | } 557 | 558 | switch (message.type) { 559 | case EventType.FETCH_SOCKETS_RESPONSE: 560 | case EventType.SERVER_SIDE_EMIT_RESPONSE: 561 | const workerId = message.data.workerId; 562 | // emit back to the requester 563 | if (hasOwnProperty.call(cluster.workers, workerId)) { 564 | cluster.workers[workerId].send(message, null, ignoreError); 565 | } 566 | break; 567 | default: 568 | const emitterIdAsString = "" + worker.id; 569 | // emit to all workers but the requester 570 | for (const workerId in cluster.workers) { 571 | if ( 572 | hasOwnProperty.call(cluster.workers, workerId) && 573 | workerId !== emitterIdAsString 574 | ) { 575 | cluster.workers[workerId].send(message, null, ignoreError); 576 | } 577 | } 578 | } 579 | }); 580 | 581 | cluster.on("exit", (worker) => { 582 | // notify all active workers 583 | for (const workerId in cluster.workers) { 584 | if (hasOwnProperty.call(cluster.workers, workerId)) { 585 | cluster.workers[workerId].send( 586 | { 587 | source: MESSAGE_SOURCE, 588 | type: EventType.WORKER_EXIT, 589 | data: worker.id, 590 | }, 591 | null, 592 | ignoreError 593 | ); 594 | } 595 | } 596 | }); 597 | } 598 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@socket.io/cluster-adapter", 3 | "version": "0.2.2", 4 | "description": "The Socket.IO cluster 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-cluster-adapter.git" 9 | }, 10 | "files": [ 11 | "dist/" 12 | ], 13 | "main": "./dist/index.js", 14 | "types": "./dist/index.d.ts", 15 | "scripts": { 16 | "test": "npm run format:check && tsc && nyc mocha --require ts-node/register test/index.ts", 17 | "format:check": "prettier --parser typescript --check 'lib/**/*.ts' 'test/**/*.ts'", 18 | "format:fix": "prettier --parser typescript --write 'lib/**/*.ts' 'test/**/*.ts'", 19 | "prepack": "tsc" 20 | }, 21 | "dependencies": { 22 | "debug": "~4.3.1" 23 | }, 24 | "peerDependencies": { 25 | "socket.io-adapter": "^2.4.0" 26 | }, 27 | "devDependencies": { 28 | "@types/expect.js": "^0.3.29", 29 | "@types/mocha": "^10.0.0", 30 | "@types/node": "^15.12.4", 31 | "expect.js": "0.3.1", 32 | "mocha": "^10.0.0", 33 | "nyc": "^15.1.0", 34 | "prettier": "^2.1.2", 35 | "socket.io": "^4.6.1", 36 | "socket.io-client": "^4.7.1", 37 | "ts-node": "^9.1.1", 38 | "typescript": "^4.0.5" 39 | }, 40 | "engines": { 41 | "node": ">=10.0.0" 42 | }, 43 | "keywords": [ 44 | "socket.io", 45 | "cluster", 46 | "adapter" 47 | ] 48 | } 49 | -------------------------------------------------------------------------------- /test/index.ts: -------------------------------------------------------------------------------- 1 | import { io as ioc, Socket as ClientSocket } from "socket.io-client"; 2 | import expect = require("expect.js"); 3 | import { setupPrimary } from ".."; 4 | import { times, sleep } from "./util"; 5 | import cluster = require("cluster"); 6 | import { Worker } from "cluster"; 7 | 8 | const NODES_COUNT = 3; 9 | 10 | cluster.setupMaster({ 11 | exec: "./test/worker.js", 12 | // @ts-ignore 13 | serialization: "advanced", // needed for packets containing buffers 14 | }); 15 | 16 | setupPrimary(); 17 | 18 | const getRooms = (worker): Promise> => { 19 | worker.send("get rooms"); 20 | return new Promise((resolve) => { 21 | worker.once("message", (content) => { 22 | resolve(content); 23 | }); 24 | }); 25 | }; 26 | 27 | describe("@socket.io/cluster-adapter", () => { 28 | let clientSockets: ClientSocket[], workers: Worker[]; 29 | 30 | beforeEach((done) => { 31 | clientSockets = []; 32 | workers = []; 33 | 34 | for (let i = 1; i <= NODES_COUNT; i++) { 35 | const PORT = 40000 + i; 36 | const worker = cluster.fork({ 37 | PORT, 38 | }); 39 | 40 | worker.on("listening", () => { 41 | const clientSocket = ioc(`http://localhost:${PORT}`); 42 | 43 | clientSocket.on("connect", async () => { 44 | workers.push(worker); 45 | clientSockets.push(clientSocket); 46 | if (clientSockets.length === NODES_COUNT) { 47 | done(); 48 | } 49 | }); 50 | }); 51 | } 52 | }); 53 | 54 | afterEach(() => { 55 | for (const id in cluster.workers) { 56 | cluster.workers[id].kill(); 57 | } 58 | clientSockets.forEach((socket) => { 59 | socket.disconnect(); 60 | }); 61 | }); 62 | 63 | describe("broadcast", function () { 64 | it("broadcasts to all clients", (done) => { 65 | const partialDone = times(3, done); 66 | 67 | clientSockets.forEach((clientSocket) => { 68 | clientSocket.on("test", (arg1, arg2, arg3) => { 69 | expect(arg1).to.eql(1); 70 | expect(arg2).to.eql("2"); 71 | expect(Buffer.isBuffer(arg3)).to.be(true); 72 | partialDone(); 73 | }); 74 | }); 75 | 76 | workers[0].send("broadcasts to all clients"); 77 | }); 78 | 79 | it("broadcasts to all clients in a namespace", (done) => { 80 | const partialDone = times(3, done); 81 | 82 | const onConnect = times(3, async () => { 83 | workers[0].send("broadcasts to all clients in a namespace"); 84 | }); 85 | 86 | clientSockets.forEach((clientSocket) => { 87 | const socket = clientSocket.io.socket("/custom"); 88 | socket.on("connect", onConnect); 89 | socket.on("test", () => { 90 | socket.disconnect(); 91 | partialDone(); 92 | }); 93 | }); 94 | }); 95 | 96 | it("broadcasts to all clients in a room", (done) => { 97 | workers[1].send("join room1"); 98 | 99 | clientSockets[0].on("test", () => { 100 | done(new Error("should not happen")); 101 | }); 102 | 103 | clientSockets[1].on("test", () => { 104 | done(); 105 | }); 106 | 107 | clientSockets[2].on("test", () => { 108 | done(new Error("should not happen")); 109 | }); 110 | 111 | workers[0].send("broadcasts to all clients in a room"); 112 | }); 113 | 114 | it("broadcasts to all clients except in room", (done) => { 115 | const partialDone = times(2, done); 116 | workers[1].send("join room1"); 117 | 118 | clientSockets[0].on("test", () => { 119 | partialDone(); 120 | }); 121 | 122 | clientSockets[1].on("test", () => { 123 | done(new Error("should not happen")); 124 | }); 125 | 126 | clientSockets[2].on("test", () => { 127 | partialDone(); 128 | }); 129 | 130 | workers[0].send("broadcasts to all clients except in room"); 131 | }); 132 | 133 | it("broadcasts to local clients only", (done) => { 134 | clientSockets[0].on("test", () => { 135 | done(); 136 | }); 137 | 138 | clientSockets[1].on("test", () => { 139 | done(new Error("should not happen")); 140 | }); 141 | 142 | clientSockets[2].on("test", () => { 143 | done(new Error("should not happen")); 144 | }); 145 | 146 | workers[0].send("broadcasts to local clients only"); 147 | }); 148 | 149 | it("broadcasts with multiple acknowledgements", (done) => { 150 | clientSockets[0].on("test", (cb) => { 151 | cb(1); 152 | }); 153 | 154 | clientSockets[1].on("test", (cb) => { 155 | cb(2); 156 | }); 157 | 158 | clientSockets[2].on("test", (cb) => { 159 | cb(3); 160 | }); 161 | 162 | workers[0].send("broadcasts with multiple acknowledgements"); 163 | 164 | workers[0].on("message", (result) => { 165 | if (result === "ok") { 166 | done(); 167 | } 168 | }); 169 | }); 170 | 171 | it("broadcasts with multiple acknowledgements (binary content)", (done) => { 172 | clientSockets[0].on("test", (cb) => { 173 | cb(Buffer.from([1])); 174 | }); 175 | 176 | clientSockets[1].on("test", (cb) => { 177 | cb(Buffer.from([2])); 178 | }); 179 | 180 | clientSockets[2].on("test", (cb) => { 181 | cb(Buffer.from([3])); 182 | }); 183 | 184 | workers[0].send( 185 | "broadcasts with multiple acknowledgements (binary content)" 186 | ); 187 | 188 | workers[0].on("message", (result) => { 189 | if (result === "ok") { 190 | done(); 191 | } 192 | }); 193 | }); 194 | 195 | it("broadcasts with multiple acknowledgements (no client)", (done) => { 196 | workers[0].send("broadcasts with multiple acknowledgements (no client)"); 197 | 198 | workers[0].on("message", (result) => { 199 | if (result === "ok") { 200 | done(); 201 | } 202 | }); 203 | }); 204 | 205 | it("broadcasts with multiple acknowledgements (timeout)", (done) => { 206 | clientSockets[0].on("test", (cb) => { 207 | cb(1); 208 | }); 209 | 210 | clientSockets[1].on("test", (cb) => { 211 | cb(2); 212 | }); 213 | 214 | clientSockets[2].on("test", (cb) => { 215 | // do nothing 216 | }); 217 | 218 | workers[0].send("broadcasts with multiple acknowledgements (timeout)"); 219 | 220 | workers[0].on("message", (result) => { 221 | if (result === "ok") { 222 | done(); 223 | } 224 | }); 225 | }); 226 | }); 227 | 228 | describe("socketsJoin", () => { 229 | it("makes all socket instances join the specified room", async () => { 230 | workers[0].send("makes all socket instances join the specified room"); 231 | 232 | await sleep(100); 233 | 234 | expect((await getRooms(workers[0])).has("room1")).to.be(true); 235 | expect((await getRooms(workers[1])).has("room1")).to.be(true); 236 | expect((await getRooms(workers[2])).has("room1")).to.be(true); 237 | }); 238 | 239 | it("makes the matching socket instances join the specified room", async () => { 240 | workers[0].send("join room1"); 241 | workers[2].send("join room1"); 242 | 243 | workers[0].send( 244 | "makes the matching socket instances join the specified room" 245 | ); 246 | 247 | await sleep(100); 248 | 249 | expect((await getRooms(workers[0])).has("room2")).to.be(true); 250 | expect((await getRooms(workers[1])).has("room2")).to.be(false); 251 | expect((await getRooms(workers[2])).has("room2")).to.be(true); 252 | }); 253 | }); 254 | 255 | describe("socketsLeave", () => { 256 | it("makes all socket instances leave the specified room", async () => { 257 | workers[0].send("join room1"); 258 | workers[2].send("join room1"); 259 | 260 | workers[0].send("makes all socket instances leave the specified room"); 261 | 262 | await sleep(100); 263 | 264 | expect((await getRooms(workers[0])).has("room1")).to.be(false); 265 | expect((await getRooms(workers[1])).has("room1")).to.be(false); 266 | expect((await getRooms(workers[2])).has("room1")).to.be(false); 267 | }); 268 | 269 | it("makes the matching socket instances leave the specified room", async () => { 270 | workers[0].send("join room1 & room2"); 271 | workers[2].send("join room2"); 272 | 273 | workers[0].send( 274 | "makes the matching socket instances leave the specified room" 275 | ); 276 | 277 | await sleep(100); 278 | 279 | expect((await getRooms(workers[0])).has("room2")).to.be(false); 280 | expect((await getRooms(workers[1])).has("room2")).to.be(false); 281 | expect((await getRooms(workers[2])).has("room2")).to.be(true); 282 | }); 283 | }); 284 | 285 | describe("disconnectSockets", () => { 286 | it("makes all socket instances disconnect", (done) => { 287 | const partialDone = times(3, done); 288 | 289 | clientSockets.forEach((clientSocket) => { 290 | clientSocket.on("disconnect", (reason) => { 291 | expect(reason).to.eql("io server disconnect"); 292 | partialDone(); 293 | }); 294 | }); 295 | 296 | workers[0].send("makes all socket instances disconnect"); 297 | }); 298 | }); 299 | 300 | describe("fetchSockets", () => { 301 | it("returns all socket instances", (done) => { 302 | workers[0].send("returns all socket instances"); 303 | 304 | workers[0].on("message", (result) => { 305 | if (result === "ok") { 306 | done(); 307 | } 308 | }); 309 | }); 310 | }); 311 | 312 | describe("serverSideEmit", () => { 313 | it("sends an event to other server instances", (done) => { 314 | const partialDone = times(2, done); 315 | 316 | workers[0].send("sends an event to other server instances"); 317 | 318 | workers[0].on("message", (result) => { 319 | if (result === "ok") { 320 | done(new Error("should not happen")); 321 | } 322 | }); 323 | 324 | workers[1].on("message", (result) => { 325 | expect(result).to.eql("ok"); 326 | partialDone(); 327 | }); 328 | 329 | workers[2].on("message", (result) => { 330 | expect(result).to.eql("ok"); 331 | partialDone(); 332 | }); 333 | }); 334 | 335 | it("sends an event and receives a response from the other server instances", (done) => { 336 | workers[0].send( 337 | "sends an event and receives a response from the other server instances (1)" 338 | ); 339 | workers[1].send( 340 | "sends an event and receives a response from the other server instances (2)" 341 | ); 342 | workers[2].send( 343 | "sends an event and receives a response from the other server instances (3)" 344 | ); 345 | 346 | workers[0].on("message", (result) => { 347 | if (result === "ok") { 348 | done(); 349 | } 350 | }); 351 | }); 352 | 353 | it("sends an event but timeout if one server does not respond", (done) => { 354 | workers[0].send( 355 | "sends an event but timeout if one server does not respond (1)" 356 | ); 357 | workers[1].send( 358 | "sends an event but timeout if one server does not respond (2)" 359 | ); 360 | workers[2].send( 361 | "sends an event but timeout if one server does not respond (3)" 362 | ); 363 | 364 | workers[0].on("message", (result) => { 365 | if (result === "ok") { 366 | done(); 367 | } 368 | }); 369 | }); 370 | }); 371 | }); 372 | -------------------------------------------------------------------------------- /test/util.ts: -------------------------------------------------------------------------------- 1 | export function times(count: number, fn: () => void) { 2 | let i = 0; 3 | return () => { 4 | i++; 5 | if (i === count) { 6 | fn(); 7 | } 8 | }; 9 | } 10 | 11 | export function sleep(duration: number) { 12 | return new Promise((resolve) => setTimeout(resolve, duration)); 13 | } 14 | -------------------------------------------------------------------------------- /test/worker.js: -------------------------------------------------------------------------------- 1 | const { createServer } = require("http"); 2 | const { Server } = require("socket.io"); 3 | const { createAdapter } = require(".."); 4 | 5 | const httpServer = createServer(); 6 | const io = new Server(httpServer); 7 | const expect = require("expect.js"); 8 | 9 | io.adapter(createAdapter()); 10 | 11 | let serverSocket; 12 | 13 | io.on("connection", (socket) => { 14 | serverSocket = socket; 15 | }); 16 | 17 | const customNamespace = io.of("/custom"); 18 | 19 | process.on("message", async (msg) => { 20 | switch (msg) { 21 | case "broadcasts to all clients": 22 | io.emit("test", 1, "2", Buffer.from([3, 4])); 23 | break; 24 | case "broadcasts to all clients in a namespace": 25 | customNamespace.emit("test"); 26 | break; 27 | case "join room1": 28 | serverSocket.join("room1"); 29 | break; 30 | case "join room1 & room2": 31 | serverSocket.join(["room1", "room2"]); 32 | break; 33 | case "join room2": 34 | serverSocket.join("room2"); 35 | break; 36 | case "broadcasts to all clients in a room": 37 | io.to("room1").emit("test"); 38 | break; 39 | case "broadcasts to all clients except in room": 40 | io.of("/").except("room1").emit("test"); 41 | break; 42 | case "broadcasts to local clients only": 43 | io.local.emit("test"); 44 | break; 45 | 46 | case "broadcasts with multiple acknowledgements": { 47 | io.timeout(500).emit("test", (err, responses) => { 48 | expect(err).to.be(null); 49 | expect(responses).to.contain(1); 50 | expect(responses).to.contain(2); 51 | expect(responses).to.contain(3); 52 | 53 | setTimeout(() => { 54 | expect(io.of("/").adapter.ackRequests.size).to.eql(0); 55 | 56 | process.send("ok"); 57 | }, 500); 58 | }); 59 | break; 60 | } 61 | 62 | case "broadcasts with multiple acknowledgements (binary content)": { 63 | io.timeout(500).emit("test", (err, responses) => { 64 | expect(err).to.be(null); 65 | responses.forEach((response) => { 66 | expect(Buffer.isBuffer(response)).to.be(true); 67 | }); 68 | 69 | process.send("ok"); 70 | }); 71 | break; 72 | } 73 | 74 | case "broadcasts with multiple acknowledgements (no client)": { 75 | io 76 | .to("abc") 77 | .timeout(500) 78 | .emit("test", (err, responses) => { 79 | expect(err).to.be(null); 80 | expect(responses).to.eql([]); 81 | 82 | process.send("ok"); 83 | }); 84 | break; 85 | } 86 | 87 | case "broadcasts with multiple acknowledgements (timeout)": { 88 | io.timeout(500).emit("test", (err, responses) => { 89 | expect(err).to.be.an(Error); 90 | expect(responses).to.contain(1); 91 | expect(responses).to.contain(2); 92 | 93 | process.send("ok"); 94 | }); 95 | break; 96 | } 97 | 98 | case "get rooms": 99 | process.send(serverSocket.rooms); 100 | break; 101 | case "makes all socket instances join the specified room": 102 | io.socketsJoin("room1"); 103 | break; 104 | 105 | case "makes the matching socket instances join the specified room": 106 | io.in("room1").socketsJoin("room2"); 107 | break; 108 | 109 | case "makes all socket instances leave the specified room": 110 | io.socketsLeave("room1"); 111 | break; 112 | case "makes the matching socket instances leave the specified room": 113 | io.in("room1").socketsLeave("room2"); 114 | break; 115 | 116 | case "makes all socket instances disconnect": 117 | io.disconnectSockets(); 118 | break; 119 | 120 | case "returns all socket instances": 121 | const sockets = await io.fetchSockets(); 122 | 123 | expect(sockets).to.be.an(Array); 124 | expect(sockets).to.have.length(3); 125 | expect(io.of("/").adapter.requests.size).to.eql(0); // clean up 126 | 127 | process.send("ok"); 128 | break; 129 | 130 | case "sends an event to other server instances": 131 | io.serverSideEmit("hello", "world", 1, "2"); 132 | break; 133 | case "sends an event and receives a response from the other server instances (1)": 134 | io.serverSideEmit("hello with ack", (err, response) => { 135 | expect(err).to.be(null); 136 | expect(response).to.be.an(Array); 137 | expect(response).to.contain(2); 138 | expect(response).to.contain("3"); 139 | process.send("ok"); 140 | }); 141 | break; 142 | case "sends an event and receives a response from the other server instances (2)": 143 | io.on("hello with ack", (cb) => { 144 | cb(2); 145 | }); 146 | break; 147 | case "sends an event and receives a response from the other server instances (3)": 148 | io.on("hello with ack", (cb) => { 149 | cb("3"); 150 | }); 151 | break; 152 | case "sends an event but timeout if one server does not respond (1)": 153 | io.of("/").adapter.requestsTimeout = 200; 154 | 155 | io.serverSideEmit("hello with ack", (err, response) => { 156 | expect(err.message).to.be( 157 | "timeout reached: only 1 responses received out of 2" 158 | ); 159 | expect(response).to.be.an(Array); 160 | expect(response).to.contain(2); 161 | process.send("ok"); 162 | }); 163 | break; 164 | case "sends an event but timeout if one server does not respond (2)": 165 | io.on("hello with ack", (cb) => { 166 | cb(2); 167 | }); 168 | break; 169 | case "sends an event but timeout if one server does not respond (3)": 170 | io.on("hello with ack", (cb) => { 171 | // do nothing 172 | }); 173 | break; 174 | } 175 | }); 176 | 177 | io.on("hello", (arg1, arg2, arg3) => { 178 | expect(arg1).to.eql("world"); 179 | expect(arg2).to.eql(1); 180 | expect(arg3).to.eql("2"); 181 | process.send("ok"); 182 | }); 183 | 184 | httpServer.listen(parseInt(process.env.PORT, 10)); 185 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------