socket.io-rpc
Version:
Minimalistic remote procedure call(RPC/RMI) library bootstrapped on socket.io
229 lines (213 loc) • 6.93 kB
JavaScript
var logger = require('debug')
var traverse = require('traverse')
var co = require('co')
var errToPOJO = require('./lib/err-serialization')
var noop = function () {}
function isGeneratorFunction (fn) {
return typeof fn === 'function' &&
fn.constructor &&
fn.constructor.name === 'GeneratorFunction'
}
/**
* @param {Object} socket
* @param {Object} tree
* @param {String} clientOrServer
*/
module.exports = function (socket, tree, clientOrServer) {
var debug = logger('socket.io-rpc:' + clientOrServer)
/**
* for external use, simple function is used rather than an event emitter, because we lack event emitter in the browser
* @type {{batchStarts: Function, batchEnds: Function, wasCalled: Function, calling: Function, response: Function}}
*/
var eventHandlers = {
batchStarts: noop,
batchEnds: noop,
calling: noop,
wasCalled: noop,
response: noop
}
var socketId
var deferreds = []
var invocationCounter = 0
var endCounter = 0
var remoteCallEnded = function (Id) {
if (deferreds[Id]) {
delete deferreds[Id]
endCounter++
eventHandlers.response(endCounter)
if (endCounter === invocationCounter) {
eventHandlers.batchEnds(endCounter)
invocationCounter = 0
endCounter = 0
}
} else {
// the client can maliciously try and resolve/reject something more than once. We should not throw an error on this, just warn
throw new Error('Deferred Id ' + Id + ' was resolved/rejected more than once, this should not occur')
}
}
/**
* @param {String} fnPath
* @returns {Function} which will call the backend/client when executed
*/
function prepareRemoteCall (fnPath, argumentLength) {
function remoteCall () {
var args = Array.prototype.slice.call(arguments, 0)
return new Promise(function (resolve, reject) {
if (clientOrServer === 'server' && rpc.disconnected) {
return reject(new Error('socket ' + socketId + ' disconnected, call rejected'))
}
invocationCounter++
debug('calling ', fnPath, 'on ', socketId, ', invocation counter ', invocationCounter)
var callParams = {Id: invocationCounter, fnPath: fnPath, args: args}
socket.emit('call', callParams)
eventHandlers.calling(callParams)
if (invocationCounter === 1) {
eventHandlers.batchStarts(invocationCounter)
}
deferreds[invocationCounter] = {resolve: resolve, reject: reject}
})
}
remoteCall.remoteLength = argumentLength
return remoteCall
}
var rpc = prepareRemoteCall
socket.rpc = rpc
socket.rpc.events = eventHandlers
/**
* @type {boolean} indicates when client is disconnected
*/
rpc.disconnected = false
if (clientOrServer === 'client') {
rpc.initializedP = new Promise(function (resolve, reject) {
var assignAndResolveInitP = function () {
socketId = socket.io.engine.id
resolve()
}
socket.on('connect', function () {
assignAndResolveInitP()
debug('connected socket ', socketId)
}).on('connect_error', function (err) {
if (!socketId) {
reject(err)
}
}).on('reconnect', function () {
if (!socketId) {
assignAndResolveInitP()
}
debug('reconnected rpc socket', socketId)
rpc.disconnected = false
})
})
} else {
socketId = socket.id
}
socket.on('disconnect', function onDisconnect () {
rpc.disconnected = true
}).on('connect_error', function (err) {
debug('connect error: ', err)
}).on('call', function (data) {
debug('invocation with ', data)
if (!(data && typeof data.Id === 'number')) {
return socket.emit('rpcError', {
reason: 'Id is a required property for a call data payload'
})
}
/**
* @param {String} resType
* @param {*} resData
*/
var emitRes = function (resType, resData) {
resData.Id = data.Id
socket.emit(resType, resData)
eventHandlers.wasCalled(data, resData)
}
try {
var method = traverse(tree).get(data.fnPath.split('.'))
} catch (err) {
debug(err, ' when resolving an invocation')
return emitRes('reject', errToPOJO(err))
}
if (method && method.apply) { // we could also check if it is a function, but this might be bit faster
var retVal
if (isGeneratorFunction(method)) {
method = co.wrap(method)
}
try {
retVal = method.apply(socket, data.args)
} catch (err) {
debug('RPC method invocation ' + data.fnPath + 'from ' + socket.id + ' thrown an error : ', err.stack)
emitRes('reject', errToPOJO(err))
return
}
Promise.resolve(retVal).then(function (asyncRetVal) {
emitRes('resolve', {value: asyncRetVal})
}, function (error) {
emitRes('reject', errToPOJO(error))
})
} else {
var msg = 'no function exposed on: ' + data.fnPath
debug(msg)
emitRes('reject', {error: {message: msg}})
}
}).on('fetchNode', function (path, resCB) {
debug('fetchNode handler, path ', path)
var methods = tree
if (path) {
methods = traverse(tree).get(path.split('.'))
} else {
methods = tree
}
if (!methods) {
resCB({path: path})
debug('socket ', socketId, ' requested node ' + path + ' which was not found')
return
}
var localFnTree = traverse(methods).map(function (el) {
if (this.isLeaf) {
return el.length
} else {
return el
}
})
resCB({path: path, tree: localFnTree})
debug('socket ', socketId, ' requested node "' + path + '" which was sent as: ', localFnTree)
}).on('resolve', function (data) {
deferreds[data.Id].resolve(data.value)
remoteCallEnded(data.Id)
}).on('reject', function (data) {
deferreds[data.Id].reject(data.error)
remoteCallEnded(data.Id)
})
/**
* @param {String} path
* @returns {Promise}
*/
socket.rpc.fetchNode = function (path) {
return new Promise(function (resolve, reject) {
socket.once('connect_error', function (err) {
debug('connect error: ', err)
reject(err)
})
debug('fetchNode ', path)
socket.emit('fetchNode', path, function (data) {
if (data.tree) {
var remoteMethods = traverse(data.tree).map(function (el) {
if (this.isLeaf) {
debug('path', this.path)
var path = this.path.join('.')
if (data.path) {
path = data.path + '.' + path
}
this.update(prepareRemoteCall(path, el))
}
})
resolve(remoteMethods)
} else {
var err = new Error('Node is not defined on the socket ' + socketId)
err.path = data.path
reject(err)
}
})
})
}
}