├── .gitignore ├── LICENSE.APACHE2 ├── LICENSE.MIT ├── index.js ├── package.json ├── readme.markdown └── test ├── create-remote.js ├── custom-flatten.js ├── double-cb.js ├── error.js ├── index.js ├── single-arg.js └── support └── cp.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | node_modules/* 3 | npm_debug.log 4 | -------------------------------------------------------------------------------- /LICENSE.APACHE2: -------------------------------------------------------------------------------- 1 | Apache License, Version 2.0 2 | 3 | Copyright (c) 2012 Dominic Tarr 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | -------------------------------------------------------------------------------- /LICENSE.MIT: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2012 Dominic Tarr 4 | 5 | Permission is hereby granted, free of charge, 6 | to any person obtaining a copy of this software and 7 | associated documentation files (the "Software"), to 8 | deal in the Software without restriction, including 9 | without limitation the rights to use, copy, modify, 10 | merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom 12 | the Software is furnished to do so, 13 | subject to the following conditions: 14 | 15 | The above copyright notice and this permission notice 16 | shall be included in all copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 19 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 20 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 21 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 22 | ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 23 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 24 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 25 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var through = require('through') 2 | var serialize = require('stream-serializer')() 3 | 4 | function get(obj, path) { 5 | if(Array.isArray(path)) { 6 | for(var i in path) 7 | obj = obj[path[i]] 8 | return obj 9 | } 10 | return obj[path] 11 | } 12 | 13 | module.exports = function (obj, opts) { 14 | if('boolean' == typeof opts) opts = { raw: opts } 15 | opts = opts || {} 16 | var cbs = {}, count = 1, local = obj || {} 17 | var flattenError = opts.flattenError || function (err) { 18 | if(!(err instanceof Error)) return err 19 | var err2 = { message: err.message } 20 | for(var k in err) 21 | err2[k] = err[k] 22 | return err2 23 | } 24 | function expandError(err) { 25 | if (!err || !err.message) return err 26 | var err2 = new Error(err.message) 27 | for(var k in err) 28 | err2[k] = err[k] 29 | return err2 30 | } 31 | 32 | if(obj) { 33 | local = {} 34 | function callable (k) { 35 | return function (args, cb) { 36 | return obj[k].apply(obj, cb ? args.concat(cb) : args) 37 | } 38 | } 39 | for(var k in obj) 40 | local[k] = callable(k) 41 | 42 | } 43 | var s = through(function (data) { 44 | //write - on incoming call 45 | data = data.slice() 46 | var i = data.pop(), args = data.pop(), name = data.pop() 47 | //if(~i) then there was no callback. 48 | 49 | if (args[0]) args[0] = expandError(args[0]) 50 | 51 | if(name != null) { 52 | var called = 0 53 | var cb = function () { 54 | if (called++) return 55 | var args = [].slice.call(arguments) 56 | args[0] = flattenError(args[0]) 57 | if(~i) s.emit('data', [args, i]) //responses don't have a name. 58 | } 59 | try { 60 | local[name].call(obj, args, cb) 61 | } catch (err) { 62 | if(~i) s.emit('data', [[flattenError(err)], i]) 63 | } 64 | } else if(!cbs[i]) { 65 | //there is no callback with that id. 66 | //either one end mixed up the id or 67 | //it was called twice. 68 | //log this error, but don't throw. 69 | //this process shouldn't crash because another did wrong 70 | 71 | s.emit('invalid callback id') 72 | return console.error('ERROR: unknown callback id: '+i, data) 73 | } else { 74 | //call the callback. 75 | var cb = cbs[i] 76 | delete cbs[i] //delete cb before calling it, incase cb throws. 77 | cb.apply(null, args) 78 | } 79 | }) 80 | 81 | var rpc = s.rpc = function (name, args, cb) { 82 | if(cb) cbs[++count] = cb 83 | if('string' !== typeof name) 84 | throw new Error('name *must* be string') 85 | s.emit('data', [name, args, cb ? count : -1]) 86 | if(cb && count == 9007199254740992) count = 0 //reset if max 87 | //that is 900 million million. 88 | //if you reach that, dm me, 89 | //i'll buy you a beer. @dominictarr 90 | } 91 | 92 | function keys (obj) { 93 | var keys = [] 94 | for(var k in obj) keys.push(k) 95 | return keys 96 | } 97 | 98 | s.createRemoteCall = function (name) { 99 | return function () { 100 | var args = [].slice.call(arguments) 101 | var cb = ('function' == typeof args[args.length - 1]) 102 | ? args.pop() 103 | : null 104 | rpc(name, args, cb) 105 | } 106 | } 107 | 108 | s.createLocalCall = function (name, fn) { 109 | local[name] = fn 110 | } 111 | 112 | s.wrap = function (remote, _path) { 113 | _path = _path || [] 114 | var w = {} 115 | ;(Array.isArray(remote) ? remote 116 | : 'string' == typeof remote ? [remote] 117 | : remote = keys(remote) 118 | ).forEach(function (k) { 119 | w[k] = s.createRemoteCall(k) 120 | }) 121 | return w 122 | } 123 | if(opts.raw) 124 | return s 125 | 126 | return serialize(s) 127 | } 128 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rpc-stream", 3 | "version": "2.1.2", 4 | "description": "very simple rpc. use with any thing that has .pipe()", 5 | "homepage": "http://github.com/dominictarr/rpc-stream", 6 | "repository": { 7 | "type": "git", 8 | "url": "git://github.com/dominictarr/rpc-stream.git" 9 | }, 10 | "dependencies": { 11 | "stream-serializer": "~1.0.0", 12 | "through": "~2.3.1" 13 | }, 14 | "devDependencies": { 15 | "event-stream": "~2", 16 | "tape": "~0.3.3" 17 | }, 18 | "author": "Dominic Tarr (dominictarr.com)", 19 | "optionalDependencies": {}, 20 | "scripts": { 21 | "test": "set -e; for t in test/*.js; do node $t; done" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /readme.markdown: -------------------------------------------------------------------------------- 1 | #rpc-stream 2 | 3 | `RpcStream` is a dead simple (75 loc) rpc system that works over any full-duplex text/byte/json stream. 4 | There's also a [Python port](https://github.com/riga/rpyc-stream), e.g. for cross-language RPC-over-SSH. 5 | 6 | ## ports 7 | 8 | * [rpyc-stream](https://github.com/riga/rpyc-stream) Python 9 | 10 | ## rant 11 | 12 | There are a bunch of people have already written node rpc systems, but most of them has done it _right_ yet. 13 | My beef is that all these systems is that they are tightly coupled to, or wrapped around servers. what if I want to encrypt the messages? what if I want to send the messages over stdin/out, or over ssh? of write them to a temp file? there is one abstraction that matches all of these cases; the `Stream` 14 | 15 | I should just be able to do this: 16 | 17 | ``` js 18 | REMOTE_SSH_STREAM //<-- pipe data from a remote source 19 | .pipe(DECRYPT_STREAM) //through some ('middleware') streams (ssh already encrypts, but I'm paranoid) 20 | .pipe(GUNZIP_STREAM) 21 | .pipe(RPC) //<--- pipe the data through the RPC system. 22 | .pipe(GZIP_STREAM) 23 | .pipe(ENCYPT_STREAM) 24 | .pipe(REMOTE_SSH_STREAM) //<-- and back to the remote 25 | 26 | //with something very similar on the other side. 27 | ``` 28 | RPC framework (AHEM! RPC MODULE!), _you_ just worry about calling the right function, _I'll_ decide where you go... 29 | 30 | update: [dnode@1.0.0](https://github.com/substack/dnode) now has first class streams, and you can pipe it where you like! 31 | 32 | ## usage 33 | 34 | ```js 35 | var rpc = require('rpc-stream') 36 | 37 | //create a server, that answers questions. 38 | //pass in functions that may be called remotely. 39 | var server = rpc({hello: function (name, cb) { 40 | cb(null, 'hello, '+name) 41 | }}) 42 | 43 | //create a client, that asks questions. 44 | var client = rpc() 45 | 46 | //pipe rpc instances together! 47 | client.pipe(server).pipe(client) 48 | 49 | var remote = client.wrap(['hello']) 50 | remote.hello('JIM', function (err, mess) { 51 | if(err) throw err 52 | console.log(mess) 53 | }) 54 | ``` 55 | 56 | ## over tcp 57 | 58 | server 59 | 60 | ```js 61 | net.createServer(function(con) { 62 | // create one server per connection 63 | var server = rpc(/* ... */) 64 | server.pipe(con).pipe(server) 65 | }).listen(3000)) 66 | ``` 67 | 68 | client 69 | 70 | ```js 71 | var client = rpc() 72 | var con = net.connect(3000) 73 | client.pipe(con).pipe(client) 74 | 75 | var remote = client.wrap(['hola']) 76 | remote.hola('steve', function(err, res) { 77 | console.log(res) 78 | }) 79 | ``` 80 | 81 | 82 | ### rpc(methods, opts) 83 | 84 | returns a `RpcStream` that will call `methods` when written to. 85 | 86 | If `opts.raw` is set to `true`, `JSON.stringify()` is turned off and you just 87 | get a stream of objects, in case you want to do your own parsing/stringifying. 88 | 89 | With `opts.flattenError` you can override the default method of converting 90 | errors to plain js objects. For example, to include non-enumerable properties 91 | too, pass: 92 | 93 | ```js 94 | {flattenError: function (err) { 95 | if(!(err instanceof Error)) return err 96 | var err2 = { message: err.message } 97 | var props = Object.getOwnPropertyNames(err) 98 | for(var k in err) 99 | err2[k] = err[k] 100 | return err2 101 | }} 102 | ``` 103 | 104 | ### RpcStream\#wrap(methodNames) 105 | 106 | returns a wrapped object with the remote's methods. 107 | the client needs to already know the names of the methods. 108 | accepts a string, and array of strings, or a object. 109 | if it's an object, `wrap` will use the keys as the method names. 110 | 111 | ```js 112 | //create rpc around the fs module. 113 | var fsrpc = rpc(require('fs')) 114 | //pipe, etc 115 | ``` 116 | 117 | then, in another process... 118 | 119 | ```js 120 | var fsrpc = rpc() 121 | //pipe, etc 122 | 123 | //wrap, with the right method names. 124 | var remoteFs = fsrpc.wrap(require('fs')) 125 | 126 | remoteFs.mkdir('/tmp/whatever', function (err, dir) { 127 | //yay! 128 | }) 129 | 130 | ``` 131 | 132 | now, the second process can call the `fs` module in the first process! 133 | `wrap` does not use the methods for anything. it just wants the names. 134 | 135 | ### RpcStream#rpc(name, args, cb) 136 | 137 | this gets invoked by wrap. but you could call it directly. 138 | 139 | ``` js 140 | rpc().wrap('hello').hello(name, callback) 141 | //is the same as 142 | rpc().rpc('hello', [name], callback) 143 | ``` 144 | 145 | ### RpcStream#pipe 146 | 147 | this is why we are here. read [this](http://nodejs.org/api/stream.html#stream_stream_pipe_destination_options) and [this](https://github.com/joyent/node/blob/master/lib/stream.js) 148 | 149 | 150 | ## license 151 | 152 | MIT/APACHE2 153 | -------------------------------------------------------------------------------- /test/create-remote.js: -------------------------------------------------------------------------------- 1 | var rpc = require('../') 2 | var es = require('event-stream') 3 | var test = require('tape') 4 | 5 | var obj = { 6 | helloThere: function (name, cb) { 7 | cb(null, 'Hello, ' + name) 8 | } 9 | } 10 | 11 | test('simple', function (t) { 12 | 13 | //second arg=true means stream of raw js objects, 14 | //do not stringify/parse. 15 | var a = rpc(null, true) 16 | b = rpc(null, true) 17 | 18 | a.createLocalCall('echo', function (args, cb) { 19 | cb(null, args) 20 | }) 21 | 22 | var echo = b.createRemoteCall('echo') 23 | //a and b are streams. connect them with pipe. 24 | b.pipe(a).pipe(b) 25 | var r1 = Math.random(), r2 = Math.random() 26 | echo(r1, r2, function (err, args) { 27 | t.deepEqual(args, [r1, r2]) 28 | t.end() 29 | }) 30 | }) 31 | 32 | -------------------------------------------------------------------------------- /test/custom-flatten.js: -------------------------------------------------------------------------------- 1 | var rpc = require('../') 2 | var test = require('tape') 3 | 4 | test('custom flattenError', function (t) { 5 | t.plan(2) 6 | 7 | function flatten(err) { 8 | return {message: 'private'} 9 | } 10 | 11 | var b = rpc() 12 | b.pipe(rpc({ 13 | hello: function (args, cb) { 14 | var err = new Error('oops') 15 | err.foo = 'bar' 16 | cb(err) 17 | } 18 | }, {flattenError: flatten})).pipe(b) 19 | 20 | b.createRemoteCall('hello')(function (err) { 21 | t.ok(err instanceof Error) 22 | t.equal(err.message, 'private') 23 | }) 24 | }) -------------------------------------------------------------------------------- /test/double-cb.js: -------------------------------------------------------------------------------- 1 | var rpc = require('../') 2 | var test = require('tape') 3 | 4 | test('double callback', function (t) { 5 | var b = rpc() 6 | b.pipe(rpc({ 7 | hello: function (cb) { 8 | cb(null, 'first') 9 | cb(null, 'second') 10 | } 11 | })).pipe(b) 12 | 13 | b.on('invalid callback id', function () { 14 | t.fail() 15 | }) 16 | 17 | b.createRemoteCall('hello')(function (err, str) { 18 | t.equal(str, 'first') 19 | }) 20 | 21 | t.end() 22 | }) 23 | -------------------------------------------------------------------------------- /test/error.js: -------------------------------------------------------------------------------- 1 | var rpc = require('../') 2 | var test = require('tape') 3 | 4 | test('error', function (t) { 5 | t.plan(3) 6 | 7 | var b = rpc() 8 | b.pipe(rpc({ 9 | hello: function (cb) { 10 | var err = new Error('oops'); 11 | err.foo = 'bar'; 12 | cb(err); 13 | } 14 | })).pipe(b) 15 | 16 | b.createRemoteCall('hello')(function (err) { 17 | t.ok(err instanceof Error, 'instanceof') 18 | t.equal(err.message, 'oops', 'message') 19 | t.equal(err.foo, 'bar', 'custom properties') 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | var rpc = require('../') 2 | var es = require('event-stream') 3 | var test = require('tape') 4 | 5 | var obj = { 6 | hello: function (name, cb) { 7 | cb(null, 'HI, ' + name) 8 | } 9 | } 10 | 11 | test('simple', function (t) { 12 | 13 | //second arg=true means stream of raw js objects, 14 | //do not stringify/parse. 15 | var a = rpc(obj, true) 16 | b = rpc(null, true) 17 | 18 | //a and b are streams. connect them with pipe. 19 | b.pipe(a).pipe(b) 20 | 21 | b.rpc('hello', ['JIM'], function (err, message) { 22 | t.error(err) 23 | t.equal(message, 'HI, JIM') 24 | }) 25 | 26 | var B = b.wrap('hello') 27 | B.hello('JIM', function (err, message) { 28 | t.error(err) 29 | t.equal(message, 'HI, JIM') 30 | t.end() 31 | }) 32 | }) 33 | 34 | test('tcp', function (t) { 35 | var net = require('net') 36 | var port = Math.round(40000 * Math.random()) + 1000 37 | var a = rpc(obj) 38 | var b = rpc() 39 | 40 | var server = net.createServer(function (sock) { 41 | a.pipe(sock).pipe(a) 42 | }).listen(port, function () { 43 | b.pipe(net.connect(port)).pipe(b) 44 | 45 | b.wrap('hello').hello('SILLY', function (err, mes) { 46 | t.equal(mes, 'HI, SILLY') 47 | a.end() 48 | b.end() 49 | server.close() 50 | t.end() 51 | }) 52 | }) 53 | }) 54 | 55 | 56 | var path = require('path') 57 | 58 | test('child_process', function(t) { 59 | var cp = require('child_process') 60 | .spawn(process.execPath, [require.resolve('./support/cp')]) 61 | var b = rpc() 62 | 63 | b.pipe(es.duplex(cp.stdin, cp.stdout)).pipe(b) 64 | 65 | cp.stderr.pipe(process.stderr, {end: false}) 66 | b.wrap('hello').hello('WHO?', function (err, mes) { 67 | t.error(err) 68 | t.equal(mes, 'HELLO WHO?') 69 | cp.kill() 70 | t.end() 71 | }) 72 | }) 73 | 74 | -------------------------------------------------------------------------------- /test/single-arg.js: -------------------------------------------------------------------------------- 1 | var rpc = require('../') 2 | var test = require('tape') 3 | 4 | test('single arg', function (t) { 5 | t.plan(2) 6 | 7 | var b = rpc() 8 | b.pipe(rpc({ 9 | hello: function (cb) { cb(null, 'hello') } 10 | })).pipe(b) 11 | 12 | b.createRemoteCall('hello')(function (err, str) { 13 | t.notOk(err) 14 | t.equal(str, 'hello') 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /test/support/cp.js: -------------------------------------------------------------------------------- 1 | var rpc = require('../') 2 | var es = require('event-stream') 3 | 4 | var a = rpc({ 5 | hello: function (n, cb) { 6 | console.error('child: HELLO ', n) 7 | cb(null, 'HELLO ' + n) 8 | } 9 | }) 10 | 11 | a.pipe(es.duplex(process.stdout, process.stdin)).pipe(a) 12 | process.stdin.resume() 13 | 14 | --------------------------------------------------------------------------------