diff --git a/dist/simple-jsonrpc-js.min.js b/dist/simple-jsonrpc-js.min.js index 8c20334..4138707 100644 --- a/dist/simple-jsonrpc-js.min.js +++ b/dist/simple-jsonrpc-js.min.js @@ -1 +1 @@ -!function(e){"use strict";var d=Promise;if(void 0===(d=void 0===d?e.Promise:d))throw"Promise is not supported! Use latest version node/browser or promise-polyfill";function f(e){return void 0===e}function p(e){var r=typeof e;return null!=e&&("object"==r||"function"==r)}function l(e){return"function"==typeof e}function m(e){return"string"==typeof e}function h(e){if(p(e)){for(var r in e)if(e.hasOwnProperty(r))return;return 1}return g(e)?!e.length:!e}function y(e,r){if(g(e))return e.map(r);for(var n in e)e.hasOwnProperty(n)&&r(e[n])}var g=Array.isArray,v={PARSE_ERROR:{code:-32700,message:"Invalid JSON was received by the server. An error occurred on the server while parsing the JSON text."},INVALID_REQUEST:{code:-32600,message:"Invalid Request. The JSON sent is not a valid Request object."},METHOD_NOT_FOUND:{code:-32601,message:"Method not found. The method does not exist / is not available."},INVALID_PARAMS:{code:-32602,message:"Invalid params. Invalid method parameter(s)."},INTERNAL_ERROR:{code:-32603,message:"Internal error. Internal JSON-RPC error."}};function O(e,r,n){this.message=r||"",this.code=e||-32e3,Boolean(n)&&(this.data=n)}function r(){var o=this,n={},t=0,s={};function a(e,r){e=e,e=JSON.parse(JSON.stringify(e));return r&&(p(r)&&r.hasOwnProperty("message")?e.data=r.message:m(r)&&(e.data=r),r instanceof O&&(e={message:r.message,code:r.code},r.hasOwnProperty("data")&&(e.data=r.data))),e}function i(e){try{return e.error?(r=e,void(n.hasOwnProperty(r.id)?n[r.id].reject(r.error):console.log("Unknown request",r))):(r=e).hasOwnProperty("result")&&r.hasOwnProperty("id")?(r=e,void(n.hasOwnProperty(r.id)?(n[r.id].resolve(r.result),delete n[r.id]):console.log("unknown request",r))):e.method?function(r){{if(!s.hasOwnProperty(r.method))return d.resolve({jsonrpc:"2.0",id:r.id,error:a(v.METHOD_NOT_FOUND,{message:r.method})});try{var e;if(r.hasOwnProperty("params")){if("pass"==s[r.method].params)e=s[r.method].fn.call(s,r.params);else if(g(r.params))e=s[r.method].fn.apply(s,r.params);else if(p(r.params)){if(!(s[r.method].params instanceof Array))return d.resolve({jsonrpc:"2.0",id:r.id,error:a(v.INVALID_PARAMS,"Undeclared arguments of the method "+r.method)});var n=[];if(s[r.method].params.forEach(function(e){r.params.hasOwnProperty(e)?(n.push(r.params[e]),delete r.params[e]):n.push(void 0)}),0 0) { - return _Promise.resolve({ - "jsonrpc": "2.0", - "id": request.id, - "error": setError(ERRORS.INVALID_PARAMS, { - message: "Params: " + Object.keys(request.params).toString() + " not used" - }) - }); + return errorResponse(setError(ERRORS.INVALID_PARAMS, { + message: "Params: " + Object.keys(request.params).toString() + " not used" + })); } else { result = dispatcher[request.method].fn.apply(dispatcher, argsValues); } } else { - return _Promise.resolve({ - "jsonrpc": "2.0", - "id": request.id, - "error": setError(ERRORS.INVALID_PARAMS, "Undeclared arguments of the method " + request.method) - }); + return errorResponse(setError( + ERRORS.INVALID_PARAMS, + "Undeclared arguments of the method " + request.method + )); } } } @@ -274,7 +418,7 @@ result = dispatcher[request.method].fn(); } - if (request.hasOwnProperty('id')) { + if (!isNotification) { if (isPromise(result)) { return result.then(function (res) { if (isUndefined(res)) { @@ -308,25 +452,25 @@ } } else { - return _Promise.resolve(); //nothing, it notification + // Notification success: never respond; swallow rejected promises + if (isPromise(result)) { + return result.then(function () { + return; + }, function () { + return; + }); + } + return noResponse(); } } catch (e) { - return _Promise.resolve({ - "jsonrpc": "2.0", - "id": request.id, - "error": setError(ERRORS.INTERNAL_ERROR, e) - }); + return errorResponse(setError(ERRORS.INTERNAL_ERROR, e)); } } else { - return _Promise.resolve({ - "jsonrpc": "2.0", - "id": request.id, - "error": setError(ERRORS.METHOD_NOT_FOUND, { - message: request.method - }) - }); + return errorResponse(setError(ERRORS.METHOD_NOT_FOUND, { + message: request.method + })); } } @@ -358,10 +502,11 @@ return { promise: new _Promise(function (resolve, reject) { - waitingframe[id.toString()] = { + // Store under the same typed id that goes on the wire (number) + waitingSet(id, { resolve: resolve, reject: reject - }; + }); }), message: message }; @@ -418,7 +563,7 @@ var message = []; forEach(requests, function (req) { - if (req.hasOwnProperty('call')) { + if (hasOwn(req, 'call')) { var _call = call(req.call.method, req.call.params); message.push(_call.message); //TODO(jershell): batch reject if one promise reject, so catch reject and resolve error as result; @@ -428,7 +573,7 @@ return err; })); } - else if (req.hasOwnProperty('notification')) { + else if (hasOwn(req, 'notification')) { message.push(notification(req.notification.method, req.notification.params)); } }); diff --git a/test/tests.js b/test/tests.js index b22705c..66edb19 100644 --- a/test/tests.js +++ b/test/tests.js @@ -2,11 +2,27 @@ if (typeof require !== 'undefined') { var chai = require('chai'); chai.should(); var simple_jsonrpc = require('../simple-jsonrpc-js'); + var simple_jsonrpc_dist = require('../dist/simple-jsonrpc-js.min'); } var assert = chai.assert; var expect = chai.expect; +// Capture console.log and return a restore function that is safe to call multiple times. +function captureConsoleLog(logs) { + var originalLog = console.log; + var restored = false; + console.log = function () { + logs.push(Array.prototype.slice.call(arguments).join(' ')); + }; + return function restoreLog() { + if (!restored) { + console.log = originalLog; + restored = true; + } + }; +} + describe('Request object', function(){ var JsonRpc; @@ -347,6 +363,41 @@ describe('Response object', function () { JsonRpc.messageHandler('{"jsonrpc": "2.0", "id": 1, "result": 5}'); }); + it('Incoming error response should free waitingframe (no permanent leak)', function(done){ + var logs = []; + var restoreLog = captureConsoleLog(logs); + + JsonRpc.toStream = function () {}; + + JsonRpc.call('add', [2, 3]) + .then(function () { + restoreLog(); + done(new Error('call should have been rejected')); + }, function (err) { + try { + expect(err).to.have.ownProperty('code'); + expect(err.code).to.equal(ERRORS.METHOD_NOT_FOUND); + + // Same id again: frame must already be gone → "Unknown request" + JsonRpc.messageHandler('{"jsonrpc": "2.0", "id": 1, "error": {"code": -32601, "message": "Method not found"}}'); + + var sawUnknown = logs.some(function (line) { + return line.indexOf('Unknown request') !== -1; + }); + expect(sawUnknown).to.equal(true); + done(); + } + catch (e) { + done(e); + } + finally { + restoreLog(); + } + }); + + JsonRpc.messageHandler('{"jsonrpc": "2.0", "id": 1, "error": {"code": -32601, "message": "Method not found"}}'); + }); + }); describe('Errors', function(){ @@ -369,7 +420,9 @@ describe('Response object', function () { done(); }; - JsonRpc.messageHandler('{"jsonrpc": "2.0", "id": 2, "method": "delete", "params": {"id"'); + // messageHandler rejects on parse error; swallow so modern Node does not treat it as unhandled + JsonRpc.messageHandler('{"jsonrpc": "2.0", "id": 2, "method": "delete", "params": {"id"') + .catch(function () {}); }); @@ -406,6 +459,83 @@ describe('Response object', function () { JsonRpc.messageHandler('{"jsonrpc": "2.0", "id":44, "method": "delete", "params": {"id": 7}}'); }); + // JSON-RPC 2.0 §4.1: Server MUST NOT reply to a Notification (no id), including on errors + it('Notification with unknown method MUST NOT produce a response', function(done){ + var responded = false; + + JsonRpc.toStream = function(message){ + responded = true; + done(new Error('Notification must not produce a response, got: ' + message)); + }; + + JsonRpc.messageHandler('{"jsonrpc": "2.0", "method": "missing"}') + .then(function(){ + expect(responded).to.equal(false); + done(); + }) + .catch(done); + }); + + it('Notification that throws synchronously MUST NOT produce a response', function(done){ + var responded = false; + + JsonRpc.on('boom', function(){ + throw new Error('sync failure'); + }); + + JsonRpc.toStream = function(message){ + responded = true; + done(new Error('Notification must not produce a response, got: ' + message)); + }; + + JsonRpc.messageHandler('{"jsonrpc": "2.0", "method": "boom"}') + .then(function(){ + expect(responded).to.equal(false); + done(); + }) + .catch(done); + }); + + it('Notification whose handler rejects a Promise MUST NOT produce a response', function(done){ + var responded = false; + + JsonRpc.on('asyncBoom', function(){ + return Promise.reject(new Error('async failure')); + }); + + JsonRpc.toStream = function(message){ + responded = true; + done(new Error('Notification must not produce a response, got: ' + message)); + }; + + JsonRpc.messageHandler('{"jsonrpc": "2.0", "method": "asyncBoom"}') + .then(function(){ + expect(responded).to.equal(false); + done(); + }) + .catch(done); + }); + + it('Notification with invalid named params MUST NOT produce a response', function(done){ + var responded = false; + + JsonRpc.on('named', ['a'], function(a){ + return a; + }); + + JsonRpc.toStream = function(message){ + responded = true; + done(new Error('Notification must not produce a response, got: ' + message)); + }; + + JsonRpc.messageHandler('{"jsonrpc": "2.0", "method": "named", "params": {"a": 1, "extra": 2}}') + .then(function(){ + expect(responded).to.equal(false); + done(); + }) + .catch(done); + }); + it('Named params. should be contained "Invalid params" the error property and not contained the result property', function(done){ var inputJson; @@ -617,6 +747,65 @@ describe('Response object', function () { }); + // Mixed batch: failed notifications must be stripped; only id-bearing request responses remain + it('Batch with failed notifications MUST only return responses for requests with id', function(done){ + JsonRpc.on('add', function(a, b){ + return a + b; + }); + JsonRpc.on('boom', function(){ + throw new Error('sync failure'); + }); + JsonRpc.on('asyncBoom', function(){ + return Promise.reject(new Error('async failure')); + }); + JsonRpc.on('named', ['a'], function(a){ + return a; + }); + JsonRpc.on('ok', function(){ + return 'done'; + }); + + JsonRpc.toStream = function(message){ + var inputJson = JSON.parse(message); + + // Batch response must be an array (more than one id-bearing item) + expect(inputJson).to.be.an('array'); + // 3 requests with id → 3 responses; 4 failing notifications contribute nothing + expect(inputJson.length).to.equal(3); + + inputJson.forEach(function(item){ + expect(item).to.have.ownProperty('jsonrpc'); + expect(item).to.have.ownProperty('id'); + expect(item.id).to.not.equal(undefined); + }); + + // Order preserved among non-notification items only + expect(inputJson[0].id).to.equal(1); + expect(inputJson[0]).to.have.ownProperty('result'); + expect(inputJson[0].result).to.equal(5); + + expect(inputJson[1].id).to.equal(2); + expect(inputJson[1]).to.have.ownProperty('result'); + expect(inputJson[1].result).to.equal('done'); + + expect(inputJson[2].id).to.equal(3); + expect(inputJson[2]).to.have.ownProperty('error'); + expect(inputJson[2].error.code).to.equal(ERRORS.METHOD_NOT_FOUND); + + done(); + }; + + JsonRpc.messageHandler(JSON.stringify([ + {"jsonrpc": "2.0", "method": "missing"}, + {"jsonrpc": "2.0", "method": "add", "params": [2, 3], "id": 1}, + {"jsonrpc": "2.0", "method": "boom"}, + {"jsonrpc": "2.0", "method": "asyncBoom"}, + {"jsonrpc": "2.0", "method": "named", "params": {"a": 1, "extra": 2}}, + {"jsonrpc": "2.0", "method": "ok", "id": 2}, + {"jsonrpc": "2.0", "method": "nope", "id": 3} + ])); + }); + it('Incoming response batch', function(done){ JsonRpc.batch([ @@ -635,12 +824,13 @@ describe('Response object', function () { }); + // Response ids must match request ids in both value and type (numbers from call()) JsonRpc.messageHandler('[' + - '{"jsonrpc":"2.0","id":"1","result":7},' + - '{"jsonrpc":"2.0","id":"2","result":19},' + + '{"jsonrpc":"2.0","id":1,"result":7},' + + '{"jsonrpc":"2.0","id":2,"result":19},' + '{"id":null,"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request. The JSON sent is not a valid Request object."}},' + - '{"jsonrpc":"2.0","id":"3","error":{"code":-32603,"message":"Internal error. Internal JSON-RPC error.","data":"dispatcher[request.method] is not a function"}},' + - '{"jsonrpc":"2.0","id":"4","result":{"name":"Darkwing duck"}}]' + + '{"jsonrpc":"2.0","id":3,"error":{"code":-32603,"message":"Internal error. Internal JSON-RPC error.","data":"dispatcher[request.method] is not a function"}},' + + '{"jsonrpc":"2.0","id":4,"result":{"name":"Darkwing duck"}}]' + ''); }); @@ -676,15 +866,285 @@ describe('Response object', function () { }); JsonRpc.messageHandler('[' + - '{"jsonrpc":"2.0","id":"1","result":7},' + - '{"jsonrpc":"2.0","id":"2","result":19},' + + '{"jsonrpc":"2.0","id":1,"result":7},' + + '{"jsonrpc":"2.0","id":2,"result":19},' + '{"id":null,"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request. The JSON sent is not a valid Request object."}},' + - '{"jsonrpc":"2.0","id":"3","error":{"code":-32603,"message":"Internal error. Internal JSON-RPC error.","data":"dispatcher[request.method] is not a function"}},' + - '{"jsonrpc":"2.0","id":"4","result":{"name":"Darkwing duck"}}]' + + '{"jsonrpc":"2.0","id":3,"error":{"code":-32603,"message":"Internal error. Internal JSON-RPC error.","data":"dispatcher[request.method] is not a function"}},' + + '{"jsonrpc":"2.0","id":4,"result":{"name":"Darkwing duck"}}]' + ''); }); }); + describe('Protocol validation', function(){ + + beforeEach(function() { + JsonRpc = new simple_jsonrpc(); + JsonRpc.on('add', function(a, b){ return a + b; }); + }); + + function expectInvalidRequest(raw, done, expectedId) { + JsonRpc.toStream = function(message){ + try { + var inputJson = JSON.parse(message); + expect(inputJson).to.have.ownProperty('jsonrpc'); + expect(inputJson.error.code).to.equal(ERRORS.INVALID_REQUEST); + expect(inputJson.id).to.equal(expectedId); + done(); + } + catch (e) { + done(e); + } + }; + JsonRpc.messageHandler(raw).catch(function(){}); + } + + it('jsonrpc "1.0" MUST be rejected as Invalid Request with id null', function(done){ + expectInvalidRequest( + '{"jsonrpc":"1.0","method":"add","params":[1,2],"id":1}', + done, + null + ); + }); + + it('params as primitive MUST be rejected as Invalid Request with id null', function(done){ + expectInvalidRequest( + '{"jsonrpc":"2.0","method":"add","params":123,"id":1}', + done, + null + ); + }); + + it('non-string method MUST be rejected as Invalid Request with id null', function(done){ + expectInvalidRequest( + '{"jsonrpc":"2.0","method":42,"id":1}', + done, + null + ); + }); + + it('top-level null MUST be rejected as Invalid Request', function(done){ + expectInvalidRequest('null', done, null); + }); + + it('top-level number MUST be rejected as Invalid Request', function(done){ + expectInvalidRequest('1', done, null); + }); + + it('top-level string MUST be rejected as Invalid Request', function(done){ + expectInvalidRequest('"hello"', done, null); + }); + + it('response id number/string type mismatch MUST NOT settle the call', function(done){ + var resolvedValue; + JsonRpc.toStream = function(){}; + + JsonRpc.call('add', [2, 3]) + .then(function(v){ resolvedValue = v; }, function(e){ resolvedValue = e; }); + + // call() uses numeric id 1; string "1" must not match + JsonRpc.messageHandler('{"jsonrpc":"2.0","id":"1","result":5}') + .then(function(){ + expect(resolvedValue).to.equal(undefined); + return JsonRpc.messageHandler('{"jsonrpc":"2.0","id":1,"result":5}'); + }) + .then(function(){ + expect(resolvedValue).to.equal(5); + done(); + }) + .catch(done); + }); + + it('response with both result and error MUST NOT settle the call', function(done){ + var resolvedValue; + var logs = []; + var restoreLog = captureConsoleLog(logs); + JsonRpc.toStream = function(){}; + + JsonRpc.call('add', [2, 3]) + .then(function(v){ resolvedValue = v; }, function(e){ resolvedValue = e; }); + + JsonRpc.messageHandler('{"jsonrpc":"2.0","id":1,"result":5,"error":{"code":-32603,"message":"Internal error"}}') + .then(function(){ + expect(resolvedValue).to.equal(undefined); + var sawInvalid = logs.some(function(line){ + return line.indexOf('Invalid response') !== -1; + }); + expect(sawInvalid).to.equal(true); + return JsonRpc.messageHandler('{"jsonrpc":"2.0","id":1,"result":5}'); + }) + .then(function(){ + expect(resolvedValue).to.equal(5); + done(); + }) + .catch(done) + .then(function(){ + restoreLog(); + }, function(){ + restoreLog(); + }); + }); + + it('response with non-object error MUST NOT settle or free the waiting call', function(done){ + var resolvedValue; + var logs = []; + var restoreLog = captureConsoleLog(logs); + JsonRpc.toStream = function(){}; + + JsonRpc.call('add', [2, 3]) + .then(function(v){ resolvedValue = v; }, function(e){ resolvedValue = e; }); + + JsonRpc.messageHandler('{"jsonrpc":"2.0","id":1,"error":"not-an-error-object"}') + .then(function(){ + expect(resolvedValue).to.equal(undefined); + var sawInvalid = logs.some(function(line){ + return line.indexOf('Invalid response') !== -1; + }); + expect(sawInvalid).to.equal(true); + // Slot must still be present — valid error can still settle the same id + return JsonRpc.messageHandler('{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"Method not found"}}'); + }) + .then(function(){ + expect(resolvedValue).to.have.ownProperty('code'); + expect(resolvedValue.code).to.equal(ERRORS.METHOD_NOT_FOUND); + done(); + }) + .catch(done) + .then(function(){ + restoreLog(); + }, function(){ + restoreLog(); + }); + }); + + it('response error missing integer code or string message MUST NOT settle', function(done){ + var resolvedValue; + JsonRpc.toStream = function(){}; + + JsonRpc.call('add', [2, 3]) + .then(function(v){ resolvedValue = v; }, function(e){ resolvedValue = e; }); + + JsonRpc.messageHandler('{"jsonrpc":"2.0","id":1,"error":{"code":"-32601","message":"Method not found"}}') + .then(function(){ + expect(resolvedValue).to.equal(undefined); + return JsonRpc.messageHandler('{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":1}}'); + }) + .then(function(){ + expect(resolvedValue).to.equal(undefined); + return JsonRpc.messageHandler('{"jsonrpc":"2.0","id":1,"result":5}'); + }) + .then(function(){ + expect(resolvedValue).to.equal(5); + done(); + }) + .catch(done); + }); + + it('overridden hasOwnProperty on input MUST NOT break request handling', function(done){ + JsonRpc.toStream = function(message){ + try { + var inputJson = JSON.parse(message); + expect(inputJson).to.have.ownProperty('result'); + expect(inputJson.result).to.equal(5); + expect(inputJson.id).to.equal(1); + done(); + } + catch (e) { + done(e); + } + }; + + // hasOwnProperty: 0 makes message.hasOwnProperty throw if called as a method + JsonRpc.messageHandler('{"jsonrpc":"2.0","method":"add","params":[2,3],"id":1,"hasOwnProperty":0}') + .catch(done); + }); + + it('overridden hasOwnProperty on invalid input MUST still yield Invalid Request', function(done){ + expectInvalidRequest( + '{"jsonrpc":"1.0","method":"add","id":7,"hasOwnProperty":0}', + done, + null + ); + }); + + it('empty batch MUST return a single Invalid Request with id null', function(done){ + expectInvalidRequest('[]', done, null); + }); + + it('response with boolean/object/array id MUST NOT settle the call', function(done){ + var resolvedValue; + var logs = []; + var restoreLog = captureConsoleLog(logs); + JsonRpc.toStream = function(){}; + + JsonRpc.call('add', [2, 3]) + .then(function(v){ resolvedValue = v; }, function(e){ resolvedValue = e; }); + + JsonRpc.messageHandler('{"jsonrpc":"2.0","id":true,"result":5}') + .then(function(){ + expect(resolvedValue).to.equal(undefined); + return JsonRpc.messageHandler('{"jsonrpc":"2.0","id":{},"result":5}'); + }) + .then(function(){ + expect(resolvedValue).to.equal(undefined); + return JsonRpc.messageHandler('{"jsonrpc":"2.0","id":[],"result":5}'); + }) + .then(function(){ + expect(resolvedValue).to.equal(undefined); + var sawInvalid = logs.some(function(line){ + return line.indexOf('Invalid response') !== -1; + }); + expect(sawInvalid).to.equal(true); + return JsonRpc.messageHandler('{"jsonrpc":"2.0","id":1,"result":5}'); + }) + .then(function(){ + expect(resolvedValue).to.equal(5); + done(); + }) + .catch(done) + .then(function(){ + restoreLog(); + }, function(){ + restoreLog(); + }); + }); + + }); + }); + +if (typeof require !== 'undefined') { + describe('Distribution build', function(){ + + function protocolOutput(JsonRpcConstructor) { + var JsonRpc = new JsonRpcConstructor(); + var output = []; + + JsonRpc.toStream = function(message){ + output.push(JSON.parse(message)); + }; + + return JsonRpc.messageHandler('{"jsonrpc":"2.0","method":"missing"}') + .then(function(){ + return JsonRpc.messageHandler('[]'); + }) + .then(function(){ + return output; + }); + } + + it('minified build MUST match source protocol behavior', function(){ + return Promise.all([ + protocolOutput(simple_jsonrpc), + protocolOutput(simple_jsonrpc_dist) + ]).then(function(results){ + expect(results[0]).to.deep.equal(results[1]); + expect(results[0]).to.have.length(1); + expect(results[0][0].id).to.equal(null); + expect(results[0][0].error.code).to.equal(-32600); + }); + }); + + }); +}