'use strict';
const events = require('events');
const isFunction = require('lodash/isFunction');
const jayson = require('../');
const utils = require('../utils');
/**
* Constructor for a Jayson Server
* @class Server
* @extends require('events').EventEmitter
* @param {Object<String,Function>} [methods] Methods to add
* @param {Object} [options]
* @param {Array|Object} [options.params] Passed to Jayson.Method as an option when created
* @param {Boolean} [options.useContext=false] Passed to Jayson.Method as an option when created
* @param {Function} [options.reviver] Reviver function for JSON
* @param {Function} [options.replacer] Replacer function for JSON
* @param {Function} [options.methodConstructor] Methods will be made instances of this class
* @param {String} [options.encoding="utf8"] Encoding to use
* @param {Number} [options.version=2] JSON-RPC version to use (1|2)
* @param {Function} [options.router] Function to use for routing methods
* @property {Object} options A reference to the internal options object that can be modified directly
* @property {Object} errorMessages Map of error code to error message pairs that will be used in server responses
* @property {ServerHttp} http HTTP interface constructor
* @property {ServerHttps} https HTTPS interface constructor
* @property {ServerTcp} tcp TCP interface constructor
* @property {ServerTls} tls TLS interface constructor
* @property {Middleware} middleware Middleware generator function
* @return {Server}
*/
const Server = function(methods, options) {
if(!(this instanceof Server)) {
return new Server(methods, options);
}
const defaults = {
reviver: null,
replacer: null,
encoding: 'utf8',
version: 2,
useContext: false,
methodConstructor: jayson.Method,
router: function(method) {
return this.getMethod(method);
}
};
this.options = utils.merge(defaults, options || {});
// bind router to the server
this.options.router = this.options.router.bind(this);
this._methods = {};
// adds methods passed to constructor
this.methods(methods || {});
// assigns interfaces to this instance
const interfaces = Server.interfaces;
for(let name in interfaces) {
this[name] = interfaces[name].bind(interfaces[name], this);
}
// copies error messages for defined codes into this instance
this.errorMessages = {};
for(let handle in Server.errors) {
const code = Server.errors[handle];
this.errorMessages[code] = Server.errorMessages[code];
}
};
require('util').inherits(Server, events.EventEmitter);
module.exports = Server;
/**
* Interfaces that will be automatically bound as properties of a Server instance
* @enum {Function}
* @static
*/
Server.interfaces = {
http: require('./http'),
https: require('./https'),
tcp: require('./tcp'),
tls: require('./tls'),
websocket: require('./websocket'),
middleware: require('./middleware')
};
/**
* JSON-RPC specification errors that map to an integer code
* @enum {Number}
* @static
*/
Server.errors = {
PARSE_ERROR: -32700,
INVALID_REQUEST: -32600,
METHOD_NOT_FOUND: -32601,
INVALID_PARAMS: -32602,
INTERNAL_ERROR: -32603
};
/*
* Error codes that map to an error message
* @enum {String}
* @static
*/
Server.errorMessages = {};
Server.errorMessages[Server.errors.PARSE_ERROR] = 'Parse Error';
Server.errorMessages[Server.errors.INVALID_REQUEST] = 'Invalid request';
Server.errorMessages[Server.errors.METHOD_NOT_FOUND] = 'Method not found';
Server.errorMessages[Server.errors.INVALID_PARAMS] = 'Invalid method parameter(s)';
Server.errorMessages[Server.errors.INTERNAL_ERROR] = 'Internal error';
/**
* Adds a single method to the server
* @param {String} name Name of method to add
* @param {Function|Client} definition Function or Client for a relayed method
* @throws {TypeError} Invalid parameters
*/
Server.prototype.method = function(name, definition) {
const Method = this.options.methodConstructor;
const isRelay = definition instanceof jayson.Client;
const isMethod = definition instanceof Method;
const isDefinitionFunction = isFunction(definition);
// a valid method is either a function or a client (relayed method)
if(!isRelay && !isMethod && !isDefinitionFunction) {
throw new TypeError('method definition must be either a function, an instance of jayson.Client or an instance of jayson.Method');
}
if(!name || typeof(name) !== 'string') {
throw new TypeError('"' + name + '" must be a non-zero length string');
}
if(/^rpc\./.test(name)) {
throw new TypeError('"' + name + '" is a reserved method name');
}
// make instance of jayson.Method
if(!isRelay && !isMethod) {
definition = new Method(definition, {
params: this.options.params,
useContext: this.options.useContext
});
}
this._methods[name] = definition;
};
/**
* Adds a batch of methods to the server
* @param {Object} methods Methods to add
*/
Server.prototype.methods = function(methods) {
methods = methods || {};
for(let name in methods) {
this.method(name, methods[name]);
}
};
/**
* Checks if a method is registered with the server
* @param {String} name Name of method
* @return {Boolean}
*/
Server.prototype.hasMethod = function(name) {
return name in this._methods;
};
/**
* Removes a method from the server
* @param {String} name
*/
Server.prototype.removeMethod = function(name) {
if(this.hasMethod(name)) {
delete this._methods[name];
}
};
/**
* Gets a method from the server
* @param {String} name
* @return {Method}
*/
Server.prototype.getMethod = function(name) {
return this._methods[name];
};
/**
* Returns a JSON-RPC compatible error property
* @param {Number} [code=-32603] Error code
* @param {String} [message="Internal error"] Error message
* @param {Object} [data] Additional data that should be provided
* @return {Object}
*/
Server.prototype.error = function(code, message, data) {
if(typeof(code) !== 'number') {
code = Server.errors.INTERNAL_ERROR;
}
if(typeof(message) !== 'string') {
message = this.errorMessages[code] || '';
}
const error = { code: code, message: message };
if(typeof(data) !== 'undefined') {
error.data = data;
}
return error;
};
/**
* Calls a method on the server
* @param {Object|Array|String} request A JSON-RPC request object. Object for single request, Array for batches and String for automatic parsing (using the reviver option)
* @param {Object} [context] Optional context object passed to methods
* @param {Function} [originalCallback] Callback that receives one of two arguments: first is an error and the second a response
*/
Server.prototype.call = function(request, context, originalCallback) {
const self = this;
if(typeof(context) === 'function') {
originalCallback = context;
context = {};
}
if(typeof(context) === 'undefined') {
context = {};
}
if(typeof(originalCallback) !== 'function') {
originalCallback = function() {};
}
// compose the callback so that we may emit an event on every response
const callback = function(error, response) {
self.emit('response', request, response || error);
originalCallback.apply(null, arguments);
};
maybeParse(request, this.options, function(err, request) {
let error = null; // JSON-RPC error
if(err) {
error = self.error(Server.errors.PARSE_ERROR, null, err);
callback(utils.response(error, undefined, undefined, self.options.version));
return;
}
// is this a batch request?
if(utils.Request.isBatch(request)) {
// batch requests not allowed for version 1
if(self.options.version === 1) {
error = self.error(Server.errors.INVALID_REQUEST);
callback(utils.response(error, undefined, undefined, self.options.version));
return;
}
// special case if empty batch request
if(!request.length) {
error = self.error(Server.errors.INVALID_REQUEST);
callback(utils.response(error, undefined, undefined, self.options.version));
return;
}
self._batch(request, context, callback);
return;
}
self.emit('request', request);
// is the request valid?
if(!utils.Request.isValidRequest(request, self.options.version)) {
error = self.error(Server.errors.INVALID_REQUEST);
callback(utils.response(error, undefined, undefined, self.options.version));
return;
}
// from now on we are "notification-aware" and can deliberately ignore errors for such requests
const respond = function(error, result) {
if(utils.Request.isNotification(request)) {
callback();
return;
}
const response = utils.response(error, result, request.id, self.options.version);
if(response.error) {
callback(response);
} else {
callback(null, response);
}
};
const method = self._resolveRouter(request.method, request.params);
// are we attempting to invoke a relayed method?
if(method instanceof jayson.Client) {
return method.request(request.method, request.params, request.id, function(error, response) {
if(utils.Request.isNotification(request)) {
callback();
return;
}
callback(error, response);
});
}
// does the method exist?
if(!(method instanceof jayson.Method)) {
respond(self.error(Server.errors.METHOD_NOT_FOUND));
return;
}
// execute jayson.Method instance
method.execute(self, request.params, context, function(error, result) {
if(utils.Response.isValidError(error, self.options.version)) {
respond(error);
return;
}
// got an invalid error
if(error) {
respond(self.error(Server.errors.INTERNAL_ERROR));
return;
}
respond(null, result);
});
});
};
/**
* Invoke the router
* @param {String} method Method to resolve
* @param {Array|Object} params Request params
* @return {Method}
*/
Server.prototype._resolveRouter = function(method, params) {
let router = this.options.router;
if(!isFunction(router)) {
router = function(method) {
return this.getMethod(method);
};
}
const resolved = router.call(this, method, params);
// got a jayson.Method or a jayson.Client, return it
if((resolved instanceof jayson.Method) || (resolved instanceof jayson.Client)) {
return resolved;
}
// got a regular function, make it an instance of jayson.Method
if(isFunction(resolved)) {
return new jayson.Method(resolved);
}
};
/**
* Evaluates a batch request
* @private
*/
Server.prototype._batch = function(requests, context, callback) {
const self = this;
const responses = [];
this.emit('batch', requests);
/**
* @ignore
*/
const maybeRespond = function() {
// done when we have filled up all the responses with a truthy value
const isDone = responses.every(function(response) { return response !== null; });
if(isDone) {
// filters away notifications
const filtered = responses.filter(function(res) {
return res !== true;
});
// only notifications in request means empty response
if(!filtered.length) {
return callback();
}
callback(null, filtered);
}
};
/**
* @ignore
*/
const wrapper = function(request, index) {
responses[index] = null;
return function() {
if(utils.Request.isValidRequest(request, self.options.version)) {
self.call(request, context, function(error, response) {
responses[index] = error || response || true;
maybeRespond();
});
} else {
const error = self.error(Server.errors.INVALID_REQUEST);
responses[index] = utils.response(error, undefined, undefined, self.options.version);
maybeRespond();
}
};
};
const stack = requests.map(function(request, index) {
// ignore possibly nested requests
if(utils.Request.isBatch(request)) {
return null;
}
return wrapper(request, index);
});
stack.forEach(function(method) {
if(typeof(method) === 'function') {
method();
}
});
};
/**
* Parse "request" if it is a string, else just invoke callback
* @ignore
*/
function maybeParse(request, options, callback) {
if(typeof(request) === 'string') {
utils.JSON.parse(request, options, callback);
} else {
callback(null, request);
}
}