UNPKG

mongoscope-client

Version:
896 lines (820 loc) 23.9 kB
/** * @todo More reliable func tests (travis runs mongoscope-server) * @todo Examples in client.js * @todo Doc tags for possible `err` objects (api blueprint like) * @todo Doc tags for possible `data` objects (api blueprint like) */ var request = require('superagent'), util = require('util'), EventEmitter = require('events').EventEmitter, Token = require('./token'), Context = require('./context'), createRouter = require('./router'), Subscription = require('./subscription'), assert = require('assert'), socketio = require('socket.io-client'), pkg = require('../package.json'), EJSON = require('mongodb-extended-json'), valid = require('./valid'), Resource = require('./resource'), defaults = require('./defaults'), debug = require('debug')('mongoscope:client'); // Override superagent's parse and encode handlers rather than // adding another content-type tooling doesnt like. request.serialize['application/json'] = EJSON.stringify; if(typeof window === 'undefined'){ request.parse['application/json'] = function(res, fn){ res.text = ''; res.setEncoding('utf8'); res.on('data', function(chunk){ res.text += chunk; }); res.on('end', function(){ try { fn(null, EJSON.parse(res.text)); } catch (err) { fn(err); } }); }; } else { request.parse['application/json'] = EJSON.parse; } var clients = {}; function clientId(opts){ return opts.scope + '/' + opts.seed; } module.exports = function(opts){ opts = opts || {}; if(typeof opts !== 'object'){ opts = {scope: opts}; } if(!opts.scope) opts.scope = defaults.scope; if(!opts.auth) opts.auth = defaults.auth; if(!opts.seed) opts.seed = defaults.seed; opts.seed = opts.seed.replace('mongodb://', ''); var _id = clientId(opts); if(!clients[_id]){ debug('creating new client', _id, clients); clients[_id] = new Client(opts); clients[_id].connect(); return clients[_id]; } else { debug('already have client'); return clients[_id]; } }; module.exports.defaults = defaults; module.exports.Backbone = require('./adapters/backbone.js'); function Client(opts){ if(!(this instanceof Client)) return new Client(opts); assert(opts.seed, 'Missing `seed` config value'); assert(opts.scope, 'Missing `scope` config value'); this.config = { seed: opts.seed, scope: opts.scope, driver: { name: pkg.name, version: pkg.version, lang: 'javascript' } }; if(opts.timeout) this.config.timeout = opts.timeout; if(opts.auth) this.config.auth = opts.auth; this.context = new Context(); this.readable = false; this.original = true; this.dead = false; this.closed = false; this._id = clientId(this.config); } util.inherits(Client, EventEmitter); /** * Accquire a token. * * @api private */ Client.prototype.connect = function(){ if(this.token) { console.warn('Already connected'); return this; } this.token = new Token(this.config) .on('readable', this.onTokenReadable.bind(this)) .on('error', this.emit.bind(this, 'error')); return this; }; /** * Get details of the instance you're currently connected to * like database_names, results of the hostInfo and buildInfo mongo commands. * * ```javascript * scope.instance(function(err, data){ * if(err) return console.error(err); * console.log('Databases on ' + scope.context.get('instance_id') + ':'); * data.datbase_names.map(console.log.bind(console, ' -')); * }); * ``` * * @param {Object} [opts] Placeholder for future options * @param {Function} [fn] A response callback `(err, data)` * * @stability production * @group resource */ Client.prototype.instance = function(opts, fn){ opts = opts || {}; if(typeof opts === 'function'){ fn = opts; opts = {}; } return this.read('/', opts, fn); }; /** * List all deployments this mongoscope instance has connected to. * * @param {Object} [opts] Placeholder for future options * @param {Function} [fn] A response callback `(err, data)` * * @stability production * @group resource */ Client.prototype.deployments = function(opts, fn){ opts = opts || {}; if(typeof opts === 'function'){ fn = opts; opts = {}; } return this.read('/deployments', opts, fn); }; /** * List collection names and stats. * * @param {String} name * @param {Object} [opts] Placeholder for future options * @param {Function} [fn] A response callback `(err, data)` * * @stability production * @return {resource.Database} * @group resource */ Client.prototype.database = function(name, opts, fn){ opts = opts || {}; if(typeof opts === 'function'){ fn = opts; opts = {}; } if(!valid.database(name)){ throw new TypeError('Invalid database name `' + name + '`'); } var resource = new Resource.Database(this, '/databases', name); return (!fn) ? resource : resource.read(opts, fn); }; /** * Collection stats * * @param {String} ns A namespace string, eg `#{database_name}.#{collection_name}` * @param {Object} [opts] Placeholder for future options * @param {Function} [fn] A response callback `(err, data)` * * @stability production * @resource {resource.Collection} * @group resource */ Client.prototype.collection = function(ns, opts, fn){ opts = opts || {}; if(typeof opts === 'function'){ fn = opts; opts = {}; } if(!valid.ns(ns)){ throw new TypeError('Invalid namespace string `' + ns + '`'); } var resource = new Resource.Collection(this, '/collections', ns); return (!fn) ? resource : resource.read(opts, fn); }; /** * A currently running operation. * * @param {Number} _id * @param {Object} [opts] Placeholder for future options * @param {Function} [fn] A response callback `(err, data)` * * @stability development * @group resource */ Client.prototype.operation = function(_id, opts, fn){ opts = opts || {}; if(typeof opts === 'function'){ fn = opts; opts = {}; } var resource = new Resource.Operation(this, '/ops', _id); return (!fn) ? resource : resource.read(opts, fn); }; /** * Index details * * @param {String} ns A namespace string, eg `#{database_name}.#{collection_name}` * @param {String} name The index name * @param {Object} [opts] Placeholder for future options * @param {Function} [fn] A response callback `(err, data)` * * @stability development * @return {resource.Index} * @group resource */ Client.prototype.index = function(ns, name, opts, fn){ opts = opts || {}; if(typeof opts === 'function'){ fn = opts; opts = {}; } if(!valid.ns(ns)){ throw new TypeError('Invalid namespace string `' + ns + '`'); } var resource = new Resource.Index(this, '/indexes/' + ns, name); return (!fn) ? resource : resource.read(opts, fn); }; /** * Work with a single document. * * @param {String} ns A namespace string, eg `#{database_name}.#{collection_name}` * @param {String} _id The document's `_id` value * @param {Object} [opts] Placeholder for future options * @param {Function} [fn] A response callback `(err, data)` * * @stability development * @return {resource.Document} * @group resource */ Client.prototype.document = function(ns, _id, opts, fn){ opts = opts || {}; if(typeof opts === 'function'){ fn = opts; opts = {}; } if(!valid.ns(ns)){ throw new TypeError('Invalid namespace string `' + ns + '`'); } var resource = new Resource.Document(this, '/documents/' + ns, _id); return (!fn) ? resource : resource.read(opts, fn); }; /** * Open an ssh tunnel to securely connect to a remote host. * * @stability development * @return {resource.Tunnel} * @group resource */ Client.prototype.tunnel = function(){ return new Resource.Tunnel(this); }; /** * Capture the deltas of top over `opts.interval` ms. * * @param {Object} [opts] Placeholder for future options * @param {Function} fn A response callback `(err, data)` * @option {Number} interval Duration of sample in ms default `1000` * * @example http://codepen.io/imlucas/pen/Lokpn mongotop * * @stability production * @group rt * @streamable */ Client.prototype.top = function(opts, fn){ opts = opts || {}; if(typeof opts === 'function'){ fn = opts; opts = {}; } opts._streamable = true; return this.read('/top', opts, fn); }; /** * A structured view of the ramlog. * * @param {Object} [opts] Placeholder for future options * @param {Function} fn A response callback `(err, data)` * @stability production * @group rt * @streamable */ Client.prototype.log = function(opts, fn){ opts = opts || {}; if(typeof opts === 'function'){ fn = opts; opts = {}; } opts._streamable = true; return this.read('/log', opts, fn); }; /** * List currently running operations. * * @param {Object} [opts] Placeholder for future options * @param {Function} [fn] A response callback `(err, data)` * @stability development * @group rt * @streamable */ Client.prototype.ops = function(opts, fn){ opts = opts || {}; if(typeof opts === 'function'){ fn = opts; opts = {}; } opts._streamable = true; return this.read('/ops', opts, fn); }; /** * Get the sharding info for the cluster the instance you're connected * to is a member of, similar to the `printShardingStatus()` helper function * in the mongo shell. * * @example http://codepen.io/imlucas/pen/JgzAh Sharding Report * * @param {Object} [opts] Placeholder for future options * @param {Function} fn A response callback `(err, data)` * * @stability development * @group rt */ Client.prototype.sharding = function(opts, fn){ opts = opts || {}; if(typeof opts === 'function'){ fn = opts; opts = {}; } return this.read('/sharding', opts, fn); }; /** * View current state of all members and oplog details. * * If called as a stream, events will be emitted for membership events. * * @param {Object} [opts] Placeholder for future options * @param {Function} fn A response callback `(err, data)` * @stability development * * @group rt * @streamable */ Client.prototype.replication = function(opts, fn){ opts = opts || {}; if(typeof opts === 'function'){ fn = opts; opts = {}; } opts._streamable = true; return this.read('/replication', opts, fn); }; /** * Get oplog entries. * * @param {Object} [opts] * @param {Function} fn A response callback `(err, data)` * @option {Number} since Epoch time lower bounds default `Date.now() - 1000 * 60` * @option {Array} filters List of tuples `({key}, {regex})` default `[]` * * @stability development * @group rt * @streamable */ Client.prototype.oplog = function(opts, fn){ opts = opts || {}; if(typeof opts === 'function'){ fn = opts; opts = {}; } var params = { _streamable: true, since: opts.since !== undefined ? EJSON.stringify(opts.since) : opts.since, filters: opts.filters !== undefined ? EJSON.stringify(opts.filters) : opts.filters }; return this.read('/replication/oplog', params, fn); }; /** * Get or stream a group of analytics, which can be any of one * durability, operations, memory, replication, network or indexes. * * @param {String} group * @param {Object} [opts] Placeholder for future options * @param {Function} [fn] A response callback `(err, data)` * * @stability development * @group rt */ Client.prototype.analytics = function(group, opts, fn){ opts = opts || {}; if(typeof opts === 'function'){ fn = opts; opts = {}; } if(this.analytics.groups.indexOf(group) === -1){ var msg = 'Unknown analytics group `'+group+'`'; if(fn) return fn(new Error(msg)); throw new Error(msg); } return this.read('/analytics/' + group, { interval: opts.interval || 1000 }, fn); }; Client.prototype.analytics.groups = [ 'durability', 'operations', 'memory', 'replication', 'network', 'indexes' ]; /** * Run a query on `db.collection`. * * @param {String} ns A namespace string, eg `#{database_name}.#{collection_name}` * @param {Object} [opts] Placeholder for future options * @param {Function} [fn] A response callback `(err, data)` * * @option {Object} query default `{}` * @option {Number} limit default `10`, max 200 * @option {Number} skip default 0 * @option {Boolean} explain Return explain instead of documents default `false` * @option {Object} sort `{key: (1|-1)}` spec default `null` * @option {Object} fields * @option {Object} options * @option {Number} batchSize * * @group query * @stability production * @streamable */ Client.prototype.find = function(ns, opts, fn){ opts = opts || {}; if(typeof opts === 'function'){ fn = opts; opts = {}; } if(!valid.ns(ns)){ return fn(new TypeError('Invalid namespace string `' + ns + '`')); } if(!fn) return new Resource.Collection(this, '/collections', ns).createReadStream(opts); return this.read('/collections/' + ns + '/find', { query: EJSON.stringify((opts.query || {})), limit: (opts.limit || 10), skip: (opts.skip || 0), explain: (opts.explain || false), sort: EJSON.stringify((opts.sort || undefined)), fields: EJSON.stringify((opts.fields || undefined)), options: EJSON.stringify((opts.options || undefined)), batchSize: EJSON.stringify((opts.batchSize || undefined)) }, fn); }; /** * Run a count on `db.collection`. * * @param {String} ns A namespace string, eg `#{database_name}.#{collection_name}` * @param {Object} [opts] * @param {Function} [fn] A response callback `(err, data)` * * @option {Object} query default `{}` * @option {Number} limit default `10`, max 200 * @option {Number} skip default 0 * @option {Boolean} explain Return explain instead of documents default `false` * @option {Object} sort `{key: (1|-1)}` spec default `null` * @option {Object} fields * @option {Object} options * @option {Number} batchSize * * @group query * @stability production */ Client.prototype.count = function(ns, opts, fn){ opts = opts || {}; if(typeof opts === 'function'){ fn = opts; opts = {}; } if(!valid.ns(ns)){ return fn(new TypeError('Invalid namespace string `' + ns + '`')); } var params = { query: EJSON.stringify((opts.query || {})), limit: (opts.limit || 10), skip: (opts.skip || 0), explain: (opts.explain || false), sort: EJSON.stringify((opts.sort || null)), fields: EJSON.stringify((opts.fields || null)), options: EJSON.stringify((opts.options || null)), batchSize: EJSON.stringify((opts.batchSize || null)) }; return this.read('/collections/' + ns+ '/count', params, fn); }; /** * Run an aggregation pipeline on `db.collection`. * * @example http://codepen.io/imlucas/pen/BHvLE Run an aggregation and chart it * * @param {String} ns A namespace string, eg `#{database_name}.#{collection_name}` * @param {Array} pipeline * @param {Object} [opts] * @param {Function} fn A response callback `(err, data)` * * @option {Boolean} explain * @option {Boolean} allowDiskUse * @option {Object} cursor * * @group query * @stability development */ Client.prototype.aggregate = function(ns, pipeline, opts, fn){ if(!Array.isArray(pipeline)){ return fn(new TypeError('pipeline must be an array')); } opts = opts || {}; if(typeof opts === 'function'){ fn = opts; opts = {}; } if(!valid.ns(ns)){ return fn(new TypeError('Invalid namespace string `' + ns + '`')); } return this.read('/collections/' + ns + '/aggregate', { pipeline: EJSON.stringify(pipeline), explain: (opts.explain || false), allowDiskUse: EJSON.stringify((opts.allowDiskUse || null)), cursor: EJSON.stringify((opts.cursor || null)), _streamable: true }, fn); }; /** * Use [resevoir sampling](http://en.wikipedia.org/wiki/Reservoir_sampling) to * get a slice of documents from a collection efficiently. * * @param {String} ns A namespace string, eg `#{database_name}.#{collection_name}` * @param {Object} [opts] * @param {Function} fn A response callback `(err, data)` * * @option {Number} size The number of samples to obtain default `5` * @option {Object} query Restrict the sample to a subset default `{}` * * @group query * @stability development */ Client.prototype.sample = function(ns, opts, fn){ opts = opts || {}; if(typeof opts === 'function'){ fn = opts; opts = {}; } if(!valid.ns(ns)){ return fn(new TypeError('Invalid namespace string `' + ns + '`')); } return this.read('/collections/' + ns + '/sample', opts, fn); }; /** * Convenience to get 1 document via `Client.prototype.sample`. * * @param {String} ns A namespace string, eg `#{database_name}.#{collection_name}` * @param {Object} [opts] * @param {Function} fn A response callback `(err, data)` * * @option {Object} query Restrict the sample to a subset default `{}` * * @group query * @stability development */ Client.prototype.random = function(ns, opts, fn){ opts = opts || {}; if(typeof opts === 'function'){ fn = opts; opts = {}; } if(!valid.ns(ns)){ return fn(new TypeError('Invalid namespace string `' + ns + '`')); } return this.sample(ns, opts, function(err, docs){ if(err) return fn(err); fn(null, docs[0]); }); }; /** * Working set size estimator. * @param {Object} [opts] * @param {Function} fn A response callback `(err, data)` * * @stability prototype * @group query */ Client.prototype.workingSet = function(opts, fn){ opts = opts || {}; if(typeof opts === 'function'){ fn = opts; opts = {}; } return this.read('/working-set', {}, fn); }; /** * Maps backbone.js/express.js style routes to `Client.prototype` methods. * * @api private */ Client.prototype.routes = { '/instance': 'instance', '/deployments': 'deployments', '/deployments/:deployment_id': 'deployment', '/databases/:database': 'database', '/collections/:ns': 'collection', '/collections/:ns/count': 'count', '/collections/:ns/find': 'find', '/collections/:ns/aggregate': 'aggregate', '/log': 'log', '/top': 'top', '/ops': 'ops', '/replication': 'replication', '/sharding': 'sharding' }; /** * Route `fragment` to a call on `Client.prototype`, which is substantially * easier for users on the client-side. More detailled usage is available * in the [backbone.js adapter](/lib/backbone.js). * * @param {String} fragment One of `Client.prototype.routes` * @param {Object} [params] * @param {Function} [fn] * * @ignore */ Client.prototype.get = function(fragment, opts, fn){ opts = opts || {}; if(typeof opts === 'function'){ fn = opts; opts = {}; } var resolved = this.resolve(fragment), handler = resolved[0], args = resolved[1]; args.push.apply(args, [opts, fn]); return handler.apply(this, args); }; /** * Resolve a client handler with a fragment string. * * @param {String} fragment * @return {Array} The {Function} and [{String}] args * * @ignore */ Client.prototype.resolve = function(fragment){ if(!this.router) this.router = createRouter(this.routes); var route = this.router.resolve(fragment); return [this[route.method], route.args]; }; /** * Disconnect everything. Done automatically for you on window unload/process * exit if in nodejs. * * @param {Function} [fn] Optional callback for completely closed. * * @ignore */ Client.prototype.close = function(fn){ if(this.io) this.io.close(); this.emit('close'); this.closed = true; this.token.close(fn); clients[this._id] = null; }; /** * All read requests come through here. * Handles queuing if still connecting and promoting streamables. * * @param {String} path Everything under `/api/v1` automatically prefixing instance. * @param {Object} [params] * @param {Function} [fn] A response callback `(err, data)` * * @api private */ Client.prototype.read = function(path, params, fn){ if(this.dead) return fn(this.dead); if(this.closed) return fn(new Error('Client already closed')); if(!this.readable){ debug('%s not readable. queueing read', this._id, path, params); return this.on('readable', this.read.bind(this, path, params, fn)); } if(typeof params === 'function'){ fn = params; params = {}; } var instance_id = this.context.get('instance_id'), streamable = params._streamable; delete params._streamable; if(!fn && !streamable){ var msg = 'not streamable and missing callback'; if(fn) return fn(new Error(msg)); throw new Error(msg); } if(streamable && !fn) return new Subscription(this, path, params); path = (path === '/') ? '/' + instance_id : (path !== '/deployments') ? '/' + instance_id + path : path; assert(this.token.toString()); return request.get(this.config.scope + '/api/v1' + path) .set('Accept', 'application/json') .set('Authorization', 'Bearer ' + this.token.toString()) .query(params) .end(this.ender(fn)); }; /** * Reponse handler for all superagent responses. * * @param {Function} fn A response callback `(err, data, res)` * * @api private */ Client.prototype.ender = function(fn){ return function(err, res){ if(!err && res.status >= 400){ err = new Error(res.body ? res.body.message : res.text); Error.captureStackTrace(err, Client.prototype.ender); err.status = res.status; } fn.apply(null, [err, (res && res.body), res]); }; }; /** * When we've acquired a security token, do child connections (eg socketio) * and unload handlers. * * If we're reusing an instance, but the user has changed context, * emit `change` so any open streams can easily end the old one * and open a new stream on the current one. * * @api private */ Client.prototype.onTokenReadable = function(){ debug('token now readable'); this.readable = true; this.context.set(this.token.session); if(!this.io){ this.io = socketio(this.config.scope); this.io.on('reconnecting', this.emit.bind(this, 'reconnecting')) .on('reconnect', this.onReconnect.bind(this)) .on('reconnect_attempt', this.emit.bind(this, 'reconnect_attempt')) .on('reconnect_failed', this.emit.bind(this, 'reconnect_failed')) .on('disconnect', this.emit.bind(this, 'disconnect')); debug('sending highfive'); this.io.emit('highfive', {token: this.token.toString()}); } this.emit('readable', this.token.session); debug('emitted readable on client'); if(!this.original){ this.emit('change'); } else { if(typeof window !== 'undefined' && window.document){ if(window.attachEvent){ window.attachEvent('onunload', this.onUnload.bind(this)); } else if(window.addEventListener){ window.addEventListener('beforeunload', this.onUnload.bind(this)); } } else if(process && process.on){ process.on('exit', this.onUnload.bind(this)); } } }; /** * On browser window unload or process exit if running in nodejs, * make sure we clean up after ourselves. * * @api private */ Client.prototype.onUnload = function(){ if(!this.closed){ debug('unloading so im closin'); this.close(); } }; /** * When a token error occurs, the browser can't provide us with good * context in the error, so for now assuming all token errors * mean mongoscope is not running. * * @param {Error} err * @api private */ Client.prototype.onTokenError = function(err){ this.dead = err; if(err >= 500){ this.dead.message += ' (mongoscope server dead at '+this.config.scope+'?)'; } this.emit('error', err); }; Client.prototype.onReconnect = function(){ debug('reconnected. getting new token'); this.token = new Token(this.config) .on('readable', function(){ this.readable = true; this.context.set(this.token.session); this.io.emit('highfive', {token: this.token.toString()}); }.bind(this)) .on('error', this.emit.bind(this, 'error')); };