UNPKG

mongoscope-client

Version:
823 lines (748 loc) 20.6 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'), clientCursor = require('./cursor'), Subscription = require('./subscription'), assert = require('assert'), socketio = require('socket.io-client'), pkg = require('../package.json'), EJSON = require('mongodb-extended-json'), valid = require('./valid'), debug = require('debug')('mongoscope:client'); var Resource = require('./resource'), Document = Resource.Document, Index = Resource.Index, Tunnel = Resource.Tunnel, Collection = Resource.Collection, Database = Resource.Database, Operation = Resource.Operation; // 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; } module.exports = function(opts){ var client = new Client(opts); client.token = new Token(client.config) .on('readable', client.onTokenReadable.bind(client)) .on('error', client.onTokenError.bind(client)); debug('waiting for token to become readable'); return client; }; module.exports.createClient = Client; 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' }, auth: opts.auth, timeout: opts.timeout }; debug('new connection', this.config); this.context = new Context(); this.readable = false; this.original = true; this.dead = false; this.closed = false; } util.inherits(Client, EventEmitter); /** * @ignore */ Client.prototype.close = function(fn){ if(this.io) this.io.close(); this.emit('close'); this.closed = true; this.token.close(fn); }; /** * 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, ' -')); * }); * ``` * * @stability production */ 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. * * @stability production */ Client.prototype.deployments = function(opts, fn){ opts = opts || {}; if(typeof opts === 'function'){ fn = opts; opts = {}; } return this.read('/deployments', 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 * * @stability development */ 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. * * @stability development * @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. * * @option {Number} since Epoch time lower bounds default `Date.now() - 1000 * 60` * @option {Array} filters List of tuples `({key}, {regex})` default `[]` * * @stability prototype * @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); }; /** * Capture the deltas of top over `opts.interval` ms. * * @option {Number} interval Duration of sample in ms default `1000` * * @stability development * @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. * * @stability development * @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 collection names and stats. * * @param {String} name * * @stability production * @resource database */ Client.prototype.database = function database(name, opts, fn){ opts = opts || {}; if(typeof opts === 'function'){ fn = opts; opts = {}; } if(!valid.database(name)){ var msg = 'Invalid database name `' + name + '`'; if(fn) return fn(new TypeError(msg)); throw new TypeError(msg); } if(!fn) return new Database(this, '/databases', name); return this.read('/databases/' + name, opts, fn); }; /** * Collection stats * * @param {String} ns * * @stability production * @resource collection */ Client.prototype.collection = function collection(ns, opts, fn){ opts = opts || {}; if(typeof opts === 'function'){ fn = opts; opts = {}; } if(!valid.ns(ns)){ var msg = 'Invalid namespace string `' + ns + '`'; if(fn) return fn(new TypeError(msg)); throw new TypeError(msg); } if(!fn) return new Collection(this, '/collections', ns); return this.read('/collections/' + ns, opts, fn); }; /** * List currently running operations. * * @stability development * @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); }; /** * A currently running operation. * * @param {Number} _id * * @stability development */ Client.prototype.operation = function(_id, opts, fn){ opts = opts || {}; if(typeof opts === 'function'){ fn = opts; opts = {}; } if(!fn) return new Operation(this, '/ops', _id); return this.read('del', '/ops/' + _id, {}, fn); }; /** * Index details * * @param {String} ns * @param {String} name The index name * * @stability prototype * @resource index */ Client.prototype.index = function(ns, name, fn){ if(!valid.ns(ns)){ return fn(new TypeError('Invalid namespace string `' + ns + '`')); } if(!fn) return new Index(this, '/indexes/' + ns, name); return this.read('/indexes/' + ns + '/' + name, {}, fn); }; /** * Get a document * * @todo Need to get extended JSON support sorted before moving forward. * * @param {String} ns * @param {String} _id * * @stability prototype * @resource document */ Client.prototype.document = function(ns, _id, fn){ if(!valid.ns(ns)){ return fn(new TypeError('Invalid namespace string `' + ns + '`')); } if(!fn) return new Document(this, '/documents/' + ns, _id); return this.read('/documents/' + ns + '/' + _id, {}, fn); }; /** * Run a query on `db.collection`. * * @param {String} ns * * @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 * * @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 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); } var client = this, cursor = clientCursor(ns, opts.query || {}, opts.limit || 100, opts.skip || 0, opts.fields || null, opts.options || null); cursor.requestMore = function(cb){ var p = { query: cursor.query, skip: cursor.nToSkip, limit: cursor.nToReturn, fields: cursor.fieldsToReturn, options: cursor.queryOptions }; client.find(ns, p, function(err, res){ if(err) return cb(err); cursor.batch = cursor.createBatch(); cursor.batch.nReturned = res.length; cursor.batch.data = res; cursor.nToSkip += cursor.nToReturn; cb(); }); }; return cursor; }; /** * Run a count on `db.collection`. * * @param {String} ns * @param {Object} opts * @param {Function} fn * @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 * @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 * @param {Array} pipeline * @param {Object} opts * @option {Boolean} explain * @option {Boolean} allowDiskUse * @option {Object} cursor * @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 * * @option {Number} size default `5` * * @stability prototype */ 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', { size: opts.size || 5 }, fn); }; /** * Convenience to get 1 document via `Client.prototype.sample`. * * @param {String} ns * @param {Object} opts * @stability prototype */ 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, {size: 1}, function(err, docs){ if(err) return fn(err); fn(null, docs[0]); }); }; /** * Open an ssh tunnel to securely connect to a remote host. * * @stability prototype */ Client.prototype.tunnel = function(){ return new Tunnel(this); }; /** * Get or stream a group of analytics. * * @param {String} group One of `Client.prototype.analytics.groups` * @stability prototype * @streamable */ 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' ]; /** * Working set size estimator. * * @stability prototype */ 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] * * @stability development */ 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); }; 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]; }; /** * Get an instance of the backbone adapter bound to this client instance. * * @example http://codepen.io/imlucas/pen/Ltigv Backbone.js adapter * @stability production */ Object.defineProperty(Client.prototype, 'backbone', {enumerable: true, writeable: false, configurable: false, get: function(){ if(!this.adapters) this.adapters = {}; if(!this.adapters.backbone){ this.adapters.backbone = require('./adapters/backbone.js')(this); } return this.adapters.backbone; }}); /** * Point at a difference instance, only replacing the token object * so all of your event listeners remain intact. * * @param {String} seed * @stability development */ Client.prototype.connect = function(seed, opts){ if(seed === this.config.seed){ debug('already connected to ' + seed); return this; } this.readable = false; this.token.close(); this.original = false; opts = opts || { auth: {}, timeout: undefined }; this.config.seed = seed; this.config.auth = opts.auth; this.config.timeout = opts.timeout; this.token = new Token(this.config) .on('readable', this.onTokenReadable.bind(this)) .on('error', this.emit.bind(this, 'error')); return this; }; /** * 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] * * @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) 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. * * @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.context.set(this.token.session); if(!this.io){ this.io = socketio(this.config.scope); debug('sending highfive'); this.io.emit('highfive', {token: this.token.toString()}); } this.readable = true; this.emit('readable', this.token.session); 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); };