├── .gitignore ├── .travis.yml ├── LICENSE ├── Procfile ├── README.md ├── app.json ├── bin.js ├── example.js ├── index.js ├── package.json ├── server.js └── test.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '4' 4 | - '6' 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Mathias Buus 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: node ./bin.js listen -p $PORT 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # signalhub 2 | 3 | Simple signalling server that can be used to coordinate handshaking with webrtc or other fun stuff. 4 | 5 | ``` 6 | npm install signalhub 7 | ``` 8 | 9 | Or to install the command line tool 10 | 11 | ``` 12 | npm install -g signalhub 13 | ``` 14 | 15 | [![build status](http://img.shields.io/travis/mafintosh/signalhub.svg?style=flat)](http://travis-ci.org/mafintosh/signalhub) 16 | 17 | ## Usage 18 | 19 | ``` js 20 | var signalhub = require('signalhub') 21 | var hub = signalhub('my-app-name', [ 22 | 'http://yourhub.com' 23 | ]) 24 | 25 | hub.subscribe('my-channel') 26 | .on('data', function (message) { 27 | console.log('new message received', message) 28 | }) 29 | 30 | hub.broadcast('my-channel', {hello: 'world'}) 31 | ``` 32 | 33 | ## API 34 | 35 | #### `hub = signalhub(appName, urls)` 36 | 37 | Create a new hub client. If you have more than one hub running specify them in an array 38 | 39 | ``` js 40 | // use more than one server for redundancy 41 | var hub = signalhub('my-app-name', [ 42 | 'https://signalhub1.example.com', 43 | 'https://signalhub2.example.com', 44 | 'https://signalhub3.example.com' 45 | ]) 46 | ``` 47 | 48 | The `appName` is used to namespace the subscriptions/broadcast so you can reuse the 49 | signalhub for more than one app. 50 | 51 | #### `stream = hub.subscribe(channel)` 52 | 53 | Subscribe to a channel on the hub. Returns a readable stream of messages 54 | 55 | #### `hub.broadcast(channel, message, [callback])` 56 | 57 | Broadcast a new message to a channel on the hub 58 | 59 | #### `hub.close([callback])` 60 | 61 | Close all subscriptions 62 | 63 | ## CLI API 64 | 65 | You can use the command line api to run a hub server 66 | 67 | ``` 68 | signalhub listen -p 8080 # starts a signalhub server on 8080 69 | ``` 70 | 71 | To listen on https, use the `--key` and `--cert` flags to specify the path to the private 72 | key and certificate files, respectively. These will be passed through to the node `https` 73 | package. 74 | 75 | To avoid logging to console on every subscribe/broadcast event use the `--quiet` or `-q` flag. 76 | 77 | Or broadcast/subscribe to channels 78 | 79 | ``` 80 | signalhub broadcast my-app my-channel '{"hello":"world"}' -p 8080 -h yourhub.com 81 | signalhub subscribe my-app my-channel -p 8080 -h yourhub.com 82 | ``` 83 | 84 | ## Browserify 85 | 86 | This also works in the browser using browserify :) 87 | 88 | ## Publicly available signalhubs 89 | 90 | Through the magic of free hosting, here are some free open signalhub servers! 91 | For serious applications though, consider deploying your own instances. 92 | 93 | - https://signalhub-jccqtwhdwc.now.sh 94 | - https://signalhub-hzbibrznqa.now.sh 95 | 96 | ## Deploying with popular services 97 | 98 | No additional configuration is needed. 99 | 100 | ### now.sh 101 | 102 | ``` 103 | now mafintosh/signalhub 104 | ``` 105 | 106 | ### Heroku 107 | [![Deploy](https://www.herokucdn.com/deploy/button.png)](https://heroku.com/deploy) 108 | 109 | ## License 110 | 111 | MIT 112 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "signalhub", 3 | "description": "Simple signalling server that can be used to coordinate handshaking with webrtc or other fun stuff.", 4 | "repository": "https://github.com/mafintosh/signalhub" 5 | } 6 | -------------------------------------------------------------------------------- /bin.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var fs = require('fs') 4 | var minimist = require('minimist') 5 | var argv = minimist(process.argv.slice(2), { 6 | alias: { 7 | port: 'p', 8 | host: 'h', 9 | 'max-broadcasts': 'm', 10 | key: 'k', 11 | cert: 'c', 12 | version: 'v', 13 | quiet: 'q' 14 | }, 15 | boolean: [ 'version', 'quiet' ], 16 | default: { 17 | port: process.env.PORT || 80 18 | } 19 | }) 20 | 21 | var cmd = argv._[0] 22 | 23 | if (argv.version) console.log(require('./package.json').version) 24 | else if (cmd === 'listen') listen() 25 | else if (cmd === 'subscribe') subscribe() 26 | else if (cmd === 'broadcast') broadcast() 27 | else console.error('Usage: signalhub listen|subscribe|broadcast') 28 | 29 | function listen () { 30 | var max = Number(argv['max-broadcasts']) || 0 31 | var server = require('./server')({ 32 | maxBroadcasts: max, 33 | key: argv.key && fs.readFileSync(argv.key), 34 | cert: argv.cert && fs.readFileSync(argv.cert), 35 | host: argv.host 36 | }) 37 | 38 | if (!argv.quiet) { 39 | server.on('subscribe', function (channel) { 40 | console.log('subscribe: %s', channel) 41 | }) 42 | 43 | server.on('publish', function (channel, message) { 44 | console.log('broadcast: %s (%d)', channel, message.length) 45 | }) 46 | } 47 | 48 | server.listen(argv.port, argv.host, function () { 49 | console.log('signalhub listening on port %d', server.address().port) 50 | }) 51 | } 52 | 53 | function subscribe () { 54 | if (argv._.length < 3) return console.error('Usage: signalhub subscribe [app] [channel]') 55 | var client = require('./')(argv._[1], argv.host + ':' + argv.port || 'localhost:8080') 56 | client.subscribe(argv._[2]).on('data', function (data) { 57 | if (!argv.quiet) { 58 | console.log(data) 59 | } 60 | }) 61 | } 62 | 63 | function broadcast () { 64 | if (argv._.length < 4) return console.error('Usage: signalhub broadcast [app] [channel] [json-message]') 65 | var client = require('./')(argv._[1], argv.host + ':' + argv.port || 'localhost:8080') 66 | client.broadcast(argv._[2], JSON.parse(argv._[3])) 67 | } 68 | -------------------------------------------------------------------------------- /example.js: -------------------------------------------------------------------------------- 1 | var client = require('./') 2 | 3 | var c = client('swarmtest', 'https://signalhub.mafintosh.com') 4 | 5 | c.subscribe('hello').on('data', console.log) 6 | 7 | c.broadcast('hello', {hello: 'world'}, function () { 8 | console.log('broadcasted message') 9 | c.close(function () { 10 | console.log('closed client') 11 | }) 12 | }) 13 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var events = require('events') 2 | var ess = require('event-source-stream') 3 | var nets = require('nets') 4 | var pump = require('pump') 5 | var through = require('through2') 6 | var inherits = require('inherits') 7 | 8 | module.exports = SignalHub 9 | 10 | function SignalHub (app, urls) { 11 | if (!(this instanceof SignalHub)) return new SignalHub(app, urls) 12 | if (!app) throw new Error('app name required') 13 | if (!urls || !urls.length) throw new Error('signalhub url(s) required') 14 | 15 | events.EventEmitter.call(this) 16 | this.setMaxListeners(0) 17 | 18 | this.app = app 19 | if (!Array.isArray(urls)) urls = [urls] 20 | this.urls = urls.map(function (url) { 21 | url = url.replace(/\/$/, '') 22 | return url.indexOf('://') === -1 ? 'http://' + url : url 23 | }) 24 | this.subscribers = [] 25 | this.closed = false 26 | } 27 | 28 | inherits(SignalHub, events.EventEmitter) 29 | 30 | SignalHub.prototype.subscribe = function (channel) { 31 | if (this.closed) throw new Error('Cannot subscribe after close') 32 | 33 | var self = this 34 | var endpoint = Array.isArray(channel) ? channel.join(',') : channel 35 | var streams = this.urls.map(function (url) { 36 | return ess(url + '/v1/' + self.app + '/' + endpoint, {json: true}) 37 | }) 38 | 39 | var subscriber 40 | if (streams.length === 1) { 41 | subscriber = streams[0] 42 | } else { 43 | subscriber = through.obj() 44 | subscriber.setMaxListeners(0) 45 | streams.forEach(function (stream) { 46 | stream.on('open', function () { 47 | subscriber.emit('open') 48 | }) 49 | pump(stream, subscriber) 50 | }) 51 | } 52 | 53 | this.subscribers.push(subscriber) 54 | 55 | subscriber.once('close', function () { 56 | var i = self.subscribers.indexOf(subscriber) 57 | if (i > -1) self.subscribers.splice(i, 1) 58 | }) 59 | 60 | return subscriber 61 | } 62 | 63 | SignalHub.prototype.broadcast = function (channel, message, cb) { 64 | if (this.closed) throw new Error('Cannot broadcast after close') 65 | if (!message) message = {} 66 | if (!cb) cb = noop 67 | 68 | var pending = this.urls.length 69 | var errors = 0 70 | 71 | var self = this 72 | this.urls.forEach(function (url) { 73 | broadcast(self.app, url, channel, message, function (err) { 74 | if (err) errors++ 75 | if (--pending) return 76 | if (errors === self.urls.length) return cb(err) 77 | cb() 78 | }) 79 | }) 80 | } 81 | 82 | SignalHub.prototype.close = function (cb) { 83 | if (this.closed) return 84 | this.closed = true 85 | 86 | if (cb) this.once('close', cb) 87 | var len = this.subscribers.length 88 | if (len > 0) { 89 | var self = this 90 | var closed = 0 91 | this.subscribers.forEach(function (subscriber) { 92 | subscriber.once('close', function () { 93 | if (++closed === len) { 94 | self.emit('close') 95 | } 96 | }) 97 | process.nextTick(function () { 98 | subscriber.destroy() 99 | }) 100 | }) 101 | } else { 102 | this.emit('close') 103 | } 104 | } 105 | 106 | function broadcast (app, url, channel, message, cb) { 107 | return nets({ 108 | method: 'POST', 109 | json: message, 110 | url: url + '/v1/' + app + '/' + channel 111 | }, function (err, res) { 112 | if (err) return cb(err) 113 | if (res.statusCode !== 200) return cb(new Error('Bad status: ' + res.statusCode)) 114 | cb() 115 | }) 116 | } 117 | 118 | function noop () {} 119 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "signalhub", 3 | "version": "4.9.0", 4 | "description": "Simple signalling server that can be used to coordinate handshaking with webrtc or other fun stuff.", 5 | "main": "index.js", 6 | "dependencies": { 7 | "corsify": "^2.1.0", 8 | "end-of-stream": "^1.1.0", 9 | "event-source-stream": "^1.3.1", 10 | "inherits": "^2.0.1", 11 | "minimist": "^1.1.1", 12 | "nets": "^3.1.0", 13 | "pump": "^1.0.0", 14 | "random-iterate": "^1.0.1", 15 | "size-limit-stream": "^1.0.0", 16 | "stream-collector": "^1.0.1", 17 | "through2": "^0.6.5" 18 | }, 19 | "devDependencies": { 20 | "standard": "^10.0.3", 21 | "tape": "^4.8.0" 22 | }, 23 | "scripts": { 24 | "start": "node ./bin.js listen", 25 | "test": "standard && tape test.js" 26 | }, 27 | "bin": { 28 | "signalhub": "./bin.js" 29 | }, 30 | "repository": { 31 | "type": "git", 32 | "url": "https://github.com/mafintosh/signalhub.git" 33 | }, 34 | "author": "Mathias Buus (@mafintosh)", 35 | "license": "MIT", 36 | "bugs": { 37 | "url": "https://github.com/mafintosh/signalhub/issues" 38 | }, 39 | "homepage": "https://github.com/mafintosh/signalhub" 40 | } 41 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | var http = require('http') 2 | var https = require('https') 3 | var corsify = require('corsify') 4 | var collect = require('stream-collector') 5 | var pump = require('pump') 6 | var iterate = require('random-iterate') 7 | var limiter = require('size-limit-stream') 8 | var eos = require('end-of-stream') 9 | 10 | var flushHeaders = function (res) { 11 | if (res.flushHeaders) { 12 | res.flushHeaders() 13 | } else { 14 | if (!res._header) res._implicitHeader() 15 | res._send('') 16 | } 17 | } 18 | 19 | module.exports = function (opts) { 20 | var channels = {} 21 | var maxBroadcasts = (opts && opts.maxBroadcasts) || Infinity 22 | var subs = 0 23 | 24 | var get = function (channel) { 25 | if (channels[channel]) return channels[channel] 26 | var sub = {name: channel, subscribers: [], heartbeat: null} 27 | sub.heartbeat = setInterval(heartbeater(sub), 30 * 1000) 28 | channels[channel] = sub 29 | return channels[channel] 30 | } 31 | 32 | var cors = corsify({ 33 | 'Access-Control-Allow-Origin': '*', 34 | 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE', 35 | 'Access-Control-Allow-Headers': 'X-Requested-With, X-HTTP-Method-Override, Content-Type, Accept, Authorization' 36 | }) 37 | 38 | var onRequest = cors(function (req, res) { 39 | if (req.url === '/') { 40 | res.setHeader('Content-Type', 'application/json; charset=utf-8') 41 | flushHeaders(res) 42 | res.end(JSON.stringify({name: 'signalhub', version: require('./package').version, subscribers: subs}, null, 2) + '\n') 43 | return 44 | } 45 | 46 | if (req.url.slice(0, 4) !== '/v1/') { 47 | res.statusCode = 404 48 | res.end() 49 | return 50 | } 51 | 52 | var name = req.url.slice(4).split('?')[0] 53 | 54 | if (req.method === 'POST') { 55 | collect(pump(req, limiter(64 * 1024)), function (err, data) { 56 | if (err) return res.end() 57 | if (!channels[name]) return res.end() 58 | var channel = get(name) 59 | 60 | server.emit('publish', channel.name, data) 61 | data = Buffer.concat(data).toString() 62 | 63 | var ite = iterate(channel.subscribers) 64 | var next 65 | var cnt = 0 66 | 67 | while ((next = ite()) && cnt++ < maxBroadcasts) { 68 | next.write('data: ' + data + '\n\n') 69 | } 70 | 71 | res.end() 72 | }) 73 | return 74 | } 75 | 76 | if (req.method === 'GET') { 77 | res.setHeader('Content-Type', 'text/event-stream; charset=utf-8') 78 | res.setHeader('Cache-Control', 'no-cache') 79 | // Disable NGINX request buffering 80 | res.setHeader('X-Accel-Buffering', 'no') 81 | 82 | var app = name.split('/')[0] 83 | var channelNames = name.slice(app.length + 1) 84 | 85 | channelNames.split(',').forEach(function (channelName) { 86 | var channel = get(app + '/' + channelName) 87 | server.emit('subscribe', channel.name) 88 | channel.subscribers.push(res) 89 | subs++ 90 | eos(res, function () { 91 | subs-- 92 | var i = channel.subscribers.indexOf(res) 93 | if (i > -1) channel.subscribers.splice(i, 1) 94 | if (!channel.subscribers.length && channel === channels[channel.name]) { 95 | clearInterval(channel.heartbeat) 96 | delete channels[channel.name] 97 | } 98 | }) 99 | }) 100 | 101 | flushHeaders(res) 102 | return 103 | } 104 | 105 | res.statusCode = 404 106 | res.end() 107 | }) 108 | 109 | var useHttps = !!(opts && opts.key && opts.cert) 110 | var server = useHttps ? https.createServer(opts) : http.createServer() 111 | 112 | server.on('request', onRequest) 113 | server.on('close', function () { 114 | var names = Object.keys(channels) 115 | for (var i = 0; i < names.length; i++) { 116 | clearInterval(channels[names[i]].heartbeat) 117 | } 118 | }) 119 | 120 | return server 121 | } 122 | 123 | function heartbeater (sub) { 124 | return function () { 125 | for (var i = 0; i < sub.subscribers.length; i++) { 126 | sub.subscribers[i].write(':heartbeat signal\n\n') 127 | } 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | var server = require('./server')() 2 | var client = require('./') 3 | var tape = require('tape') 4 | 5 | server.listen(9000, function () { 6 | tape('subscribe', function (t) { 7 | var c = client('app', ['localhost:9000']) 8 | 9 | c.subscribe('hello').on('data', function (message) { 10 | t.same(message, {hello: 'world'}) 11 | t.end() 12 | c.close() 13 | }).on('open', function () { 14 | c.broadcast('hello', {hello: 'world'}) 15 | }) 16 | }) 17 | 18 | tape('subscribe two apps', function (t) { 19 | t.plan(2) 20 | 21 | var missing = 2 22 | var c1 = client('app1', ['localhost:9000']) 23 | 24 | c1.subscribe('hello').on('data', function (message) { 25 | t.same(message, {hello: 'world'}) 26 | done() 27 | }).on('open', function () { 28 | c1.broadcast('hello', {hello: 'world'}) 29 | }) 30 | 31 | var c2 = client('app2', ['localhost:9000']) 32 | 33 | c2.subscribe('hello').on('data', function (message) { 34 | t.same(message, {hello: 'world'}) 35 | done() 36 | }).on('open', function () { 37 | c2.broadcast('hello', {hello: 'world'}) 38 | }) 39 | 40 | function done () { 41 | if (--missing) return 42 | setTimeout(function () { 43 | c1.close() 44 | c2.close() 45 | t.end() 46 | }, 100) 47 | } 48 | }) 49 | 50 | tape('subscribe with trailing /', function (t) { 51 | var c = client('app', ['localhost:9000/']) 52 | 53 | c.subscribe('hello').on('data', function (message) { 54 | t.same(message, {hello: 'world'}) 55 | t.end() 56 | c.close() 57 | }).on('open', function () { 58 | c.broadcast('hello', {hello: 'world'}) 59 | }) 60 | }) 61 | 62 | tape('subscribe to many', function (t) { 63 | var c = client('app', ['localhost:9000']) 64 | var msgs = ['stranger', 'friend'] 65 | 66 | c.subscribe(['hello', 'goodbye']).on('data', function (message) { 67 | t.same(message, {msg: msgs.shift()}) 68 | if (msgs.length === 0) { 69 | c.close(function () { 70 | t.equal(c.subscribers.length, 0, 'all subscribers closed') 71 | t.end() 72 | }) 73 | } 74 | }).on('open', function () { 75 | c.broadcast('hello', {msg: 'stranger'}, function () { 76 | c.broadcast('goodbye', {msg: 'friend'}) 77 | }) 78 | }) 79 | }) 80 | 81 | tape('close multiple', function (t) { 82 | var c = client('app', ['localhost:9000']) 83 | 84 | c.subscribe(['hello', 'goodbye']) 85 | c.subscribe(['hi', 'bye']) 86 | c.close(function () { 87 | t.equal(c.subscribers.length, 0, 'all subscribers closed') 88 | t.end() 89 | }) 90 | }) 91 | 92 | tape('subscribe to channels with slash in the name', function (t) { 93 | var c = client('app', ['localhost:9000']) 94 | 95 | c.subscribe('hello/people').on('data', function (message) { 96 | t.same(message, [1, 2, 3]) 97 | t.end() 98 | c.close() 99 | }).on('open', function () { 100 | c.broadcast('hello/people', [1, 2, 3]) 101 | }) 102 | }) 103 | 104 | tape('open emitted with multiple hubs', function (t) { 105 | var c = client('app', [ 106 | 'localhost:9000', 107 | 'localhost:9000' 108 | ]) 109 | c.subscribe('hello').on('open', function () { 110 | t.ok(true, 'got an open event') 111 | c.close() 112 | t.end() 113 | }) 114 | }) 115 | 116 | tape('end', function (t) { 117 | server.close() 118 | t.ok(true) 119 | t.end() 120 | }) 121 | }) 122 | --------------------------------------------------------------------------------