UNPKG

dynamic.io

Version:

socket.io server subclass for dynamic hosts and namespaces

443 lines (400 loc) 14.1 kB
/* dynamic.io.js, author: David Bau. DynamicServer is a subclass of the socket.io Server that knows how to deal with multiple hostnames and dynamically created namespaces that delete themselves when idle. It also provides an optional socket.io/status page for debugging. The key new entrypoint for users is "setupNamespace", which accepts a namespace name (or '*' for any-namespace) and a callback that can initialize a (passed) namespace instance when it is dynamically created. Return false from this callback to reject the namespace. New options include: * mainHost (default '*') - set to a hostname if you want to differentiate between hosts. * publicStatus (default false) - set to true to serve a debugging page on socket.io/status * retirement (default 10000) - the number of milliseconds to wait after a namespace becomes empty until starting to consider deleting it. */ var util = require('util'), Emitter = require('events').EventEmitter, IOServer = require('socket.io'), IOClient = require('socket.io/lib/client'), IOSocket = require('socket.io/lib/socket'), IONamespace = require('socket.io/lib/namespace'), parser = require('socket.io-parser'), Adapter = require('socket.io-adapter'), debug = require('debug')('dynamic.io'), exports = DynamicServer; function fullNamespaceName(name, host) { return host == null ? name : '//' + host + name; } function makePattern(pattern) { if (pattern === true) return new RegExp('.^'); // matches nothing. if (pattern === '*') return new RegExp('.*'); if (pattern instanceof RegExp) return pattern; return pattern; } function matchPattern(pattern, str) { if (pattern instanceof RegExp) { return pattern.exec(str); } else { return pattern == str ? {'0': str, index: 0, input: str} : null; } } // Override constructor, to add new fields and options. function DynamicServer(srv, opts) { if (!(this instanceof DynamicServer)) return new DynamicServer(srv, opts); var options = opts; if ('object' == typeof srv && !srv.listen) { options = srv; } options = options || {}; this._cleanupTimer = null; this._cleanupTime = null; this._namepaceNames = {}; this._namepacePatterns = []; // By default, serve all hosts as if they are the main host. this._mainHost = makePattern(options.host || '*'); // By default, retire automatically created namespaces in 10 seconds. this._defaultRetirement = options.retirement || 10000; // By default, do not expose public /socket.io/status page. this._publicStatus = options.publicStatus || false; IOServer.apply(this, arguments); } util.inherits(DynamicServer, IOServer) exports.DynamicServer = DynamicServer; // This is the setup for initializing dynamic namespaces. DynamicServer.prototype.setupNamespace = function(name, fn) { var pattern = makePattern(name); if (pattern instanceof RegExp) { this._namepacePatterns.push({pattern: pattern, setup: fn}); } else { this._namepaceNames[name] = fn; } // If there is a matching namespace already, then set it up. for (var j in this.nsps) { if (this.nsps.hasOwnProperty(j)) { var nsp = this.nsps[j]; if (!nsp.setupDone && !!(match = matchPattern(pattern, j))) { nsp.setupDone = -1; if (false === fn.apply(this, [nsp, match])) { // If setup is aborted, mark it as not-setup. nsp.setupDone = 0; } else { nsp.setupDone = 1; } } } } }; // Create DynamicClient instead of IOClient when there is a connection. DynamicServer.prototype.onconnection = function(conn) { var host = this.getHost(conn); var client = new DynamicClient(this, conn, host); client.connect('/'); return this; }; // Allow users to override this in order to normalize hostnames. DynamicServer.prototype.getHost = function(conn) { if (matchPattern(this._mainHost, conn.request.headers.host)) { // The main host gets nulled out. return null; } return conn.request.headers.host; }; // Do the work of initializing a namespace when it is needed. DynamicServer.prototype.initializeNamespace = function(name, host, auto) { // First, look up our instructions for this namespace. var fullname = fullNamespaceName(name, host); var setup, match; if (this._namepaceNames.hasOwnProperty(fullname)) { // Prefer exact matches over pattern matches. setup = this._namepaceNames[fullname]; match = {'0': fullname, index: 0, input: fullname}; } else for (var j = this._namepacePatterns.length - 1; j >= 0; --j) { // Scan patterns starting with the last one registered. match = matchPattern(this._namepacePatterns[j].pattern, fullname); if (match) { setup = this._namepacePatterns[j].setup; break; } } // Automatically created namespaces require setup. if (auto && !setup) { return null; } // Create a namespace, register it, and call setup. var nsp = new DynamicNamespace(this, name, host); // Automatically created namespaces retire automatically. if (auto) { nsp.retirement = this._defaultRetirement; } this.nsps[fullname] = nsp; if (setup) { // During setup, setupDone is -1. nsp.setupDone = -1; if (false === setup.apply(this, [nsp, match])) { // If setup returns false, undo the operation and return null. delete this.nsps[fullname]; return null; } else { // After setup, setupDone is 1. nsp.setupDone = 1; } } return nsp; }; // When namespaces are emptied, they ask the server to poll // them back for expiration. DynamicServer.prototype.requestCleanupAfter = function(delay) { delay = Math.max(0, delay || 0); // This form check rejects both NaN and Infinity. if (!(delay < Infinity)) return; // If somebody has requested cleanup earlier, we should // redo the timer. var cleanupTime = delay + +(new Date); if (this._cleanupTimer && cleanupTime < this._cleanupTime) { clearTimeout(this._cleanupTimer); this._cleanupTimer = null; } // Don't check directly at the requested time, but up to 5s later. // That way, if a lot of namespaces expire around the same // time, we process them as a batch. delay += Math.max(1, Math.min(delay, 5000)); if (!this._cleanupTimer) { var server = this; this._cleanupTime = cleanupTime; this._cleanupTimer = setTimeout(function() { server._cleanupTimer = null; server._cleanupTime = null; server.cleanupExpiredNamespaces(); }, delay); } }; // When doing cleanup, we scan all namespaces for their // expiration dates. DynamicServer.prototype.cleanupExpiredNamespaces = function() { var earliestUnexpired = Infinity; var now = +(new Date); for (var j in this.nsps) { if (this.nsps.hasOwnProperty(j)) { var nsp = this.nsps[j]; var expiration = nsp._expiration(); if (expiration <= now) { nsp.expire(true); delete this.nsps[j]; } else { earliestUnexpired = Math.min(earliestUnexpired, expiration); } } } this.requestCleanupAfter(earliestUnexpired - now); }; // Override "of" to handle an optional 'host' argument // an an "fn" of "true", which indicates a request for // andautomatically created namespace. DynamicServer.prototype.of = function(name, host, fn) { if (fn == null && typeof(host) == 'function') { fn = host; host = null; } if (!/^\//.test(name)) { // Insert a leading slash if needed. name = '/' + name; } // Add a leading hostname for lookup. var fullname = fullNamespaceName(name, host); if (!this.nsps[fullname]) { debug('initializing namespace %s', fullname); var nsp = this.initializeNamespace(name, host, fn === true); if (nsp == null) { debug('unrecognized namespace', fullname); return; } } if (typeof(fn) == 'function') this.nsps[fullname].on('connect', fn); return this.nsps[fullname]; }; // Hook in the /socket.io/status URL DynamicServer.prototype.attachServe = function(srv) { debug('attaching web request handler'); var prefix = this._path; var clienturl = prefix + '/socket.io.js'; var statusurl = prefix + '/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(clienturl)) { self.serve(req, res); } else if (self._publicStatus && 0 == req.url.indexOf(statusurl)) { self.serveStatus(req, res); } else { for (var i = 0; i < evs.length; i++) { evs[i].call(srv, req, res); } } }); }; DynamicServer.prototype.serveStatus = function(req, res) { debug('serve status'); var match = '*'; if (!matchPattern(this._mainHost, req.headers.host)) { match = req.headers.host; } var html = ['<!doctype html>', '<html>', '<body>', '<pre>']; html.push('<a href="status">Refresh</a> active namespaces on ' + match, ''); var sorted = []; for (var j in this.nsps) { if (this.nsps.hasOwnProperty(j)) { var nsp = this.nsps[j]; if (match != '*' && nsp.host != match) continue; sorted.push(j); } } sorted.sort(function(a, b) { // Sort slashes last. if (a == b) return 0; a = a.replace(/\//g, '\uffff'); b = b.replace(/\//g, '\uffff'); if (a < b) return -1; else return 1; }); var now = +(new Date); for (j = 0; j < sorted.length; ++j) { var nsp = this.nsps[sorted[j]]; html.push(match == '*' ? nsp.fullname() : nsp.name); if (nsp.rooms && nsp.rooms.length > 1) { html.push(' rooms: ' + nsp.rooms.join(' ')); } if (nsp.sockets.length == 0) { var remaining = nsp._expiration() - now; var expinfo = ''; if (remaining < Infinity) { expinfo = '; expires ' + remaining / 1000 + 's'; } html.push(' (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]; } } } html.push(' socket ' + socket.id + clientdesc + roomdesc); } html.push(''); } res.setHeader('Content-Type', 'text/html'); res.writeHead(200); res.end(html.join('\n')); }; // This subclass relies on "of" to make a namespace. function DynamicClient(server, conn, host) { IOClient.apply(this, arguments); this.host = host; } util.inherits(DynamicClient, IOClient) exports.DynamicClient = DynamicClient; // Add hostname to namespace even if it doesn't yet exist. DynamicClient.prototype.connect = function(name) { debug('connecting to namespace %s (%s)', name, this.host); var nsp = this.server.of(name, this.host, true); if (nsp == null) { this.packet({ type: parser.ERROR, nsp: name, data : 'Invalid namespace'}); return; } if (name != '/' && !this.nsps['/']) { this.connectBuffer.push(name); return; } var self = this; var socket = nsp.add(this, function() { self.sockets.push(socket); debug('client %s adding socket as self.nsps[%s]', self.id, name); self.nsps[name] = socket; if (name == '/' && self.connectBuffer.length > 0) { self.connectBuffer.forEach(self.connect, self); self.connectBuffer = []; } }); }; // Start ids at some big number instead of 0. // Tell server to delete me after I have no sockets. function DynamicNamespace(server, name, host) { IONamespace.apply(this, arguments); // Remember the host name. this.host = host; // Only call setup once. this.setupDone = 0; // Default retirement is "Infinity", but will be reduced to 10s // for dynamically created namespaces. this.retirement = Infinity; // Choose one of a billion starting ids to reduce collisions // when a namespace restarts. this.ids = Math.floor(Math.random() * 1000000000); // Set the expiration date to never. this._expirationTime = Infinity; // No expiration callback by default. this._expirationCallbacks = null; } util.inherits(DynamicNamespace, IONamespace) exports.DynamicNamespace = DynamicNamespace; // At the end of remove, request cleanup if there // are no sockets. DynamicNamespace.prototype.remove = function(socket) { IONamespace.prototype.remove.apply(this, arguments); if (!this.sockets.length) { // Once a namespace is empty, it goes into a period of retirement, // after which it may be deleted. Set the expiration for 10 // seconds from now. this._expirationTime = +(new Date) + this.retirement; this.server.requestCleanupAfter(this.retirement); } }; // Set up expire callbacks. DynamicNamespace.prototype.expire = function(callback) { if (callback !== true) { if (!this._expirationCallbacks) { this._expirationCallbacks = []; } this._expirationCallbacks.push(callback); } else { // expire(true) is an internal convention for // triggering the expiration callbacks. var callbacks = this._expirationCallbacks; if (callbacks) { this._expirationCallbacks = null; while (callbacks.length > 0) { callbacks.pop().apply(null, [this]); } } } } // Concatenate host and name for the full namespace name. DynamicNamespace.prototype.fullname = function() { return fullNamespaceName(this.name, this.host); }; // After there are no sockets, each namespace has an // expiration time. DynamicNamespace.prototype._expiration = function() { if (this.sockets.length) return Infinity; return this._expirationTime; }; // When we have a socket added, we are no longer in retirement, // so reset our expirationTime. Back in business! DynamicNamespace.prototype.add = function() { this._expirationTime = Infinity; return IONamespace.prototype.add.apply(this, arguments); }; exports.DynamicSocket = IOSocket; module.exports = exports;