/** * JsonRpcClient * * A JSON RPC Client that uses WebSockets if available otherwise fallbacks to ajax. * Depends on JSON, if browser lacks native support either use JSON3 or jquery.json. * Usage example: * * var foo = new $.JsonRpcClient({ ajaxUrl: '/backend/jsonrpc' }); * foo.call( * 'bar', [ 'A parameter', 'B parameter' ], * function(result) { alert('Foo bar answered: ' + result.my_answer); }, * function(error) { console.log('There was an error', error); } * ); * * More examples are available in README.md */ (function($) { /** * @fn new * @memberof JsonRpcClient * * @param {object} options An object stating the backends: * ajaxUrl A url (relative or absolute) to a http(s) backend. * headers An object that will be passed along to $.ajax in options.headers * xhrFields An object that will be passed along to $.ajax in options.xhrFields * socketUrl A url (relative of absolute) to a ws(s) backend. * onmessage A socket message handler for other messages (non-responses). * onopen A socket onopen handler. (Not used for custom getSocket.) * onclose A socket onclose handler. (Not used for custom getSocket.) * onerror A socket onerror handler. (Not used for custom getSocket.) * getSocket A function returning a WebSocket or null. * It must take an onmessage_cb and bind it to the onmessage event * (or chain it before/after some other onmessage handler). * Or, it could return null if no socket is available. * The returned instance must have readyState <= 1, and if less than 1, * react to onopen binding. * timeout (optional) A number of ms to wait before timing out and failing a * call. If specified a setTimeout will be used to keep track of calls * made through a websocket. */ var JsonRpcClient = function(options) { var self = this; var noop = function() {}; this.options = $.extend({ ajaxUrl : null, headers : {}, ///< Optional additional headers to send in $.ajax request. socketUrl : null, ///< WebSocket URL. (Not used if a custom getSocket is supplied.) onmessage : noop, ///< Optional onmessage-handler for WebSocket. onopen : noop, ///< Optional onopen-handler for WebSocket. onclose : noop, ///< Optional onclose-handler for WebSocket. onerror : noop, ///< Optional onerror-handler for WebSocket. /// Custom socket supplier for using an already existing socket getSocket : function(onmessageCb) { return self._getSocket(onmessageCb); } }, options); // Declare an instance version of the onmessage callback to wrap 'this'. this.wsOnMessage = function(event) { self._wsOnMessage(event); }; /// Holding the WebSocket on default getsocket. this._wsSocket = null; /// Object : { success_cb: cb, error_cb: cb } this._wsCallbacks = {}; /// The next JSON-RPC request id. this._currentId = 1; //queue for ws request sent *before* ws is open. this._wsRequestQueue = []; if (!window.JSON && $ && $.toJSON) { this.JSON = { stringify: $.toJSON, parse: $.parseJSON }; } else { this.JSON = JSON; } }; /** * @fn call * @memberof JsonRpcClient * * @param {string} method The method to run on JSON-RPC server. * @param {object|array} params The params; an array or object. * @param {function} successCb A callback for successful request. * @param {function} errorCb A callback for error. * * @return {object} Returns the deferred object that $.ajax returns or {null} for websockets */ JsonRpcClient.prototype.call = function(method, params, successCb, errorCb) { successCb = typeof successCb === 'function' ? successCb : function() {}; errorCb = typeof errorCb === 'function' ? errorCb : function() {}; // Construct the JSON-RPC 2.0 request. var request = { jsonrpc : '2.0', method : method, params : params, id : this._currentId++ // Increase the id counter to match request/response }; // Try making a WebSocket call. var socket = this.options.getSocket(this.wsOnMessage); if (socket !== null) { this._wsCall(socket, request, successCb, errorCb); return null; } // No WebSocket, and no HTTP backend? This won't work. if (this.options.ajaxUrl === null) { throw 'JsonRpcClient.call used with no websocket and no http endpoint.'; } var self = this; var deferred = $.ajax({ type : 'POST', url : this.options.ajaxUrl, contentType: 'application/json', data : this.JSON.stringify(request), dataType : 'json', cache : false, headers : this.options.headers, xhrFields : this.options.xhrFields, timeout : this.options.timeout, success : function(data) { if ('error' in data) { errorCb(data.error); } else { successCb(data.result); } }, // JSON-RPC Server could return non-200 on error error : function(jqXHR, textStatus, errorThrown) { try { var response = self.JSON.parse(jqXHR.responseText); if ('console' in window) { console.log(response); } errorCb(response.error); } catch (err) { // Perhaps the responseText wasn't really a jsonrpc-error. errorCb({error: jqXHR.responseText}); } } }); return deferred; }; /** * Notify sends a command to the server that won't need a response. In http, there is probably * an empty response - that will be dropped, but in ws there should be no response at all. * * This is very similar to call, but has no id and no handling of callbacks. * * @fn notify * @memberof JsonRpcClient * * @param {string} method The method to run on JSON-RPC server. * @param {object|array} params The params; an array or object. * * @return {object} Returns the deferred object that $.ajax returns or {null} for websockets */ JsonRpcClient.prototype.notify = function(method, params) { // Construct the JSON-RPC 2.0 request. var request = { jsonrpc: '2.0', method: method, params: params }; // Try making a WebSocket call. var socket = this.options.getSocket(this.wsOnMessage); if (socket !== null) { this._wsCall(socket, request); return null; } // No WebSocket, and no HTTP backend? This won't work. if (this.options.ajaxUrl === null) { throw 'JsonRpcClient.notify used with no websocket and no http endpoint.'; } var deferred = $.ajax({ type : 'POST', url : this.options.ajaxUrl, contentType: 'application/json', data : this.JSON.stringify(request), dataType : 'json', cache : false, headers : this.options.headers, xhrFields : this.options.xhrFields }); return deferred; }; /** * Make a batch-call by using a callback. * * The callback will get an object "batch" as only argument. On batch, you can call the methods * "call" and "notify" just as if it was a normal JsonRpcClient object, and all calls will be * sent as a batch call then the callback is done. * * @fn batch * @memberof JsonRpcClient * * @param {function} callback This function will get a batch handler to run call and notify on. * @param {function} allDoneCb A callback function to call after all results have been handled. * @param {function} errorCb A callback function to call if there is an error from the server. * Note, that batch calls should always get an overall success, and the * only error */ JsonRpcClient.prototype.batch = function(callback, allDoneCb, errorCb) { var batch = new JsonRpcClient._batchObject(this, allDoneCb, errorCb); callback(batch); batch._execute(); }; /** * The default getSocket handler. * * @param {function} onmessageCb The callback to be bound to onmessage events on the socket. * * @fn _getSocket * @memberof JsonRpcClient */ JsonRpcClient.prototype._getSocket = function(onmessageCb) { // If there is no ws url set, we don't have a socket. // Likewise, if there is no window.WebSocket. if (this.options.socketUrl === null || !('WebSocket' in window)) { return null; } if (this._wsSocket === null || this._wsSocket.readyState > 1) { try { // No socket, or dying socket, let's get a new one. this._wsSocket = new WebSocket(this.options.socketUrl); } catch (e) { // This can happen if the server is down, or malconfigured. return null; } // Set up onmessage handler. this._wsSocket.onmessage = onmessageCb; var that = this; // Set up onclose handler. this._wsSocket.onclose = function(ev) { that._wsOnClose(ev); }; // Set up onerror handler. this._wsSocket.onerror = function(ev) { that._wsOnError(ev); }; } return this._wsSocket; }; /** * Internal handler to dispatch a JRON-RPC request through a websocket. * * @fn _wsCall * @memberof JsonRpcClient */ JsonRpcClient.prototype._wsCall = function(socket, request, successCb, errorCb) { var requestJson = this.JSON.stringify(request); // Setup callbacks. If there is an id, this is a call and not a notify. if ('id' in request && typeof successCb !== 'undefined') { this._wsCallbacks[request.id] = {successCb: successCb, errorCb: errorCb}; } if (socket.readyState < 1) { // Queue request this._wsRequestQueue.push(requestJson); if (!socket.onopen) { // The websocket is not open yet; we have to set sending of the message in onopen. var self = this; // In closure below, this is set to the WebSocket. Use self instead. // Set up sending of message for when the socket is open. socket.onopen = function(event) { // Hook for extra onopen callback self.options.onopen(event); // Send queued requests. var timeout = self.options.timeout; var request; for (var i = 0; i < self._wsRequestQueue.length; i++) { request = self._wsRequestQueue[i]; // Do we use timeouts, and if so, is it a call? if (timeout && self._wsCallbacks[request.id]) { self._wsCallbacks[request.id].timeout = self._createTimeout(request.id); } socket.send(request); } self._wsRequestQueue = []; }; } } else { // Do we use timeouts, and if so, is it a call? if (this.options.timeout && this._wsCallbacks[request.id]) { this._wsCallbacks[request.id].timeout = this._createTimeout(request.id); } // We have a socket and it should be ready to send on. socket.send(requestJson); } }; /** * Internal handler for the websocket messages. It determines if the message is a JSON-RPC * response, and if so, tries to couple it with a given callback. Otherwise, it falls back to * given external onmessage-handler, if any. * * @param {event} event The websocket onmessage-event. */ JsonRpcClient.prototype._wsOnMessage = function(event) { // Check if this could be a JSON RPC message. var response; try { response = this.JSON.parse(event.data); } catch (err) { this.options.onmessage(event); return; } /// @todo Make using the jsonrcp 2.0 check optional, to use this on JSON-RPC 1 backends. if (typeof response === 'object' && response.jsonrpc === '2.0') { /// @todo Handle bad response (without id). // If this is an object with result, it is a response. if ('result' in response && this._wsCallbacks[response.id]) { // Get the success callback. var successCb = this._wsCallbacks[response.id].successCb; // Clear any timeout if (this._wsCallbacks[response.id].timeout) { clearTimeout(this._wsCallbacks[response.id].timeout); } // Delete the callback from the storage. delete this._wsCallbacks[response.id]; // Run callback with result as parameter. successCb(response.result); return; } // If this is an object with error, it is an error response. else if ('error' in response && this._wsCallbacks[response.id]) { // Get the error callback. var errorCb = this._wsCallbacks[response.id].errorCb; // Delete the callback from the storage. delete this._wsCallbacks[response.id]; // Run callback with the error object as parameter. errorCb(response.error); return; } } // If we get here it's an invalid JSON-RPC response, pass to fallback message handler. this.options.onmessage(event); }; /** * Internal WebSocket error handler. * Will execute all unresolved calls immideatly. **/ JsonRpcClient.prototype._wsOnError = function(event) { this._failAllCalls('Socket errored.'); this.options.onerror(event); }; /** * Internal WebSocket close handler. * Will execute all unresolved calls immideatly. **/ JsonRpcClient.prototype._wsOnClose = function(event) { this._failAllCalls('Socket closed.'); this.options.onclose(event); }; /** * Execute error handler on all pending calls. */ JsonRpcClient.prototype._failAllCalls = function(error) { for (var key in this._wsCallbacks) { if (this._wsCallbacks.hasOwnProperty(key)) { // Get the error callback. var errorCb = this._wsCallbacks[key].errorCb; // Run callback with the error object as parameter. errorCb(error); } } // Throw 'em away this._wsCallbacks = {}; }; /** * Create a timeout for this request */ JsonRpcClient.prototype._createTimeout = function(id) { if (this.options.timeout) { var that = this; return setTimeout(function() { if (that._wsCallbacks[id]) { var errorCb = that._wsCallbacks[id].errorCb; delete that._wsCallbacks[id]; errorCb('Call timed out.'); } }, this.options.timeout); } }; /************************************************************************************************ * Batch object with methods ************************************************************************************************/ /** * Handling object for batch calls. */ JsonRpcClient._batchObject = function(jsonrpcclient, allDoneCb, errorCb) { // Array of objects to hold the call and notify requests. Each objects will have the request // object, and unless it is a notify, successCb and errorCb. this._requests = []; this.jsonrpcclient = jsonrpcclient; this.allDoneCb = allDoneCb; this.errorCb = typeof errorCb === 'function' ? errorCb : function() {}; }; /** * @sa JsonRpcClient.prototype.call */ JsonRpcClient._batchObject.prototype.call = function(method, params, successCb, errorCb) { this._requests.push({ request : { jsonrpc : '2.0', method : method, params : params, id : this.jsonrpcclient._currentId++ // Use the client's id series. }, successCb : successCb, errorCb : errorCb }); }; /** * @sa JsonRpcClient.prototype.notify */ JsonRpcClient._batchObject.prototype.notify = function(method, params) { this._requests.push({ request : { jsonrpc : '2.0', method : method, params : params } }); }; /** * Executes the batched up calls. * * @return {object} Returns the deferred object that $.ajax returns or {null} for websockets */ JsonRpcClient._batchObject.prototype._execute = function() { var self = this; var deferred = null; // Used to store and return the deffered that $.ajax returns if (this._requests.length === 0) { return; } // All done :P // Collect all request data and sort handlers by request id. var batchRequest = []; // If we have a WebSocket, just send the requests individually like normal calls. var socket = self.jsonrpcclient.options.getSocket(self.jsonrpcclient.wsOnMessage); if (socket !== null) { // We need to keep track of results for the all done callback var expectedNrOfCb = 0; var cbResults = []; var wrapCb = function(cb) { if (!self.allDoneCb) { // No all done callback? no need to keep track return cb; } return function(data) { cb(data); cbResults.push(data); expectedNrOfCb--; if (expectedNrOfCb <= 0) { // Change order so that it maps to request order var i; var resultMap = {}; for (i = 0; i < cbResults.length; i++) { resultMap[cbResults[i].id] = cbResults[i]; } var results = []; for (i = 0; i < self._requests.length; i++) { if (resultMap[self._requests[i].id]) { results.push(resultMap[self._requests[i].id]); } } // Call all done! self.allDoneCb(results); } }; }; for (var i = 0; i < this._requests.length; i++) { var call = this._requests[i]; if ('id' in call.request) { // We expect an answer expectedNrOfCb++; } self.jsonrpcclient._wsCall( socket, call.request, wrapCb(call.successCb), wrapCb(call.errorCb) ); } return null; } else { // No websocket, let's use ajax var handlers = {}; for (var i = 0; i < this._requests.length; i++) { var call = this._requests[i]; batchRequest.push(call.request); // If the request has an id, it should handle returns (otherwise it's a notify). if ('id' in call.request) { handlers[call.request.id] = { successCb : call.successCb, errorCb : call.errorCb }; } } var successCb = function(data) { self._batchCb(data, handlers, self.allDoneCb); }; // No WebSocket, and no HTTP backend? This won't work. if (self.jsonrpcclient.options.ajaxUrl === null) { throw 'JsonRpcClient.batch used with no websocket and no http endpoint.'; } // Send request deferred = $.ajax({ url : self.jsonrpcclient.options.ajaxUrl, contentType: 'application/json', data : this.jsonrpcclient.JSON.stringify(batchRequest), dataType : 'json', cache : false, type : 'POST', headers : self.jsonrpcclient.options.headers, xhrFields : self.jsonrpcclient.options.xhrFields, // Batch-requests should always return 200 error : function(jqXHR, textStatus, errorThrown) { self.errorCb(jqXHR, textStatus, errorThrown); }, success : successCb }); return deferred; } }; /** * Internal helper to match the result array from a batch call to their respective callbacks. * * @fn _batchCb * @memberof JsonRpcClient */ JsonRpcClient._batchObject.prototype._batchCb = function(result, handlers, allDoneCb) { for (var i = 0; i < result.length; i++) { var response = result[i]; // Handle error if ('error' in response) { if (response.id === null || !(response.id in handlers)) { // An error on a notify? Just log it to the console. if ('console' in window) { console.log(response); } } else { handlers[response.id].errorCb(response.error); } } else { // Here we should always have a correct id and no error. if (!(response.id in handlers) && 'console' in window) { console.log(response); } else { handlers[response.id].successCb(response.result); } } } if (typeof allDoneCb === 'function') { allDoneCb(result); } }; $.JsonRpcClient = JsonRpcClient; })(this.jQuery);