├── .gitignore ├── panic-logo.jpg ├── .travis.yml ├── src ├── clients.js ├── index.js ├── matcher.js ├── server.js ├── Client.js └── ClientList.js ├── .editorconfig ├── .eslintrc.js ├── test ├── mock.js ├── Client.js └── index.js ├── package.json ├── CHANGELOG.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .DS_Store 3 | *.min.js* 4 | notes.js 5 | -------------------------------------------------------------------------------- /panic-logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gundb/panic-server/HEAD/panic-logo.jpg -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - node 4 | 5 | script: npm run all-tests 6 | -------------------------------------------------------------------------------- /src/clients.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var List = require('./ClientList'); 3 | module.exports = new List(); 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | # global file settings 4 | [*] 5 | end_of_line = lf 6 | insert_final_newline = true 7 | charset = utf-8 8 | indent_style = space 9 | indent_size = 2 10 | trim_trailing_whitespace = true 11 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var eslint = exports; 3 | 4 | eslint.env = { 5 | node: true, 6 | commonjs: true, 7 | }; 8 | 9 | eslint.extends = [ 10 | 'eslint:recommended', 11 | 'llama', 12 | ]; 13 | 14 | eslint.rules = { 15 | 'no-var': 'off', 16 | 'prefer-template': 'off', 17 | }; 18 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var ClientList = require('./ClientList'); 4 | var Client = require('./Client'); 5 | var server = require('./server'); 6 | var clients = require('./clients'); 7 | 8 | exports.server = server; 9 | exports.clients = clients; 10 | exports.Client = Client; 11 | exports.ClientList = ClientList; 12 | -------------------------------------------------------------------------------- /test/mock.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var Emitter = require('events'); 3 | var Client = require('../src/Client'); 4 | 5 | /** 6 | * Creates a client wrapping a new fake websocket. 7 | * @param {Object} [platform] - A platform.js-style object. 8 | * @return {Client} - Draws from a fake socket instance. 9 | */ 10 | function mock (platform) { 11 | var rand = Math.random(); 12 | 13 | // Fake a socket.io socket. 14 | var socket = new Emitter(); 15 | socket.connected = true; 16 | socket.id = rand.toString(36).slice(2); 17 | 18 | return new Client({ 19 | socket: socket, 20 | platform: platform || {}, 21 | }); 22 | } 23 | 24 | module.exports = { 25 | Client: mock, 26 | }; 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "panic-server", 3 | "version": "1.1.1", 4 | "description": "Distributed Javascript runner", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "lint": "eslint src/**", 8 | "test": "mocha", 9 | "all-tests": "npm run test && npm run lint" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/gundb/panic-server.git" 14 | }, 15 | "keywords": [ 16 | "gun", 17 | "gundb", 18 | "test", 19 | "eval", 20 | "testing", 21 | "distributed", 22 | "cross-platform" 23 | ], 24 | "author": "Jesse Gibson (http://techllama.com)", 25 | "license": "(Zlib OR MIT OR Apache-2.0)", 26 | "bugs": { 27 | "url": "https://github.com/gundb/panic-server/issues" 28 | }, 29 | "homepage": "https://github.com/gundb/panic-server#readme", 30 | "dependencies": { 31 | "panic-client": "^1.0.0", 32 | "socket.io": "^1.4.5" 33 | }, 34 | "devDependencies": { 35 | "chai": "^3.5.0", 36 | "eslint": "^3.0.1", 37 | "eslint-config-llama": "^3.0.0", 38 | "expect": "^1.20.2", 39 | "mocha": "^3.0.1" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/matcher.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var match; 4 | 5 | /** 6 | * Whether two values look the same. 7 | * @param {Mixed} expected - An expression to match the value against. 8 | * Can be a regular expression, an object containing more comparisons, 9 | * or just a primitive. 10 | * @param {Mixed} value - A value to compare against the expression. 11 | * @return {Boolean} - Whether the values look similar. 12 | */ 13 | function matches (expected, value) { 14 | 15 | /** Matchers can be regular expressions. */ 16 | if (expected instanceof RegExp) { 17 | return expected.test(value); 18 | } 19 | 20 | /** Matchers can be nested objects. */ 21 | if (expected instanceof Object) { 22 | return match(expected, value || {}); 23 | } 24 | 25 | /** Or, just a primitive value. */ 26 | return value === expected; 27 | } 28 | 29 | /** 30 | * Runs a platform query against a platform. 31 | * @param {Object} query - Contains properties to match against the 32 | * platform. If it contains nested objects, they will match against the 33 | * platform object of the same name, regardless of depth. If a property 34 | * is a regular expression, it will run against the platform property of 35 | * the same name. 36 | * @param {Object} platform - A platform.js object. 37 | * @return {Boolean} - Whether the platform matches the query. 38 | */ 39 | match = function (query, platform) { 40 | var fields = Object.keys(query); 41 | 42 | var invalid = fields.some(function isInvalid (field) { 43 | var expected = query[field]; 44 | var value = platform[field]; 45 | 46 | /** Look for values that don't match the query. */ 47 | return matches(expected, value) === false; 48 | }); 49 | 50 | return !invalid; 51 | }; 52 | 53 | module.exports = match; 54 | -------------------------------------------------------------------------------- /src/server.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-sync*/ 2 | 'use strict'; 3 | 4 | var io = require('socket.io'); 5 | var fs = require('fs'); 6 | var clients = require('./clients'); 7 | var file = require.resolve('panic-client/panic.js'); 8 | var Server = require('http').Server; 9 | var panic = require('./index'); 10 | var Client = require('./Client'); 11 | var client; 12 | 13 | /** 14 | * Lazy getter for the panic-client bundle. 15 | * @returns {String} - The whole webpacked client bundle. 16 | */ 17 | Object.defineProperty(panic, 'client', { 18 | get: function () { 19 | if (!client) { 20 | client = fs.readFileSync(file, 'utf8'); 21 | } 22 | 23 | return client; 24 | }, 25 | }); 26 | 27 | /** 28 | * Filter requests for /panic.js and send 29 | * the bundle.js file. 30 | * @param {Object} req - http request object. 31 | * @param {Object} res - http response object. 32 | * @return {undefined} 33 | */ 34 | function serve (req, res) { 35 | if (req.url === '/panic.js') { 36 | res.end(panic.client); 37 | } 38 | } 39 | 40 | /** 41 | * Listen for a panic handshake, 42 | * only then adding it to the panic.clients list. 43 | * @param {Socket} socket - A socket.io websocket. 44 | * @return {undefined} 45 | */ 46 | function upgrade (socket) { 47 | socket.on('handshake', function (platform) { 48 | 49 | /** Create a new panic client. */ 50 | var client = new Client({ 51 | socket: socket, 52 | platform: platform, 53 | }); 54 | 55 | /** Add the new client. */ 56 | clients.add(client); 57 | 58 | }); 59 | } 60 | 61 | /** 62 | * Attach to a server, handling incoming panic traffic. 63 | * @param {Server} [server] 64 | * An http server instance. 65 | * If none is provided, a server will be created. 66 | * @return {Server} - Either the server passed, or a new server. 67 | */ 68 | function open (server) { 69 | 70 | if (!server) { // https is not instance of http, so be more relaxed in our check. 71 | server = new Server(); 72 | } 73 | 74 | /** Handle /panic.js route. */ 75 | server.on('request', serve); 76 | 77 | /** Upgrade with socket.io */ 78 | io(server).on('connection', upgrade); 79 | 80 | return server; 81 | } 82 | 83 | module.exports = open; 84 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v1.1.0 4 | ### Added 5 | - New `Client#matches` method accepts platform queries like `ClientList#filter`, returning a boolean of whether it matches. 6 | 7 | ## v1.0.0 8 | ### Changed 9 | - Several breaking changes in `panic-client` v1.0, reference [their changelog](https://github.com/gundb/panic-client/blob/master/CHANGELOG.md#v100) for differences. 10 | 11 | ### Removed 12 | - `.len()` has been permanently removed (was previously under deprecation notice). 13 | 14 | ### Added 15 | - Each client object held within a `ClientList` is capable of dispatching it's own jobs, independently of a list. 16 | 17 | ## v0.4.1 18 | ### Added 19 | - Method `.atLeast`, useful for pausing until a minimum number of clients join. 20 | 21 | ## v0.4.0 22 | ### Added 23 | - Upgraded to panic-client `v0.3` (brings `.get` and `.set` client methods). 24 | 25 | ## v0.3.0 26 | ### Changed 27 | - position of the `done` changed to the first parameter. `this` context is no longer passed. 28 | - `export vars` is no longer enabled by default, but opt-in using the `{ '@scope': true }` property. 29 | 30 | ### Removed 31 | - `.len()` has been deprecated in favor of `.length`. 32 | 33 | ### Added 34 | - Lazily loads the panic-client bundle through `panic.client` property. 35 | - Subclassing support by chains instantiating `this.constructor`. 36 | - Added `.chain` method which ensures the proper class is called. 37 | 38 | ## v0.2.4 39 | ### Fixed 40 | - Set the `constructor` property on the ClientList prototype. 41 | 42 | ## v0.2.3 43 | ### Fixed 44 | - Removed the `Function.prototype.toJSON` extension. 45 | 46 | ## v0.2.2 47 | ### Fixed 48 | - `.excluding()` did not listen for remove events on exclusion lists. If a client was removed yet still connected, it wouldn't make it into the exclusion set. 49 | - The "add" event would fire each time you add the same client, even though it was already contained. 50 | 51 | ### Added 52 | - New `clients.pluck(Number)` method will create a new list constrained to a maximum number of clients. 53 | 54 | ## v0.2.1 55 | ### Added 56 | - The `ClientList` constructor to the `panic` object. 57 | - The `ClientList` constructor now accepts an array of smaller lists to pull from. 58 | 59 | ## v0.2.0 60 | ### Changed 61 | - `panic.serve` has been renamed to `panic.server`. 62 | - `panic.server` accepts an `http.Server` instance instead of an options object. 63 | - The server no longer automatically listens on a port. 64 | - `panic.server` returns the server, not the options object. 65 | - `panic.js` is no longer served on the root route, only from `/panic.js`. 66 | 67 | ## v0.1.0 68 | First minor release. 69 | -------------------------------------------------------------------------------- /src/Client.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var matches = require('./matcher'); 4 | 5 | /** 6 | * A wrapper around a websocket, providing 7 | * methods for interacting with clients. 8 | * @throws {Error} - The socket and platform must be valid. 9 | * @param {Object} client - A panic-client handshake. 10 | * @param {Socket} client.socket - The socket.io connection. 11 | * @param {Object} client.platform - The client platform.js object. 12 | * @class Client 13 | */ 14 | function Client (client) { 15 | 16 | /** Basic input validation. */ 17 | if (!client.socket) { 18 | throw new Error('Invalid "client.socket" property.'); 19 | } 20 | if (!client.platform) { 21 | throw new Error('Invalid "client.platform" property.'); 22 | } 23 | 24 | this.socket = client.socket; 25 | this.platform = client.platform; 26 | } 27 | 28 | Client.prototype = { 29 | constructor: Client, 30 | 31 | /** 32 | * Sends a function to be run on the client. 33 | * @param {Function} job - A function to be run remotely. 34 | * The function will be stringified, so it cannot depend on 35 | * external "local" variables, including other functions. 36 | * @param {Object} [props] - Any variables used in the job. 37 | * @return {Promise} - Resolves if the job finishes, 38 | * rejects if it throws an error. 39 | */ 40 | run: function (job, props) { 41 | if (typeof job !== 'function') { 42 | throw new TypeError( 43 | 'Expected job "' + job + '" to be a function.' 44 | ); 45 | } 46 | 47 | var source = String(job); 48 | var jobID = Math.random() 49 | .toString(36) 50 | .slice(2); 51 | 52 | var socket = this.socket; 53 | 54 | /** Report the success or failure of the job. */ 55 | var promise = new Promise(function (resolve, reject) { 56 | socket.once('disconnect', resolve); 57 | 58 | socket.once(jobID, function (report) { 59 | socket.removeListener('disconnect', resolve); 60 | 61 | if (report.hasOwnProperty('error')) { 62 | reject(report.error); 63 | } else { 64 | resolve(report.value); 65 | } 66 | }); 67 | }); 68 | 69 | socket.emit('run', source, jobID, props); 70 | 71 | return promise; 72 | }, 73 | 74 | /** 75 | * Test whether the client matches a platform query. 76 | * @param {Object|RegExp|String} query - A platform description. 77 | * @return {Boolean} - Whether the platform satisfies the query. 78 | */ 79 | matches: function (query) { 80 | 81 | /** Assume non-objects are matching against the platform name. */ 82 | if (typeof query === 'string' || query instanceof RegExp) { 83 | query = { name: query }; 84 | } 85 | 86 | return matches(query, this.platform); 87 | }, 88 | }; 89 | 90 | module.exports = Client; 91 | -------------------------------------------------------------------------------- /test/Client.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable require-jsdoc */ 2 | /* eslint-env mocha */ 3 | 'use strict'; 4 | var Client = require('../src/Client'); 5 | var Emitter = require('events'); 6 | var expect = require('expect'); 7 | 8 | describe('A client', function () { 9 | var client, socket, platform; 10 | 11 | beforeEach(function () { 12 | socket = new Emitter(); 13 | 14 | platform = { 15 | name: 'Node.js', 16 | version: '6.6.0', 17 | }; 18 | 19 | client = new Client({ 20 | socket: socket, 21 | platform: platform, 22 | }); 23 | }); 24 | 25 | it('should expose the socket', function () { 26 | expect(client.socket).toBe(socket); 27 | }); 28 | 29 | it('should expose the platform', function () { 30 | expect(client.platform).toBe(platform); 31 | }); 32 | 33 | it('should validate the socket', function () { 34 | function fail () { 35 | return new Client({ 36 | // Missing "socket". 37 | platform: platform, 38 | }); 39 | } 40 | expect(fail).toThrow(); 41 | }); 42 | 43 | it('should validate the platform', function () { 44 | function fail () { 45 | return new Client({ 46 | socket: new Emitter(), 47 | // Missing "platform". 48 | }); 49 | } 50 | expect(fail).toThrow(); 51 | }); 52 | 53 | describe('"run" call', function () { 54 | var spy; 55 | 56 | beforeEach(function () { 57 | spy = expect.createSpy(); 58 | }); 59 | 60 | it('should send jobs to the client', function () { 61 | client.socket.on('run', spy); 62 | client.run(function () {}); 63 | expect(spy).toHaveBeenCalled(); 64 | }); 65 | 66 | it('should make sure the job is a function', function () { 67 | function fail () { 68 | client.run(9000); 69 | } 70 | expect(fail).toThrow(TypeError); 71 | }); 72 | 73 | it('should send the stringified job', function () { 74 | client.socket.on('run', spy); 75 | client.run(function () { 76 | // I haz a comment. 77 | }); 78 | var str = spy.calls[0].arguments[0]; 79 | expect(str).toContain('I haz a comment'); 80 | }); 81 | 82 | it('should pass a job ID', function () { 83 | client.socket.on('run', spy); 84 | client.run(function () {}); 85 | client.run(function () {}); 86 | var id1 = spy.calls[0].arguments[1]; 87 | var id2 = spy.calls[1].arguments[1]; 88 | expect(id1).toBeA('string'); 89 | expect(id2).toBeA('string'); 90 | expect(id1).toNotBe(id2); 91 | }); 92 | 93 | it('should send the props to the client', function () { 94 | client.socket.on('run', spy); 95 | var props = {}; 96 | client.run(function () {}, props); 97 | var args = spy.calls[0].arguments; 98 | expect(args[2]).toBe(props); 99 | }); 100 | 101 | it('should return a promise', function () { 102 | var job = client.run(function () {}); 103 | expect(job).toBeA(Promise); 104 | }); 105 | 106 | it('should resolve when the job does', function (done) { 107 | client.socket.on('run', spy); 108 | var job = client.run(function () {}); 109 | var jobID = spy.calls[0].arguments[1]; 110 | 111 | job.then(done); 112 | client.socket.emit(jobID, {}); 113 | }); 114 | 115 | it('should resolve to the job value', function () { 116 | client.socket.on('run', spy); 117 | var job = client.run(function () {}); 118 | 119 | var jobID = spy.calls[0].arguments[1]; 120 | 121 | client.socket.emit(jobID, { 122 | value: 'Hello world!', 123 | }); 124 | 125 | return job.then(function (value) { 126 | expect(value).toBe('Hello world!'); 127 | }); 128 | }); 129 | 130 | it('should reject if the job fails', function (done) { 131 | client.socket.on('run', spy); 132 | var job = client.run(function () {}); 133 | var jobID = spy.calls[0].arguments[1]; 134 | var error = new Error('".run" rejection test.'); 135 | 136 | job.catch(function (err) { 137 | expect(err).toBe(error); 138 | done(); 139 | }); 140 | 141 | client.socket.emit(jobID, { 142 | error: error, 143 | }); 144 | }); 145 | 146 | it('should unsubscribe once finished', function () { 147 | var socket = client.socket; 148 | socket.on('run', spy); 149 | client.run(function () {}); 150 | var jobID = spy.calls[0].arguments[1]; 151 | expect(socket.listenerCount(jobID)).toBe(1); 152 | socket.emit(jobID, {}); 153 | expect(socket.listenerCount(jobID)).toBe(0); 154 | }); 155 | 156 | it('should resolve if disconnected', function (done) { 157 | var job = client.run(function () {}); 158 | job.then(done); 159 | client.socket.emit('disconnect'); 160 | }); 161 | 162 | }); 163 | 164 | describe('platform query', function () { 165 | 166 | beforeEach(function () { 167 | client.platform = { 168 | name: 'Node.js', 169 | version: '7.1.0', 170 | os: { family: 'Darwin' }, 171 | }; 172 | }); 173 | 174 | it('should return a boolean', function () { 175 | var matches = client.matches({ name: 'Node.js' }); 176 | expect(matches).toBeA('boolean'); 177 | }); 178 | 179 | it('should pass if the given fields match', function () { 180 | var matches = client.matches({ 181 | name: 'Node.js', 182 | }); 183 | expect(matches).toBe(true); 184 | }); 185 | 186 | it('should only pass if all the fields match', function () { 187 | var matches = client.matches({ 188 | name: 'Node.js', 189 | version: 'nah', 190 | }); 191 | 192 | expect(matches).toBe(false); 193 | }); 194 | 195 | it('should accept regular expressions', function () { 196 | var matches = client.matches({ 197 | name: /node/i, 198 | }); 199 | expect(matches).toBe(true); 200 | }); 201 | 202 | it('should assume regex input matches the name', function () { 203 | expect(client.matches(/Node/)).toBe(true); 204 | expect(client.matches(/Firefox/)).toBe(false); 205 | }); 206 | 207 | it('should assume string input matches the name', function () { 208 | expect(client.matches('Node.js')).toBe(true); 209 | expect(client.matches('Firefox')).toBe(false); 210 | }); 211 | 212 | it('should match against nested fields', function () { 213 | var matches; 214 | 215 | matches = client.matches({ 216 | os: { family: 'Darwin' }, 217 | }); 218 | expect(matches).toBe(true); 219 | 220 | matches = client.matches({ 221 | os: { family: 'Honestly it\'s just a box of potatoes' }, 222 | }); 223 | expect(matches).toBe(false); 224 | }); 225 | 226 | it('should fail if the nested query is not in the platform', function () { 227 | var matches = client.matches({ 228 | 'super-weird~field': { 229 | burger: true, 230 | fries: 'yes please', 231 | }, 232 | }); 233 | expect(matches).toBe(false); 234 | }); 235 | 236 | it('should allow nested regex matching', function () { 237 | var matches; 238 | 239 | matches = client.matches({ 240 | os: { family: /Darwin/ }, 241 | }); 242 | expect(matches).toBe(true); 243 | 244 | matches = client.matches({ 245 | os: { family: /why do they call these regular?/ }, 246 | }); 247 | expect(matches).toBe(false); 248 | }); 249 | 250 | it('should fail if the properties given do not match', function () { 251 | var matches = client.matches({ 252 | name: 'Some Non-Existent Platform™', 253 | }); 254 | expect(matches).toBe(false); 255 | }); 256 | 257 | }); 258 | 259 | }); 260 | -------------------------------------------------------------------------------- /src/ClientList.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var Emitter = require('events'); 3 | 4 | /** 5 | * Creates reactive lists of clients. 6 | * @param {Array} [lists] - A list of other lists 7 | * to join into a larger list. 8 | * @class ClientList 9 | * @augments EventEmitter 10 | */ 11 | function ClientList (lists) { 12 | var list = this; 13 | Emitter.call(this); 14 | list.clients = {}; 15 | 16 | var add = list.add.bind(list); 17 | 18 | /** See if the user passed an array. */ 19 | if (lists instanceof Array) { 20 | lists.forEach(function (list) { 21 | 22 | /** Add each client, listening for additions. */ 23 | list.each(add).on('add', add); 24 | }); 25 | } 26 | } 27 | 28 | var API = ClientList.prototype = new Emitter(); 29 | API.setMaxListeners(Infinity); 30 | 31 | API.constructor = ClientList; 32 | 33 | /** 34 | * Call the correct subclass when creating 35 | * new chains. 36 | * @param {Array} [list] - A list of client lists to add. 37 | * @return {ClientList} - Either a ClientList instance 38 | * or a subclass. 39 | */ 40 | API.chain = function (list) { 41 | return new this.constructor(list); 42 | }; 43 | 44 | /** 45 | * Iterate over the collection of low-level clients. 46 | * @param {Function} cb - Callback, invoked for each client. 47 | * @return {this} - The current context. 48 | */ 49 | API.each = function (cb) { 50 | var key; 51 | for (key in this.clients) { 52 | if (this.clients.hasOwnProperty(key)) { 53 | cb(this.clients[key], key, this); 54 | } 55 | } 56 | return this; 57 | }; 58 | 59 | /** 60 | * Add a low-level client object to the list. 61 | * 62 | * @param {Object} client - A strict client interface. 63 | * @param {Socket} client.socket - A socket.io interface. 64 | * @param {Object} client.platform - The `platform.js` object. 65 | * @returns {this} - The current context. 66 | */ 67 | API.add = function (client) { 68 | var socket, list = this; 69 | socket = client.socket; 70 | 71 | /** 72 | * Ignore disconnected clients, 73 | * or those already in the list. 74 | */ 75 | if (!socket.connected || this.get(socket.id)) { 76 | return this; 77 | } 78 | 79 | /** Add the client. */ 80 | this.clients[socket.id] = client; 81 | 82 | /** Remove on disconnect. */ 83 | socket.on('disconnect', function () { 84 | list.remove(client); 85 | }); 86 | 87 | /** Fire the 'add' event. */ 88 | this.emit('add', client, socket.id); 89 | 90 | return this; 91 | }; 92 | 93 | /** 94 | * Remove a client from the list. 95 | * @param {Object} client - A client object. 96 | * @return {this} - The current context. 97 | */ 98 | API.remove = function (client) { 99 | 100 | /** Make sure we really have that client. */ 101 | if (client.socket.id in this.clients) { 102 | 103 | /** Remove the client. */ 104 | delete this.clients[client.socket.id]; 105 | 106 | /** Fire the 'remove' event. */ 107 | this.emit('remove', client, client.socket.id); 108 | } 109 | 110 | return this; 111 | }; 112 | 113 | /** 114 | * Get the client corresponding to an ID. 115 | * @param {String} ID - The socket.id of the client. 116 | * @return {Object|null} - The client object, if found. 117 | */ 118 | API.get = function (ID) { 119 | return this.clients[ID] || null; 120 | }; 121 | 122 | /** 123 | * Create a new reactive list as the result of a 124 | * platform query. 125 | * @param {Object|String|RegExp|Function} filter - Platform query. 126 | * @return {ClientList} - A new list of clients. 127 | */ 128 | API.filter = function (filter) { 129 | 130 | /** Create a new target list. */ 131 | var list = this.chain(); 132 | 133 | if (typeof filter !== 'function') { 134 | var query = filter; 135 | filter = function (client) { 136 | return client.matches(query); 137 | }; 138 | } 139 | 140 | /** 141 | * Adds a client to the new list if it satisfies the query. 142 | * @param {Client} client - A client instance. 143 | * @param {String} ID - The unique client ID. 144 | * @return {undefined} 145 | */ 146 | function add (client, ID) { 147 | var matches = filter(client, ID); 148 | 149 | if (matches) { 150 | list.add(client); 151 | } 152 | } 153 | 154 | /** 155 | * Filter everything in the list, then listen 156 | * for future clients. 157 | */ 158 | this.each(add).on('add', add); 159 | 160 | return list; 161 | }; 162 | 163 | /** 164 | * Create a new reactive list containing the original 165 | * items, minus anything in a provided exclusion list. 166 | * @param {ClientList} exclude - A list of clients. 167 | * @return {ClientList} - A new client list. 168 | */ 169 | API.excluding = function (exclude) { 170 | 171 | /** 172 | * Add anything not in the exclusion list. 173 | * Remember .filter is reactive. 174 | */ 175 | if(exclude instanceof Array){ 176 | exclude = new ClientList(exclude); 177 | } 178 | 179 | var list = this.filter(function (client) { 180 | var excluded = exclude.get(client.socket.id); 181 | 182 | return !excluded; 183 | }); 184 | 185 | var self = this; 186 | 187 | /** 188 | * Add clients removed from the exclusion list, 189 | * and contained in the original list. 190 | */ 191 | exclude.on('remove', function (client) { 192 | var socket = client.socket; 193 | var connected = socket.connected; 194 | var relevant = self.get(socket.id); 195 | 196 | if (connected && relevant) { 197 | list.add(client); 198 | } 199 | }); 200 | 201 | return list; 202 | }; 203 | 204 | /** 205 | * Run a function remotely on a group of clients. 206 | * @param {Function} job - The function eval on clients. 207 | * @param {Object} [props] - Any variables the job needs. 208 | * @return {Promise} - Resolves when the jobs finish, 209 | * rejects if any of them fail. 210 | */ 211 | API.run = function (job, props) { 212 | var jobs = []; 213 | 214 | /** Run the job on each client. */ 215 | this.each(function (client) { 216 | var promise = client.run(job, props); 217 | jobs.push(promise); 218 | }); 219 | 220 | /** Wait for all jobs to finish. */ 221 | return Promise.all(jobs); 222 | }; 223 | 224 | /** 225 | * Wait until a number of clients have joined the list. 226 | * @param {Number} min - The minimum number of clients needed. 227 | * @return {Promise} - Resolves when the minimum is reached. 228 | */ 229 | API.atLeast = function (min) { 230 | var list = this; 231 | 232 | /** Check to see if we already have enough. */ 233 | if (list.length >= min) { 234 | return Promise.resolve(); 235 | } 236 | 237 | return new Promise(function (resolve) { 238 | 239 | /** Wait for new clients. */ 240 | list.on('add', function cb () { 241 | 242 | /** If we have enough... */ 243 | if (list.length >= min) { 244 | 245 | /** Unsubscribe and resolve. */ 246 | list.removeListener('add', cb); 247 | resolve(); 248 | } 249 | }); 250 | }); 251 | }; 252 | 253 | /** 254 | * Create a new list with a maximum number of clients. 255 | * 256 | * @param {Number} num - The maximum number of items. 257 | * @return {ClientList} - A new constrained list. 258 | */ 259 | API.pluck = function (num) { 260 | 261 | /** Create a new target list. */ 262 | var list = this.chain(); 263 | var self = this; 264 | 265 | /** 266 | * Add a client if there's still room. 267 | * @param {Object} client - A client object. 268 | * @return {undefined} 269 | */ 270 | function measure (client) { 271 | if (!list.atCapacity) { 272 | list.add(client); 273 | } 274 | } 275 | 276 | /** Check to see if it's already full. */ 277 | list.on('add', function () { 278 | if (list.length === num) { 279 | list.atCapacity = true; 280 | } 281 | }); 282 | 283 | /** See if we can replace the lost client. */ 284 | list.on('remove', function () { 285 | list.atCapacity = false; 286 | self.each(measure); 287 | }); 288 | 289 | /** Add as many clients as we can. */ 290 | this.each(measure).on('add', measure); 291 | 292 | return list; 293 | }; 294 | 295 | API.atCapacity = false; 296 | 297 | /** 298 | * A getter, providing the number of clients in a list. 299 | * @returns {Number} - The length of the list. 300 | */ 301 | Object.defineProperty(API, 'length', { 302 | get: function () { 303 | 304 | /** Feature detect Object.keys. */ 305 | if (Object.keys instanceof Function) { 306 | return Object.keys(this.clients).length; 307 | } 308 | 309 | /** Fall back to iterating. */ 310 | var length = 0; 311 | this.each(function () { 312 | length += 1; 313 | }); 314 | 315 | return length; 316 | }, 317 | }); 318 | 319 | module.exports = ClientList; 320 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable require-jsdoc */ 2 | /* eslint-env mocha */ 3 | 'use strict'; 4 | var mock = require('./mock'); 5 | var Client = mock.Client; 6 | var ClientList = require('../src/ClientList'); 7 | 8 | var expect = require('chai').expect; 9 | 10 | describe('A clientList', function () { 11 | var list, client; 12 | 13 | beforeEach(function () { 14 | list = new ClientList(); 15 | client = new Client({ 16 | name: 'Node.js', 17 | }); 18 | }); 19 | 20 | it('should emit when a client is added', function () { 21 | var fired = false; 22 | list.on('add', function () { 23 | fired = true; 24 | }).add(client); 25 | expect(fired).to.eq(true); 26 | }); 27 | 28 | it('should emit when a client is removed', function () { 29 | var fired = false; 30 | list.on('remove', function () { 31 | fired = true; 32 | }) 33 | .add(client) 34 | .remove(client); 35 | expect(fired).to.eq(true); 36 | }); 37 | 38 | it('should return length when "len()" is called', function () { 39 | list.add(client); 40 | expect(list.length).to.eq(1); 41 | list.remove(client); 42 | expect(list.length).to.eq(0); 43 | }); 44 | 45 | it('should not add disconnected clients', function () { 46 | client.socket.connected = false; 47 | list.add(client); 48 | expect(list.length).to.eq(0); 49 | }); 50 | 51 | it('should remove a client on disconnect', function () { 52 | list.add(client); 53 | expect(list.length).to.eq(1); 54 | client.socket.emit('disconnect'); 55 | expect(list.length).to.eq(0); 56 | }); 57 | 58 | it('should resolve when all clients finish', function (done) { 59 | var socket = client.socket; 60 | socket.on('run', function (cb, jobID) { 61 | socket.emit(jobID, {}); 62 | }); 63 | list.add(client).run(function () {}) 64 | .then(function () { 65 | done(); 66 | }) 67 | .catch(done); 68 | }); 69 | 70 | it('should reject a promise if an error is sent', function (done) { 71 | client.socket.on('run', function (cb, job) { 72 | client.socket.emit(job, { 73 | error: 'fake error', 74 | }); 75 | }); 76 | list.add(client).run(function () {}) 77 | .catch(function (err) { 78 | expect(err).to.eq('fake error'); 79 | done(); 80 | }); 81 | }); 82 | 83 | it('should not emit "add" if it contains the client', function () { 84 | var called = 0; 85 | list.on('add', function () { 86 | called += 1; 87 | }); 88 | list.add(client); 89 | list.add(client); 90 | expect(called).to.eq(1); 91 | }); 92 | 93 | describe('filter', function () { 94 | it('should not mutate the original list', function () { 95 | list.add(client); 96 | expect(list.length).to.eq(1); 97 | list.filter(function () { 98 | return false; 99 | }); 100 | expect(list.length).to.eq(1); 101 | }); 102 | 103 | it('should return a new, filtered list', function () { 104 | list.add(client); 105 | var servers = list.filter('Node.js'); 106 | var browsers = list.filter(function (client) { 107 | return client.platform.name !== 'Node.js'; 108 | }); 109 | expect(servers.length).to.eq(1); 110 | expect(browsers.length).to.eq(0); 111 | }); 112 | 113 | it('should be reactive to changes to the parent list', function () { 114 | var servers = list.filter('Node.js'); 115 | expect(servers.length).to.eq(0); 116 | list.add(client); 117 | expect(servers.length).to.eq(1); 118 | }); 119 | }); 120 | 121 | describe('exclusion', function () { 122 | it('should not contain excluded clients', function () { 123 | list.add(client); 124 | var filtered = list.excluding(list); 125 | expect(filtered.length).to.eq(0); 126 | }); 127 | 128 | it('should react to removals if they are connected', function () { 129 | var decoy = new Client(); 130 | var exclusion = new ClientList() 131 | .add(client) 132 | .add(decoy); 133 | var filtered = list.excluding(exclusion); 134 | list.add(client).add(new Client()); 135 | expect(filtered.length).to.eq(1); 136 | exclusion.remove(client).remove(decoy); 137 | expect(filtered.length).to.eq(2); 138 | }); 139 | }); 140 | 141 | describe('number constraint', function () { 142 | it('should return no more than the number requested', function () { 143 | list.add(client) 144 | .add(new Client()) 145 | .add(new Client()); 146 | expect(list.pluck(1).length).to.eq(1); 147 | }); 148 | 149 | it('should listen for additions', function () { 150 | var subset = list.pluck(2); 151 | expect(subset.length).not.to.eq(2); 152 | list.add(new Client()).add(new Client()); 153 | expect(subset.length).to.eq(2); 154 | list.add(new Client()); 155 | expect(subset.length).to.eq(2); 156 | }); 157 | 158 | it('should replace a client when it disconnects', function () { 159 | var subset = list.pluck(1); 160 | list.add(client).add(new Client()); 161 | expect(subset.length).to.eq(1); 162 | client.socket.emit('disconnect'); 163 | // It should be replaced with 164 | // the second connected client. 165 | expect(subset.length).to.eq(1); 166 | }); 167 | 168 | it('should set a flag whether the constraint is met', function () { 169 | var subset = list.pluck(1); 170 | expect(subset.atCapacity).to.eq(false); 171 | list.add(client); 172 | expect(subset.atCapacity).to.eq(true); 173 | client.socket.emit('disconnect'); 174 | expect(subset.atCapacity).to.eq(false); 175 | }); 176 | 177 | it('should play well with exclusions', function () { 178 | var bob, alice = list.pluck(1); 179 | bob = list.excluding(alice).pluck(1); 180 | list.add(client) 181 | .add(new Client()) 182 | .add(new Client()); 183 | expect(alice.length).to.eq(1); 184 | expect(bob.length).to.eq(1); 185 | }); 186 | }); 187 | 188 | describe('minimum qualifier', function () { 189 | 190 | it('should resolve when the minimum is reached', function () { 191 | var promise = list.atLeast(1); 192 | var called = false; 193 | promise.then(function () { 194 | called = true; 195 | }); 196 | expect(called).to.eq(false); 197 | list.add(new Client()); 198 | 199 | // Mocha will wait for this to resolve. 200 | return promise; 201 | }); 202 | 203 | it('should resolve if the min is already reached', function () { 204 | var promise = list.atLeast(0); 205 | 206 | // This will time out if unresolved. 207 | return promise; 208 | }); 209 | 210 | it('should resolve to undefined', function () { 211 | function validate (arg) { 212 | expect(arg).to.eq(undefined); 213 | } 214 | var immediate = list.atLeast(0).then(validate); 215 | var later = list.atLeast(1).then(validate); 216 | 217 | list.add(new Client()); 218 | 219 | return Promise.all([immediate, later]); 220 | }); 221 | 222 | it('should resolve if it has more than enough', function () { 223 | list.add(new Client()).add(new Client()); 224 | 225 | return list.atLeast(1); 226 | }); 227 | 228 | it('should unsubscribe after resolving', function () { 229 | list.atLeast(1); 230 | expect(list.listenerCount('add')).to.eq(1); 231 | list.add(new Client()); 232 | expect(list.listenerCount('add')).to.eq(0); 233 | }); 234 | 235 | }); 236 | }); 237 | 238 | describe('The ClientList constructor', function () { 239 | var list1, list2, client1, client2; 240 | 241 | beforeEach(function () { 242 | list1 = new ClientList(); 243 | list2 = new ClientList(); 244 | client1 = new Client(); 245 | client2 = new Client(); 246 | list1.add(client1); 247 | list2.add(client2); 248 | }); 249 | 250 | it('should accept an array of clientLists', function () { 251 | var list = new ClientList([list1, list2]); 252 | 253 | // it should contain both clients 254 | expect(list.get(client1.socket.id)).to.eq(client1); 255 | expect(list.get(client2.socket.id)).to.eq(client2); 256 | }); 257 | 258 | it('should reactively add new clients from source lists', function () { 259 | var list = new ClientList([list1, list2]); 260 | var client3 = new Client(); 261 | expect(list.get(client3.socket.id)).to.eq(null); 262 | list1.add(client3); 263 | expect(list.get(client3.socket.id)).to.eq(client3); 264 | }); 265 | 266 | it('should be subclassable', function () { 267 | function Sub () { 268 | ClientList.call(this); 269 | } 270 | Sub.prototype = new ClientList(); 271 | Sub.prototype.constructor = Sub; 272 | 273 | var sub = new Sub(); 274 | expect(sub).to.be.an.instanceof(Sub); 275 | 276 | // chained inheritance 277 | expect(sub.filter('Firefox')).to.be.an.instanceof(Sub); 278 | expect(sub.pluck(1)).to.be.an.instanceof(Sub); 279 | }); 280 | }); 281 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | PANIC 2 | 3 | [![Travis branch](https://img.shields.io/travis/PsychoLlama/panic-server/master.svg?style=flat-square)](https://travis-ci.org/PsychoLlama/panic-server) 4 | [![npm](https://img.shields.io/npm/dt/panic-server.svg?style=flat-square)](https://www.npmjs.com/package/panic-server) 5 | [![npm](https://img.shields.io/npm/v/panic-server.svg?style=flat-square)](https://www.npmjs.com/package/panic-server) 6 | [![Gitter](https://img.shields.io/gitter/room/amark/gun.svg?style=flat-square)](https://gitter.im/amark/gun) 7 | 8 | > **TL;DR:**
9 | A remote control for browsers and servers. 10 | 11 | Panic is an end-to-end testing framework, designed specifically for distributed systems and collaborative apps. 12 | 13 | ## Why 14 | At [gunDB](http://gun.js.org/), we're building a real-time, distributed JS database. 15 | 16 | We needed a testing tool that could simulate complex scenarios, and programmatically report success or failure. For instance, how would you write this test? 17 | 18 | 1. Start a server. 19 | 2. Spin up two browsers, each syncing with the server. 20 | 3. Save data on just one browser. 21 | 4. Assert that it replicated to the other. 22 | 23 | And that's just browser to browser replication. What about simulating app failures? 24 | 25 | 1. Start a server. 26 | 2. Start two browsers, each synced with the server. 27 | 3. Save some initial data. 28 | 4. Kill the server, and erase all it's data. 29 | 5. Make conflicting edits on the browsers. 30 | 6. Start the server again. 31 | 7. Assert both conflicting browsers converge on a value. 32 | 33 | That's why we built panic. 34 | 35 | ## How it works 36 | Well, there are two repos: `panic-server`, and [`panic-client`](https://github.com/gundb/panic-client/). 37 | 38 | You'll start a panic server (sometimes called a coordinator), then you'll connect to it from panic clients. 39 | 40 | Loading the client software into a browser or Node.js process exposes the mother of all XSS vulnerabilities. Connect it to the coordinator, then it'll have full control over your process. 41 | 42 | That's where panic gets its power. It remotely controls every client and server in your app. 43 | 44 | Now obviously, due to those vulnerabilities, you wouldn't want panic in user-facing code. Hang on, lemme make this bigger... 45 | 46 | ### DO NOT USE PANIC IN USER-FACING CODE. 47 | 48 | Well, unless running `eval` on arbitrary code is an app feature. 49 | 50 | Cool, so we've covered the "why" and the "how it works". Now onto the API! 51 | 52 | ## API 53 | > If you're massively bored by documentation and just wanna copy/paste working code, well, [happy birthday](#scaffolding). 54 | 55 | ### Clients 56 | A client is an instance of `panic.Client`, and represents another computer or process connected through websockets. 57 | 58 | #### Properties 59 | Every client has some properties you can use, although you probably won't need to. 60 | 61 | ##### `.socket` 62 | References the [`socket.io`](http://socket.io/) interface connecting you to the other process. Unless you're developing a plugin, you'll probably never need to use this. 63 | 64 | ##### `.platform` 65 | This references the [`platform.js`](https://github.com/bestiejs/platform.js/) object. It's sent as part of the handshake by [`panic-client`](https://github.com/gundb/panic-client/). 66 | 67 | #### Methods 68 | Right now there's only one, but it's where the whole party's at! 69 | 70 | ##### `.run()` 71 | Sends code to execute on the client. 72 | 73 | It takes two parameters: 74 | 75 | 1. The function to execute remotely. 76 | 2. Optionally, some variables to send with it. 77 | 78 | This is by far the weirdest part of panic. Your function is run, but not in the same context, not even in the same process, maybe a different JS environment and OS entirely. 79 | 80 | It's stringified, sent to the client, then evaluated in a special job context. 81 | 82 | ```js 83 | console.log('This runs in your process.') 84 | 85 | client.run(function () { 86 | console.log("This doesn't.") 87 | }) 88 | ``` 89 | 90 | Some of the common confusion points: 91 | 92 | - You can't use any variables outside your function. 93 | - That includes other functions. 94 | - If the client is a browser, obviously you won't have `require` available. 95 | - The client might have different packages or package versions installed. 96 | 97 | Bottom line, your code is run on the client, not where you wrote it. 98 | 99 | Inside the function, you've got access to the whole [`panic-client` API](https://github.com/gundb/panic-client/#api). 100 | 101 | Because your function can't see any local scope variables, anything the function depends on needs to be sent with it. That's our second parameter, `props`. 102 | 103 | **Example** 104 | ```js 105 | var clientPort = 8085 106 | 107 | client.run(function () { 108 | var http = require('http') 109 | var server = new http.Server() 110 | 111 | // The variable you sent. 112 | var port = this.props.port 113 | 114 | server.listen(port) 115 | }, { 116 | 117 | // Sends the local variable 118 | // as `props.port`. 119 | port: clientPort 120 | }) 121 | ``` 122 | 123 | Careful though, any props you send have to be JSON compatible. It'll crash if you try to send a circular reference. 124 | 125 | ###### Return values 126 | So, we've showed how values can be sent to the client, but what about getting values back? 127 | 128 | Prepare yourself, this is pretty awesome. 129 | 130 | `.run` returns a promise. Any return value from the client will be the resolve value. For instance: 131 | 132 | ```js 133 | client.run(function () { 134 | var ip = require('ip') 135 | return ip.address() 136 | }).then(function (ip) { 137 | 138 | // The address of the other machine 139 | console.log(ip) 140 | }) 141 | ``` 142 | 143 | > For more details on return values and edge cases, read the panic client [API](https://github.com/gundb/panic-client/#api). 144 | 145 | So, if one of your clients is a node process... 146 | 147 | ```js 148 | function sh () { 149 | var child = require('child_process') 150 | var spawn = child.spawnSync 151 | 152 | var cmd = this.props.cmd 153 | var args = this.props.args 154 | 155 | var result = spawn(cmd, args || []) 156 | 157 | return result.stdout 158 | } 159 | 160 | client.run(sh, { 161 | cmd: 'ls', 162 | args: ['-lah'] 163 | }).then(function (dir) { 164 | var output = dir.toString('utf8') 165 | console.log(output) 166 | }) 167 | ``` 168 | 169 | Tada, now you have SSH over node. 170 | 171 | > If you're into node stuff, you probably noticed `result.stdout` is a Buffer. That's allowed, since socket.io has first-class support for binary streams. Magical. 172 | 173 | ###### Errors 174 | What's a test suite without error reporting? I dunno. I've never seen one. 175 | 176 | If your job throws an error, you'll get the message back on the server: 177 | 178 | ```js 179 | client.run(function () { 180 | throw new Error( 181 | 'Hrmm, servers are on fire.' 182 | ) 183 | }).catch(function (error) { 184 | console.log(error) 185 | /* 186 | { 187 | message: 'Hrmm, servers...', 188 | source: `function () { 189 | throw new Error( 190 | 'Hrmm, servers are on fire.' 191 | ) 192 | }`, 193 | platform: {} // platform.js 194 | } 195 | */ 196 | }) 197 | ``` 198 | 199 | As you can see, some extra debugging information is attached to each error. 200 | 201 | - `.message`: the error message thrown. 202 | - `.source`: the job that failed. 203 | - `.platform`: the platform it failed on, courtesy of platform.js. 204 | 205 | However, due to complexity, stack traces aren't included. `eval` and socket.io make it hard to parse. Maybe in the future. 206 | 207 | #### `.matches()` 208 | Every client has a [`platform`](https://github.com/gundb/panic-server#platform) property. The `matches` method allows you to query it. 209 | 210 | This is useful when filtering a group of clients, or ensuring you're sending code to the platform you expect. 211 | 212 | > You probably won't use this method directly. However, it's used heavily by the `ClientList#filter` method to select platform groups, which passes through to the `.matches()` API. 213 | 214 | When passed a platform expression (more on this in a second), `.matches` returns a boolean of whether the client's platform satisfies the expression. 215 | 216 | For example, this code is asking if the platform name matches the given regex: 217 | 218 | ```js 219 | // Is this client a Chrome or Firefox browser? 220 | client.matches(/(Chrome|Firefox)/) 221 | ``` 222 | 223 | To be more specific, you can pass the exact string you're looking for: 224 | 225 | ```js 226 | // Is this a Node.js process? 227 | client.matches('Node.js') 228 | ``` 229 | 230 | Though as you can imagine, there's more to a platform than it's name. You can see the full list [here](https://github.com/bestiejs/platform.js/tree/master/doc). 231 | 232 | More complex queries can be written by passing an object with more fields to match. 233 | 234 | ```javascript 235 | // Is the client a Node.js process running 236 | // on 64-bit Fedora? 237 | clients.matches({ 238 | name: 'Node.js', 239 | os: { 240 | architecture: 64, 241 | family: 'Fedora', 242 | }, 243 | }) 244 | ``` 245 | 246 | If you crave more power, you can use regular expressions as the field names. 247 | 248 | ```js 249 | // Is this an ether Opera Mini or an 250 | // IE browser running on either Mac 251 | // or Android? 252 | client.matches({ 253 | name: /(Opera Mini|Internet Explorer)/, 254 | 255 | os: { 256 | family: /(OS X|Android)/, 257 | }, 258 | }) 259 | ``` 260 | 261 | Only the fields given are matched, so you can be as specific or as loose as you want to be. 262 | 263 | ### Lists of clients 264 | Often, you're working with groups of clients. Like, only run this code on IE, or only on Node.js processes. 265 | 266 | That's where dynamic lists come in. Declaratively, you describe what the list should contain, and panic keeps them up to date. 267 | 268 | #### `panic.clients` 269 | This is the top-level reactive list, containing every client currently connected. As new clients join, they're added to this list. When disconnected, they're removed. 270 | 271 | 272 | ##### Events 273 | Every list of clients will emit these events. 274 | 275 | ###### `"add"` 276 | Fires when a new client is added to the list. 277 | 278 | It'll pass both the `Client` and the socket ID. 279 | 280 | ```js 281 | clients.on('add', function (client, id) { 282 | console.log('New client:', id) 283 | }) 284 | ``` 285 | 286 | ###### `"remove"` 287 | Basically the same as `"add"`, just backwards. 288 | 289 | ```js 290 | clients.on('remove', function (client, id) { 291 | console.log('Client', id, 'left.') 292 | }) 293 | ``` 294 | 295 | #### `panic.ClientList` 296 | Every list is an instance of `ClientList`. You can manually create a new lists, but generally you won't need to. 297 | 298 | It's most useful for creating a new reactive list as the union of others. For example: 299 | 300 | ```js 301 | var explorer = clients.filter('Internet Explorer') 302 | var opera = clients.filter('Opera Mini') 303 | 304 | var despicable = new ClientList([ 305 | explorer, 306 | opera, 307 | ]) 308 | ``` 309 | 310 | In the example above, any new clients added to either `explorer` or `opera` will make it into the `despicable` list. 311 | 312 | All clients are deduplicated automatically. 313 | 314 | If you don't pass an array, you're left with a sad, empty client list. 315 | 316 | #### `ClientList` API 317 | 318 | **Table of Contents** 319 | - [`.filter()`](#filter) 320 | - [`.excluding()`](#excluding) 321 | - [`.pluck()`](#pluck) 322 | - [`.atLeast`](#at-least) 323 | - [`.run()`](#run) 324 | - [`.length`](#length) 325 | - [`.get()`](#get) 326 | - [`.add()`](#add) 327 | - [`.remove()`](#remove) 328 | - [`.each()`](#each) 329 | - [`.chain()`](#chain) 330 | 331 | ##### `.filter(query)` 332 | Creates a new list of clients filtered by their platform. 333 | 334 | For simpler queries, you can select via string or regular expression, which is matched against the `platform.name`: 335 | 336 | ```js 337 | // Selects all the chrome browsers. 338 | var chrome = clients.filter('Chrome') 339 | 340 | // Selects all firefox and chrome browsers. 341 | var awesome = clients.filter(/(Firefox|Chrome)/) 342 | ``` 343 | 344 | You can also do more complex queries by passing an object. Refer to the `Client#matches` API to see more examples. 345 | 346 | If you're looking for something really specific, you can filter by passing a callback, which functions almost exactly like [`Array.prototype.filter`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter). 347 | 348 | ```javascript 349 | var firefox = clients.filter(function (client, id, list) { 350 | // `id`: The unique client id 351 | // `list`: The parent list object, in this case `clients` 352 | 353 | var platform = client.platform; 354 | 355 | /* 356 | This query only adds versions of 357 | Firefox later than version 36. 358 | */ 359 | if (platform.name === 'Firefox' && platform.version > '36') { 360 | // add this client to the new list 361 | return true; 362 | } else { 363 | // leave the client out of the new list 364 | return false; 365 | } 366 | }); 367 | ``` 368 | 369 | To make things cooler, you can chain filters off one another. For example, the above query only allows versions of firefox after 36. You could write that as two queries composed together... 370 | 371 | ```javascript 372 | // The list of all firefox clients. 373 | var firefox = clients.filter('Firefox') 374 | 375 | // The list of firefox newer than version 36. 376 | var firefoxAfter36 = firefox.filter(function (client) { 377 | var version = client.platform.version 378 | var major = version.split('.')[0] 379 | 380 | return Number(major) > 36; 381 | }); 382 | ``` 383 | 384 | As new clients are added, they'll be run through the firefox filters, and if added, will be run through the version filter. The dynamic filtering process allows for some cool RxJS style code. 385 | 386 | ##### `.excluding(ClientList)` 387 | You can also create lists that exclude other lists, like a list of browsers might be anything that isn't a server, or perhaps you want to exclude all Chrome browsers from a list. You can do that with `.excluding`. 388 | 389 | ```javascript 390 | // create a dynamic list of all node.js clients 391 | var servers = clients.filter('Node.js') 392 | 393 | // the list of all clients, 394 | // except anything that belongs to `servers`. 395 | var browsers = clients.excluding(servers) 396 | ``` 397 | 398 | Like filter, you can chain queries off each other to create really powerful queries. 399 | 400 | ```javascript 401 | // using `browsers` from above 402 | var chrome = browsers.filter('Chrome') 403 | var notChrome = browsers.excluding(chrome) 404 | ``` 405 | 406 | ##### `.pluck(Number)` 407 | `.pluck` restricts the list length to a number, reactively listening for changes to ensure it's as close to the maximum as it can be. An excellent use case for `.pluck` is singling out clients of the same platform. This becomes especially powerful when paired with [`.excluding`](#excluding) and the `ClientList` constructor. For example, if you want to control 3 clients individually, it might look like this: 408 | 409 | ```javascript 410 | var clients = panic.clients 411 | var List = panic.ClientList 412 | 413 | // grab one client from the list 414 | var alice = clients.pluck(1) 415 | 416 | // grab another, so long as it isn't alice 417 | var bob = clients 418 | .excluding(alice) 419 | .pluck(1) 420 | 421 | // and another, so long as it isn't alice or bob 422 | var carl = clients 423 | .excluding( 424 | new List([ alice, bob ]) 425 | ) 426 | .pluck(1) 427 | ``` 428 | 429 | > `.pluck` is highly reactive, and will readjust itself to hold as many clients as possible. 430 | 431 | ##### `.atLeast(Number)` 432 | Oftentimes, you need a certain number of clients before running any tests. `.atLeast` takes that minimum number, and returns a promise. 433 | 434 | That promise resolves when the minimum has been reached. 435 | 436 | Here's an example: 437 | ```js 438 | var clients = panic.clients 439 | 440 | // Waits for 2 clients before resolving. 441 | var minimum = clients.atLeast(2) 442 | 443 | minimum.then(function () { 444 | 445 | // 2 clients are connected now. 446 | return clients.run(/* ... */) 447 | }) 448 | ``` 449 | 450 | It can also be used on derived lists, like so: 451 | 452 | ```js 453 | var node = clients.filter('Node.js') 454 | node.atLeast(3).then(/* ... */) 455 | ``` 456 | 457 | > **Pro tip:** `.atLeast` goes great with mocha's `before` function. 458 | 459 | ##### `.run(Function)` 460 | It just calls the `client.run` function for every item in the list, wrapping them in `Promise.all`. 461 | 462 | When every client reports success, it resolves to a list of return values. 463 | 464 | However, if any client fails, the promise rejects. 465 | 466 | ```js 467 | panic.clients.run(function () { 468 | var ip = require('ip') 469 | return ip.address() 470 | }).then(function (ips) { 471 | console.log(ips) // Array of IPs. 472 | }) 473 | ``` 474 | 475 | ##### `.length` 476 | A getter property which returns the number of clients in a list. 477 | 478 | ##### `.get(id)` 479 | Returns the client corresponding to the id. Presently, socket.io's `socket.id` is used to uniquely key clients. 480 | 481 | ##### `.add(client)` 482 | Manually adds a client to the list, triggering the `"add"` event, but only if the client wasn't there before. 483 | 484 | ##### `.remove(client)` 485 | Removes a client from the list, emitting a `remove` event. Again, if the client wasn't in the list, the event doesn't fire. 486 | 487 | ##### `.each(Function)` 488 | It's basically a `.forEach` on the list. The function you pass will get the client, the client's ID, and the list it was called on. 489 | 490 | **Example** 491 | ```javascript 492 | clients.each(function (client, id, list) { 493 | client.run(function () { 494 | // Fun stuff 495 | }) 496 | }) 497 | ``` 498 | 499 | ##### `.chain([...lists])` 500 | This is a low-level API for subclasses. It makes sure the right class context is kept even when chaining off methods that create new lists, like `.filter` and `.pluck`. 501 | 502 | ```javascript 503 | var list = new ClientList() 504 | list.chain() instanceof ClientList // true 505 | 506 | class SubClass extends ClientList { 507 | coolNewMethod() { /* bacon */ } 508 | } 509 | 510 | var sub = new SubClass() 511 | sub.chain() instanceof SubClass // true 512 | sub.chain() instanceof ClientList // true 513 | sub.chain().coolNewMethod() // properly inherits 514 | ``` 515 | 516 | If you're making an extension that creates a new list instance, use this method to play nice with other extensions. 517 | 518 | ### `panic.server(Server)` 519 | If an [`http.Server`](https://nodejs.org/api/http.html#http_class_http_server) is passed, panic will use it to configure [socket.io](http://socket.io/) and the `/panic.js` route will be added that servers up the [`panic-client`](https://github.com/gundb/panic-client) browser code. 520 | 521 | If no server is passed, a new one will be created. 522 | 523 | If you're not familiar with Node.js' http module, that's okay. The quickest way to get up and running is to call `.listen(8080)` which listens for requests on port 8080. In a browser, the url will look something like this: `http://localhost:8080/panic.js`. 524 | 525 | **Create a new server** 526 | ```javascript 527 | var panic = require('panic-server') 528 | 529 | // create a new http server instance 530 | var server = panic.server() 531 | 532 | // listen for requests on port 8080 533 | server.listen(8080) 534 | ``` 535 | 536 | **Reuse an existing one** 537 | ```javascript 538 | var panic = require('panic-server') 539 | 540 | // create a new http server 541 | var server = require('http').Server() 542 | 543 | // pass it to panic 544 | panic.server(server) 545 | 546 | // start listening on a port 547 | server.listen(8080) 548 | ``` 549 | 550 | > If you want to listen on port 80 (the default for browsers), you may need to run node as `sudo`. 551 | 552 | Once you have a server listening, point browsers/servers to your address. More API details on the [panic-client readme](https://github.com/gundb/panic-client/#loading-panic-client). 553 | 554 | > **Note:** if you're using [PhantomJS](https://github.com/ariya/phantomjs), you'll need to serve the html page over http/s for socket.io to work. 555 | 556 | ### `panic.client` 557 | Returns the panic-client webpack bundle. This is useful for injection into a WebDriver instance (using `driver.executeScript`) without needing to do file system calls. 558 | 559 | ## Basic test example 560 | A simple "Hello world" panic app. 561 | 562 | **index.html** 563 | ```html 564 | 566 | 567 | 571 | ``` 572 | 573 | **demo.js** 574 | ```js 575 | var panic = require('panic-server') 576 | 577 | // Start the server on port 8080. 578 | panic.server().listen(8080) 579 | 580 | // Get the dynamic list of clients. 581 | var clients = panic.clients 582 | 583 | // Create dynamic lists of 584 | // browsers and servers. 585 | var servers = clients.filter('Node.js') 586 | var browsers = clients.excluding(servers) 587 | 588 | // Wait for the browser to connect. 589 | browsers.on('add', function (browser) { 590 | 591 | browser.run(function () { 592 | 593 | // This is run in the browser! 594 | var header = document.createElement('h1') 595 | header.innerHTML = 'OHAI BROWSR!' 596 | document.body.appendChild(header) 597 | }) 598 | }) 599 | ``` 600 | 601 | Run `demo.js`, then open `index.html` in a browser. Enjoy! 602 | 603 | ## Support 604 | - Oh, why thank you! Just star this repo, that's all the support we need :heart: 605 | 606 | Oh. 607 | 608 | Just drop by [our gitter channel](https://gitter.im/amark/gun/) and ping @PsychoLlama, or submit an issue on the repo. We're there for ya. 609 | --------------------------------------------------------------------------------