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(); + } + }); + }); +});