├── index.js ├── browser.js ├── package.json ├── test ├── app.js └── index.js ├── lib ├── client.js └── server.js └── README.md /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/server') 2 | -------------------------------------------------------------------------------- /browser.js: -------------------------------------------------------------------------------- 1 | module.exports = sse 2 | 3 | var through = require('through') 4 | 5 | function sse(at) { 6 | var es = new EventSource(at || '/sse') 7 | , stream = through() 8 | 9 | es.onmessage = function(ev) { 10 | if(closed) return 11 | 12 | stream.queue(ev.data) 13 | } 14 | 15 | es.addEventListener('end', function() { 16 | es.close() 17 | closed = true 18 | stream.queue(null) 19 | }, true) 20 | 21 | es.onerror = function(e) { 22 | closed = true 23 | stream.emit('error', e) 24 | } 25 | 26 | return stream 27 | } 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sse-stream", 3 | "version": "0.0.5", 4 | "description": "expose html5 server sent events (sse) as a writable stream", 5 | "main": "index.js", 6 | "dependencies": { 7 | "through": "~2.2.7" 8 | }, 9 | "devDependencies": {}, 10 | "browser": { 11 | "index.js": "browser.js" 12 | }, 13 | "scripts": { 14 | "test": "node test/index.js" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git://github.com/chrisdickinson/sse-stream.git" 19 | }, 20 | "keywords": [ 21 | "sse", 22 | "eventsource", 23 | "stream", 24 | "writable" 25 | ], 26 | "author": "Chris Dickinson ", 27 | "license": "MIT" 28 | } 29 | -------------------------------------------------------------------------------- /test/app.js: -------------------------------------------------------------------------------- 1 | var http = require('http') 2 | , fs = require('fs') 3 | , through = require('through') 4 | , sse = require('./lib/server')('/sse') 5 | , brake = require('brake') 6 | , serv 7 | 8 | module.exports = serv = http.createServer(function(req, resp) { 9 | resp.setHeader('content-type', 'text/html') 10 | resp.end('') 11 | }) 12 | 13 | sse.install(serv) 14 | 15 | sse.on('connection', function write(client) { 16 | fs.createReadStream('/usr/share/dict/words') 17 | .pipe(through(function(buf) { this.emit('data', buf.toString()) })) 18 | .pipe(client) 19 | }) 20 | 21 | function js() { 22 | var es = new EventSource('/sse') 23 | , pre = document.createElement('pre') 24 | , closed = false 25 | 26 | document.body.appendChild(pre) 27 | 28 | es.onmessage = function(ev) { 29 | if(closed) return console.error('ALREADY CLOSED') 30 | 31 | pre.appendChild(document.createTextNode(ev.data)) 32 | 33 | window.scrollTo(0, pre.clientHeight) 34 | } 35 | 36 | es.addEventListener('end', function() { 37 | es.close() 38 | closed = true 39 | }, true) 40 | 41 | es.onerror = function(e) { 42 | console.error(e) 43 | closed = true 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /lib/client.js: -------------------------------------------------------------------------------- 1 | module.exports = Client 2 | 3 | var Stream = require('stream') 4 | 5 | function Client(req, res) { 6 | Stream.call(this) 7 | 8 | this.req = req 9 | this.res = res 10 | 11 | this.legacy = req.headers['user-agent'] && false // (/^Opera/).test(req.headers['user-agent']) 12 | this.writable = true 13 | this.readable = false 14 | 15 | res.once('close', this.emit.bind(this, 'close')) 16 | res.on('drain', this.emit.bind(this, 'drain')) 17 | 18 | req.socket.setNoDelay(true) 19 | res.writeHead(200, {'content-type': 'text/'+(this.legacy ? 'x-dom-event-stream' : 'event-stream')}) 20 | res.write(':ok\n\n') 21 | 22 | this.data_header = this.legacy ? 'data:' : 'data: ' 23 | this._id = 0 24 | } 25 | 26 | var cons = Client 27 | , proto = cons.prototype = Object.create(Stream.prototype) 28 | 29 | proto.constructor = cons 30 | 31 | proto.retry = function(ms) { 32 | this.res.write('retry: '+ms+'\n\n') 33 | } 34 | 35 | proto.write = function(data) { 36 | var response = 37 | 'id: '+(this._id++)+'\n\n' 38 | + (this.legacy ? 'Event: data\n' : '') 39 | + this.data_header 40 | + String(data).split('\n').join('\n'+this.data_header)+'\n\n' 41 | 42 | return this.res.write(response) 43 | } 44 | 45 | proto.end = function(data) { 46 | if(arguments.length) { 47 | this.write(data) 48 | } 49 | this.writable = false 50 | 51 | this.res.end('event: end\ndata: 1\n\n') 52 | } 53 | -------------------------------------------------------------------------------- /lib/server.js: -------------------------------------------------------------------------------- 1 | module.exports = Server 2 | 3 | var URL = require('url') 4 | , EE = require('events').EventEmitter 5 | , Client = require('./client') 6 | 7 | function Server(options) { 8 | if(this.constructor !== Server) 9 | return new Server(options) 10 | 11 | EE.call(this) 12 | 13 | options = options || {path: '/sse', fallback: true, keepalive: 1000} 14 | options = typeof options === 'string' ? {path: options, fallback: true, keepalive: 1000} : options 15 | this.options = options 16 | this.pool = [] 17 | this.interval = null 18 | } 19 | 20 | var cons = Server 21 | , proto = cons.prototype = Object.create(EE.prototype) 22 | 23 | proto.constructor = cons 24 | 25 | proto.install = function(server) { 26 | var self = this 27 | , listeners = server.listeners('request') 28 | , options = self.options 29 | 30 | server.removeAllListeners('request') 31 | 32 | server.on('request', on_request) 33 | 34 | server.once('listening', function() { 35 | self.interval = setInterval(function() { 36 | for(var i = 0, len = self.pool.length; i < len; ++i) { 37 | self.pool[i].res.write(':keepalive '+Date.now()+'\n\n') 38 | } 39 | }, self.options.keepalive) 40 | 41 | server.once('close', function() { 42 | clearInterval(self.interval) 43 | self.interval = null 44 | }) 45 | }) 46 | 47 | self.install = function() { throw new Error('cannot install twice') } 48 | 49 | return self 50 | 51 | function on_request(req, resp) { 52 | var okay = is_okay(req) 53 | 54 | if(!okay) { 55 | return defaultresponse(req, resp) 56 | } 57 | 58 | return self.handle(req, resp) 59 | } 60 | 61 | function defaultresponse(req, resp) { 62 | for(var i = 0, len = listeners.length; i < len; ++i) { 63 | listeners[i].call(server, req, resp) 64 | } 65 | } 66 | 67 | function is_okay(req) { 68 | var is_okay = req.method === 'GET' 69 | , accept = req.headers.accept || '' 70 | 71 | is_okay = is_okay && (!!~accept.indexOf('text/event-stream') || !!~accept.indexOf('text/x-dom-event-stream')) 72 | 73 | is_okay = is_okay && URL.parse(req.url).pathname === options.path 74 | 75 | return is_okay 76 | } 77 | } 78 | 79 | proto.handle = function(req, resp) { 80 | var client = new Client(req, resp) 81 | , self = this 82 | , idx = self.pool.push(client) - 1 83 | 84 | self.emit('connection', client) 85 | 86 | client.once('close', function() { 87 | self.pool.splice(idx, 1) 88 | }) 89 | } 90 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sse-stream 2 | 3 | Expose [HTML5 Server Sent Events](https://developer.mozilla.org/en-US/docs/Server-sent_events/Using_server-sent_events) as an installable appliance on Node.JS `http` servers; connections are emitted as [Writable streams](https://github.com/dominictarr/stream-spec/blob/master/stream_spec.md#writablestream). 4 | 5 | ```javascript 6 | 7 | var http = require('http') 8 | , fs = require('fs') 9 | , through = require('through') 10 | , sse = require('sse-stream')('/sse') 11 | , serv 12 | 13 | module.exports = serv = http.createServer(function(req, resp) { 14 | resp.setHeader('content-type', 'text/html') 15 | resp.end('') 16 | }) 17 | 18 | sse.install(serv) 19 | 20 | sse.on('connection', function(client) { 21 | fs.createReadStream('/usr/share/dict/words') 22 | .pipe(through(function(buf) { this.emit('data', buf.toString()) })) 23 | .pipe(client) 24 | }) 25 | 26 | // client-side code: 27 | function js() { 28 | var es = new EventSource('/sse') 29 | , pre = document.createElement('pre') 30 | , closed = false 31 | 32 | document.body.appendChild(pre) 33 | 34 | es.onmessage = function(ev) { 35 | if(closed) return 36 | 37 | pre.appendChild(document.createTextNode(ev.data)) 38 | 39 | window.scrollTo(0, pre.clientHeight) 40 | } 41 | 42 | es.addEventListener('end', function() { 43 | es.close() 44 | closed = true 45 | }, true) 46 | 47 | es.onerror = function(e) { 48 | closed = true 49 | } 50 | } 51 | 52 | ``` 53 | 54 | # API 55 | 56 | ### sse = require('sse-stream')(path | options) 57 | 58 | Create a SSE server that emits `connection` events on new, successful eventstream connections. 59 | 60 | The argument may either be a string `path` to listen on (defaults to `/sse/`) or an object: 61 | 62 | ```javascript 63 | { path: '/listen/on/this/path' 64 | , keepalive: 1000 } 65 | ``` 66 | 67 | `keepalive` determines the interval time in ms that keepalives will be sent to all connected clients. 68 | 69 | ### sse.on('connection', function(client)) 70 | 71 | `client` is a writable stream representing a client connection (request response pair). 72 | 73 | Of note, all data sent through this connection will be stringified before sending due to 74 | the event stream spec. 75 | 76 | ### client.retry(integer ms) 77 | 78 | Send a "retry" message that lets the client know how many MS to wait until retrying a connection that ended. 79 | 80 | # license 81 | 82 | MIT 83 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert') 2 | , sse = require('../index') 3 | , http = require('http') 4 | , EE = require('events').EventEmitter 5 | , Stream = require('stream').Stream 6 | 7 | var tests = [ 8 | test_install_modifies_listeners 9 | , test_install_twice_raises 10 | , test_server_emits_connections 11 | , test_server_ignores_appropriate_requests 12 | , test_end_emits_end_event 13 | , test_server_emits_keepalives_on_listening 14 | ] 15 | 16 | start() 17 | 18 | function setup() { 19 | 20 | } 21 | 22 | // integration tests because reasons. 23 | 24 | function test_install_modifies_listeners() { 25 | var server = http.createServer() 26 | 27 | assert.equal(server.listeners('request').length, 0) 28 | sse('/sse').install(server) 29 | assert.equal(server.listeners('request').length, 1) 30 | 31 | sse('/asdf').install(server = http.createServer(function(r, s) { })) 32 | assert.equal(server.listeners('request').length, 1) 33 | 34 | } 35 | 36 | function test_install_twice_raises() { 37 | var server = http.createServer() 38 | , _ = sse('/sse') 39 | 40 | _.install(server) 41 | 42 | assert.throws(function() { 43 | _.install(server) 44 | }) 45 | } 46 | 47 | function test_server_emits_connections() { 48 | var req = make_request() 49 | , res = make_response() 50 | , server = new EE 51 | , conn = sse('/sse').install(server) 52 | , triggered = false 53 | 54 | conn.once('connection', function(client) { 55 | triggered = true 56 | assert.ok(req.nodelay) 57 | assert.equal(res.code, 200) 58 | assert.equal(res.headers['content-type'], 'text/event-stream') 59 | assert.equal(res.data, ':ok\n\n') 60 | assert.ok(client.writable) 61 | 62 | client.write('hello\nworld') 63 | assert.ok(client.writable) 64 | assert.equal(res.data, ':ok\n\nid: 0\n\ndata: hello\ndata: world\n\n') 65 | res.paused = Math.random() 66 | 67 | assert.equal(client.write('alright'), res.paused) 68 | }) 69 | 70 | server.emit('request', req, res) 71 | 72 | assert.ok(triggered) 73 | } 74 | 75 | function test_server_ignores_appropriate_requests() { 76 | var server = new EE 77 | , conn = sse('/sse').install(server) 78 | , req 79 | , res = make_response() 80 | 81 | conn.once('connection', function(client) { 82 | assert.fail('unexpected connection from '+req.method+' '+req.url+' accept: '+req.headers.accept) 83 | }) 84 | 85 | req = make_request('POST') 86 | server.emit('request', req, res) 87 | 88 | req = make_request('GET', '/sse/test') 89 | server.emit('request', req, res) 90 | 91 | req = make_request('GET', '/sse', 'not-event-stream') 92 | server.emit('request', req, res) 93 | 94 | } 95 | 96 | function test_end_emits_end_event() { 97 | var req = make_request() 98 | , res = make_response() 99 | , server = new EE 100 | , conn = sse('/sse').install(server) 101 | , triggered = false 102 | 103 | conn.once('connection', function(client) { 104 | triggered = true 105 | assert.ok(req.nodelay) 106 | assert.equal(res.code, 200) 107 | assert.equal(res.headers['content-type'], 'text/event-stream') 108 | assert.equal(res.data, ':ok\n\n') 109 | assert.ok(client.writable) 110 | 111 | client.end() 112 | assert.ok(!client.writable) 113 | assert.ok(!res.writable) 114 | assert.equal(res.data, ':ok\n\nevent: end\ndata: 1\n\n') 115 | }) 116 | 117 | server.emit('request', req, res) 118 | 119 | assert.ok(triggered) 120 | } 121 | 122 | function test_server_emits_keepalives_on_listening(ready) { 123 | var req = make_request() 124 | , res = make_response() 125 | , server = new EE 126 | , wait = Math.random() * 100 + 20 127 | , keepalive = 10 128 | , conn = sse({keepalive: keepalive, path: '/sse'}).install(server) 129 | , triggered = false 130 | , expected = wait / keepalive 131 | 132 | assert.ok(!conn.interval) 133 | 134 | server.emit('listening') 135 | server.emit('request', req, res) 136 | 137 | setTimeout(function() { 138 | var count = 0 139 | res.data.replace(/:keepalive \d+/g, function() { ++count }) 140 | 141 | assert.ok(res.data.slice(':ok\n\n'.length)) 142 | assert.ok(Math.abs(count - expected) < 3) 143 | server.emit('close') 144 | assert.strictEqual(conn.interval, null) 145 | 146 | ready() 147 | }, wait) 148 | } 149 | 150 | // utils 151 | 152 | function out(what) { 153 | process.stdout.write(what) 154 | } 155 | 156 | function make_request(method, url, accept) { 157 | var ee = new EE 158 | 159 | ee.socket = {setNoDelay: function() { ee.nodelay = true }} 160 | ee.method = method || 'GET' 161 | ee.headers = {accept: accept || 'text/event-stream'} 162 | ee.url = url || 'http://localhost:80/sse' 163 | 164 | return ee 165 | } 166 | 167 | function make_response() { 168 | var stream = new Stream 169 | 170 | stream.data = '' 171 | stream.writable = true 172 | stream.writeHead = function(code, headers) { 173 | stream.code = code 174 | stream.headers = headers 175 | } 176 | stream.write = function(x) { 177 | stream.data += x 178 | return stream.paused 179 | } 180 | stream.end = function(data) { 181 | if(arguments.length) this.write(data) 182 | 183 | this.writable = false 184 | } 185 | 186 | return stream 187 | } 188 | 189 | // test runner 190 | 191 | function start() { 192 | Function.prototype.before = function(fn) { 193 | var self = this 194 | return function ret() { 195 | var args = [].slice.call(arguments) 196 | 197 | fn.call(ret, args) 198 | 199 | return self.apply(this, args) 200 | } 201 | } 202 | 203 | run() 204 | } 205 | 206 | function run() { 207 | if(!tests.length) 208 | return out('\n') 209 | 210 | var test = tests.shift() 211 | , now = Date.now() 212 | 213 | setup() 214 | 215 | out(test.name+' - ') 216 | test.length ? test(done) : (test(), done()) 217 | 218 | function done() { 219 | out(''+(Date.now() - now)+'ms\n') 220 | run() 221 | } 222 | } 223 | --------------------------------------------------------------------------------