syncsocket
Version:
Synchronized messaging application framework server
392 lines (358 loc) • 11 kB
JavaScript
const http = require('http');
const sio = require('socket.io');
const read = require('fs').readFileSync;
const debug = require('debug')('syncsocket:server');
const Channel = require('./channel');
const Client = require('./client');
const uuid = require('node-uuid');
const util = require('util');
const EventEmitter = require('events').EventEmitter;
const clientVersion = require('syncsocket-client/package').version;
const clientSource = read(require.resolve('syncsocket-client/syncsocket.js'), 'utf-8');
const ClockServer = require('syncsocket-clock-server');
module.exports = Server;
util.inherits(Server, EventEmitter);
/**
* Server constructor
* @param {http.Server|number|object} srv http server, port or options
* @param {object} opts
* @property {boolean} embeddedTimeserver If set to true, an embedded timeserver will be launched
* @property {string} timeserverHost Clients will connect to this timeserver if no timeserver specified for channel
* @property {number} timeserverPort Clients will connect to this timeserver if no timeserver specified for channel
* @constructor
* @public
*/
function Server(srv, opts) {
if (!(this instanceof Server)) return new Server(srv, opts);
if ('object' === typeof srv && !srv.listen) {
opts = srv;
srv = null;
}
opts = opts || {};
this.path('/syncsocket');
this.serveClient(false !== opts.serveClient);
this.embeddedTimeserver(opts.embeddedTimeserver || false);
this.timeserverHost(opts.timeserverHost || 'localhost');
this.timeserverPort(opts.timeserverPort || 5579);
this.channels = [];
if (srv) this.attach(srv, opts);
}
/**
* URL of server's default timeserver (set via timeserverHost() and timeserverPort())
* @returns {string} URL
*/
Server.prototype.timeserverUrl = function () {
return 'http://' + this.timeserverHost() + ':' + this.timeserverPort();
};
/**
* Sets client serving path
* @param {string} p path
* @returns {Server|string} self when setting or value when getting
* @private
*/
Server.prototype.path = function (p) {
if (!arguments.length) return this._path;
this._path = p.replace(/\/$/, '');
return this;
};
/**
* Sets/gets whether client code is being served
* @param {boolean} v whether to serve client code
* @return {Server|boolean} self when setting or value when getting
* @public
*/
Server.prototype.serveClient = function (v) {
if (!arguments.length) return this._serveClient;
this._serveClient = v;
return this;
};
/**
* Sets/gets whether embedded timeserver is active
* @param {boolean} v whether to activate integrated timeserver
* @returns {Server|boolean} self when setting or value when getting
* @public
*/
Server.prototype.embeddedTimeserver = function (v) {
if (!arguments.length) return this._embeddedTimeserver;
this._embeddedTimeserver = v;
return this;
};
/**
* Sets/gets timeserver host to which clients will connect if no timeserver specified for channel
* @param {string} v default host
* @returns {Server|string} self when setting or value when getting
* @public
*/
Server.prototype.timeserverHost = function (v) {
if (!arguments.length) return this._timeserverHost;
this._timeserverHost = v;
return this;
};
/**
* Sets/gets timeserver port to which clients will connect if no timeserver specified for channel
* @param {number} v default port
* @returns {Server|number} self when setting or value when getting
* @public
*/
Server.prototype.timeserverPort = function (v) {
if (!arguments.length) return this._timeserverPort;
this._timeserverPort = v;
return this;
};
/**
* Attaches to a server or port
* @param {http.Server|number} server or port
* @param {Object} options
* @returns {Server} self
* @public
*/
Server.prototype.listen =
Server.prototype.attach = function (srv, opts) {
if ('function' === typeof srv) {
var msg = 'You are trying to attach socket.io to an express ' +
'request handler function. Please pass a http.Server instance.';
throw new Error(msg);
}
// handle a port as a string
if (Number(srv) == srv) {
srv = Number(srv);
}
if ('number' === typeof srv) {
debug('creating server and binding to port %d', srv);
var port = srv;
srv = http.Server(function (req, res) {
res.writeHead(404);
res.end();
});
srv.listen(port);
}
opts = opts || {};
opts.path = this.path();
// Initialize socket.io
debug('creating socket.io instance with opts %j', opts);
this.io = sio(srv, opts);
// static client file serving
if (this._serveClient) this.attachServe(srv);
if (this._embeddedTimeserver) this.setupTimeserver();
this.httpServer = srv;
this.io.on('connection', this.onconnection.bind(this));
this.io.use(this.validateConnection.bind(this));
return this;
};
/**
* Sets up a parallel, embedded timeserver
* @private
*/
Server.prototype.setupTimeserver = function () {
debug('activating embedded timeserver on default port');
this.timeserver = ClockServer();
this.timeserver.listen(this.timeserverPort());
};
/**
* Incoming connection verification function. Server-side handshake
* @param {sio.Socket} socket incoming connection
* @param {Function} next middleware handler
* @private
*/
Server.prototype.validateConnection = function (socket, next) {
var id = socket.handshake.query.instanceId;
if ('undefined' === typeof id) {
debug('cannot accept connection from client, no instanceId is provided (addr: %s)',
socket.handshake.address);
socket.disconnect();
return next(new Error('handshake failed'));
}
next();
};
/**
* Attaches static file serving
* @param {Function|http.Server} srv http server
* @private
*/
Server.prototype.attachServe = function (srv) {
debug('attaching handler for serving client');
var url = this._path + '/syncsocket.js';
var evs = srv.listeners('request').slice(0);
var self = this;
srv.removeAllListeners('request');
srv.on('request', function (req, res) {
if (0 === req.url.indexOf(url)) {
self.serve(req, res);
} else {
for (var i = 0; i < evs.length; i++) {
evs[i].call(srv, req, res);
}
}
});
};
/**
* Handles request for `/syncsocket.js`
* @param {http.Request} req
* @param {http.Response} res
* @private
*/
Server.prototype.serve = function (req, res) {
var etag = req.headers['if-none-match'];
if (etag) {
if (clientVersion === etag) {
debug('serve client 304');
res.writeHead(304);
res.end();
return;
}
}
debug('serving client source');
res.setHeader('Content-Type', 'application/javascript');
res.setHeader('ETag', clientVersion);
res.writeHead(200);
res.end(clientSource);
};
/**
* Creates a channel
* @param {?string} channelId - channel id or null. If null, then id will be generated
* @returns {?Channel} that has been created or null
* @public
*/
Server.prototype.createChannel = function (channelId) {
if ('object' === typeof this.getChannel(channelId)) {
return null;
}
var opts = {};
opts.channelId = channelId || uuid.v4();
var channel = new Channel(this, opts);
this.channels.push(channel);
debug('created channel with id %s', channel.channelId);
return channel;
};
/**
* Called on every validated incoming connection
* @param {sio.Socket} socket
* @private
*/
Server.prototype.onconnection = function (socket) {
var client = new Client(this, socket);
client.instanceId = socket.handshake.query.instanceId;
client.id = uuid.v4();
client.tag = client.instanceId + '@' + client.id;
debug('new client: ' + client.id);
/**
* Client has successfully connected
* @event Server#connection
* @type {Client}
*/
this.emit('connection', client);
};
/**
*
* @param req
* @param fn
* @param client
* @private
*/
Server.prototype.handleRequest = function (req, client, fn) {
var what = req.what;
var data = req.body;
debug('handling request %s from client %s', what, client.id);
switch (what) {
case 'join_channel':
if (!data) {
fn(new Error('invalid request'));
return;
}
var opts = {
canPublish: data.canPublish,
channelId: data.channelId
};
if (this.addToChannel(client, opts) === true) {
fn();
} else {
fn(new Error('cannot add user to channel'));
}
break;
default:
fn(new Error('invalid request'));
break;
}
};
/**
* All incoming from clients messages handled here
* @param {object} envelope topic and data
* @param {Client} client
* @private
*/
Server.prototype.handleMessage = function (envelope, client) {
debug('handling message from client ' + client.id);
if ('object' !== typeof envelope) {
client.kick();
return;
}
var channelId = envelope.channelId;
var channel = this.getChannel(channelId);
if ('object' === typeof channel) {
// Verify that the client currently in that channel
if (channel.hasClient(client)) {
channel.injectMessage(envelope, client);
return;
}
}
client.kick();
};
/**
* Adds a client to a specific channel
* @param {Client} client
* @param {object} opts Options
* @param {string} opts.channelId The channel ID to add client
* @returns {boolean} Operation result
* @public
*/
Server.prototype.addToChannel = function (client, opts) {
var channel = this.getChannel(opts.channelId);
if (channel) {
channel.addClient(client);
return true;
}
return false;
};
/**
* Get specific channel
* @param channelId Channel id
* @returns {?Channel}
* @public
*/
Server.prototype.getChannel = function (channelId) {
var ch;
this.channels.forEach(function (channel) {
if (channel.channelId === channelId) {
ch = channel;
}
});
return ch;
};
Server.prototype.notifyClientDisconnected = function (client) {
this.removeClientFromChannels(client);
/**
* Client has disconnected
* @event Server#disconnect
* @type {Client}
*/
this.emit('disconnect', client);
};
Server.prototype.removeClientFromChannels = function (client) {
this.channels.forEach(function (channel) {
channel.removeClient(client);
});
};
/**
* Shuts down the server
* @returns {Server}
* @public
*/
Server.prototype.close = function () {
this.io.close();
if (this.httpServer) {
this.httpServer.close();
}
if (this.timeserver) {
this.timeserver.close();
}
};