├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── README.md ├── __tests__ ├── adapter.js └── primus-cluster.js ├── lib ├── adapter.js ├── index.js └── primus-cluster.js ├── package.json ├── tsconfig.json └── yarn.lock /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: CI 5 | 6 | on: 7 | push: 8 | branches: [main] 9 | pull_request: 10 | branches: [main] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | # Docker Hub image that `container-job` executes in 16 | container: node:10.18-jessie 17 | 18 | services: 19 | redis: 20 | image: redis 21 | options: >- 22 | --health-cmd "redis-cli ping" 23 | --health-interval 10s 24 | --health-timeout 5s 25 | --health-retries 5 26 | 27 | strategy: 28 | matrix: 29 | node-version: [14.x, 16.x] 30 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 31 | 32 | steps: 33 | - name: Check out repository code 34 | uses: actions/checkout@v2 35 | 36 | - name: Use Node.js ${{ matrix.node-version }} 37 | uses: actions/setup-node@v1 38 | with: 39 | node-version: ${{ matrix.node-version }} 40 | 41 | - name: Install dependencies 42 | run: yarn --frozen-lockfile 43 | 44 | - name: Run tests 45 | run: yarn test 46 | env: 47 | # The hostname used to communicate with the Redis service container 48 | REDIS_HOST: redis 49 | # The default Redis port 50 | REDIS_PORT: 6379 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /* 2 | !/lib/*.js -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ## [2.0.0](https://github.com/lemonde/primus-cluster/compare/v1.1.0...v2.0.0) (2021-09-15) 6 | 7 | 8 | ### ⚠ BREAKING CHANGES 9 | 10 | * Primus v8 is required, Node.js >=14 is tested and 11 | supported. 12 | 13 | ### Features 14 | 15 | * allow prefixing redis keys & channel ([0e39c2c](https://github.com/lemonde/primus-cluster/commit/0e39c2c7b6c8e5d7ef6e27dafeed86ab1bd92767)) 16 | 17 | 18 | * modernize project ([aa197fd](https://github.com/lemonde/primus-cluster/commit/aa197fd300c0ed0e8fd08631f46ec8b5c47510c8)) 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # primus-cluster 2 | 3 | ![CI](https://github.com/lemonde/primus-cluster/workflows/CI/badge.svg) 4 | 5 | Primus cluster runs Primus accross multiple servers, it use Redis to store data and distribute messages across Primus instances. For more informations you can see [Redis Pub/Sub](http://redis.io/topics/pubsub). 6 | 7 | This project is a fork of the original project [primus-redis](https://github.com/mmalecki/primus-redis) that 8 | is not compatible with other Primus plugins and with Primus v2+. 9 | 10 | This plugin works with [primus-emitter](https://github.com/cayasso/primus-emitter/), [primus-rooms](https://github.com/cayasso/primus-rooms/), and [primus-resource](https://github.com/cayasso/primus-resource/). 11 | 12 | ## Usage 13 | 14 | ```js 15 | const http = require("http"); 16 | const Primus = require("primus"); 17 | const PrimusCluster = require("primus-cluster"); 18 | const 19 | const server = http.createServer(); 20 | const primus = new Primus(server); 21 | 22 | primus.plugin("cluster", PrimusCluster); 23 | ``` 24 | 25 | ## Options 26 | 27 | ### redis 28 | 29 | Type: `Object` or `Function` 30 | 31 | If you specify an **object**, the properties will be used to call `redis.createClient` method. The redis module used 32 | will be the Redis module installed. This project doesn't have [node_redis](https://github.com/mranney/node_redis/) module as dependency. 33 | 34 | ```js 35 | new Primus(server, { 36 | cluster: { 37 | redis: { 38 | port: 6379, 39 | host: "127.0.0.1", 40 | connect_timeout: 200, 41 | }, 42 | }, 43 | }); 44 | ``` 45 | 46 | If you specify a **function**, it will be called to create redis clients. 47 | 48 | ```js 49 | const redis = require("redis"); 50 | 51 | new Primus(server, { 52 | cluster: { 53 | redis: createClient, 54 | }, 55 | }); 56 | 57 | function createClient() { 58 | const client = redis.createClient(); 59 | client.select(1); // Choose a custom database. 60 | return client; 61 | } 62 | ``` 63 | 64 | ### prefix 65 | 66 | Type: `String` 67 | 68 | Prefix added to every redis key and default channel, default channel is "primus-cluster:". 69 | 70 | ```js 71 | new Primus(server, { 72 | cluster: { 73 | prefix: "my-client:", 74 | }, 75 | }); 76 | ``` 77 | 78 | ### channel 79 | 80 | Type: `String` 81 | 82 | The name of the channel to use, the default channel is `${prefix}pubsub`. 83 | 84 | ```js 85 | new Primus(server, { 86 | cluster: { 87 | channel: "my-client:awesome-channel", 88 | }, 89 | }); 90 | ``` 91 | 92 | ### ttl 93 | 94 | Type: `Number` 95 | 96 | The TTL of the data stored in redis in second, the default value is 86400 (1 day). If you use [primus-rooms](https://github.com/cayasso/primus-rooms/), Primus cluster will store rooms data in redis. 97 | 98 | ```js 99 | new Primus(server, { 100 | cluster: { 101 | ttl: 86400, 102 | }, 103 | }); 104 | ``` 105 | 106 | ## Use with other plugins 107 | 108 | When you use primus-redis with other plugins, you must take care of calling primus-cluster after all plugins. 109 | 110 | ```js 111 | primus.plugin("rooms", PrimusRooms); 112 | primus.plugin("emitter", PrimusEmitter); 113 | primus.plugin("cluster", PrimusCluster); 114 | ``` 115 | 116 | ## License 117 | 118 | MIT 119 | -------------------------------------------------------------------------------- /__tests__/adapter.js: -------------------------------------------------------------------------------- 1 | const { promisify } = require("util"); 2 | const redis = require("redis"); 3 | const async = require("async"); 4 | const { Adapter } = require("../lib/adapter"); 5 | 6 | describe("Adapter", () => { 7 | let adapter; 8 | let client; 9 | let publish; 10 | 11 | beforeEach(async () => { 12 | publish = jest.fn(); 13 | client = redis.createClient({ 14 | host: process.env.REDIS_HOST ?? undefined, 15 | port: process.env.REDIS_PORT ?? undefined, 16 | }); 17 | 18 | adapter = new Adapter({ 19 | client: client, 20 | publish: publish, 21 | }); 22 | }); 23 | 24 | afterEach(async () => { 25 | await promisify(client.flushdb.bind(client))(); 26 | await promisify(client.quit.bind(client))(); 27 | }); 28 | 29 | describe("#add", () => { 30 | it("should add a socket into a room", (done) => { 31 | adapter.add("12", "my:room:name", (err) => { 32 | if (err) return done(err); 33 | adapter.get("12", (err, rooms) => { 34 | if (err) return done(err); 35 | 36 | expect(rooms).toContain("my:room:name"); 37 | done(); 38 | }); 39 | }); 40 | }); 41 | 42 | it("should expire after given ttl", (done) => { 43 | adapter.ttl = 1; 44 | adapter.add("12", "my:room:name", (err) => { 45 | if (err) return done(err); 46 | setTimeout(() => { 47 | adapter.get("12", (err, rooms) => { 48 | if (err) return done(err); 49 | 50 | expect(rooms).not.toContain("my:room:name"); 51 | done(); 52 | }); 53 | }, 1100); 54 | }); 55 | }); 56 | 57 | it("should not expire", (done) => { 58 | adapter.ttl = 100; 59 | adapter.add("12", "my:room:name", (err) => { 60 | if (err) return done(err); 61 | 62 | setTimeout(() => { 63 | adapter.get("12", (err, rooms) => { 64 | if (err) return done(err); 65 | 66 | expect(rooms).toContain("my:room:name"); 67 | done(); 68 | }); 69 | }, 1100); 70 | }); 71 | }); 72 | }); 73 | 74 | describe("#get", () => { 75 | beforeEach((done) => { 76 | async.series( 77 | [ 78 | adapter.add.bind(adapter, "12", "my:room:name"), 79 | adapter.add.bind(adapter, "12", "my:second:room:name"), 80 | ], 81 | done 82 | ); 83 | }); 84 | 85 | it("should return client rooms", (done) => { 86 | adapter.get("12", (err, rooms) => { 87 | if (err) return done(err); 88 | expect(rooms).toContain("my:room:name"); 89 | expect(rooms).toContain("my:second:room:name"); 90 | done(); 91 | }); 92 | }); 93 | 94 | it("should return all rooms when the `id` argument is falsy", (done) => { 95 | adapter.get(null, (err, rooms) => { 96 | if (err) return done(err); 97 | expect(rooms).toContain("my:room:name"); 98 | expect(rooms).toContain("my:second:room:name"); 99 | done(); 100 | }); 101 | }); 102 | }); 103 | 104 | describe("#del", () => { 105 | beforeEach((done) => { 106 | async.series( 107 | [ 108 | adapter.add.bind(adapter, "12", "my:room:name"), 109 | adapter.add.bind(adapter, "12", "my:second:room:name"), 110 | ], 111 | done 112 | ); 113 | }); 114 | 115 | it("should remove room from a client", (done) => { 116 | adapter.del("12", "my:room:name", (err) => { 117 | if (err) return done(err); 118 | adapter.get("12", (err, rooms) => { 119 | if (err) return done(err); 120 | 121 | expect(rooms).not.toContain("my:room:name"); 122 | done(); 123 | }); 124 | }); 125 | }); 126 | 127 | it("should remove client from a room", (done) => { 128 | adapter.del("12", "my:room:name", (err) => { 129 | if (err) return done(err); 130 | adapter.clients("my:room:name", (err, client) => { 131 | if (err) return done(err); 132 | 133 | expect(client).not.toContain("12"); 134 | done(); 135 | }); 136 | }); 137 | }); 138 | 139 | it("should remove all rooms from the client if called without room", (done) => { 140 | adapter.del("12", null, (err) => { 141 | if (err) return done(err); 142 | adapter.get("12", (err, rooms) => { 143 | if (err) return done(err); 144 | expect(rooms).toEqual([]); 145 | done(); 146 | }); 147 | }); 148 | }); 149 | 150 | it("should remove client from all rooms if called without room", (done) => { 151 | adapter.del("12", null, (err) => { 152 | if (err) return done(err); 153 | 154 | async.series( 155 | [ 156 | adapter.clients.bind(adapter, "my:room:name"), 157 | adapter.clients.bind(adapter, "my:second:room:name"), 158 | ], 159 | (err, results) => { 160 | results.forEach((result) => { 161 | expect(result).not.toContain("12"); 162 | }); 163 | done(); 164 | } 165 | ); 166 | }); 167 | }); 168 | }); 169 | 170 | describe("#clients", () => { 171 | beforeEach((done) => { 172 | async.series( 173 | [ 174 | adapter.add.bind(adapter, "12", "my:room:name"), 175 | adapter.add.bind(adapter, "13", "my:room:name"), 176 | ], 177 | done 178 | ); 179 | }); 180 | 181 | it("should return clients", (done) => { 182 | adapter.clients("my:room:name", (err, ids) => { 183 | if (err) return done(err); 184 | expect(ids).toContain("12"); 185 | expect(ids).toContain("13"); 186 | done(); 187 | }); 188 | }); 189 | }); 190 | 191 | describe("#broadcast", () => { 192 | let clients; 193 | let data; 194 | 195 | beforeEach((done) => { 196 | async.series( 197 | [ 198 | adapter.add.bind(adapter, "marc", "news"), 199 | adapter.add.bind(adapter, "jose", "sport"), 200 | adapter.add.bind(adapter, "jose", "news"), 201 | adapter.add.bind(adapter, "greg", "news"), 202 | adapter.add.bind(adapter, "vincent", "sport"), 203 | adapter.add.bind(adapter, "ludowic", "sport"), 204 | adapter.add.bind(adapter, "ludowic", "news"), 205 | adapter.add.bind(adapter, "samuel", "geek"), 206 | ], 207 | done 208 | ); 209 | 210 | const createSocket = () => { 211 | return { 212 | write: jest.fn(), 213 | send: jest.fn(), 214 | }; 215 | }; 216 | 217 | clients = { 218 | marc: createSocket(), 219 | jose: createSocket(), 220 | greg: createSocket(), 221 | vincent: createSocket(), 222 | ludowic: createSocket(), 223 | samuel: createSocket(), 224 | }; 225 | 226 | data = ["mydata"]; 227 | }); 228 | 229 | it("should broadcast to all clients", (done) => { 230 | adapter.broadcast(data, {}, clients, (err) => { 231 | if (err) return done(err); 232 | Object.keys(clients).forEach((id) => { 233 | var socket = clients[id]; 234 | expect(socket.write).toHaveBeenCalledTimes(1); 235 | }); 236 | done(); 237 | }); 238 | }); 239 | 240 | it("should broadcast to a specific room", (done) => { 241 | adapter.broadcast(data, { rooms: ["sport", "geek"] }, clients, (err) => { 242 | if (err) return done(err); 243 | expect(clients.marc.write).not.toHaveBeenCalled(); 244 | expect(clients.jose.write).toHaveBeenCalledTimes(1); 245 | expect(clients.greg.write).not.toHaveBeenCalled(); 246 | expect(clients.vincent.write).toHaveBeenCalledTimes(1); 247 | expect(clients.ludowic.write).toHaveBeenCalledTimes(1); 248 | expect(clients.samuel.write).toHaveBeenCalledTimes(1); 249 | done(); 250 | }); 251 | }); 252 | 253 | it("should not send to excepted clients (with rooms)", (done) => { 254 | adapter.broadcast( 255 | data, 256 | { rooms: ["sport", "geek"], except: ["jose"] }, 257 | clients, 258 | (err) => { 259 | if (err) return done(err); 260 | expect(clients.marc.write).not.toHaveBeenCalled(); 261 | expect(clients.jose.write).not.toHaveBeenCalled(); 262 | expect(clients.greg.write).not.toHaveBeenCalled(); 263 | expect(clients.vincent.write).toHaveBeenCalledTimes(1); 264 | expect(clients.ludowic.write).toHaveBeenCalledTimes(1); 265 | expect(clients.samuel.write).toHaveBeenCalledTimes(1); 266 | done(); 267 | } 268 | ); 269 | }); 270 | 271 | it("should not send to excepted clients (without rooms)", (done) => { 272 | adapter.broadcast(data, { except: ["jose"] }, clients, (err) => { 273 | if (err) return done(err); 274 | expect(clients.marc.write).toHaveBeenCalledTimes(1); 275 | expect(clients.jose.write).not.toHaveBeenCalled(); 276 | expect(clients.greg.write).toHaveBeenCalledTimes(1); 277 | expect(clients.vincent.write).toHaveBeenCalledTimes(1); 278 | expect(clients.ludowic.write).toHaveBeenCalledTimes(1); 279 | expect(clients.samuel.write).toHaveBeenCalledTimes(1); 280 | done(); 281 | }); 282 | }); 283 | 284 | it("should called a custom method", (done) => { 285 | adapter.broadcast(data, { method: "send" }, clients, (err) => { 286 | if (err) return done(err); 287 | expect(clients.marc.send).toHaveBeenCalledTimes(1); 288 | expect(clients.jose.send).toHaveBeenCalledTimes(1); 289 | expect(clients.greg.send).toHaveBeenCalledTimes(1); 290 | expect(clients.vincent.send).toHaveBeenCalledTimes(1); 291 | expect(clients.ludowic.send).toHaveBeenCalledTimes(1); 292 | expect(clients.samuel.send).toHaveBeenCalledTimes(1); 293 | done(); 294 | }); 295 | }); 296 | 297 | it("should publish data", (done) => { 298 | adapter.broadcast( 299 | data, 300 | { method: "send", except: ["jose"] }, 301 | clients, 302 | (err) => { 303 | if (err) return done(err); 304 | expect(publish).toHaveBeenCalledWith(data, "room", { 305 | method: "send", 306 | except: ["jose"], 307 | rooms: [], 308 | }); 309 | done(); 310 | } 311 | ); 312 | }); 313 | }); 314 | 315 | describe("#empty", () => { 316 | beforeEach((done) => { 317 | async.series( 318 | [ 319 | adapter.add.bind(adapter, "12", "my:room:name"), 320 | adapter.add.bind(adapter, "13", "my:room:name"), 321 | ], 322 | done 323 | ); 324 | }); 325 | 326 | it("should remove all clients from a room", (done) => { 327 | adapter.empty("my:room:name", (err) => { 328 | if (err) return done(err); 329 | adapter.clients("my:room:name", (err, clients) => { 330 | if (err) return done(err); 331 | expect(clients).toHaveLength(0); 332 | done(); 333 | }); 334 | }); 335 | }); 336 | }); 337 | 338 | describe("#isEmpty", () => { 339 | beforeEach((done) => { 340 | async.series( 341 | [ 342 | adapter.add.bind(adapter, "12", "my:room:name"), 343 | adapter.add.bind(adapter, "13", "my:room:name"), 344 | ], 345 | done 346 | ); 347 | }); 348 | 349 | it("should return true if the room is empty", (done) => { 350 | adapter.isEmpty("my:second:room:name", (err, empty) => { 351 | if (err) return done(err); 352 | expect(empty).toBe(true); 353 | done(); 354 | }); 355 | }); 356 | 357 | it("should return false if the room is not empty", (done) => { 358 | adapter.isEmpty("my:room:name", (err, empty) => { 359 | if (err) return done(err); 360 | expect(empty).toBe(false); 361 | done(); 362 | }); 363 | }); 364 | }); 365 | 366 | describe("#_getTimeFraction", () => { 367 | beforeEach(() => { 368 | jest.useFakeTimers(); 369 | }); 370 | 371 | afterEach(() => { 372 | jest.useRealTimers(); 373 | }); 374 | 375 | it("should substract from the current time interval when offset provided", () => { 376 | const current = adapter._getTimeFraction(); 377 | const oneBefore = adapter._getTimeFraction(1); 378 | expect(current - 1).toEqual(oneBefore); 379 | }); 380 | 381 | it("should change fraction every 10 seconds if ttl is 100 seconds", () => { 382 | adapter = new Adapter({ ttl: 100 }); 383 | const current = adapter._getTimeFraction(); 384 | jest.advanceTimersByTime(10 * 1000); 385 | const next = adapter._getTimeFraction(); 386 | expect(current + 1).toEqual(next); 387 | }); 388 | 389 | it("should change time every 2h and 24min by default", () => { 390 | const current = adapter._getTimeFraction(); 391 | jest.advanceTimersByTime((2 * 60 + 24) * 60 * 1000); 392 | const next = adapter._getTimeFraction(); 393 | expect(current + 1).toEqual(next); 394 | }); 395 | }); 396 | }); 397 | -------------------------------------------------------------------------------- /__tests__/primus-cluster.js: -------------------------------------------------------------------------------- 1 | const http = require("http"); 2 | const async = require("async"); 3 | const Primus = require("primus"); 4 | const PrimusEmitter = require("primus-emitter"); 5 | const PrimusRooms = require("primus-rooms"); 6 | const PrimusCluster = require("../lib"); 7 | 8 | function createPrimus() { 9 | const server = http.createServer(); 10 | const primus = new Primus(server, { 11 | cluster: { 12 | redis: { 13 | host: process.env.REDIS_HOST ?? undefined, 14 | port: process.env.REDIS_PORT ?? undefined, 15 | }, 16 | }, 17 | }); 18 | 19 | // Plugins. 20 | primus.plugin("emitter", PrimusEmitter); 21 | primus.plugin("rooms", PrimusRooms); 22 | primus.plugin("cluster", PrimusCluster); 23 | 24 | primus.on("connection", (spark) => { 25 | spark.join("myroom"); 26 | }); 27 | 28 | server.listen(0); 29 | primus.port = server.address().port; 30 | 31 | return primus; 32 | } 33 | 34 | function getClient(primus, cb) { 35 | const client = new primus.Socket("http://localhost:" + primus.port); 36 | client.on("open", () => { 37 | cb(null, client); 38 | }); 39 | } 40 | 41 | function expectClientToReceive(client, expectedMsg, cb) { 42 | client.on("data", (msg) => { 43 | try { 44 | expect(expectedMsg).toEqual(msg); 45 | cb(); 46 | } catch (error) { 47 | cb(error); 48 | } 49 | }); 50 | } 51 | 52 | describe("Primus cluster", () => { 53 | describe("E2E", () => { 54 | let servers; 55 | let clients; 56 | 57 | beforeEach((done) => { 58 | const cbs = []; 59 | servers = []; 60 | clients = []; 61 | 62 | for (var i = 0; i < 2; i++) { 63 | servers[i] = createPrimus(); 64 | cbs.push(getClient.bind(null, servers[i])); 65 | } 66 | 67 | async.parallel(cbs, function (err, _clients) { 68 | if (err) return done(err); 69 | clients = _clients; 70 | done(); 71 | }); 72 | }); 73 | 74 | afterEach((done) => { 75 | async.parallel( 76 | servers.map((server) => { 77 | return (cb) => server.destroy({}, cb); 78 | }), 79 | done 80 | ); 81 | }); 82 | 83 | it('should forward message using "write" method', (done) => { 84 | async.parallel( 85 | [ 86 | expectClientToReceive.bind(null, clients[0], "hello"), 87 | expectClientToReceive.bind(null, clients[1], "hello"), 88 | ], 89 | done 90 | ); 91 | 92 | servers[0].write("hello"); 93 | }); 94 | 95 | it('should forward message using "send" method', (done) => { 96 | async.parallel( 97 | [ 98 | expectClientToReceive.bind(null, clients[0], { 99 | type: 0, 100 | data: ["hello"], 101 | }), 102 | expectClientToReceive.bind(null, clients[1], { 103 | type: 0, 104 | data: ["hello"], 105 | }), 106 | ], 107 | done 108 | ); 109 | 110 | servers[0].send("hello"); 111 | }); 112 | 113 | it("should forward room message", (done) => { 114 | async.parallel( 115 | [ 116 | expectClientToReceive.bind(null, clients[0], "hello"), 117 | expectClientToReceive.bind(null, clients[1], "hello"), 118 | ], 119 | done 120 | ); 121 | 122 | servers[0].room("myroom").write("hello"); 123 | }); 124 | }); 125 | }); 126 | -------------------------------------------------------------------------------- /lib/adapter.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Module exports. 3 | */ 4 | 5 | exports.Adapter = Adapter; 6 | 7 | /** 8 | * Redis adapter constructor. 9 | */ 10 | 11 | function Adapter(options) { 12 | this.prefix = options.prefix ?? ""; 13 | this.ttl = options.ttl || 86400; 14 | this.publish = options.publish; 15 | this.client = options.client; 16 | 17 | // numberOfSets # of sets you want to seperate your keys 18 | this.numberOfSets = options.numberOfSets || 10; 19 | this.interval = (this.ttl * 1000) / this.numberOfSets; 20 | } 21 | 22 | /** 23 | * Adds a socket to a room. 24 | * 25 | * @param {String} id Socket id 26 | * @param {String} room The room name 27 | * @param {Function} cb Callback 28 | * @api public 29 | */ 30 | 31 | Adapter.prototype.add = function add(id, room, cb) { 32 | cb = cb || noop; 33 | 34 | const multi = this.client.multi(); 35 | 36 | multi.sadd(this._socketKey(id), room).sadd(this._roomKey(room), id); 37 | 38 | if (this.ttl) { 39 | multi 40 | .expire(this._socketKey(id), this.ttl) 41 | .expire(this._roomKey(room), this.ttl); 42 | } 43 | 44 | multi.exec(cb); 45 | }; 46 | 47 | /** 48 | * Get the list of rooms joined by the socket or the list 49 | * of all active rooms. 50 | * 51 | * @param {String} id Socket id 52 | * @param {Function} cb callback 53 | * @api public 54 | */ 55 | 56 | Adapter.prototype.get = function get(id, cb) { 57 | cb = cb || noop; 58 | 59 | if (id) { 60 | const socketKeys = this._getSocketKeys(id); 61 | this.client.sunion(socketKeys, cb); 62 | return; 63 | } 64 | 65 | this.client.keys(`${this.prefix}room:*`, (err, rooms) => { 66 | if (err) return cb(err); 67 | rooms = rooms.map((room) => 68 | room.slice(`${this.prefix}room:`.length).replace(/\:(\d*)$/g, "") 69 | ); 70 | 71 | rooms = Array.from(new Set(rooms)); 72 | 73 | cb(undefined, rooms); 74 | }); 75 | }; 76 | 77 | /** 78 | * Removes a socket from a room or from all rooms 79 | * if a room is not passed. 80 | * 81 | * @param {String} id Socket id 82 | * @param {String|Function} [room] The room name or callback 83 | * @param {Function} cb Callback 84 | * @api public 85 | */ 86 | 87 | Adapter.prototype.del = function del(id, room, cb) { 88 | if (!room) return this.delAll(id, cb); 89 | 90 | cb = cb || noop; 91 | 92 | var multi = this.client.multi(); 93 | 94 | var i = 0; 95 | for (; i < this.numberOfSets; i++) { 96 | multi.srem(this._roomKey(room, i), id); 97 | multi.srem(this._socketKey(id, i), room); 98 | } 99 | 100 | multi.exec(cb); 101 | }; 102 | 103 | /** 104 | * Broadcasts a packet. 105 | * 106 | * Options: 107 | * - `except` {Array} sids that should be excluded 108 | * - `rooms` {Array} list of rooms to broadcast to 109 | * - `method` {String} 'write' or 'send' if primus-emitter is present 110 | * 111 | * @param {Object} data 112 | * @param {Object} opts 113 | * @param {Object} clients Connected clients 114 | * @param {Function} cb Callback 115 | * @api public 116 | */ 117 | 118 | Adapter.prototype.broadcast = function broadcast(data, opts, clients, cb) { 119 | opts = opts || {}; 120 | cb = cb || noop; 121 | const rooms = (opts.rooms = opts.rooms || []); 122 | const except = (opts.except = opts.except || []); 123 | const method = (opts.method = opts.method || "write"); 124 | const ids = {}; 125 | 126 | this.publish(data, "room", opts); 127 | 128 | if (rooms.length) { 129 | const multi = this.client.multi(); 130 | rooms.forEach((room) => { 131 | const roomKeys = this._getRoomKeys(room); 132 | multi.sunion(roomKeys); 133 | }); 134 | 135 | multi.exec((err, replies) => { 136 | if (err) { 137 | return cb(err); 138 | } 139 | 140 | replies.forEach((roomIds) => { 141 | roomIds.forEach((id) => { 142 | if (ids[id] || except.indexOf(id) !== -1) return; 143 | if (!clients[id]) return; 144 | clients[id][method].apply(clients[id], data); 145 | ids[id] = true; 146 | }); 147 | }); 148 | 149 | cb(); 150 | }); 151 | } else { 152 | // TODO replace this `KEYS` should be only used for debugging 153 | this.client.keys("socket:*", (err, sockets) => { 154 | if (err) { 155 | return cb(err); 156 | } 157 | 158 | let ids = sockets.map((key) => 159 | key.slice(`${this.prefix}socket:`.length).replace(/\:(\d+)$/, "") 160 | ); 161 | ids = Array.from(new Set(ids)); 162 | ids.forEach((id) => { 163 | if (except.indexOf(id) !== -1) return; 164 | if (!clients[id]) return; 165 | clients[id][method].apply(clients[id], data); 166 | }); 167 | 168 | cb(); 169 | }); 170 | } 171 | }; 172 | 173 | /** 174 | * Get client ids connected to this room. 175 | * 176 | * @param {String} room The room name 177 | * @param {Function} cb Callback 178 | * @api public 179 | */ 180 | 181 | Adapter.prototype.clients = function clients(room, cb) { 182 | const roomKeys = this._getRoomKeys(room); 183 | this.client.sunion(roomKeys, cb); 184 | }; 185 | 186 | /** 187 | * Remove all sockets from a room. 188 | * 189 | * @param {String|Array} room 190 | * @param {Function} cb Callback 191 | * @api public 192 | */ 193 | 194 | Adapter.prototype.empty = function empty(room, cb) { 195 | const multi = this.client.multi(); 196 | const roomKeys = this._getRoomKeys(room); 197 | roomKeys.forEach((key) => { 198 | multi.del(key); 199 | }); 200 | multi.exec(cb); 201 | }; 202 | 203 | /** 204 | * Check to see if a room is empty. 205 | * 206 | * @param {String} room 207 | * @param {Function} cb Callback 208 | * @api public 209 | */ 210 | Adapter.prototype.isEmpty = function isEmpty(room, cb) { 211 | const roomKeys = this._getRoomKeys(room); 212 | this.client.sunion(roomKeys, (err, clients) => { 213 | if (err) { 214 | cb(err); 215 | return; 216 | } 217 | 218 | cb(null, clients.length === 0); 219 | }); 220 | }; 221 | 222 | /** 223 | * Removes a socket from all rooms it's joined. 224 | * 225 | * @param {String} id Socket id 226 | * @param {Function} cb Callback 227 | */ 228 | 229 | Adapter.prototype.delAll = function delAll(id, cb) { 230 | cb = cb || noop; 231 | // Get rooms. 232 | this.get(id, (err, rooms) => { 233 | if (err) { 234 | return cb(err); 235 | } 236 | 237 | const multi = this.client.multi(); 238 | 239 | // Remove id from each rooms. 240 | rooms.forEach((room) => { 241 | const roomKeys = this._getRoomKeys(room); 242 | roomKeys.forEach((key) => { 243 | multi.srem(key, id); 244 | }); 245 | }); 246 | 247 | const socketKeys = this._getSocketKeys(id); 248 | 249 | // Remove id key. 250 | socketKeys.forEach((key) => { 251 | multi.del(key); 252 | }); 253 | 254 | multi.exec(cb); 255 | }); 256 | }; 257 | 258 | Adapter.prototype._getRoomKeys = function (room) { 259 | const roomKeys = []; 260 | for (let i = 0; i < this.numberOfSets; i++) { 261 | roomKeys.push(this._roomKey(room, i)); 262 | } 263 | return roomKeys; 264 | }; 265 | 266 | Adapter.prototype._roomKey = function (room, offset) { 267 | const time = this._getTimeFraction(offset); 268 | return `${this.prefix}room:${room}:${time}`; 269 | }; 270 | 271 | Adapter.prototype._getSocketKeys = function (room) { 272 | const roomKeys = []; 273 | for (let i = 0; i < this.numberOfSets; i++) { 274 | roomKeys.push(this._socketKey(room, i)); 275 | } 276 | return roomKeys; 277 | }; 278 | 279 | Adapter.prototype._socketKey = function (id, offset) { 280 | const time = this._getTimeFraction(offset); 281 | return `${this.prefix}socket:${id}:${time}`; 282 | }; 283 | 284 | Adapter.prototype._getTimeFraction = function (offset) { 285 | const now = Date.now(); 286 | offset = offset || 0; 287 | const interval = this.interval; 288 | return Math.floor(now / interval) - offset; 289 | }; 290 | 291 | /** 292 | * Noop function. 293 | */ 294 | 295 | function noop() {} 296 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | exports.Adapter = require("./adapter").Adapter; 2 | exports.server = require("./primus-cluster").server; 3 | -------------------------------------------------------------------------------- /lib/primus-cluster.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Module dependencies. 3 | */ 4 | 5 | const { Adapter } = require("./adapter"); 6 | 7 | /** 8 | * Expose module. 9 | */ 10 | 11 | exports.server = function (primus, options) { 12 | return new PrimusCluster(primus, options); 13 | }; 14 | 15 | /** 16 | * Create a new PrimusCluster instance. 17 | * Enable pub/sub for write and send method (if avalaible). 18 | * 19 | * @param {Primus} primus 20 | * @param {Object} options 21 | */ 22 | 23 | function PrimusCluster( 24 | primus, 25 | { 26 | cluster: { 27 | prefix = "primus-cluster:", 28 | channel = `${prefix}pubsub`, 29 | ttl = 86400, 30 | redis = {}, 31 | } = {}, 32 | } = {} 33 | ) { 34 | this.primus = primus; 35 | this.channel = channel; 36 | this.silent = false; 37 | 38 | // Generate a random id for this cluster node. 39 | this.id = Math.random(); 40 | 41 | this.initializeClients(redis); 42 | this.initializeAdapter({ ttl, prefix }); 43 | this.wrapPrimusMethods(); 44 | this.initializeMessageDispatcher(); 45 | 46 | this.primus.on("close", this.close.bind(this)); 47 | } 48 | 49 | /** 50 | * Initialize Redis clients. 51 | */ 52 | 53 | PrimusCluster.prototype.initializeClients = function initializeClients( 54 | options 55 | ) { 56 | this.clients = {}; 57 | 58 | // Create redis clients. 59 | ["pub", "sub", "storage"].forEach( 60 | function (name) { 61 | var client = createClient(); 62 | 63 | // Forward errors to Primus. 64 | client.on( 65 | "error", 66 | function (err) { 67 | this.primus.emit("error", err); 68 | }.bind(this) 69 | ); 70 | 71 | this.clients[name] = client; 72 | }.bind(this) 73 | ); 74 | 75 | /** 76 | * Create a new redis client. 77 | * 78 | * @returns {RedisClient} 79 | */ 80 | 81 | function createClient() { 82 | if (typeof options === "function") return options(); 83 | 84 | try { 85 | return require("redis").createClient(options); 86 | } catch (err) { 87 | throw new Error("You must add redis as dependency."); 88 | } 89 | } 90 | }; 91 | 92 | PrimusCluster.prototype.initializeAdapter = function initializeAdapter({ 93 | ttl, 94 | prefix, 95 | }) { 96 | // Create adapter. 97 | const adapter = new Adapter({ 98 | publish: this.publish.bind(this), 99 | client: this.clients.storage, 100 | ttl: ttl, 101 | prefix, 102 | }); 103 | 104 | // Replace adapter in options. 105 | this.primus.options.rooms = this.primus.options.rooms || {}; 106 | this.primus.options.rooms.adapter = adapter; 107 | 108 | // Replace adapter in primus and in rooms plugin. 109 | if (this.primus.adapter) this.primus.adapter = adapter; 110 | if (this.primus._rooms) this.primus._rooms.adapter = adapter; 111 | }; 112 | 113 | /** 114 | * Wrap primus methods. 115 | */ 116 | 117 | PrimusCluster.prototype.wrapPrimusMethods = function wrapPrimusMethods() { 118 | ["write", "send"].forEach((method) => { 119 | if (!this.primus[method]) return; 120 | this.primus["__original" + method] = this.primus[method]; 121 | Object.defineProperty(this.primus, method, { 122 | value: (...args) => { 123 | this.publish(args, "primus", { method: method }); 124 | this.primus["__original" + method].apply(this.primus, args); 125 | }, 126 | }); 127 | }); 128 | }; 129 | 130 | /** 131 | * Initialize the message dispatcher to dispatch message over cluster nodes. 132 | */ 133 | 134 | PrimusCluster.prototype.initializeMessageDispatcher = 135 | function initializeMessageDispatcher() { 136 | this.clients.sub.subscribe(this.channel); 137 | 138 | this.clients.sub.on("message", (channel, message) => { 139 | this.dispatchMessage(message); 140 | }); 141 | }; 142 | 143 | /** 144 | * Dispatch message depending on its type. 145 | * 146 | * @param {Object} msg 147 | */ 148 | 149 | PrimusCluster.prototype.dispatchMessage = function dispatchMessage(msg) { 150 | this.primus.decoder(msg, (err, msg) => { 151 | // Do a "save" emit('error') when we fail to parse a message. We don't 152 | // want to throw here as listening to errors should be optional. 153 | if (err) { 154 | if (this.primus.listeners("error").length) { 155 | this.primus.emit("error", err); 156 | } 157 | return; 158 | } 159 | 160 | // If message have no type, we ignore it. 161 | if (!msg.type) return; 162 | 163 | // If we are the emitter, we ignore it. 164 | if (msg.id === this.id) return; 165 | 166 | this.callDispatcher(msg); 167 | }); 168 | }; 169 | 170 | /** 171 | * Call the dispatcher in silent mode. 172 | * 173 | * @param {Object} msg 174 | */ 175 | 176 | PrimusCluster.prototype.callDispatcher = function callDispatcher(msg) { 177 | // Enter silent mode. 178 | this.silent = true; 179 | 180 | // Call the dispatcher. 181 | this[msg.type + "MessageDispatcher"](msg); 182 | 183 | // Exit silent mode. 184 | this.silent = false; 185 | }; 186 | 187 | /** 188 | * Room message dispatcher. 189 | * Handle message published by adapter. 190 | * 191 | * @param {Object} msg 192 | */ 193 | 194 | PrimusCluster.prototype.roomMessageDispatcher = function roomMessageDispatcher( 195 | msg 196 | ) { 197 | msg.opts.rooms.forEach((room) => { 198 | const rooms = this.primus.room(room).except(msg.opts.except); 199 | rooms[msg.opts.method].apply(rooms, Array.from(msg.data)); 200 | }); 201 | }; 202 | 203 | /** 204 | * Primus message dispatcher. 205 | * Write message on the current primus server. 206 | * 207 | * @param {Object} msg 208 | */ 209 | 210 | PrimusCluster.prototype.primusMessageDispatcher = 211 | function primusMessageDispatcher(msg) { 212 | this.primus["__original" + msg.opts.method].apply( 213 | this.primus, 214 | Array.from(msg.data) 215 | ); 216 | }; 217 | 218 | /** 219 | * Publish message over the cluster. 220 | * 221 | * @param {mixed} data 222 | * @param {String} type ('primus', 'room') 223 | * @param {Object} [opts] 224 | */ 225 | 226 | PrimusCluster.prototype.publish = function publish(data, type, opts = {}) { 227 | // In silent mode, we do nothing. 228 | if (this.silent) return; 229 | 230 | const message = { 231 | id: this.id, 232 | data: data, 233 | type: type, 234 | opts: opts, 235 | }; 236 | 237 | this.primus.encoder(message, (err, msg) => { 238 | // Do a "save" emit('error') when we fail to parse a message. We don't 239 | // want to throw here as listening to errors should be optional. 240 | if (err) { 241 | if (this.primus.listeners("error").length) { 242 | this.primus.emit("error", err); 243 | } 244 | return; 245 | } 246 | 247 | this.clients.pub.publish(this.channel, msg); 248 | }); 249 | }; 250 | 251 | /** 252 | * Called when primus is closed. 253 | * Quit all redis clients. 254 | */ 255 | 256 | PrimusCluster.prototype.close = function close() { 257 | Object.values(this.clients).forEach((client) => { 258 | client.quit(); 259 | }); 260 | }; 261 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "primus-cluster", 3 | "version": "2.0.0", 4 | "description": "Scale Primus across multiple servers.", 5 | "main": "./lib/index.js", 6 | "devDependencies": { 7 | "async": "^3.2.1", 8 | "conventional-github-releaser": "^3.1.5", 9 | "jest": "^27.1.0", 10 | "standard-version": "^9.1.0", 11 | "primus": "^8.0.5", 12 | "primus-emitter": "^3.1.1", 13 | "primus-rooms": "^3.4.1", 14 | "redis": "^3.1.2", 15 | "ws": "^8.2.2" 16 | }, 17 | "peerDependencies": { 18 | "primus": ">=8.0.0" 19 | }, 20 | "repository": "github:lemonde/primus-cluster", 21 | "scripts": { 22 | "test": "jest", 23 | "release": "standard-version && conventional-github-releaser --preset angular" 24 | }, 25 | "author": "Greg Bergé ", 26 | "license": "MIT" 27 | } 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["lib/**/*"], 3 | "compilerOptions": { 4 | "target": "es2015", 5 | "baseUrl": "server", 6 | "moduleResolution": "node", 7 | "allowJs": true, 8 | "noEmit": true, 9 | "strict": true, 10 | "noImplicitAny": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "maxNodeModuleJsDepth": 2, 14 | "skipLibCheck": true 15 | } 16 | } 17 | --------------------------------------------------------------------------------