├── .npmignore ├── index.js ├── changelog ├── .vscode └── launch.json ├── example.html ├── example.js ├── package.json ├── LICENSE ├── .gitignore ├── lib ├── index.js └── sse.js └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/index'); -------------------------------------------------------------------------------- /changelog: -------------------------------------------------------------------------------- 1 | v0.1.1 2 | In the bubble phase, add safety inspection logic. 3 | If the ctx.body is a stream, the test can be written, and if it can be written, it is written to the stream. 4 | If the ctx.body is another type, determine whether the sse flow is over. If it is not finished, the ctx.sse.send(ctx.body) is called, and then ctx.body=ctx.sse. -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // 使用 IntelliSense 了解相关属性。 3 | // 悬停以查看现有属性的描述。 4 | // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch Program", 11 | "program": "${workspaceFolder}/example.js" 12 | } 13 | ] 14 | } -------------------------------------------------------------------------------- /example.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | SSE Test 8 | 9 | 10 | Please open the console 11 | 21 | 22 | -------------------------------------------------------------------------------- /example.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | const Koa = require('koa'); 4 | const koaSse = require('./index'); 5 | const cors = require('@koa/cors'); 6 | 7 | const app = new Koa(); 8 | app.use(cors()); 9 | app.use(koaSse()); 10 | 11 | app.use(async (ctx) => { 12 | let n = 0; 13 | let interval = setInterval(() => { 14 | let date = (new Date()).toString(); 15 | ctx.sse.send(date); 16 | console.log('send Date : ' + date); 17 | n++; 18 | if (n >= 10) { 19 | console.log('send manual close'); 20 | ctx.sse.sendEnd(); 21 | } 22 | }, 1000); 23 | ctx.sse.on('close', (...args) => { 24 | console.log('clear interval') 25 | clearInterval(interval) 26 | }); 27 | }) 28 | 29 | 30 | app.listen(9099); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "koa-sse-stream", 3 | "version": "0.2.0", 4 | "description": "koa sse(server side event) middleware", 5 | "main": "index.js", 6 | "directories": { 7 | "lib": "lib" 8 | }, 9 | "scripts": { 10 | "test": "echo \"Error: no test specified\" && exit 1", 11 | "example": "node example.js" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/yklykl530/koa-sse.git" 16 | }, 17 | "keywords": [ 18 | "sse", 19 | "koa", 20 | "server" 21 | ], 22 | "author": "yklykl123", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/yklykl530/koa-sse/issues" 26 | }, 27 | "homepage": "https://github.com/yklykl530/koa-sse#readme", 28 | "dependencies": { 29 | "stream": "0.0.2" 30 | }, 31 | "devDependencies": { 32 | "@koa/cors": "^2.2.1", 33 | "koa": "^2.5.0" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 kailu 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | const SSE = require('./sse'); 2 | const Stream = require('stream'); 3 | const DEFAULT_OPTS = { 4 | maxClients: 10000, 5 | pingInterval: 60000, 6 | closeEvent: 'close' 7 | } 8 | /** 9 | * koa sse middleware 10 | * @param {Object} opts 11 | * @param {Number} opts.maxClients max client number, default is 10000 12 | * @param {Number} opts.pingInterval heartbeat sending interval time(ms), default 60s 13 | * @param {String} opts.closeEvent if not provide end([data]), send default close event to client, default event name is "close" 14 | * @param {String} opts.matchQuery when set matchQuery, only has query (whatever the value) , sse will create 15 | */ 16 | module.exports = function sse (opts = {}) { 17 | opts = Object.assign({}, DEFAULT_OPTS, opts); 18 | const ssePool = []; 19 | 20 | let interval = setInterval(() => { 21 | let ts = +new Date; 22 | if (ssePool.length > 0) { 23 | ssePool.forEach(s => s.send(':')); 24 | console.log('SSE run ping: for ' + ssePool.length + ' clients'); 25 | } 26 | }, opts.pingInterval); 27 | 28 | return async function sse (ctx, next) { 29 | if (ctx.res.headersSent) { 30 | if (!(ctx.sse instanceof SSE)) { 31 | console.error('SSE response header has been send, Unable to create the sse response'); 32 | } 33 | return await next(); 34 | } 35 | if (ssePool.length >= opts.maxClients) { 36 | console.error('SSE sse client number more than the maximum, Unable to create the sse response'); 37 | return await next(); 38 | } 39 | if (opts.matchQuery && typeof ctx.query[opts.matchQuery] === 'undefined') { 40 | return await next(); 41 | } 42 | let sse = new SSE(ctx); 43 | ssePool.push(sse); 44 | sse.on('close', function() { 45 | ssePool.splice(ssePool.indexOf(sse), 1); 46 | }); 47 | ctx.sse = ctx.response.sse = sse; 48 | await next(); 49 | // if (!ctx.sse) { 50 | // ssePool.splice(ssePool.indexOf(sse), 1); 51 | // sse.destroy(); 52 | // return; 53 | // } 54 | if (!ctx.body) { 55 | ctx.body = ctx.sse; 56 | } else if (ctx.body instanceof Stream) { 57 | if (ctx.body.writable) { 58 | ctx.body = ctx.body.pipe(ctx.sse); 59 | } 60 | } else { 61 | if (!ctx.sse.ended) { 62 | ctx.sse.send(ctx.body); 63 | } 64 | ctx.body = sse; 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | koa-sse-stream 2 | === 3 | > koa sse(server side event) middleware , use stream programming model 4 | 5 | ![KoaJs Slack](https://img.shields.io/badge/Koa.Js-Slack%20Channel-Slack.svg?longCache=true&style=for-the-badge) 6 | 7 | Install 8 | --- 9 | > npm install --save koa-sse-stream 10 | 11 | Usage 12 | --- 13 | ```js 14 | const Koa = require('koa'); 15 | const compress = require('koa-compress'); 16 | const sse = require('koa-sse-stream'); 17 | 18 | 19 | const app = new Koa(); 20 | // !!attention : if you use compress, sse must use after compress 21 | app.use(compress()) 22 | 23 | /** 24 | * koa sse middleware 25 | * @param {Object} opts 26 | * @param {Number} opts.maxClients max client number, default is 10000 27 | * @param {Number} opts.pingInterval heartbeat sending interval time(ms), default 60s 28 | * @param {String} opts.closeEvent if not provide end([data]), send default close event to client, default event name is "close" 29 | * @param {String} opts.matchQuery when set matchQuery, only has query (whatever the value) , sse will create 30 | */ 31 | app.use(sse({ 32 | maxClients: 5000, 33 | pingInterval: 30000 34 | })); 35 | 36 | app.use(async (ctx) => { 37 | // ctx.sse is a writable stream and has extra method 'send' 38 | ctx.sse.send('a notice'); 39 | ctx.sse.sendEnd(); 40 | }); 41 | ``` 42 | 43 | ctx.sse 44 | --- 45 | a writable stream 46 | > ctx.sse.send(data) 47 | ```js 48 | /** 49 | * Event `close` Triggered when an SSE connection is closed, whether the server is actively closed or the client is closed 50 | */ 51 | /** 52 | * 53 | * @param {String} data sse data to send, if it's a string, an anonymous event will be sent. 54 | * @param {Object} data sse send object mode 55 | * @param {Object|String} data.data data to send, if it's object, it will be converted to json 56 | * @param {String} data.event sse event name 57 | * @param {Number} data.id sse event id 58 | * @param {Number} data.retry sse retry times 59 | * @param {*} encoding not use 60 | * @param {function} callback same as the write method callback 61 | */ 62 | send(data, encoding, callback) 63 | ``` 64 | >ctx.sse.sendEnd(data) 65 | ```js 66 | /** 67 | * 68 | * @param {String} data sse data to send, if it's a string, an anonymous event will be sent. 69 | * @param {Object} data sse send object mode 70 | * @param {Object|String} data.data data to send, if it's object, it will be converted to json 71 | * @param {String} data.event sse event name 72 | * @param {Number} data.id sse event id 73 | * @param {Number} data.retry sse retry times 74 | * @param {*} encoding not use 75 | * @param {function} callback same as the write method callback 76 | */ 77 | sendEnd(data, encoding, callback) 78 | ``` 79 | 80 | Attention !!! 81 | ------ 82 | if you use compress, sse must use after compress 83 | -------------------------------------------------------------------------------- /lib/sse.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | const stream = require('stream'); 4 | const Transform = stream.Transform; 5 | 6 | 7 | class SSETransform extends Transform { 8 | 9 | constructor(ctx, opts) { 10 | super({ 11 | writableObjectMode: true 12 | }); 13 | this.opts = {...SSETransform.defOpts, opts}; 14 | this.ctx = ctx; 15 | this.ended = false; 16 | ctx.req.socket.setTimeout(0); 17 | ctx.req.socket.setNoDelay(true); 18 | ctx.req.socket.setKeepAlive(true); 19 | ctx.set({ 20 | 'Content-Type': 'text/event-stream', 21 | 'Cache-Control': 'no-cache, no-transform', 22 | 'Connection': 'keep-alive', 23 | // 'Keep-Alive': 'timeout=120', 24 | 'X-Accel-Buffering': 'no' 25 | }); 26 | this.send(':ok'); 27 | } 28 | /** 29 | * 30 | * @param {String} data sse data to send, if it's a string, an anonymous event will be sent. 31 | * @param {Object} data sse send object mode 32 | * @param {Object|String} data.data data to send, if it's object, it will be converted to json 33 | * @param {String} data.event sse event name 34 | * @param {Number} data.id sse event id 35 | * @param {Number} data.retry sse retry times 36 | * @param {*} encoding not use 37 | * @param {function} callback same as the write method callback 38 | */ 39 | send(data, encoding, callback) { 40 | if (arguments.length === 0 || this.ended) return false; 41 | Transform.prototype.write.call(this, data, encodeURI, callback); 42 | } 43 | /** 44 | * 45 | * @param {String} data sse data to send, if it's a string, an anonymous event will be sent. 46 | * @param {Object} data sse send object mode 47 | * @param {Object|String} data.data data to send, if it's object, it will be converted to json 48 | * @param {String} data.event sse event name 49 | * @param {Number} data.id sse event id 50 | * @param {Number} data.retry sse retry times 51 | * @param {*} encoding not use 52 | * @param {function} callback same as the write method callback 53 | */ 54 | sendEnd(data, encoding, callback) { 55 | // if not set end(data), send end event before end, the event name is configurable 56 | // Because you override the native end method, In order to prevent multiple sending end events, add the ended prop 57 | if (this.ended) { 58 | return false; 59 | } 60 | if (!data && !this.ended) { 61 | data = {event: this.opts.closeEvent} 62 | } 63 | this.ended = true; 64 | Transform.prototype.end.call(this, data, encodeURI, callback); 65 | } 66 | end() { 67 | if (!this.ended) { 68 | this.ended = true; 69 | } 70 | } 71 | _transform(data, encoding, callback) { 72 | let senderObject, dataLines, prefix = 'data: ', commentReg = /^\s*:\s*/; 73 | let res = []; 74 | if (typeof data === 'string') { 75 | senderObject = {data: data}; 76 | } else { 77 | senderObject = data; 78 | } 79 | if (senderObject.event) res.push('event: ' + senderObject.event); 80 | if (senderObject.retry) res.push('retry: ' + senderObject.retry); 81 | if (senderObject.id) res.push('id: ' + senderObject.id); 82 | 83 | if (typeof senderObject.data === 'Object') { 84 | dataLines = JSON.stringify(senderObject.data); 85 | res.push(prefix + dataLines); 86 | } else if (typeof senderObject.data === 'undefined') { 87 | // Send an empty string even without data 88 | res.push(prefix); 89 | } else { 90 | senderObject.data = String(senderObject.data); 91 | if (senderObject.data.search(commentReg) !== -1) { 92 | senderObject.data = senderObject.data.replace(commentReg, ''); 93 | prefix = ': '; 94 | } 95 | senderObject.data = senderObject.data.replace(/(\r\n|\r|\n)/g, '\n'); 96 | dataLines = senderObject.data.split(/\n/); 97 | 98 | for (var i = 0, l = dataLines.length; i < l; ++i) { 99 | var line = dataLines[i]; 100 | if ((i+1) === l) res.push(prefix + line); 101 | else res.push(prefix + line); 102 | } 103 | } 104 | // Concentrated to send 105 | res = res.join('\n') + '\n\n'; 106 | this.push(res); 107 | this.emit('message', res); 108 | // Compatible with koa-compress 109 | if (this.ctx.body && typeof this.ctx.body.flush === 'function' && this.ctx.body.flush.name !== 'deprecated') { 110 | this.ctx.body.flush(); 111 | } 112 | callback() 113 | } 114 | } 115 | 116 | SSETransform.defOpts = { 117 | closeEvent: 'close' 118 | } 119 | 120 | module.exports = SSETransform; --------------------------------------------------------------------------------