├── .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 | 
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;
--------------------------------------------------------------------------------