jstp
Version:
Node JSTP implementation
758 lines (638 loc) • 23.1 kB
JavaScript
var WebSocket = require("faye-websocket")
, clientPool = require("./clientPool")
, uuid = require("uuid")
, Dispatch = require("./dispatch")
, color = require("cli-color");
var JSTP = {
theWebsockets: [],
theTCPSockets: [],
bound: [],
transactionIDs: {},
timeoutLapse: 10000,
// API
/////////////////////////////////////////////////
dispatch: function (pack, callback, context) {
try {
pack = new Dispatch(pack);
var method = pack.method.toUpperCase();
var flags = {
fatal: {
missingCallback: false,
notAcceptable: false
},
answer: {
subscription: false,
process: false,
noTriggersLeft: false
},
subscription: false,
regular: false,
bind: false,
release: false
}
switch (method) {
// Answer Morphology
/////////////////////////////////////////////
case "ANSWER":
// Status Code: should be an Integer
if (pack.resource[0] % 1 != 0) {
flags.fatal.notAcceptable = true;
break;
}
// Transaction ID: should be present
if (!pack.resource[1]) {
flags.fatal.notAcceptable = true;
break;
}
// Triggering ID: should be present
if (!pack.resource[2]) {
flags.fatal.notAcceptable = true;
break;
}
/*
Disabled: The ANSWER is always emitted,
if it triggers nothing, it triggers nothing
// There should be some Triggering IDs assigned to this Transaction ID
if (!this.transactionIDs[pack.resource[1]]) {
flags.fatal.notAcceptable = true;
break;
}
// The Transaction ID should be present in the list
if (this.transactionIDs[pack.resource[1]].indexOf(pack.resource[2]) == -1) {
flags.fatal.notAcceptable = true;
break;
}
*/
// Is it all right? Process the Answer
flags.answer.process = true;
break;
// Subscription Morphology
/////////////////////////////////////////////
case "BIND":
// If there is no callback, there's no business binding
if (!callback) {
flags.fatal.missingCallback = true;
break;
}
// Notify that it is, in fact, a subscription
flags.subscription = true;
// Is it an ANSWER subscription?
if (pack.endpoint.method.toUpperCase() == "ANSWER")
flags.answer.subscription = true;
flags.bind = true;
break;
case "RELEASE":
// Notify that it is, in fact, a subscription
flags.subscription = true;
flags.release = true;
break;
// Regular Morphology
/////////////////////////////////////////////
case "GET":
case "POST":
case "PATCH":
case "DELETE":
case "PUT":
flags.regular = true;
break;
// Unrecognized method
/////////////////////////////////////////////
default:
// Should Answer the 405 maybe
throw new Error("405 Method Not Allowed: " + method.toUpperCase());
break;
}
// The Callback is missing and is required?
if (flags.fatal.missingCallback)
throw new Error("Missing Callback");
// If not acceptable, not acceptable
if (flags.fatal.notAcceptable) {
// Answer with the code if there is a callback
if (callback) {
var answer = new Dispatch({
method: "ANSWER",
resource: [406, pack.token[0]],
body: {
message: "Not Acceptable",
source: pack
}
});
console.log(answer.toLog());
return callback.call(context, this, answer);
}
// If there is no callback, there is no option but to throw the Exception
else
return console.log(color.xterm(237)("Answer not sent, nothing bound"));
}
// Subscriptions need Transaction ID
if (flags.subscription)
if (!pack.token[0]) pack.token = [uuid.v4()];
// It is not a Subscription? There is a callback?
if (!flags.subscription && callback) {
// Add Transaction ID if it is missing
if (!pack.token[0]) pack.token = [uuid.v4()];
// BIND locally the ANSWER to the callback
this.bind({ endpoint: {
method: "ANSWER",
resource: ["*", pack.token[0], "*"]
}}, callback, context);
}
// Is it a BIND ANSWER? Set timeout for involved Transaction ID
if (flags.answer.subscription) {
console.log(color.xterm(8)("[" + pack.endpoint.resource[1] + "] ") + color.xterm(237)(this.timeoutLapse + "ms timeout"));
setTimeout( function () {
JSTP._timeout(pack.endpoint.resource[1]);
}, this.timeoutLapse);
}
// Log the Dispatch if everything is OK
console.log(pack.toLog());
// If it is a BIND, lets _bind
if (flags.bind)
this._bind(pack, callback, context);
// If it is a RELEASE, lets _release
if (flags.release)
this._release(pack, callback, context);
// If the Dispatch has a Host, just forward it
if (pack.host && pack.host.length > 0)
return this.forward(pack);
// If Answer, remove the Triggering ID from the Transaction IDs collection
if (flags.answer.process) {
// Since answer got always emitted, check if there
// a Transaction ID table for this
if (this.transactionIDs[pack.resource[1]] &&
this.transactionIDs[pack.resource[1]].indexOf(pack.resource[2]) != -1) {
var index = this.transactionIDs[pack.resource[1]].indexOf(pack.resource[2]);
this.transactionIDs[pack.resource[1]].splice(index, 1);
if (this.transactionIDs[pack.resource[1]].length == 0) {
flags.answer.noTriggersLeft = true;
}
}
}
// If it got here, trigger the Endpoints for the Dispatch
this._trigger(pack, context);
// If answer, and no triggers left for the Subscription
if (flags.answer.noTriggersLeft) {
delete this.transactionIDs[pack.resource[1]];
// Unbind the TransactionID
this._unbindTransactionID(pack.resource[1]);
}
} catch(err) {
console.log(err.message);
console.log(err.stack);
}
return this;
},
answer: function (source, statusCode, body, callback, context) {
if (!source) throw new Error("Missing Source");
var pack = {
method: "ANSWER",
resource: [statusCode, source.token[0], source.token[1]],
}
if (body) pack.body = body;
this.dispatch(pack, callback, context);
return this;
},
listen: function (options) {
if (options.websocket) this.httpServer(options.websocket);
if (options.ws) this.httpServer(options.ws);
if (options.tcp) this.tcpServer(options.tcp);
},
// Private
/////////////////////////////////////////////////
_bind: function (pack, callback, context) {
for (i in this.bound) {
if (
this._compare(pack.endpoint, this.bound[i].endpoint.method, this.bound[i].endpoint.resource, true) &&
this.bound[i].context == context &&
this.bound[i].callback == callback
)
return console.log("Endpoint already bound for that emitter");
}
var host = [];
if (pack.host && pack.host[0])
host = pack.host[0]
return this.bound.push({
endpoint: pack.endpoint,
callback: callback,
context: context,
host: host,
transactionID: pack.token[0]
});
},
_release: function (pack, callback, context) {
var newbound = []
for (i in this.bound) {
if (
this._compare(
pack.endpoint, this.bound[i].endpoint.method,
this.bound[i].endpoint.resource, true) &&
this.bound[i].context == context
) {
console.log(color.xterm(208)("Unbinding " + this.bound[i].endpoint.method + " " + this.bound[i].endpoint.resource.join("/")));
}
else {
newbound.push(this.bound[i]);
}
}
this.bound = newbound;
},
_trigger: function (pack, context, synchronous) {
var packMethod = pack.method.toUpperCase();
var boundMethod;
var endpoint;
var index = 0;
// Put all methods in an array, NOT CALL and after the array is formed
// call all of them with async.parallel
var callbacks = [];
while (this.bound.length > index) {
// If it is a Subscription Dispatch,
// only if the Emitter is different...
if ( (packMethod != "BIND" && packMethod != "RELEASE") ||
(packMethod == "BIND" && this.bound[index].context != context) ||
(packMethod == "RELEASE" && this.bound[index].context != context)) {
endpoint = this.bound[index].endpoint;
boundMethod = endpoint.method.toLowerCase();
switch (packMethod) {
case "BIND":
case "RELEASE":
if (this._compare(endpoint, pack.method, pack.endpoint.resource)) {
var callbackItem = this._callbackItem(
this.bound[index].callback,
this.bound[index].context,
pack,
this
);
}
break;
case "PATCH":
case "PUT":
case "GET":
case "POST":
case "DELETE":
if (this._compare(endpoint, pack.method, pack.resource)) {
var callbackItem = this._callbackItem(
this.bound[index].callback,
this.bound[index].context,
pack,
this
);
}
break;
case "ANSWER":
if (this.bound[index].transactionID == pack.resource[1]) {
var callbackItem = this._callbackItem(
this.bound[index].callback,
this.bound[index].context,
pack,
this,
true
);
}
else if (this._compare(endpoint, pack.method, pack.resource)) {
var callbackItem = this._callbackItem(
this.bound[index].callback,
this.bound[index].context,
pack,
this,
true
);
}
break;
}
}
index++;
}
},
_callbackItem: function (callback, context, pack, engine, answer) {
var item = {
callback: callback,
context: context,
pack: pack,
engine: engine
}
if (pack.token[0]) {
item.pack = this._assignTriggeringID(pack);
}
if (answer) {
process.nextTick(function () {
if (this.context) {
this.callback.call(this.context, this.engine, this.pack);
}
else {
this.callback(this.engine, this.pack);
}
}.bind(item));
}
else {
process.nextTick(function () {
if (this.context) {
this.callback.call(this.context, this.engine, null, this.pack);
}
else {
this.callback(this.engine, null, this.pack);
}
}.bind(item));
}
return item;
},
_assignTriggeringID: function (pack) {
// Duplicate the Dispatch object
var newPack = this._duplicateDispatch(pack);
// Create the triggering ID token
var triggeringID = uuid.v4();
// Register the Transaction ID *only* if it is not a Subscription Dispatch
if (pack.method.toUpperCase() != "BIND" && pack.method.toUpperCase() != "RELEASE") {
// Start array for token[0] in transactionIDs
this.transactionIDs[newPack.token[0]] = this.transactionIDs[newPack.token[0]] || [];
// Push the triggering ID to the transaction ID array
this.transactionIDs[newPack.token[0]].push(triggeringID);
console.log(color.xterm(8)("▲ Triggering " + triggeringID + " for transaction " + newPack.token[0])); // DEBUG
}
// Push the triggering ID to the Token Header in the new Dispatch object
newPack.token.push(triggeringID);
// Return the new Dispatch object
return newPack;
},
_duplicateDispatch: function (pack) {
// Clone the object in a safe (although inefficient) way
return JSON.parse(JSON.stringify(pack));
},
_timeout: function (transactionID) {
// RELEASE the transaction ID if it is present in the transactionIDs
if (this.transactionIDs[transactionID]) {
var elements = [];
// Clone the transaction ID list
for (index in this.transactionIDs[transactionID])
elements.push(this.transactionIDs[transactionID][index]);
// Send the 504 Timeout for each Triggering ID still attached
for (index in elements) {
var triggeringID = elements[index];
var timeoutAnswer = {
method: "ANSWER",
resource: [504, transactionID, triggeringID],
body: { message: "Timeout" }
};
this.dispatch(timeoutAnswer);
}
delete this.transactionIDs[transactionID];
// Unbind the transactionID
this._unbindTransactionID(transactionID);
}
// If it is not present, the timeout does nothing
else {
console.log(color.xterm(8)("[" + transactionID + "]") + color.xterm(237)(" Timeout completed"));
}
},
_unbindTransactionID: function (transactionID) {
// Build the RELEASE
var release = new Dispatch({
method: "RELEASE",
endpoint: {
method: "ANSWER",
resource: ["*", transactionID, "*"]
}
});
// Manually send the RELEASE back to Inbound Subscriptions
for (i in this.bound){
if (this.bound[i].endpoint.method.toLowerCase() == "answer" &&
this.bound[i].endpoint.resource[1] == transactionID) {
if (this.bound[i].callback == JSTP._answer) {
JSTP._answer.call(this.bound[i].context, this, null, release);
}
// Actually release 'em
this.dispatch(release, this.bound[i].callback, this.bound[i].context);
}
}
},
_compare: function (endpoint, method, resource, strict) {
method = method.toLowerCase();
endpointMethod = endpoint.method.toLowerCase();
if (strict) {
if (method == endpointMethod && resource.length == endpoint.resource.length) {
for (el in endpoint.resource)
if (endpoint.resource[el] != resource[el])
return false;
return true;
}
}
else {
if (
(method == endpointMethod || endpointMethod == "*") &&
(resource.length == endpoint.resource.length)
) {
for (el in endpoint.resource)
if (
endpoint.resource[el] != "*" &&
endpoint.resource[el] != resource[el])
return false;
return true;
}
}
return false;
},
_releaseOfClient: function (context) {
var newbound = this.bound;
for (index in newbound) {
if (newbound[index].context == context) {
var pack = {
protocol: ["JSTP", "0.4"],
method: 'RELEASE',
endpoint: newbound[index].endpoint,
timestamp: +new Date().getTime(),
token: [],
referer: ["JSTPEngine"]
}
this.dispatch(pack, newbound[index].callback, newbound[index].context);
}
}
},
_answer: function (engine, answer, dispatch) {
var pack = dispatch;
if (answer) pack = answer;
var method;
var resource;
if (pack.endpoint) {
method = pack.method + " " + pack.endpoint.method;
resource = pack.endpoint.resource;
}
else {
method = pack.method;
resource = pack.resource;
}
if (isNaN(this.readyState)) {
if (this.writable) {
console.log(color.xterm(60)("▶ Forwarding " + method + " " + resource.join("/") + " back to client."));
this.write(pack.toString() + "\n");
}
else console.log(color.xterm(208)("TCP client disconnected, dispatch not sent"));
}
else {
if (this.readyState == 1) {
console.log(color.xterm(60)("▶ Forwarding " + method + " " + resource.join("/") + " back to client."));
this.send(pack.toString());
}
else console.log(color.xterm(208)("Websocket client disconnected, dispatch not sent"));
}
},
forward: function (pack) {
// Identify the host type. Websocket in port 80 is default
var address = ["", 80];
var type = "websocket";
if (pack.host[0] instanceof String)
address = pack.host[0];
else {
address[0] = pack.host[0][0];
address[1] = pack.host[0][1] || address[1];
type = pack.host[0][2] || type;
}
// First host out. If no hosts left, remove header
pack.host.shift();
if (pack.host.length == 0) delete pack.host;
var stringPack = JSON.stringify(pack);
var resource;
var method;
if (pack.endpoint) {
method = pack.method + " " + pack.endpoint.method;
resource = pack.endpoint.resource;
}
else {
resource = pack.resource;
method = pack.method;
}
console.log(color.xterm(60)("▶ " + address[0] + ":" + address[1] + ":" + type + " " + method + " " + resource.join("/")));
clientPool[type](address, stringPack, JSTP);
},
// Servers
/////////////////////////////////////////////////
httpServer: function (httpServer) {
httpServer.addListener('upgrade', function (request, socket, head) {
JSTP.theWebsockets.push(new WebSocket(request, socket, head));
console.log(color.xterm(8)("New ") + color.xterm(221)("WS") + color.xterm(8)(" client logged in [total ") + color.xterm(221)(JSTP.theWebsockets.length) + color.xterm(8)("]"));
JSTP.theWebsockets[JSTP.theWebsockets.length -1].onopen = function (event) {
var currentIndex = JSTP.theWebsockets.indexOf(this);
}
JSTP.theWebsockets[JSTP.theWebsockets.length -1].onmessage = function (event) { try {
var currentIndex = JSTP.theWebsockets.indexOf(this);
// Parse and log whats coming
// console.log(color.xterm(8)(event.data)); No need to log raw
var pack = JSON.parse(event.data);
// Only provide a callback if the Dispatch wants one
// or if it is a Subscription Dispatch
if (pack.token[0] ||
pack.method.toUpperCase() == "BIND" ||
pack.method.toUpperCase() == "RELEASE") {
JSTP.dispatch(pack, JSTP._answer, this, currentIndex);
}
else {
JSTP.dispatch(pack);
}
} catch (err) {
console.log(err.message);
console.log(err.stack);
}}
JSTP.theWebsockets[JSTP.theWebsockets.length -1].onclose = function (event) { try {
var currentIndex = JSTP.theWebsockets.indexOf(this);
console.log(color.xterm(8)("Closed ") + color.xterm(221)("WS") + color.xterm(8)(" client connection [") + color.xterm(221)("#" + currentIndex) + color.xterm(8)("]"));
JSTP._releaseOfClient(this);
JSTP.theWebsockets.splice(currentIndex, 1);
} catch (err) {
console.log(err.message);
console.log(err.stack);
}}
});
},
tcpServer: function (port) {
port = port || 33333;
var server = require('net').createServer( function(connection) { //'connection' listener
JSTP.theTCPSockets.push(connection);
console.log(color.xterm(8)("New ") + color.xterm(221)("TCP") + color.xterm(8)(" client logged in [total ") + color.xterm(221)(JSTP.theTCPSockets.length) + color.xterm(8)("]"));
connection.on('end', function() { try {
var currentIndex = JSTP.theTCPSockets.indexOf(this);
console.log(color.xterm(8)("Closed ") + color.xterm(221)("TCP") + color.xterm(8)(" client connection [") + color.xterm(221)("#" + currentIndex) + color.xterm(8)("]"));
JSTP._releaseOfClient(this);
JSTP.theTCPSockets.splice(currentIndex, 1);
} catch (err) {
console.log(err.message);
console.log(err.stack);
}});
connection.on('data', function(data) {try {
var currentIndex = JSTP.theTCPSockets.indexOf(this);
// Parse and log whats coming
// console.log(color.xterm(8)(data)); No reason to log the full dispatch
var splitted = data.toString().split("\n");
for (index in splitted) {
if (splitted[index].replace(/ /, "") != "") {
var pack = JSON.parse(splitted[index]);
// Only provide a callback if the Dispatch wants one
// or if it is a Subscription Dispatch
if (pack.token[0] ||
pack.method.toUpperCase() == "BIND" ||
pack.method.toUpperCase() == "RELEASE") {
JSTP.dispatch(pack, JSTP._answer, this, currentIndex);
}
else {
JSTP.dispatch(pack);
}
}
}
} catch (err) {
console.log(err.message);
console.log(err.stack);
}});
});
server.listen(port, function() { //'listening' listener
console.log(color.xterm(221)('TCP ') + color.xterm(8)('server bound on port ') + color.xterm(221)(port));
});
},
// Shorthands & Aliases
/////////////////////////////////////////////////
put: function (pack, callback, context) {
pack.method = "PUT";
this.dispatch(pack, callback, context);
return this;
},
get: function (pack, callback, context) {
pack.method = "GET";
this.dispatch(pack, callback, context);
return this;
},
post: function (pack, callback, context) {
pack.method = "POST";
this.dispatch(pack, callback, context);
return this;
},
delete: function (pack, callback, context) {
pack.method = "DELETE";
this.dispatch(pack, callback, context);
return this;
},
patch: function (pack, callback, context) {
pack.method = "PATCH";
this.dispatch(pack, callback, context);
return this;
},
release: function (pack, callback, context) {
pack.method = "RELEASE";
this.dispatch(pack, callback, context);
return this;
},
off: function (pack, callback, context) {
pack.method = "RELEASE";
this.dispatch(pack, callback, context);
return this;
},
bind: function (pack, callback, context) {
pack.method = "BIND";
this.dispatch(pack, callback, context);
return this;
},
on: function (pack, callback, context) {
pack.method = "BIND";
this.dispatch(pack, callback, context);
return this;
},
// Classes
/////////////////////////////////////////////////
Dispatch: Dispatch
}
module.exports = JSTP