cloudcms-server
Version:
Cloud CMS Application Server Module
640 lines (510 loc) • 21.3 kB
JavaScript
var util = require("../../util/util");
var logFactory = require("../../util/logger");
var async = require("async");
/**
* Awareness middleware.
*
* @type {Function}
*/
exports = module.exports = function()
{
var logger = logFactory("AWARENESS");
var provider = null;
var REAP_FREQUENCY_MS = 5000; // five seconds
var REAP_MAX_AGE_MS = 120000; // two minutes
var pluginPaths = ["./plugins/editorial"];
var plugins = {};
// ensure reaper only initializes once
var reaperInitialized = false;
// ensure socket IO only initializes once
var socketIOInitialized = false;
var _LOCK = function(channelId, workFunction)
{
var lockKey = "awareness_channel_" + channelId;
process.locks.lock(lockKey, workFunction);
};
var r = {};
/**
* This gets called early and should be used to set defaults and instantiate the provider.
*
* @type {Function}
*/
var init = r.init = function(callback) {
// set up defaults
if (!process.env.CLOUDCMS_AWARENESS_TYPE)
{
process.env.CLOUDCMS_AWARENESS_TYPE = "memory";
// auto-configure
if (process.env.CLOUDCMS_LAUNCHPAD_SETUP === "redis")
{
process.env.CLOUDCMS_AWARENESS_TYPE = "redis";
}
}
if (!process.configuration.awareness) {
process.configuration.awareness = {};
}
if (!process.configuration.awareness.type) {
process.configuration.awareness.type = process.env.CLOUDCMS_AWARENESS_TYPE;
}
if (!process.configuration.awareness.config) {
process.configuration.awareness.config = {};
}
// init any plugins?
if (!process.configuration.awareness.plugins) {
process.configuration.awareness.plugins = [];
}
var type = process.configuration.awareness.type;
var config = process.configuration.awareness.config;
var providerFactory = require("./providers/" + type);
provider = new providerFactory(config);
// initialize the provider
provider.init(function(err){
if (err) {
return callback(err);
}
var fns = [];
for (var i = 0; i < pluginPaths.length; i++)
{
var fn = function(awareness, pluginPath) {
return function(done) {
try
{
var plugin = require(pluginPath);
process.log("Registering Awareness plugin: " + pluginPath);
awareness.registerPlugin(pluginPath, plugin);
}
catch (e)
{
process.log("Failed to instantiate awareness plugin: " + e);
process.log(e);
}
done();
}
}(r, pluginPaths[i]);
fns.push(fn);
}
async.series(fns, function() {
callback();
});
});
};
/**
* This gets called whenever a new socket is connected to the Cloud CMS server.
*
* @param io
* @param callback
*
* @type {Function}
*/
var initSocketIO = r.initSocketIO = function(io, callback) {
// initialize socket IO event handlers so that awareness binds to any new, incoming sockets
socketInit(io);
// ensure the reaper is initialized
reaperInit(io, REAP_FREQUENCY_MS, REAP_MAX_AGE_MS, function(err) {
callback(err);
});
};
var socketInit = function(io)
{
if (socketIOInitialized)
{
return;
}
socketIOInitialized = true;
var pluginProxy = function(plugins) {
var r = {};
// allow plugins to bind on("connection") handlers
r.bindOnSocketConnection = function(socket, provider, callback)
{
var fns = [];
for (var pluginPath in plugins)
{
var plugin = plugins[pluginPath];
var fn = function(pluginPath, plugin, socket) {
return function(done) {
plugin.bindSocket(socket, provider);
done();
}
}(pluginPath, plugin, socket);
fns.push(fn);
}
async.series(fns, function(err) {
callback(err);
});
};
return r;
}(plugins);
// when a socket.io connection is established, we set up some default listeners for events that the client
// may emit to us
io.on("connection", function(socket) {
// "register" -> indicates that a user is in a channel
socket.on("register", function(channelId, user, dirty, callback) {
checkRegistered(channelId, user.id, function(err, alreadyRegistered) {
if (err) {
return callback(err);
}
// attach socket ID to user
user.socketId = socket.id;
// this will either create a new entry or update the old one
// so that the TTL is updated
register(channelId, user, function(err) {
if (err) {
return callback(err);
}
// // if we were already registered, just callback
// // however, if "dirty" is set, then we always hand back membership
// if (!dirty && alreadyRegistered)
// {
// logger.info("Already registered, not dirty - channelId: " + channelId + ",userId=" + user.id + " (" + user.name + ")");
//
// return callback();
// }
//
// if (!alreadyRegistered)
// {
// logger.info("New registration - channelId: " + channelId + ",userId=" + user.id + " (" + user.name + ")");
//logger.info("Register - channelId: " + channelId + ", userId=" + user.id + " (" + user.name + ")");
socket.join(channelId);
//}
discover(channelId, function(err, userArray) {
if (err)
{
logger.info("Discover - channelId: " + channelId + ", err: " + JSON.stringify(err));
}
else
{
//logger.info("Discover - channelId: " + channelId + ", userId=" + user.id + " (" + user.name + ") handing back: " + userArray.length);
io.sockets.in(channelId).emit("membershipChanged", channelId, userArray);
}
callback();
});
});
});
});
// "discover" -> hand back the users who are in a channel
socket.on("discover", function(channelId, callback) {
discover(channelId, callback);
});
// "acquireLock"
socket.on("acquireLock", function(channelId, user, callback) {
// attach socket ID to user
user.socketId = socket.id;
// make an attempt to acquire the lock
acquireLock(channelId, user, function(err, success) {
// if we got an error, then we didn't acquire the lock
if (err) {
return callback(err);
}
if (!success)
{
// we didn't acquire the lock, so bail
return callback(null, false);
}
// we got the lock
// notify everyone in the channel (except us) that someone else acquired the lock
socket.to(channelId).emit("lockAcquired", channelId, user);
// fire callback to let the socket called know they succeeded
callback(null, true);
});
});
// "releaseLock"
socket.on("releaseLock", function(channelId, userId, callback) {
// make an attempt to release the lock
releaseLock(channelId, userId, function(err, success) {
// if we got an error, then we didn't release the lock
if (err) {
return callback(err, false);
}
if (!success)
{
// we didn't release the lock, so bail
return callback(null, false);
}
// we released the lock
// notify everyone in the channel (except us) that someone else acquired the lock
socket.to(channelId).emit("lockReleased", channelId, userId);
// fire callback to let the socket called know they succeeded
callback(null, true);
});
});
// "lockInfo" -> requests info about a lock
socket.on("lockInfo", function(channelId, callback) {
lockInfo(channelId, callback);
});
// "notifyLockOwner" -> notifies the lock owner with a message
socket.on("notifyLockOwner", function(channelId, user, message, callback) {
notifyLockOwner(socket, channelId, user, message, callback);
});
// allow plugins to register more on() handlers if they wish
pluginProxy.bindOnSocketConnection(socket, provider, function() {
// done
});
});
};
/**
* Starts up a reaper "thread" that wakes up periodically and looks for users in channels whose registrations
* have expired. When expired registrations are found, they are noted.
*
* Upon completing, any channel members who are in a channel whose membership has changed will be notified.
* In addition, any locks held by members who are expired will be released and channel listeners will be notified.
*
* @param {*} callback
*/
var reaperInit = function(io, frequencyMs, maxAgeMs, callback) {
if (reaperInitialized) {
return callback();
}
reaperInitialized = true;
var reap = function() {
// reap anything before a calculated time in the past
var beforeMs = Date.now() - maxAgeMs;
// run expirations
expire(beforeMs, function(err, updatedMembershipChannelIds, expiredUserIdsByChannelId) {
// functions
var fns = [];
// for any channels whose membership changed, we notify everyone listening to the channel
// of the new membership list
if (!err && updatedMembershipChannelIds)
{
for (var i = 0; i < updatedMembershipChannelIds.length; i++)
{
var fn = function (channelId) {
return function (done) {
discover(channelId, function (err, userArray) {
if (!err)
{
io.sockets.in(channelId).emit("membershipChanged", channelId, userArray);
}
done();
});
}
}(updatedMembershipChannelIds[i]);
fns.push(fn);
}
}
// for any users who were expired, we attempt to release locks
// if a lock was released, we notify everyone in the channel room
if (!err && expiredUserIdsByChannelId)
{
for (var channelId in expiredUserIdsByChannelId)
{
var expiredUserIds = expiredUserIdsByChannelId[channelId];
if (expiredUserIds)
{
for (var i = 0; i < expiredUserIds.length; i++)
{
var fn = function(channelId, userId) {
return function(done) {
releaseLock(channelId, userId, function(err, success) {
if (!err && success)
{
console.log("REAPING, channel: " + channelId + ", user: " + userId);
io.sockets.in(channelId).emit("lockReleased", channelId, userId);
}
done();
});
};
}(channelId, expiredUserIds[i]);
fns.push(fn);
}
}
}
}
async.parallel(fns, function(err) {
// run reap again after some period of time
setTimeout(function() {
reap();
}, frequencyMs);
});
});
};
reap();
callback();
};
/**
* Registers a user into a channel. This can be called multiple times.
*
* If a user isn't registered in a channel, they are added along with a timestamp indicating when they registered.
* If they are already registered, the entry is re-creted so that the timestamp updates.
*
* The register() call should be called periodically from any front-end apps to signal that that the front-end
* user is "still there" and "still in the channel".
*
* @type {Function}
*/
var register = r.register = function(channelId, user, callback)
{
//console.log("Awareness - heard register, channel: " + channelId + ", user: " + user.id);
provider.register(channelId, user, callback);
};
/**
* Retrieves and hands back the users who are in a channel.
*
* @type {Function}
*/
var discover = r.discover = function(channelId, callback)
{
provider.discover(channelId, callback);
};
/**
* Checks whether a user is registered in a channel.
*
* @type {Function}
*/
var checkRegistered = r.checkRegistered = function(channelId, userId, callback)
{
provider.checkRegistered(channelId, userId, callback);
};
/**
* Runs expiration across all channels and users. Any users who were added before the given timestmap
* will be expired.
*
* @type {Function}
*/
var expire = r.expire = function(beforeMs, callback)
{
provider.expire(beforeMs, callback);
};
/**
* Acquires a lock for the given user against the channel. Only one user may have the lock at a given time.
* Locks are released when the reaper thread finds TTL expirations (or when they are explicitly released).
*
* @param channelId
* @param user
* @param callback
*/
var acquireLock = r.acquireLock = function(channelId, user, callback)
{
// take out a cluster-wide lock on the "channelId"
// so that two "threads" can't acquire/release at the same time for a given channel
_LOCK(channelId, function (err, releaseLockFn) {
if (err) {
return callback(err);
}
provider.acquireLock(channelId, user, function(err, success) {
releaseLockFn();
callback(err, success);
});
});
};
/**
* Explicitly releases a lock for a user within a channel.
*
* @param channelId
* @param userId
* @param callback
*/
var releaseLock = r.releaseLock = function(channelId, userId, callback)
{
// take out a cluster-wide lock on the "channelId"
// so that two "threads" can't acquire/release at the same time for a given channel
_LOCK(channelId, function (err, releaseLockFn) {
if (err) {
return callback(err);
}
provider.releaseLock(channelId, userId, function(err, success) {
releaseLockFn();
callback(err, success);
});
});
};
/**
* Acquires information about the lock on a given channel.
* If no lock exists, null will be handed back.
*
* @type {Function}
*/
var lockInfo = r.lockInfo = function(channelId, callback)
{
provider.lockInfo(channelId, callback);
};
var notifyLockOwner = r.notifyLockOwner = function(socket, channelId, user, message, callback)
{
if (!callback) {
callback = function(err) { };
}
provider.lockInfo(channelId, function(err, lock) {
if (err) {
return callback(err);
}
if (!lock) {
return callback({
"message": "Could not find lock for channel: " + channelId
});
}
// process.log("LOCK USER: " + JSON.stringify(lock.user, null, 2));
var socketId = lock.user.socketId;
if (!socketId)
{
return callback({
"message": "Could not find socket ID for lock user for channel: " + channelId
});
}
socket.to(socketId).emit("lockOwnerNotify", {
"fromUser": user,
"toUser": lock.user,
"channelId": channelId,
"message": message
});
});
};
// /**
// * Handles awareness commands.
// *
// * @return {Function}
// */
r.handler = function()
{
return util.createHandler("awareness", function(req, res, next, stores, cache, configuration) {
var handled = false;
if (req.method.toLowerCase() === "post" || req.method.toLowerCase() === "get") {
// take a look at what provider's up to
if (req.url.indexOf("/_awareness/diagnose") === 0)
{
if (configuration.type === "memory") {
res.json({
"provider": "memory",
"channelMap": provider.channelMap,
"lockMap": provider.lockMap
});
res.end();
}
else if (configuration.type === "redis") {
provider.listChannelIds(function(err, channelIds) {
var channelMap = {};
var fns = [];
channelIds.forEach(function(cid) {
var fn = function(provider, cid, channelMap) {
return function(done) {
provider.readChannel(cid, function(err, channel) {
channelMap[cid] = channel;
done();
});
};
}(provider, cid, channelMap);
fns.push(fn);
});
async.series(fns, function(err) {
res.json({
"provider": "redis",
"channelMap": channelMap
});
res.end();
});
});
}
handled = true;
}
}
if (!handled)
{
next();
}
});
};
r.registerPlugin = function(path, plugin)
{
plugins[path] = plugin;
};
return r;
}();