tjh-express
Version:
TogetherJS Hub express module
307 lines (281 loc) • 10.3 kB
JavaScript
var SAMPLE_STATS_INTERVAL = 60*1000; // 1 minute
var SAMPLE_LOAD_INTERVAL = 5*60*1000; // 5 minutes
var EMPTY_ROOM_LOG_TIMEOUT = 3*60*1000; // 3 minutes
var WEBSOCKET_COMPAT = true;
var WebSocketServer = WEBSOCKET_COMPAT ?
require("./websocket-compat").server :
require("websocket").server;
var parseUrl = require('url').parse;
var fs = require('fs');
var logLevel = process.env.LOG_LEVEL || 0;
var Logger = function (level, filename, stdout) {
this.level = level;
this.filename = filename;
this.stdout = !!stdout;
this._open();
process.on("SIGUSR2", (function () {
this._open();
}).bind(this));
};
Logger.prototype = {
write: function () {
if (this.stdout) {
console.log.apply(console, arguments);
}
if (this.file) {
var s = [];
for (var i=0; i<arguments.length; i++) {
var a = arguments[i];
if (typeof a == "string") {
s.push(a);
} else {
s.push(JSON.stringify(a));
}
}
s = s.join(" ") + "\n";
this.file.write(this.date() + " " + s);
}
},
date: function () {
return (new Date()).toISOString();
},
_open: function () {
if (this.file) {
this.file.end(this.date() + " Logs rotating\n");
this.file = null;
}
if (this.filename) {
this.file = fs.createWriteStream(this.filename, {flags: 'a', mode: parseInt('644', 8), encoding: "UTF-8"});
}
}
};
[["error", 4], ["warn", 3], ["info", 2], ["log", 1], ["debug", 0]].forEach(function (nameLevel) {
var name = nameLevel[0];
var level = nameLevel[1];
Logger.prototype[name] = function () {
if (logLevel <= level) {
if (name != "log") {
this.write.apply(this, [name.toUpperCase()].concat(Array.prototype.slice.call(arguments)));
} else {
this.write.apply(this, arguments);
}
}
};
});
var logger = new Logger(0, null, true);
exports.findRoom = function (prefix, max, response) {
var smallestNumber;
var smallestRooms = [];
for (var candidate in allConnections) {
if (candidate.indexOf(prefix + "__") === 0) {
var count = allConnections[candidate].length;
if (count < max && (smallestNumber === undefined || count <= smallestNumber)) {
if (smallestNumber === undefined || count < smallestNumber) {
smallestNumber = count;
smallestRooms = [candidate];
} else {
smallestRooms.push(candidate);
}
}
}
}
var room;
if (! smallestRooms.length) {
room = prefix + "__" + generateId();
} else {
room = pickRandom(smallestRooms);
}
response.end(JSON.stringify({name: room}));
};
function generateId(length) {
length = length || 10;
var letters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUV0123456789';
var s = '';
for (var i=0; i<length; i++) {
s += letters.charAt(Math.floor(Math.random() * letters.length));
}
return s;
}
function pickRandom(seq) {
return seq[Math.floor(Math.random() * seq.length)];
}
exports.getLoad = function () {
var sessions = 0;
var connections = 0;
var empty = 0;
var solo = 0;
for (var id in allConnections) {
if (allConnections[id].length) {
sessions++;
connections += allConnections[id].length;
if (allConnections[id].length == 1) {
solo++;
}
} else {
empty++;
}
}
return {
sessions: sessions,
connections: connections,
empty: empty,
solo: solo
};
};
var allConnections = {};
var connectionStats = {};
var ID = 0;
exports.startWsServer = function (server) {
var wsServer = new WebSocketServer({
httpServer: server,
// 10Mb max size (1Mb is default, maybe this bump is unnecessary)
maxReceivedMessageSize: 0x1000000,
// The browser doesn't seem to break things up into frames (not sure what this means)
// and the default of 64Kb was exceeded; raised to 1Mb
maxReceivedFrameSize: 0x100000,
// Using autoaccept because the origin is somewhat dynamic
// FIXME: make this smarter?
autoAcceptConnections: false
});
function originIsAllowed(origin) {
// Unfortunately the origin will be whatever page you are sharing,
// which could be any origin
return true;
}
wsServer.on('request', function(request) {
if (!originIsAllowed(request.origin)) {
// Make sure we only accept requests from an allowed origin
request.reject();
logger.info('Connection from origin ' + request.origin + ' rejected.');
return;
}
var id = request.httpRequest.url.replace(/^\/+hub\/+/, '').replace(/\//g, "");
if (! id) {
request.reject(404, 'No ID Found');
return;
}
// FIXME: we should use a protocol here instead of null, but I can't
// get it to work. "Protocol" is what the two clients are using
// this channel for (we don't bother to specify this)
var connection = request.accept(null, request.origin);
connection.ID = ID++;
if (! allConnections[id]) {
allConnections[id] = [];
connectionStats[id] = {
created: Date.now(),
sample: [],
clients: {},
domains: {},
urls: {},
firstDomain: null,
totalMessageChars: 0,
totalMessages: 0,
connections: 0
};
}
allConnections[id].push(connection);
connectionStats[id].connections++;
connectionStats[id].lastLeft = null;
logger.debug('Connection accepted to ' + JSON.stringify(id) + ' ID:' + connection.ID);
connection.sendUTF(JSON.stringify({
type: "init-connection",
peerCount: allConnections[id].length-1
}));
connection.on('message', function(message) {
var parsed;
try {
parsed = JSON.parse(message.utf8Data);
} catch (e) {
logger.warn('Error parsing JSON: ' + JSON.stringify(message.utf8Data) + ": " + e);
return;
}
connectionStats[id].clients[parsed.clientId] = true;
var domain = null;
if (parsed.url) {
domain = parseUrl(parsed.url).hostname;
connectionStats[id].urls[parsed.url] = true;
}
if ((! connectionStats[id].firstDomain) && domain) {
connectionStats[id].firstDomain = domain;
}
connectionStats[id].domains[domain] = true;
connectionStats[id].totalMessageChars += message.utf8Data.length;
connectionStats[id].totalMessages++;
logger.debug('Message on ' + id + ' bytes: ' +
(message.utf8Data && message.utf8Data.length) +
' conn ID: ' + connection.ID + ' data:' + message.utf8Data.substr(0, 20) +
' connections: ' + allConnections[id].length);
for (var i=0; i<allConnections[id].length; i++) {
var c = allConnections[id][i];
if (c == connection && !parsed["server-echo"]) {
continue;
}
if (message.type === 'utf8') {
c.sendUTF(message.utf8Data);
} else if (message.type === 'binary') {
c.sendBytes(message.binaryData);
}
}
});
connection.on('close', function(reasonCode, description) {
if (! allConnections[id]) {
// Got cleaned up entirely, somehow?
logger.info("Connection ID", id, "was cleaned up entirely before last connection closed");
return;
}
var index = allConnections[id].indexOf(connection);
if (index != -1) {
allConnections[id].splice(index, 1);
}
if (! allConnections[id].length) {
delete allConnections[id];
connectionStats[id].lastLeft = Date.now();
}
logger.debug('Peer ' + connection.remoteAddress + ' disconnected, ID: ' + connection.ID);
});
});
setInterval(function () {
for (var id in connectionStats) {
if (connectionStats[id].lastLeft && Date.now() - connectionStats[id].lastLeft > EMPTY_ROOM_LOG_TIMEOUT) {
logStats(id, connectionStats[id]);
delete connectionStats[id];
continue;
}
var totalClients = countClients(connectionStats[id].clients);
var connections = 0;
if (allConnections[id]) {
connections = allConnections[id].length;
}
connectionStats[id].sample.push({
time: Date.now(),
totalClients: totalClients,
connections: connections
});
}
}, SAMPLE_STATS_INTERVAL);
setInterval(function () {
var load = exports.getLoad();
load.time = Date.now();
logger.info("LOAD", JSON.stringify(load));
}, SAMPLE_LOAD_INTERVAL);
function countClients(clients) {
var n = 0;
for (var clientId in clients) {
n++;
}
return n;
}
function logStats(id, stats) {
logger.info("STATS", JSON.stringify({
id: id,
created: stats.created,
sample: stats.sample,
totalClients: countClients(stats.clients),
totalMessageChars: stats.totalMessageChars,
totalMessages: stats.totalMessages,
domain: stats.firstDomain || null,
domainCount: countClients(stats.domains),
urls: countClients(stats.urls)
}));
}
};