diff --git a/lib/client.js b/lib/client.js index 3c23ae155a..bd5aa17001 100644 --- a/lib/client.js +++ b/lib/client.js @@ -20,9 +20,10 @@ module.exports = Client; * @api private */ -function Client(server, conn){ +function Client(server, conn, host){ this.server = server; this.conn = conn; + this.host = host; this.encoder = new parser.Encoder(); this.decoder = new parser.Decoder(); this.id = conn.id; @@ -57,11 +58,11 @@ Client.prototype.setup = function(){ Client.prototype.connect = function(name){ debug('connecting to namespace %s', name); - if (!this.server.nsps[name]) { + var nsp = this.server.of(name, this.host, null, true); + if (nsp == null) { this.packet({ type: parser.ERROR, nsp: name, data : 'Invalid namespace'}); return; } - var nsp = this.server.of(name); if ('/' != name && !this.nsps['/']) { this.connectBuffer.push(name); return; diff --git a/lib/index.js b/lib/index.js index 92170c3938..58d7e4bb22 100644 --- a/lib/index.js +++ b/lib/index.js @@ -47,6 +47,14 @@ function Server(srv, opts){ this.serveClient(false !== opts.serveClient); this.adapter(opts.adapter || Adapter); this.origins(opts.origins || '*:*'); + this._cleanupTimer = null; + this._cleanupTime = null; + this._setupNames = {}; + this._setupPatterns = []; + this._mainHost = makePattern(opts.host || '*'); + this._defaultRetirement = opts.retirement || 10000; + this._serveStatus = opts.serveStatus || false; + this.sockets = this.of('/'); if (srv) this.attach(srv, opts); } @@ -226,7 +234,7 @@ Server.prototype.attach = function(srv, opts){ this.eio = engine.attach(srv, opts); // attach static file serving - if (this._serveClient) this.attachServe(srv); + if (this._serveClient || this._serveStatus) this.attachServe(srv); // Export http server this.httpServer = srv; @@ -246,13 +254,16 @@ Server.prototype.attach = function(srv, opts){ Server.prototype.attachServe = function(srv){ debug('attaching client serving req handler'); - var url = this._path + '/socket.io.js'; + var clienturl = this._path + '/socket.io.js'; + var statusurl = this._path + '/status'; 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)) { + if (self._serveClient && 0 == req.url.indexOf(clienturl)) { self.serve(req, res); + } else if (self._serveState && 0 == req.url.indexOf(statusurl)) { + self.status(req, res); } else { for (var i = 0; i < evs.length; i++) { evs[i].call(srv, req, res); @@ -287,6 +298,81 @@ Server.prototype.serve = function(req, res){ res.end(clientSource); }; +/** + * Handles a request serving `/status` + * + * @param {http.Request} req + * @param {http.Response} res + * @api private + */ + +Server.prototype.status = function(req, res){ + var match = '*'; + if (req && !matchPattern(this._mainHost, req.headers.host)) { + match = req.headers.host; + } + var html = !res ? [] : ['', '', '
', '',
+ 'Refresh active namespaces on ' + match, ''];
+ function addText(str) {
+ html.push(str.replace(/&/g, '&').replace(/ 1) {
+ addText(' rooms: ' + nsp.rooms.join(' '));
+ }
+ if (nsp.sockets.length == 0) {
+ var remaining = nsp._expiration() - now;
+ var expinfo = '';
+ if (remaining < Infinity) {
+ expinfo = '; expires ' + remaining / 1000 + 's';
+ }
+ addText(' (no sockets' + expinfo + ')');
+ } else for (var k = 0; k < nsp.sockets.length; ++k) {
+ var socket = nsp.sockets[k];
+ var clientdesc = '';
+ if (socket.request.connection.remoteAddress) {
+ clientdesc += ' from ' + socket.request.connection.remoteAddress;
+ }
+ var roomdesc = '';
+ if (socket.rooms.length > 1) {
+ for (var m = 0; m < socket.rooms.length; ++m) {
+ if (socket.rooms[m] != socket.client.id) {
+ roomdesc += ' ' + socket.rooms[m];
+ }
+ }
+ }
+ addText(' socket ' + socket.id + clientdesc + roomdesc);
+ }
+ html.push('');
+ }
+ if (!res) {
+ return html.join('\n');
+ }
+ html.push('', '', '');
+ res.setHeader('Content-Type', 'text/html');
+ res.writeHead(200);
+ res.end(html.join('\n'));
+};
+
/**
* Binds socket.io to an engine.io instance.
*
@@ -311,29 +397,135 @@ Server.prototype.bind = function(engine){
Server.prototype.onconnection = function(conn){
debug('incoming connection with id %s', conn.id);
- var client = new Client(this, conn);
+ var host = this.getHost(conn);
+ if (!host || matchPattern(this._mainHost, host)) {
+ // The main host gets nulled out.
+ host = null;
+ }
+ var client = new Client(this, conn, host);
client.connect('/');
return this;
};
+/**
+ * Extracts the host name from a connection. May be overridden.
+ *
+ * @param {Connection} connection
+ * @return {String} host name
+ * @api public
+ */
+
+Server.prototype.getHost = function(conn){
+ return conn.request.headers.host;
+};
+
+/**
+ * For initialization, allow paterns to be regexps, '*', true, or a string.
+ * Patterns containing special regexp characters are parsed as RegExps.
+ *
+ * @param {String|RegExp} given pattern
+ * @return {String|RegExp} created pattern
+ * @api private
+ */
+function makePattern(pattern) {
+ if (pattern === true) return new RegExp('.^'); // matches nothing.
+ if (pattern === '*') return new RegExp('.*'); // matches everything.
+ if (/[*?+\[\](){}]/.test(pattern)) return new RegExp(pattern);
+ if (pattern instanceof RegExp) return pattern;
+ return pattern;
+}
+
+/**
+ * Returns a match-like object, matching either a string or a RegExp.
+ *
+ * @param {String|RegExp} pattern is a string or RegExp
+ * @param {String} str to match
+ * @return {Object} regexp-match-like object
+ * @api private
+ */
+function matchPattern(pattern, str) {
+ if (pattern instanceof RegExp) {
+ return pattern.exec(str);
+ } else {
+ return pattern == str ? {'0': str, index: 0, input: str} : null;
+ }
+}
+
+/**
+ * Set up intiialization for a namespace pattern.
+ *
+ * @param {String|RegExp} nsp name
+ * @param {Function} nsp initiialization calback
+ * @api public
+ */
+
+Server.prototype.setupNamespace = function(name, fn) {
+ var pattern = makePattern(name);
+ if (pattern instanceof RegExp) {
+ this._setupPatterns.push({pattern: pattern, setup: fn});
+ } else {
+ this._setupNames[name] = fn;
+ }
+ for (var j in this.nsps) {
+ if (this.nsps.hasOwnProperty(j)) {
+ var nsp = this.nsps[j];
+ if (!nsp.setupDone && null != (match = matchPattern(pattern, j))) {
+ nsp.setupDone = -1;
+ if (false === fn.apply(this, [nsp, match])) {
+ nsp.setupDone = 0;
+ } else {
+ nsp.setupDone = 1;
+ }
+ }
+ }
+ }
+};
+
/**
* Looks up a namespace.
*
* @param {String} nsp name
+ * @param {String} optional hostname
* @param {Function} optional, nsp `connection` ev handler
+ * @param {Boolean} auto (internal) true to request a dynamic namespace
* @api public
*/
-Server.prototype.of = function(name, fn){
+Server.prototype.of = function(name, host, fn, auto){
+ if (fn == null && 'function' == typeof host) {
+ fn = host;
+ host = null;
+ }
if (String(name)[0] !== '/') name = '/' + name;
-
- if (!this.nsps[name]) {
- debug('initializing namespace %s', name);
- var nsp = new Namespace(this, name);
- this.nsps[name] = nsp;
+ var fullname = Namespace.qualify(name, host);
+ var setup = null, match, j;
+ if (!this.nsps[fullname]) {
+ debug('initializing namespace %s', fullname);
+ if (this._setupNames.hasOwnProperty(fullname)) {
+ setup = this._setupNames[fullname];
+ } else for (j = this._setupPatterns.length - 1; j >= 0; --j) {
+ if (!!(match = matchPattern(this._setupPatterns[j].pattern, fullname))) {
+ setup = this._setupPatterns[j].setup;
+ break;
+ }
+ }
+ if (auto && !setup) return null;
+ var nsp = new Namespace(this, name, host);
+ if (auto) nsp.retirement = this._defaultRetirement;
+ this.nsps[fullname] = nsp;
+ if (setup) {
+ nsp.setupDone = -1;
+ if (false === setup.apply(this, [nsp, match])) {
+ debug('namespace %s rejected', fullname);
+ delete this.nsps[fullname];
+ return null;
+ } else {
+ nsp.setupDone = 1;
+ }
+ }
}
- if (fn) this.nsps[name].on('connect', fn);
- return this.nsps[name];
+ if (fn) this.nsps[fullname].on('connect', fn);
+ return this.nsps[fullname];
};
/**
@@ -354,6 +546,48 @@ Server.prototype.close = function(){
}
};
+/**
+ * Schedules a cleanup timer for deleting unused namespaces.
+ *
+ * @param {Number} millisecond delay
+ * @api private
+ */
+Server.prototype.requestCleanupAfter = function(delay) {
+ delay = Math.max(0, delay || 0);
+ if (!(delay < Infinity)) return;
+ var cleanupTime = delay + +(new Date);
+ if (this._cleanupTimer && cleanupTime < this._cleanupTime) {
+ clearTimeout(this._cleanupTimer);
+ this._cleanupTimer = null;
+ }
+ // Do cleanup in 5-second batches.
+ delay += Math.max(1, Math.min(delay, 5000));
+ var server = this;
+ if (!this._cleanupTimer) {
+ this._cleanupTime = cleanupTime;
+ this._cleanupTimer = setTimeout(doCleanup, delay);
+ }
+ function doCleanup() {
+ server._cleanupTimer = null;
+ server._cleanupTime = null;
+ var earliestUnexpired = Infinity;
+ var now = +(new Date);
+ for (var j in server.nsps) {
+ if (server.nsps.hasOwnProperty(j)) {
+ var nsp = server.nsps[j];
+ var expiration = nsp._expiration();
+ if (expiration <= now) {
+ nsp.expire(true);
+ delete server.nsps[j];
+ } else {
+ earliestUnexpired = Math.min(earliestUnexpired, expiration);
+ }
+ }
+ }
+ server.requestCleanupAfter(earliestUnexpired - now);
+ }
+};
+
/**
* Expose main namespace (/).
*/
diff --git a/lib/namespace.js b/lib/namespace.js
index 4ae0b154a5..28eed3778f 100644
--- a/lib/namespace.js
+++ b/lib/namespace.js
@@ -45,15 +45,20 @@ var emit = Emitter.prototype.emit;
* @api private
*/
-function Namespace(server, name){
+function Namespace(server, name, host){
this.name = name;
+ this.host = host;
this.server = server;
this.sockets = [];
this.connected = {};
this.fns = [];
- this.ids = 0;
+ this.ids = Math.floor(Math.random() * 1e9);
this.acks = {};
this.initAdapter();
+ this.setupDone = 0;
+ this.retirement = Infinity;
+ this._expirationTime = Infinity;
+ this._expirationCallbacks = null;
}
/**
@@ -150,6 +155,7 @@ Namespace.prototype['in'] = function(name){
Namespace.prototype.add = function(client, fn){
debug('adding socket to nsp %s', this.name);
+ this._expirationTime = Infinity;
var socket = new Socket(this, client);
var self = this;
this.run(socket, function(err){
@@ -188,6 +194,10 @@ Namespace.prototype.remove = function(socket){
var i = this.sockets.indexOf(socket);
if (~i) {
this.sockets.splice(i, 1);
+ if (!this.sockets.length && isFinite(this.retirement)) {
+ this._expirationTime = +(new Date) + this.retirement;
+ this.server.requestCleanupAfter(this.retirement);
+ }
} else {
debug('ignoring remove for %s', socket.id);
}
@@ -240,3 +250,58 @@ Namespace.prototype.write = function(){
this.emit.apply(this, args);
return this;
};
+
+/**
+ * Registers an expire callback.
+ *
+ * @api public
+ */
+
+Namespace.prototype.expire = function(callback){
+ if (callback !== true) {
+ if (!this._expirationCallbacks) {
+ this._expirationCallbacks = [];
+ }
+ this._expirationCallbacks.push(callback);
+ } else {
+ // expire(true) is for internal use to trigger and clear expire callbacks.
+ var callbacks = this._expirationCallbacks;
+ if (callbacks) {
+ this._expirationCallbacks = null;
+ while (callbacks.length > 0) {
+ callbacks.pop().apply(null, [this]);
+ }
+ }
+ }
+}
+
+/**
+ * Forms a full namespace name out of a local namespace name and a host.
+ *
+ * @api private
+ */
+
+Namespace.qualify = function(name, host) {
+ return host == null ? name : '//' + host + name;
+}
+
+/**
+ * Returns the fully qualified //host/name for this namespace.
+ *
+ * @api public
+ */
+
+Namespace.prototype.fullname = function() {
+ return Namespace.qualify(this.name, this.host);
+};
+
+/**
+ * Returns the time after which this namespace can be expired.
+ *
+ * @api private
+ */
+
+Namespace.prototype._expiration = function() {
+ if (this.sockets.length) return Infinity;
+ return this._expirationTime;
+};
diff --git a/test/dynamic.js b/test/dynamic.js
new file mode 100644
index 0000000000..c43ab5fef9
--- /dev/null
+++ b/test/dynamic.js
@@ -0,0 +1,305 @@
+
+var http = require('http').Server;
+var io = require('..');
+var join = require('path').join;
+var ioc = require('socket.io-client');
+var request = require('supertest');
+var expect = require('expect.js');
+
+// creates a socket.io client for the given server
+function client(srv, nsp, opts){
+ if ('object' == typeof nsp) {
+ opts = nsp;
+ nsp = null;
+ }
+ var url;
+ if ('string' == typeof srv) {
+ url = srv + (nsp || '');
+ } else {
+ var addr = srv.address();
+ if (!addr) addr = srv.listen().address();
+ url = 'ws://' + addr.address + ':' + addr.port + (nsp || '');
+ }
+ return ioc(url, opts);
+}
+
+describe('dynamic.io', function(){
+ describe('hosts', function() {
+ it('should add //host:port when host is true', function(done){
+ var srv = http();
+ var sio = io(srv, { host: true });
+ var total = 1;
+ var basename = '';
+ sio.setupNamespace(/.*first/, function(nsp) {
+ expect(nsp.fullname()).to.be(basename + '/first');
+ --total || done();
+ });
+ srv.listen(function() {
+ var addr = srv.address();
+ basename = '//' + addr.address + ':' + addr.port;
+ client(srv, '/first');
+ });
+ });
+ it('should allow getHost override', function(done){
+ var srv = http();
+ var sio = io(srv, { host: true });
+ var total = 2;
+ var basename = '';
+ // Override getHost to strip port.
+ sio.getHost = function(conn) {
+ return conn.request.headers.host.replace(/:\d+$/, '');
+ }
+ sio.setupNamespace(/.*first/, function(nsp) {
+ expect(nsp.fullname()).to.be(basename + '/first');
+ --total || done();
+ });
+ sio.setupNamespace(/.*second/, function(nsp) {
+ expect(nsp.fullname()).to.be('//localhost/second');
+ --total || done();
+ });
+ srv.listen(function() {
+ var addr = srv.address();
+ basename = '//' + addr.address;
+ client(srv, '/first');
+ client('http://localhost:' + addr.port + '/second');
+ });
+ });
+ it('should support host pattern', function(done){
+ var srv = http();
+ var sio = io(srv, { host: /^\d/ });
+ var total = 2;
+ var localname = '';
+ sio.setupNamespace(/.*first/, function(nsp) {
+ expect(nsp.fullname()).to.be('/first');
+ --total || done();
+ });
+ sio.setupNamespace(/.*second/, function(nsp) {
+ expect(nsp.fullname()).to.be(localname + '/second');
+ --total || done();
+ });
+ srv.listen(function() {
+ var addr = srv.address();
+ localname = '//localhost:' + addr.port;
+ client(srv, '/first');
+ client('http://localhost:' + addr.port + '/second');
+ });
+ });
+ });
+
+ describe('namespaces', function(){
+ it('should set up / with *', function(done){
+ var srv = http();
+ var sio = io(srv);
+ var total = 1;
+ sio.setupNamespace('*', function(nsp, match) {
+ expect(match).to.eql({'0': '/', index: 0, input: '/'});
+ expect(nsp).to.be(sio.sockets);
+ --total || done();
+ });
+ });
+
+ it('should set up / with /', function(done){
+ var srv = http();
+ var sio = io(srv);
+ var total = 1;
+ sio.setupNamespace('/', function(nsp, match) {
+ expect(match).to.eql({'0': '/', index: 0, input: '/'});
+ expect(nsp).to.be(sio.sockets);
+ --total || done();
+ });
+ });
+
+ it('should match namespace regexp', function(done){
+ var srv = http();
+ var sio = io(srv);
+ var setup = 2;
+ var connect = [];
+ sio.setupNamespace(/^.*\/([^\/]*)$/, function(nsp, match){
+ expect(match).to.eql(
+ setup == 2 ? {'0': '/', '1': '', index: 0, input: '/'} :
+ setup == 1 ? {'0':'/d/sec', '1': 'sec', index: 0, input: '/d/sec'} :
+ null);
+ expect(nsp.name).to.be(match[0]);
+ expect(nsp.fullname()).to.be(match[0]);
+ if (setup == 2) {
+ expect(nsp).to.be(sio.sockets);
+ } else {
+ expect(nsp).not.to.be(sio.sockets);
+ }
+ --setup;
+ nsp.on('connect', function(socket) {
+ connect.push(socket.nsp.name);
+ if (connect.length == 2) {
+ expect(connect).to.contain('/');
+ expect(connect).to.contain('/d/sec');
+ done();
+ }
+ });
+ });
+ srv.listen(function() {
+ expect(setup).to.be(1);
+ var sec = client(srv, '/d/sec');
+ });
+ });
+
+ it('should not match restrictive regexp', function(done){
+ var srv = http();
+ var sio = io(srv);
+ var connect = 1;
+ sio.setupNamespace(/^\/dyn\/([^\/]*)$/, function(nsp, match){
+ expect(nsp).not.to.be(sio.sockets);
+ expect(nsp.name).to.be('/dyn/a');
+ expect(match[1]).to.be('a');
+ nsp.on('connect', function(socket) {
+ --connect || done();
+ });
+ });
+ srv.listen(function() {
+ var r = client(srv, '/');
+ r.on('connect', function() {
+ expect(connect).to.be(1);
+ var e = client(srv, '/doesnotexist');
+ e.on('error', function(err) {
+ expect(err).to.be('Invalid namespace');
+ var a = client(srv, '/dyn/a');
+ });
+ });
+ });
+ });
+
+ it('should prioritize names over patterns, last first', function(done){
+ var srv = http();
+ var sio = io(srv);
+ var steps = 5;
+ var setup = [];
+ sio.setupNamespace('/special-debug', function(nsp, match){S
+ setup.push('ex1:' + nsp.name);
+ --steps || finish();
+ });
+ sio.setupNamespace('/special-debug', function(nsp, match){
+ setup.push('ex2:' + nsp.name);
+ --steps || finish();
+ });
+ sio.setupNamespace(/^\/special.*$/, function(nsp, match){
+ setup.push('wc1:' + nsp.name);
+ --steps || finish();
+ });
+ sio.setupNamespace(/^\/.*debug$/, function(nsp, match){
+ setup.push('wc2:' + nsp.name);
+ --steps || finish();
+ });
+ srv.listen(function() {
+ client(srv, '/special-debug');
+ client(srv, '/special-other');
+ client(srv, '/other-debug');
+ client(srv, '/special-other-debug');
+ var e = client(srv, '/no-match');
+ e.on('error', function() {
+ setup.push('error:/no-match');
+ --steps || finish();
+ });
+ });
+ function finish() {
+ expect(setup).to.have.length(5);
+ expect(setup).to.contain('ex2:/special-debug');
+ expect(setup).to.contain('wc1:/special-other');
+ expect(setup).to.contain('wc2:/other-debug');
+ expect(setup).to.contain('wc2:/special-other-debug');
+ expect(setup).to.contain('error:/no-match');
+ done();
+ }
+ });
+
+ it('should not setup namespace twice', function(done){
+ var srv = http();
+ var sio = io(srv);
+ var steps = 5;
+ var setup = [];
+ var hello = sio.of('/hello');
+ var there = sio.of('/there');
+ sio.setupNamespace('/hello', function(nsp, match){
+ setup.push('ex1:' + nsp.name);
+ --steps || finish();
+ });
+ sio.setupNamespace('/howdy', function(nsp, match){
+ setup.push('ex2:' + nsp.name);
+ --steps || finish();
+ });
+ sio.setupNamespace(/^\/.*h.*$/, function(nsp, match){
+ setup.push('wc:' + nsp.name);
+ --steps || finish();
+ });
+ srv.listen(function() {
+ var c1 = client(srv, '/howdy');
+ c1.on('connect', function() {
+ --steps || finish();
+ });
+ var c2 = client(srv, '/howdy');
+ c2.on('connect', function() {
+ --steps || finish();
+ });
+ });
+ function finish() {
+ expect(setup).to.have.length(3);
+ // No duplicates.
+ expect(setup).to.contain('ex1:/hello');
+ expect(setup).to.contain('ex2:/howdy');
+ expect(setup).to.contain('wc:/there');
+ done();
+ }
+ });
+ it('should retire stale namespaces', function(done){
+ var srv = http();
+ var sio = io(srv, {retirement:1});
+ var steps = 7;
+ var setup = [];
+ sio.setupNamespace(/^\/dyn\/.*$/, function(nsp, match){
+ setup.push('setup:' + nsp.name);
+ --steps || finish();
+ nsp.on('connect', function(socket) {
+ setup.push('sconn:' + nsp.name);
+ --steps || finish();
+ socket.on('disconnect', function() {
+ setup.push('disc:' + nsp.name);
+ --steps || finish();
+ });
+ });
+ nsp.expire(function() {
+ setup.push('exp:' + nsp.name);
+ --steps || finish();
+ });
+ });
+ sio.of('/permanent').on('connect', function(socket) {
+ setup.push('sconn:/permanent');
+ --steps || finish();
+ });
+ srv.listen(function() {
+ var c1 = client(srv, '/dyn/fleeting');
+ c1.on('connect', function() {
+ setup.push('conn:/dyn/fleeting');
+ --steps || finish();
+ var c2 = client(srv, '/permanent');
+ c2.on('connect', function() {
+ setup.push('conn:/permanent');
+ --steps || finish();
+ c2.disconnect();
+ c1.disconnect();
+ });
+ });
+ });
+ function finish() {
+ expect(setup).to.have.length(7);
+ // No duplicates.
+ expect(setup).to.contain('setup:/dyn/fleeting');
+ expect(setup).to.contain('conn:/dyn/fleeting');
+ expect(setup).to.contain('sconn:/dyn/fleeting');
+ expect(setup).to.contain('sconn:/permanent');
+ expect(setup).to.contain('conn:/permanent');
+ expect(setup).to.contain('disc:/dyn/fleeting');
+ expect(setup).to.contain('exp:/dyn/fleeting');
+ expect(sio.nsps).to.have.property('/permanent');
+ done();
+ }
+ });
+ });
+});