├── .gitignore ├── index.js ├── CONTRIBUTING.md ├── lib ├── server.js ├── client.js └── clientManager.js ├── example └── example.js ├── package.json ├── LICENSE.md ├── README.md └── test ├── clientTest.js └── clientManagerTest.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | coverage 4 | .nyc_output 5 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | Server: require('./lib/server.js'), 3 | ClientManager: require('./lib/clientManager.js'), 4 | Client: require('./lib/client.js') 5 | }; 6 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Patchwire 2 | 3 | Patchwire is totally open to pull requests! Be sure to explain the content of the changeset in the body of your pull request. 4 | 5 | Patchwire is built with Circle CI on every commit. The CI build will lint your code, run tests, and ensure test coverage is above 85%. Only passing pull requests will be merged. 6 | 7 | If you contribute to the project, feel free to add yourself to `contributors` in `package.json` 8 | -------------------------------------------------------------------------------- /lib/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Client = require('./client.js'); 4 | const net = require('net'); 5 | 6 | class Server { 7 | constructor (socketHandler) { 8 | this.netServer = net.createServer(function (rawSocket) { 9 | const client = new Client(rawSocket); 10 | 11 | client.send({ 12 | command: '__net__handshake' 13 | }); 14 | 15 | client.onData((data) => { 16 | if (data.command === '__net__handshake') { 17 | client.send({ 18 | command: 'connected' 19 | }); 20 | 21 | socketHandler(client); 22 | } 23 | }); 24 | }); 25 | } 26 | 27 | /** 28 | * Opens the server on the given port. Calls back when the server is open. 29 | * @param {number} port The port to listen to 30 | * @param {Function} callback The callback to run when open 31 | */ 32 | listen (port, callback) { 33 | this.netServer.listen(port, callback); 34 | } 35 | } 36 | 37 | module.exports = Server; 38 | -------------------------------------------------------------------------------- /example/example.js: -------------------------------------------------------------------------------- 1 | const GMServer = require('../index.js').Server; 2 | const ClientManager = require('../index.js').ClientManager; 3 | 4 | const gameManager = new ClientManager(); 5 | 6 | gameManager.addCommandListener('register', (client, data) => { 7 | client.set('username', data.username); 8 | 9 | client.send('register', { 10 | registered: true, 11 | username: data.username, 12 | }); 13 | 14 | console.log(client.clientId, 'has registered as', data.username); 15 | }); 16 | 17 | gameManager.on('clientAdded', (client) => { 18 | console.log('Player connected. Client id:', client.clientId); 19 | 20 | client.send('welcome', { 21 | motd: 'Never pet a dog on fire' 22 | }); 23 | }); 24 | 25 | gameManager.on('clientDropped', ({ client, reason }) => { 26 | console.log('Client dropped:', client.clientId, reason); 27 | }); 28 | 29 | const server = new GMServer(function (client) { 30 | gameManager.addClient(client); 31 | }); 32 | 33 | server.listen(3001, function () { 34 | console.info('Server is running'); 35 | }); 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "patchwire", 3 | "version": "0.5.0", 4 | "description": "Multiplayer game server framework for Node.js", 5 | "engines": { 6 | "node": "^12.18.3", 7 | "npm": "^6.4.1" 8 | }, 9 | "main": "index.js", 10 | "scripts": { 11 | "lint": "semistandard", 12 | "test": "mocha --exit", 13 | "ci": "npm run lint && npm run test" 14 | }, 15 | "keywords": [ 16 | "server", 17 | "socket", 18 | "multiplayer", 19 | "game" 20 | ], 21 | "repository": { 22 | "type": "git", 23 | "url": "https://github.com/twisterghost/patchwire.git" 24 | }, 25 | "bugs": { 26 | "url": "https://github.com/twisterghost/patchwire/issues" 27 | }, 28 | "files": [ 29 | "index.js", 30 | "lib" 31 | ], 32 | "author": "Michael Barrett (https://mjb.im/)", 33 | "license": "MIT", 34 | "dependencies": { 35 | "lodash": "^4.17.20" 36 | }, 37 | "devDependencies": { 38 | "chai": "4.2.0", 39 | "mocha": "^8.1.3", 40 | "sinon": "^9.0.3" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Michael Barrett 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Patchwire 2 | Multiplayer game server framework for Node.js 3 | 4 | ## Install 5 | `npm install patchwire` 6 | 7 | ## Use 8 | ```JavaScript 9 | // MyGameServer.js 10 | const Server = require('patchwire').Server; 11 | const ClientManager = require('patchwire').ClientManager; 12 | 13 | const gameLobby = new ClientManager(); 14 | gameLobby.on('clientAdded', function() { 15 | gameLobby.broadcast('chat', { 16 | message: 'A new player has joined the game.' 17 | }); 18 | }); 19 | 20 | const server = new Server(function(client) { 21 | gameLobby.addClient(client); 22 | }); 23 | 24 | server.listen(3001); 25 | ``` 26 | 27 | ## Documentation 28 | 29 | See [the patchwire Github wiki](https://github.com/twisterghost/patchwire/wiki) 30 | 31 | ## About 32 | 33 | Patchwire is a server framework designed for multiplayer games. Originally built to work with GameMaker: Studio's networking code, it has been standardized to be unassuming about the client end framework. 34 | 35 | Patchwire uses a paradigm of sending "commands" to clients, and in turn, listening for commands from the client. A command is nothing more than a string identifier, and some data. A command looks like this: 36 | 37 | ```JavaScript 38 | { 39 | command: 'updatePosition', 40 | x: 200, 41 | y: 120 42 | } 43 | ``` 44 | 45 | ## Clients 46 | 47 | Patchwire is unassuming about the client side as it speaks primarily through JSON strings encoded over the wire. If you do not see your preferred client side below, creating your own client package is strongly encouraged, as Patchwire is built to be as easy as possible to implement. More client packages will come over time. 48 | 49 | ### List of client packages: 50 | 51 | * [GameMaker: Studio](https://github.com/twisterghost/patchwire-gm) 52 | * [iOS](https://github.com/VictorBX/patchwire-ios) 53 | 54 | -------------------------------------------------------------------------------- /test/clientTest.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | 'use strict'; 3 | 4 | const assert = require('chai').assert; 5 | const sinon = require('sinon'); 6 | const Client = require('../lib/client.js'); 7 | const _ = require('lodash'); 8 | let client; 9 | let fakeSocket; 10 | const TERM_STR = '\n\t\n'; 11 | 12 | function getFakeNetSocket () { 13 | return { 14 | on: sinon.stub(), 15 | write: sinon.stub(), 16 | setKeepAlive: sinon.stub(), 17 | }; 18 | } 19 | 20 | describe('Client', function () { 21 | beforeEach(function () { 22 | fakeSocket = getFakeNetSocket(); 23 | client = new Client(fakeSocket); 24 | }); 25 | 26 | describe('constructor', function () { 27 | it('sets up data handling on the socket', function () { 28 | assert(fakeSocket.on.called); 29 | assert.equal(fakeSocket.on.firstCall.args[0], 'data'); 30 | }); 31 | }); 32 | 33 | describe('.send()', function () { 34 | it('sends a command over the wire', function () { 35 | const commandObject = { 36 | command: 'test', 37 | hello: 'world' 38 | }; 39 | 40 | client.send(commandObject); 41 | 42 | assert.equal(fakeSocket.write.firstCall.args[0], JSON.stringify(commandObject) + TERM_STR); 43 | }); 44 | 45 | it('includes the command name if one is provided', function () { 46 | const commandObject = { 47 | hello: 'world' 48 | }; 49 | 50 | client.send('test', commandObject); 51 | commandObject.command = 'test'; 52 | 53 | assert.equal(fakeSocket.write.firstCall.args[0], JSON.stringify(commandObject) + TERM_STR); 54 | }); 55 | }); 56 | 57 | describe('.set() and .get()', function () { 58 | const types = [1, true, 'testing', { hello: 'world' }]; 59 | 60 | types.forEach(function (value) { 61 | it('can save and retrieve a(n) ' + typeof value, function () { 62 | client.set('testValue', value); 63 | assert.deepEqual(client.get('testValue'), value); 64 | }); 65 | }); 66 | }); 67 | 68 | describe('.on', function () { 69 | it('sets an event listener on the socket', function () { 70 | client.on('test', sinon.stub()); 71 | 72 | assert(fakeSocket.on.called, 'The event listener was not attached to the socket'); 73 | }); 74 | }); 75 | 76 | describe('.onData', function () { 77 | it('adds a data handler to the object', function () { 78 | client.onData(sinon.stub()); 79 | assert.isAbove(client.dataHandlers.length, 0); 80 | }); 81 | 82 | it('runs the registered functions when data arrives', function () { 83 | const stringCommand = JSON.stringify({ command: 'test' }); 84 | 85 | fakeSocket = { 86 | dataHandler: sinon.stub(), 87 | fireData: function () { 88 | this.dataHandler(Buffer.from(stringCommand, 'ascii')); 89 | }, 90 | on: function (eventName, handler) { 91 | this.dataHandler = handler; 92 | }, 93 | write: sinon.stub(), 94 | setKeepAlive: sinon.stub(), 95 | destroy: sinon.stub(), 96 | }; 97 | 98 | client = new Client(fakeSocket); 99 | 100 | const handlers = _.times(sinon.stub, 10); 101 | handlers.forEach(function (handler) { 102 | client.onData(handler); 103 | }); 104 | 105 | fakeSocket.fireData(); 106 | 107 | handlers.forEach(function (handler) { 108 | assert(handler.called); 109 | assert(handler.calledWith(JSON.stringify({ command: 'test' }))); 110 | }); 111 | }); 112 | }); 113 | 114 | describe('.setTickMode', function () { 115 | it('sets the tick mode on or off', function () { 116 | client.setTickMode(true); 117 | assert.isTrue(client.tickMode); 118 | 119 | client.setTickMode(false); 120 | assert.isFalse(client.tickMode); 121 | }); 122 | }); 123 | 124 | describe('.tick', function () { 125 | it('throws an error when not in tick mode', function () { 126 | let error; 127 | try { 128 | client.tick(); 129 | } catch (e) { 130 | error = e; 131 | } 132 | 133 | assert.instanceOf(error, Error); 134 | }); 135 | 136 | it('does not send anything when nothing has been queued', function () { 137 | client.setTickMode(true); 138 | client.tick(); 139 | 140 | assert.isFalse(fakeSocket.write.called); 141 | }); 142 | 143 | it('sends all queued commands', function () { 144 | client.setTickMode(true); 145 | client.send({ command: 'test' }); 146 | client.send({ command: 'test2' }); 147 | 148 | client.tick(); 149 | 150 | assert.isTrue(fakeSocket.write.calledTwice); 151 | }); 152 | }); 153 | }); 154 | -------------------------------------------------------------------------------- /lib/client.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'); 4 | const TERMINATING_CHARACTER = '\0'; 5 | const DEBUG_MODE = process.env.GM_SERVER_DEBUG === 'true'; 6 | const TERM_STR = '\n\t\n'; 7 | 8 | class Client { 9 | constructor (socket) { 10 | this.socket = socket; 11 | this.dataHandlers = []; 12 | this.commandHandlers = {}; 13 | this.clientId = _.uniqueId(); 14 | this.created = Date.now(); 15 | this.data = {}; 16 | this.tickMode = false; 17 | this.tickModeQueue = []; 18 | this.disconnectionCallbacks = []; 19 | 20 | socket.on('data', data => { 21 | const dataAsObject = Client.getObjectFromRaw(data); 22 | 23 | if (DEBUG_MODE) { 24 | console.info(this.clientId, ' received: ', JSON.stringify(dataAsObject)); 25 | } 26 | 27 | if (dataAsObject.command == '__net__fin') { 28 | this.disconnectionCallbacks.forEach(fn => fn()); 29 | this.send('__net__fin__ack', {}); 30 | this.disconnect(); 31 | } 32 | 33 | // Low level handlers 34 | this.dataHandlers.forEach(handler => { 35 | handler(dataAsObject); 36 | }); 37 | 38 | // Client level command handlers 39 | if (this.commandHandlers[dataAsObject.command]) { 40 | this.commandHandlers[dataAsObject.command].forEach(handler => { 41 | handler(dataAsObject); 42 | }); 43 | } 44 | }); 45 | 46 | socket.on('error', error => { 47 | if (DEBUG_MODE) { 48 | console.error(error); 49 | } 50 | }); 51 | 52 | socket.on('end', () => { 53 | this.disconnect(); 54 | }); 55 | 56 | socket.setKeepAlive(true); 57 | } 58 | 59 | /** 60 | * Disconnects the client 61 | */ 62 | disconnect () { 63 | this.socket.destroy(); 64 | } 65 | 66 | /** 67 | * Sends a command. Command string is optional 68 | * @param {string} command 69 | * @param {object} data 70 | */ 71 | send (command, data) { 72 | if (typeof data === 'undefined') { 73 | data = command; 74 | } else { 75 | data.command = command; 76 | } 77 | 78 | if (this.tickMode) { 79 | // If this is a batch send, put all of the commands into the queue. 80 | if (data.batch) { 81 | data.commands.forEach(command => { 82 | this.tickModeQueue.push(command); 83 | }); 84 | } else { 85 | this.tickModeQueue.push(data); 86 | } 87 | } else { 88 | if (DEBUG_MODE) { 89 | console.info(this.clientId, ' is sending: ', data); 90 | } 91 | this.directSend(data); 92 | } 93 | } 94 | 95 | /** 96 | * Directly writes to the wire 97 | * @param {object} command 98 | */ 99 | directSend (command) { 100 | this.socket.write(JSON.stringify(command) + TERM_STR); 101 | } 102 | 103 | /** 104 | * Sets an arbitrary value on this object 105 | * @param {string} key 106 | * @param {mixed} value 107 | */ 108 | set (key, value) { 109 | this.data[key] = value; 110 | } 111 | 112 | /** 113 | * Returns stored data on this object 114 | * @param {string} key 115 | * @return {mixed} 116 | */ 117 | get (key) { 118 | return this.data[key]; 119 | } 120 | 121 | /** 122 | * Registers an event handler on the underlying socket of this client 123 | * @param {string} eventName 124 | * @param {function} handler 125 | */ 126 | on (eventName, handler) { 127 | this.socket.on(eventName, handler); 128 | } 129 | 130 | /** 131 | * Registers an event handler for when the socket connection is gracefully closing 132 | * @param {function} handler 133 | */ 134 | onDisconnect (handler) { 135 | this.disconnectionCallbacks.push(handler); 136 | } 137 | 138 | /** 139 | * Registers a handler for when this socket receives data 140 | * @param {function} handler 141 | */ 142 | onData (handler) { 143 | this.dataHandlers.push(handler); 144 | } 145 | 146 | /** 147 | * Registers a callback function for when a given command is sent in by this client 148 | * @param {string} command The command to listen for 149 | * @param {function} handler A callback to run when the command is received 150 | */ 151 | addCommandListener (command, handler) { 152 | // If there is a command listener for this command already, push. 153 | if (this.commandHandlers[command]) { 154 | this.commandHandlers[command].push(handler); 155 | } else { 156 | this.commandHandlers[command] = [handler]; 157 | } 158 | } 159 | 160 | /** 161 | * Removes a registered callback function for when a given command is sent in by this client 162 | * @param {string} command The command to listen for 163 | * @param {function} handler The function to remove 164 | */ 165 | removeCommandListener (command, handler) { 166 | if (this.commandHandlers[command]) { 167 | this.commandHandlers[command] = this.commandHandlers[command].filter(fn => fn !== handler); 168 | } 169 | } 170 | 171 | /** 172 | * Sets tick mode on or off. 173 | * @param {boolean} onOff 174 | */ 175 | setTickMode (onOff) { 176 | this.tickMode = onOff; 177 | } 178 | 179 | /** 180 | * Sends all stored commands when in tick mode 181 | */ 182 | tick () { 183 | if (!this.tickMode) { 184 | throw new Error('Cannot tick when not in tick mode'); 185 | } 186 | 187 | if (this.tickModeQueue.length !== 0) { 188 | this.tickModeQueue.forEach(command => { 189 | this.directSend(command); 190 | }); 191 | } 192 | 193 | this.tickModeQueue = []; 194 | } 195 | 196 | /** 197 | * Gets a javascript object from an input buffer containing json 198 | * @param {Buffer} data 199 | * @return {object} 200 | */ 201 | static getObjectFromRaw (data) { 202 | const rawSocketDataString = data.toString('ascii'); 203 | const terminatingIndex = rawSocketDataString.indexOf(TERMINATING_CHARACTER); 204 | let trimmedData; 205 | if (terminatingIndex > -1) { 206 | trimmedData = rawSocketDataString.substr(0, terminatingIndex); 207 | } else { 208 | trimmedData = rawSocketDataString; 209 | } 210 | if (trimmedData === null || trimmedData.trim() === '') { 211 | trimmedData = '{"command": "missingSocketDataString"}'; 212 | } 213 | const objectFromData = JSON.parse(trimmedData); 214 | return objectFromData; 215 | } 216 | } 217 | 218 | module.exports = Client; 219 | -------------------------------------------------------------------------------- /lib/clientManager.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'); 4 | const ONE_SECOND = 1000; 5 | const DEFAULT_TICKS_PER_SECOND = 30; 6 | const DEBUG_MODE = process.env.GM_SERVER_DEBUG === 'true'; 7 | 8 | class ClientManager { 9 | constructor () { 10 | this.clients = []; 11 | this.commandHandlers = {}; 12 | this.eventHandlers = {}; 13 | this.persistedData = {}; 14 | this.tickMode = false; 15 | this.tickRate = ONE_SECOND / DEFAULT_TICKS_PER_SECOND; 16 | this.tickInterval = undefined; 17 | } 18 | 19 | /** 20 | * Returns the list of clients in this ClientManager 21 | * @return {array} A list of Client instances 22 | */ 23 | getClients () { 24 | return this.clients; 25 | } 26 | 27 | /** 28 | * Returns the number of clients in this ClientManager 29 | * @return {number} The number of clients in this ClientManager 30 | */ 31 | getClientCount () { 32 | return this.clients.length; 33 | } 34 | 35 | /** 36 | * Sets an arbitrary value on this instance 37 | * @param {string} key The key of the value to set 38 | * @param {mixed} value The value to save 39 | */ 40 | set (key, value) { 41 | this.persistedData[key] = value; 42 | } 43 | 44 | /** 45 | * Gets a value set from .set 46 | * @param {string} key The key to retrieve 47 | * @return {mixed} The data with at the given key 48 | */ 49 | get (key) { 50 | return this.persistedData[key]; 51 | } 52 | 53 | /** 54 | * Adds a client to this ClientManager 55 | * @param {Client} client The client to add to the manager 56 | */ 57 | addClient (client) { 58 | client.setTickMode(this.tickMode); 59 | 60 | client.onData(data => { 61 | this.handleIncomingCommand(client, data); 62 | }); 63 | 64 | client.on('close', () => { 65 | this.fire('clientDropped', {client, reason: 'close'}); 66 | this.removeClient(client.clientId); 67 | }); 68 | 69 | client.on('error', () => { 70 | this.fire('clientDropped', {client, reason: 'error'}); 71 | this.removeClient(client.clientId); 72 | }); 73 | 74 | const dc = (reason) => { 75 | this.disconnectClient(client, reason); 76 | }; 77 | 78 | client.on('disconnect', () => dc('disconnect')); 79 | client.on('connection_error', () => dc('connection_error')); 80 | client.on('connection_timeout', () => dc('connection_timeout')); 81 | client.on('end', () => dc('end')); 82 | 83 | this.clients.push(client); 84 | this.fire('clientAdded', client); 85 | } 86 | 87 | /** 88 | * Removes a client from this ClientManager based on the given client ID 89 | * @param {number} clientId The ID of the client to remove (as found on Client.clientId) 90 | * @return {Client} The client that was removed 91 | */ 92 | removeClient (clientId) { 93 | const removed = _.remove(this.clients, function (client) { 94 | return client.clientId === clientId; 95 | }); 96 | 97 | if (removed.length > 0) { 98 | return removed[0]; 99 | } else { 100 | return undefined; 101 | } 102 | } 103 | 104 | /** 105 | * Disconnects a client and removes it from the client manager 106 | * @param {Client} The client object to disconnect 107 | * @param {String} The reason for disconnecting 108 | */ 109 | disconnectClient (client, reason = '') { 110 | this.fire('clientDropped', { client, reason }); 111 | this.removeClient(client.clientId); 112 | client.disconnect(); 113 | } 114 | 115 | /** 116 | * Sends a command to every Client in this ClientManager 117 | * @param {[optional] string} command The name of the command 118 | * @param {object} data The command data object 119 | */ 120 | broadcast (command, data) { 121 | if (typeof data === 'undefined') { 122 | data = command; 123 | } else { 124 | data.command = command; 125 | } 126 | 127 | this.clients.forEach(client => { 128 | client.send(data); 129 | }); 130 | } 131 | 132 | /** 133 | * Routes an incoming command to the proper handler. 134 | * Meant for internal use. 135 | * @param {Client} client The client that sent the command 136 | * @param {object} data The command data object 137 | */ 138 | handleIncomingCommand (client, data) { 139 | this.fire('commandReceived', { client: client, data: data }); 140 | 141 | if (this.commandHandlers[data.command]) { 142 | this.commandHandlers[data.command].forEach(handler => { 143 | handler(client, data); 144 | }); 145 | } else if (DEBUG_MODE) { 146 | console.warn('No handler defiend for: ', data.command); 147 | } 148 | } 149 | 150 | /** 151 | * Registers a callback function for when a given command is sent in by a client 152 | * @param {string} command The command to listen for 153 | * @param {function} handler A callback to run when the command is received 154 | */ 155 | addCommandListener (command, handler) { 156 | // If there is a command listener for this command already, push. 157 | if (this.commandHandlers[command]) { 158 | this.commandHandlers[command].push(handler); 159 | } else { 160 | this.commandHandlers[command] = [handler]; 161 | } 162 | } 163 | 164 | /** 165 | * Removes a registered callback function for when a given command is sent in by a client 166 | * @param {string} command The command to listen for 167 | * @param {function} handler The function to remove 168 | */ 169 | removeCommandListener (command, handler) { 170 | if (this.commandHandlers[command]) { 171 | this.commandHandlers[command] = this.commandHandlers[command].filter(fn => fn !== handler); 172 | } 173 | } 174 | 175 | /** 176 | * Registers an event handler for ClientManager events 177 | * @param {string} eventName The event to listen for 178 | * @param {function} handler The handling function for this event 179 | */ 180 | on (eventName, handler) { 181 | // If there is a event listener for this event already, push. 182 | if (this.eventHandlers[eventName]) { 183 | this.eventHandlers.push(handler); 184 | } else { 185 | this.eventHandlers[eventName] = [handler]; 186 | } 187 | } 188 | 189 | /** 190 | * Fires an event on this ClientManager 191 | * @param {string} eventName The name of the event 192 | * @param {object} data The data associated with this event 193 | */ 194 | fire (eventName, data) { 195 | if (this.eventHandlers[eventName]) { 196 | this.eventHandlers[eventName].forEach(function (handler) { 197 | if (typeof data !== 'undefined') { 198 | handler(data); 199 | } else { 200 | handler(); 201 | } 202 | }); 203 | } 204 | } 205 | 206 | /** 207 | * Enables or disables tick mode 208 | * When you disable tick mode through this function, ticking will stop if previously started 209 | * @param {boolean} onOff true to enable, false to disable 210 | */ 211 | setTickMode (onOff) { 212 | this.tickMode = onOff; 213 | 214 | this.clients.forEach(function (client) { 215 | client.setTickMode(onOff); 216 | }); 217 | 218 | if (onOff === false) { 219 | this.stopTicking(); 220 | } 221 | } 222 | 223 | /** 224 | * Set the rate that ticks happen in ms. Default is to tick 60 times per second. 225 | * Cannot be set if already ticking. 226 | * @param {number} newTickRate The time in ms between ticks 227 | */ 228 | setTickRate (newTickRate) { 229 | if (this.tickInterval) { 230 | throw new Error('Cannot change tick rate while already ticking. Call stopTicking() first.'); 231 | } 232 | 233 | this.tickRate = newTickRate; 234 | } 235 | 236 | /** 237 | * Begins ticking when in tick mode. 238 | */ 239 | startTicking () { 240 | if (!this.tickMode) { 241 | throw new Error('Cannot begin ticking when not in tick mode. use setTickMode(true) first.'); 242 | } else if (this.tickInterval) { 243 | throw new Error('Cannot start ticking when already ticking. Call stopTicking() first.'); 244 | } 245 | 246 | this.tickInterval = setInterval(this.tick.bind(this), this.tickRate); 247 | } 248 | 249 | /** 250 | * Stops ticking when in tick mode. 251 | */ 252 | stopTicking () { 253 | clearInterval(this.tickInterval); 254 | this.tickInterval = undefined; 255 | } 256 | 257 | /** 258 | * Calls tick() on every Client in this ClientManager, sending out all stored commands. 259 | */ 260 | tick () { 261 | this.fire('tick'); 262 | this.clients.forEach(function (client) { 263 | client.tick(); 264 | }); 265 | } 266 | } 267 | 268 | module.exports = ClientManager; 269 | -------------------------------------------------------------------------------- /test/clientManagerTest.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | 'use strict'; 3 | 4 | const assert = require('chai').assert; 5 | const sinon = require('sinon'); 6 | const _ = require('lodash'); 7 | const ClientManager = require('../lib/clientManager.js'); 8 | let clientManager; 9 | 10 | function getFakeClient () { 11 | return { 12 | on: function () {}, 13 | onData: function () {}, 14 | send: sinon.stub(), 15 | clientId: _.uniqueId(), 16 | setTickMode: sinon.stub(), 17 | tick: sinon.stub() 18 | }; 19 | } 20 | 21 | describe('Client Manager', function () { 22 | beforeEach(function () { 23 | clientManager = new ClientManager(); 24 | }); 25 | 26 | describe('.addClient()', function () { 27 | it('adds a client', function () { 28 | clientManager.addClient(getFakeClient()); 29 | assert.equal(clientManager.getClientCount(), 1); 30 | }); 31 | }); 32 | 33 | describe('.removeClient()', function () { 34 | it('removes a client by an id', function () { 35 | const fakeSocket = getFakeClient(); 36 | clientManager.addClient(fakeSocket); 37 | clientManager.removeClient(fakeSocket.clientId); 38 | assert.equal(clientManager.getClientCount(), 0); 39 | }); 40 | 41 | it('returns undefined when no matching client is found', function () { 42 | const returnedClient = clientManager.removeClient(-1); 43 | assert.isUndefined(returnedClient); 44 | }); 45 | 46 | it('returns the client object it removes', function () { 47 | let fakeClients = _.times(200, getFakeClient); 48 | 49 | fakeClients.forEach(function (client) { 50 | clientManager.addClient(client); 51 | }); 52 | 53 | fakeClients = _.shuffle(fakeClients); 54 | 55 | fakeClients.forEach(function (client) { 56 | const returnedClient = clientManager.removeClient(client.clientId); 57 | assert.equal(returnedClient.clientId, client.clientId); 58 | }); 59 | }); 60 | }); 61 | 62 | describe('.getClients()', function () { 63 | it('returns its client array', function () { 64 | const fakeClients = _.times(10, getFakeClient); 65 | fakeClients.forEach(clientManager.addClient.bind(clientManager)); 66 | assert.deepEqual(fakeClients, clientManager.getClients()); 67 | }); 68 | }); 69 | 70 | describe('.getClientCount()', function () { 71 | it('returns the number of clients in the ClientManager', function () { 72 | const clientCount = 100; 73 | _.times(clientCount, getFakeClient).forEach(clientManager.addClient.bind(clientManager)); 74 | assert.equal(clientManager.getClientCount(), clientCount); 75 | }); 76 | }); 77 | 78 | describe('.set() and .get()', function () { 79 | const types = [1, true, 'testing', { hello: 'world' }]; 80 | 81 | types.forEach(function (value) { 82 | it('can save and retrieve a(n) ' + typeof value, function () { 83 | clientManager.set('testValue', value); 84 | assert.deepEqual(clientManager.get('testValue'), value); 85 | }); 86 | }); 87 | }); 88 | 89 | describe('.addCommandListener and .handleIncomingCommand()', function () { 90 | it('routes commands to their handlers', function () { 91 | const handlerStub = sinon.stub(); 92 | clientManager.addCommandListener('test', handlerStub); 93 | clientManager.handleIncomingCommand(getFakeClient(), { command: 'test' }); 94 | clientManager.handleIncomingCommand(getFakeClient(), { command: 'doNotRun' }); 95 | assert(handlerStub.called, 'The registered command handler was never called'); 96 | assert(handlerStub.calledOnce, 'The registered command handler was called too many times'); 97 | assert.equal(handlerStub.firstCall.args[1].command, 'test', 98 | 'The registered command handler was passed the wrong command'); 99 | }); 100 | }); 101 | 102 | describe('.removeCommandListener()', function () { 103 | it('removes registered commands', function () { 104 | const handlerStub = sinon.stub(); 105 | clientManager.addCommandListener('test', handlerStub); 106 | clientManager.removeCommandListener('test', handlerStub); 107 | clientManager.handleIncomingCommand(getFakeClient(), { command: 'test' }); 108 | clientManager.handleIncomingCommand(getFakeClient(), { command: 'doNotRun' }); 109 | assert(!handlerStub.called, 'The registered command handler was called'); 110 | }); 111 | }); 112 | 113 | describe('.broadcast()', function () { 114 | it('can broadcast to all clients', function () { 115 | const fakeSocket = getFakeClient(); 116 | const fakeSocket2 = getFakeClient(); 117 | 118 | clientManager.addClient(fakeSocket); 119 | clientManager.addClient(fakeSocket2); 120 | 121 | clientManager.broadcast({ testing: true }); 122 | 123 | assert(fakeSocket.send.called); 124 | assert(fakeSocket2.send.called); 125 | }); 126 | 127 | it('allows for separate specification of the command', function () { 128 | const fakeSocket = getFakeClient(); 129 | const fakeSocket2 = getFakeClient(); 130 | 131 | clientManager.addClient(fakeSocket); 132 | clientManager.addClient(fakeSocket2); 133 | 134 | clientManager.broadcast('somecommand', { testing: true }); 135 | 136 | assert(fakeSocket.send.called); 137 | assert(fakeSocket2.send.called); 138 | assert.equal(fakeSocket.send.firstCall.args[0].command, 'somecommand'); 139 | }); 140 | }); 141 | 142 | describe('.on() and .fire()', function () { 143 | it('allows the author to define handlers for events', function () { 144 | const stub = sinon.stub(); 145 | clientManager.on('testEvent', stub); 146 | clientManager.fire('testEvent'); 147 | assert(stub.called); 148 | }); 149 | }); 150 | 151 | describe('.setTickMode', function () { 152 | it('sets the tick mode for the ClientManager and all of its Clients', function () { 153 | const clients = _.times(getFakeClient, 10); 154 | 155 | clients.forEach(function (client) { 156 | clientManager.addClient(client); 157 | }); 158 | 159 | clientManager.setTickMode(true); 160 | 161 | assert.isTrue(clientManager.tickMode); 162 | 163 | let managedClients = clientManager.getClients(); 164 | managedClients.forEach(function (client) { 165 | assert.isTrue(client.tickMode); 166 | }); 167 | 168 | clientManager.setTickMode(false); 169 | 170 | assert.isFalse(clientManager.tickMode); 171 | 172 | managedClients = clientManager.getClients(); 173 | managedClients.forEach(function (client) { 174 | assert.isFalse(client.tickMode); 175 | }); 176 | }); 177 | 178 | it('stops ticking when already ticking and then set to off', function () { 179 | clientManager.setTickMode(true); 180 | clientManager.startTicking(); 181 | clientManager.setTickMode(false); 182 | assert.isUndefined(clientManager.tickInterval); 183 | }); 184 | }); 185 | 186 | describe('.setTickRate', function () { 187 | it('sets the tick mode', function () { 188 | clientManager.setTickRate(200); 189 | assert.equal(clientManager.tickRate, 200); 190 | }); 191 | 192 | it('throws an error when already ticking', function () { 193 | clientManager.setTickMode(true); 194 | clientManager.startTicking(); 195 | 196 | // Assert.throws does not seem to be working here. 197 | let error; 198 | try { 199 | clientManager.setTickRate(); 200 | } catch (e) { 201 | error = e; 202 | } 203 | 204 | assert.instanceOf(error, Error); 205 | }); 206 | }); 207 | 208 | describe('.startTicking', function () { 209 | it('starts ticking', function () { 210 | clientManager.setTickMode(true); 211 | clientManager.startTicking(); 212 | assert.isDefined(clientManager.tickInterval); 213 | }); 214 | 215 | it('throws an error if not in tick mode', function () { 216 | let error; 217 | try { 218 | clientManager.startTicking(); 219 | } catch (e) { 220 | error = e; 221 | } 222 | 223 | assert.instanceOf(error, Error); 224 | }); 225 | 226 | it('throws an error if already ticking', function () { 227 | clientManager.setTickMode(true); 228 | clientManager.startTicking(); 229 | let error; 230 | try { 231 | clientManager.startTicking(); 232 | } catch (e) { 233 | error = e; 234 | } 235 | 236 | assert.instanceOf(error, Error); 237 | }); 238 | }); 239 | 240 | describe('.tick', function () { 241 | it('calls .tick on every client it has', function () { 242 | const clients = _.times(getFakeClient, 10); 243 | 244 | clients.forEach(function (client) { 245 | clientManager.addClient(client); 246 | }); 247 | 248 | clientManager.tick(); 249 | 250 | const managedClients = clientManager.getClients(); 251 | managedClients.forEach(function (client) { 252 | assert.isTrue(client.tick.called); 253 | }); 254 | }); 255 | }); 256 | 257 | describe('event: clientDropped', function () { 258 | it('removes the dropped client', function () { 259 | const fakeClient = { 260 | on: function (event, handler) { 261 | if (event === 'close') { 262 | this.onCloseHandler = handler; 263 | } 264 | }, 265 | onData: sinon.stub(), 266 | send: sinon.stub(), 267 | clientId: 0, 268 | setTickMode: sinon.stub() 269 | }; 270 | 271 | clientManager.addClient(fakeClient); 272 | assert.isFunction(fakeClient.onCloseHandler, 'a close handler was never set'); 273 | fakeClient.onCloseHandler(); 274 | const clientCount = clientManager.getClientCount(); 275 | assert.equal(clientCount, 0); 276 | }); 277 | }); 278 | }); 279 | --------------------------------------------------------------------------------