qrpc
Version:
very fast nodejs remote procedure call
274 lines (249 loc) • 10.5 kB
JavaScript
/**
* quick little rpc package
*
* Copyright (C) 2015-2017 Andras Radics
* Licensed under the Apache License, Version 2.0
*/
var net = require('net')
var StringDecoder = require('string_decoder').StringDecoder
var EventEmitter = require('events').EventEmitter
var mongoid = require('mongoid-js')
var invoke = require('qinvoke').invoke
var interceptCall = require('qinvoke').interceptCall
var QrpcMessage = require('./qrpc-message.js')
var setImmediate = global.setImmediate || process.nextTick
/**
* Create a qrpc client.
* Options:
* - json_encode - json stringify
* - json_decode - json parse
*/
function QrpcClient( options ) {
options = options || {}
this.chunks = new Array()
this.callbacks = {}
this.message = new QrpcMessage({
v: options.v || 1,
json_encode: options.json_encode,
json_decode: options.json_decode,
})
if (options.socket) this.setTarget(socket, socket)
}
QrpcClient.prototype = {
target: null,
readable: null,
chunks: null,
callbacks: null,
message: null,
_connected: false,
_closeFunc: null,
setTarget:
function setTarget( target, readable ) {
var self = this
var decoder = new StringDecoder()
if (!target || !readable) throw new Error("setTarget requires both target and readable")
this.target = target
this.readable = readable
this._connected = true
// TODO: handle pipables with a pipe, not with pause / resume
if (typeof readable.setEncoding === 'function') readable.setEncoding('utf8')
if (readable instanceof EventEmitter) {
var onData = function(chunk) {
if (typeof chunk !== 'string') chunk = decoder.write(chunk)
self.chunks.push(chunk)
if (self.chunks.length > 5) readable.pause()
self._deliverResponses(function() {
readable.resume()
})
}
readable.on('data', onData)
var onEnd = function() {
// remote sent a FIN packet
self._abortAllCalls(new Error("unexpected end on response channel"))
// FIXME: this is an error, no more replies can be received
}
readable.on('end', onEnd)
var onError = function(err) {
// socket error, close is called immediately after
self._abortAllCalls(err)
}
readable.on('error', onError)
var onClose = function() {
if (self._connected) self._abortAllCalls(new Error("unexpected close of reply stream"))
readable.emit('qrpcDetach')
// FIXME: this is an error, no more replies can be received
}
readable.on('close', onClose)
var onQrpcDetach = function() {
readable.removeListener('data', onData)
readable.removeListener('end', onEnd)
readable.removeListener('error', onError)
readable.removeListener('close', onClose)
readable.removeListener('qrpcDetach', onQrpcDetach)
}
readable.on('qrpcDetach', onQrpcDetach)
}
else {
setImmediate(function pollResponses( ) {
var chunk = readable.read(100000)
if (chunk && chunk.length > 0) {
if (typeof chunk !== 'string') chunk = decoder.write(chunk)
self.chunks.push(chunk)
self._deliverResponses(function() {
setImmediate(pollResponses)
})
}
else if (this._connected) {
var poller = setTimeout(pollResponses, 2)
if (poller.unref) poller.unref()
}
})
}
if (target instanceof EventEmitter) {
var onError = function(err) {
// sockets do not report write errors in the callback, listen for them
self._abortAllCalls(err)
}
target.on('error', onError)
var onDrain = function() {
// write buffer empty
// TODO: throttle buffering here, or let socket take care of it?
}
target.on('drain', onDrain)
var onClose = function() {
target.emit('qrpcDetach')
}
target.on('close', onClose)
var onQrpcDetach = function() {
target.removeListener('error', onError)
target.removeListener('drain', onDrain)
target.removeListener('close', onClose)
target.removeListener('qrpcDetach', onQrpcDetach)
}
target.on('qrpcDetach', onQrpcDetach)
}
return this
},
setCloseFunc:
function setCloseFunc( closeFunc ) {
this._closeFunc = closeFunc
return this
},
close:
function close( callback ) {
// TODO: deal only with event emitters
if (this._connected) {
this._connected = false
if (this.target instanceof EventEmitter) this.target.emit('qrpcDetach')
if (this.target.end === 'function') this.target.end()
if (this.readable instanceof EventEmitter) this.readable.emit('qrpcDetach')
if (typeof this.readable.end === 'function') this.readable.end()
// send a FIN packet
// TODO: check that FIN is sent only after all pending data is written
if (this._closeFunc) this._closeFunc()
}
if (callback) callback()
},
call:
function call( handlerName, data, callback ) {
var id = mongoid()
if (typeof handlerName !== 'string') {
return callback(new Error("handler name must be a string"))
}
if (!callback) {
if (typeof data === 'function') {
callback = data; data = undefined
this.callbacks[id] = callback
}
} else {
if (typeof callback !== 'function') throw new Error("callback must be a function")
this.callbacks[id] = callback
}
var envelope = { v: 1, id: id, n: String(handlerName), m: undefined, b: undefined, e: undefined, s: undefined }
data instanceof Buffer ? envelope.b = data : envelope.m = data
return this.target.write(this.message.encode(envelope) + "\n")
// note: writes are buffered, write/socket errors show up at socket.on('error') and not here
},
wrap:
function wrap( object, methods, options ) {
if (options === undefined && methods && !Array.isArray(methods)) { options = methods ; methods = null }
if (typeof object !== 'object') throw new Error("not an object")
if (!options) options = {}
if (!methods) methods = Object.keys(object)
var self = this, caller = {}
for (var i=0; i<methods.length; i++) {
var method = methods[i]
var prefix = options.prefix || ''
caller[method] = (function(method) {
return interceptCall(method, function(func, selfContext, av) {
if (av[0]) av[0] = self.message._extractError(av[0])
var cb = (av.length > 0 && typeof av[av.length-1] === 'function') ? av.pop() : null
self.call(method, av, function(err, args) {
if (cb) err ? cb(err) : invoke(cb, args)
})
})
})(prefix + method)
}
return caller
function hashToStruct( hash ) {
// assigning to a function prototype converts the hash to a struct
// a try-catch block disables optimization, to prevent these statements from being optimized away
// newer V8 optimizes even with try/catch, so pass arguments to a function too
function f () {}
f.prototype = Array.prototype.slice.call(arguments, 0) && hash
try { return f.prototype } catch (err) { }
}
},
_deliverResponses:
function _deliverResponses( doneDelivering ) {
var start = 0, end = 0
// faster to string concat than to join
// TODO: concat chunks as they arrive, not here
var data = this.chunks.length ? this.chunks[0] : ""
for (var i=1; i<this.chunks.length; i++) data += this.chunks[i]
this.chunks = new Array()
// TODO: yield to event loop during delivery, eg repeatUntil(...)
var message, callback
while ((end = data.indexOf('\n', start)) >= start) {
var line = data.slice(start, end)
start = end + 1
message = this.message.decode(line)
if (message.b) { message.m = message.b; message.b = undefined }
callback = this.callbacks[message.id]
if (message instanceof Error) {
// skip lines that fail the json decode
// TODO: log bad lines
console.log(new Date().toISOString(), "garbled response, could not decode: ", message)
}
else if (callback) {
if (message.s === QrpcMessage.MSG_LAST) {
// end() leaves .m undefined, just close out the request
// a server-side empty cb() passes (null, undefined) to invoke client-side callback
if (message.e !== undefined || message.m !== undefined) callback(message.e, message.m)
delete this.callbacks[message.id]
}
else if (message.s === QrpcMessage.MSG_REPLY) {
callback(message.e, message.m)
}
else /* (message.s === QrpcMessage.MSG_ERROR) */ {
callback(message.e, message.m)
// no more replies after a server error
delete this.callbacks[message.id]
}
}
}
if (start < data.length) this.chunks.unshift(start > 0 ? data.slice(start) : data)
doneDelivering()
},
// connection error, send it to all waiting callbacks and clear callbacks
_abortAllCalls:
function _abortAllCalls( err ) {
for (var i in this.callbacks) {
var cb = this.callbacks[i]
delete this.callbacks[i]
cb(err)
}
},
}
module.exports = QrpcClient