├── .editorconfig ├── .eslintrc.js ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── README.md ├── example.js ├── index.js ├── package.json └── test ├── not_streams.js ├── sse_exists.js ├── stream.js └── two_events.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | # Unix-style newlines with a newline ending every file 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | charset = utf-8 10 | indent_style = space 11 | indent_size = 2 12 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | es6: true, 4 | node: true 5 | }, 6 | extends: "eslint:recommended", 7 | rules: { 8 | "accessor-pairs": "error", 9 | "array-bracket-newline": "error", 10 | "array-bracket-spacing": [ 11 | "error", 12 | "never" 13 | ], 14 | "array-callback-return": "error", 15 | "array-element-newline": "off", 16 | "arrow-body-style": "error", 17 | "arrow-parens": [ 18 | "error", 19 | "always" 20 | ], 21 | "arrow-spacing": [ 22 | "error", 23 | { 24 | after: true, 25 | before: true 26 | } 27 | ], 28 | "block-scoped-var": "error", 29 | "block-spacing": "error", 30 | "brace-style": [ 31 | "error", 32 | "1tbs" 33 | ], 34 | "callback-return": "error", 35 | camelcase: "error", 36 | "capitalized-comments": "off", 37 | "class-methods-use-this": "error", 38 | "comma-dangle": "error", 39 | "comma-spacing": [ 40 | "error", 41 | { 42 | after: true, 43 | before: false 44 | } 45 | ], 46 | "comma-style": [ 47 | "error", 48 | "last" 49 | ], 50 | complexity: ["error", 10], 51 | "computed-property-spacing": [ 52 | "error", 53 | "never" 54 | ], 55 | "consistent-return": "error", 56 | "consistent-this": "error", 57 | curly: "error", 58 | "default-case": "error", 59 | "dot-location": [ 60 | "error", 61 | "property" 62 | ], 63 | "dot-notation": "error", 64 | "eol-last": "error", 65 | eqeqeq: "error", 66 | "for-direction": "error", 67 | "func-call-spacing": "error", 68 | "func-name-matching": "error", 69 | "func-names": [ 70 | "error", 71 | "never" 72 | ], 73 | "func-style": [ 74 | "error", 75 | "expression" 76 | ], 77 | "function-paren-newline": "off", 78 | "generator-star-spacing": "error", 79 | "getter-return": "error", 80 | "global-require": "error", 81 | "guard-for-in": "error", 82 | "handle-callback-err": "error", 83 | "id-blacklist": "error", 84 | "id-length": "error", 85 | "id-match": "error", 86 | indent: "off", 87 | "indent-legacy": "off", 88 | "init-declarations": "off", 89 | "jsx-quotes": "error", 90 | "key-spacing": "error", 91 | "keyword-spacing": [ 92 | "error", 93 | { 94 | after: true, 95 | before: true 96 | } 97 | ], 98 | "line-comment-position": "error", 99 | "linebreak-style": [ 100 | "error", 101 | "unix" 102 | ], 103 | "lines-around-comment": "off", 104 | "lines-around-directive": "error", 105 | "max-depth": "error", 106 | "max-len": "off", 107 | "max-lines": "error", 108 | "max-nested-callbacks": "error", 109 | "max-params": "error", 110 | "max-statements": "off", 111 | "max-statements-per-line": "error", 112 | "new-cap": "error", 113 | "new-parens": "error", 114 | "newline-after-var": "off", 115 | "newline-before-return": "off", 116 | "newline-per-chained-call": "error", 117 | "no-alert": "error", 118 | "no-array-constructor": "error", 119 | "no-await-in-loop": "error", 120 | "no-bitwise": "error", 121 | "no-buffer-constructor": "error", 122 | "no-caller": "error", 123 | "no-catch-shadow": "error", 124 | "no-confusing-arrow": "error", 125 | "no-continue": "error", 126 | "no-div-regex": "error", 127 | "no-duplicate-imports": "error", 128 | "no-else-return": "error", 129 | "no-empty-function": "error", 130 | "no-eq-null": "error", 131 | "no-eval": "error", 132 | "no-extend-native": "error", 133 | "no-extra-bind": "error", 134 | "no-extra-label": "error", 135 | "no-extra-parens": "error", 136 | "no-floating-decimal": "error", 137 | "no-implicit-coercion": "error", 138 | "no-implicit-globals": "error", 139 | "no-implied-eval": "error", 140 | "no-inline-comments": "error", 141 | "no-invalid-this": "off", 142 | "no-iterator": "error", 143 | "no-label-var": "error", 144 | "no-labels": "error", 145 | "no-lone-blocks": "error", 146 | "no-lonely-if": "error", 147 | "no-loop-func": "error", 148 | "no-magic-numbers": "off", 149 | "no-mixed-operators": "error", 150 | "no-mixed-requires": "error", 151 | "no-multi-assign": "off", 152 | "no-multi-spaces": "error", 153 | "no-multi-str": "error", 154 | "no-multiple-empty-lines": "error", 155 | "no-native-reassign": "error", 156 | "no-negated-condition": "off", 157 | "no-negated-in-lhs": "error", 158 | "no-nested-ternary": "error", 159 | "no-new": "error", 160 | "no-new-func": "error", 161 | "no-new-object": "error", 162 | "no-new-require": "error", 163 | "no-new-wrappers": "error", 164 | "no-octal-escape": "error", 165 | "no-param-reassign": "off", 166 | "no-path-concat": "error", 167 | "no-plusplus": "error", 168 | "no-process-env": "error", 169 | "no-process-exit": "error", 170 | "no-proto": "error", 171 | "no-prototype-builtins": "off", 172 | "no-restricted-globals": "error", 173 | "no-restricted-imports": "error", 174 | "no-restricted-modules": "error", 175 | "no-restricted-properties": "error", 176 | "no-restricted-syntax": "error", 177 | "no-return-assign": "off", 178 | "no-return-await": "error", 179 | "no-script-url": "error", 180 | "no-self-compare": "error", 181 | "no-sequences": "error", 182 | "no-shadow": "off", 183 | "no-shadow-restricted-names": "error", 184 | "no-spaced-func": "error", 185 | "no-sync": "error", 186 | "no-tabs": "error", 187 | "no-template-curly-in-string": "error", 188 | "no-ternary": "off", 189 | "no-throw-literal": "error", 190 | "no-trailing-spaces": "error", 191 | "no-undef-init": "error", 192 | "no-undefined": "off", 193 | "no-underscore-dangle": "off", 194 | "no-unmodified-loop-condition": "error", 195 | "no-unneeded-ternary": "error", 196 | "no-unused-expressions": "error", 197 | "no-use-before-define": "error", 198 | "no-useless-call": "error", 199 | "no-useless-computed-key": "error", 200 | "no-useless-concat": "error", 201 | "no-useless-constructor": "error", 202 | "no-useless-rename": "error", 203 | "no-useless-return": "error", 204 | "no-var": "error", 205 | "no-void": "error", 206 | "no-warning-comments": "error", 207 | "no-whitespace-before-property": "error", 208 | "no-with": "error", 209 | "nonblock-statement-body-position": "error", 210 | "object-curly-newline": "error", 211 | "object-curly-spacing": [ 212 | "error", 213 | "never" 214 | ], 215 | "object-property-newline": [ 216 | "error", 217 | { 218 | allowMultiplePropertiesPerLine: true 219 | } 220 | ], 221 | "object-shorthand": "error", 222 | "one-var": "off", 223 | "one-var-declaration-per-line": "error", 224 | "operator-assignment": [ 225 | "error", 226 | "always" 227 | ], 228 | "operator-linebreak": "error", 229 | "padded-blocks": "off", 230 | "padding-line-between-statements": "error", 231 | "prefer-arrow-callback": "error", 232 | "prefer-const": "error", 233 | "prefer-destructuring": "off", 234 | "prefer-numeric-literals": "error", 235 | "prefer-promise-reject-errors": "error", 236 | "prefer-reflect": "error", 237 | "prefer-rest-params": "error", 238 | "prefer-spread": "error", 239 | "prefer-template": "off", 240 | "quote-props": "off", 241 | quotes: [ 242 | "error", 243 | "double" 244 | ], 245 | radix: "error", 246 | "require-await": "error", 247 | "require-jsdoc": "error", 248 | "rest-spread-spacing": "error", 249 | semi: "error", 250 | "semi-spacing": "error", 251 | "semi-style": [ 252 | "error", 253 | "last" 254 | ], 255 | "sort-imports": "error", 256 | "sort-keys": "error", 257 | "sort-vars": "error", 258 | "space-before-blocks": "error", 259 | "space-before-function-paren": "off", 260 | "space-in-parens": [ 261 | "error", 262 | "never" 263 | ], 264 | "space-infix-ops": "error", 265 | "space-unary-ops": "error", 266 | "spaced-comment": [ 267 | "error", 268 | "always" 269 | ], 270 | strict: "error", 271 | "switch-colon-spacing": "error", 272 | "symbol-description": "error", 273 | "template-curly-spacing": [ 274 | "error", 275 | "never" 276 | ], 277 | "template-tag-spacing": "error", 278 | "unicode-bom": [ 279 | "error", 280 | "never" 281 | ], 282 | "valid-jsdoc": [ 283 | "error", 284 | { 285 | requireReturn: false 286 | } 287 | ], 288 | "vars-on-top": "error", 289 | "wrap-iife": "error", 290 | "wrap-regex": "error", 291 | "yield-star-spacing": "error", 292 | yoda: [ 293 | "error", 294 | "always" 295 | ] 296 | } 297 | }; 298 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /coverage 3 | /.nyc_output 4 | 5 | package-lock.json 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "8" 4 | - "7" 5 | - "6" 6 | - "5" 7 | - "4" 8 | script: 9 | - npm test 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## v1.0.0 2 | 3 | Support Fastify v2.x 4 | 5 | ## v0.1 6 | 7 | Support of Fastify v0.x 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fastify-sse 2 | 3 | [![Build Status](https://travis-ci.org/lolo32/fastify-sse.svg?branch=master)](https://travis-ci.org/lolo32/fastify-sse) 4 | [![Coverage Status](https://coveralls.io/repos/github/lolo32/fastify-sse/badge.svg)](https://coveralls.io/github/lolo32/fastify-sse) 5 | [![Known Vulnerabilities](https://snyk.io/test/github/lolo32/fastify-sse/badge.svg)](https://snyk.io/test/github/lolo32/fastify-sse) 6 | 7 | Easily send Server-Send-Events with Fastify. 8 | 9 | _This is based on [github.com/mtharrison/susie](https://github.com/mtharrison/susie)_ 10 | 11 | ## Install 12 | 13 | `` 14 | npm install --save fastify-sse 15 | `` 16 | 17 | ## Usage 18 | 19 | Add it to you project with `register` and you are done! 20 | 21 | You can now configure a new route, and call the new `reply.sse()` to send Events to browser. 22 | 23 | When you have finished sending event, you could send an empty message, or if using stream and end of stream, an 24 | `end` event will be fired just before closing the connection. You could work with it browser side to prevent 25 | automatic reconnection. 26 | 27 | ```javascript 28 | // Register the plugin 29 | fastify.register(require("fastify-sse"), (err) => { 30 | if (err) { 31 | throw err; 32 | } 33 | }); 34 | 35 | // Define a new route in hapijs notation 36 | fastify.route({ 37 | method: "GET", 38 | url: "/sse-hapi", 39 | handler: (request, reply) => { 40 | let index; 41 | const options = {}; 42 | 43 | // Send the first data 44 | reply.sse("sample data", options); 45 | 46 | // Send a new data every seconds for 10 seconds then close 47 | const interval = setInterval(() => { 48 | reply.sse({event: "test", data: index}); 49 | if (!(index % 10)) { 50 | reply.sse(); 51 | clearInterval(interval); 52 | } 53 | }, 1000); 54 | } 55 | }); 56 | 57 | // Define a new route in express notation 58 | fastify.get("/sse-express",(request, reply) => { 59 | let index; 60 | const options = {}; 61 | 62 | // Send the first data 63 | reply.sse("sample data", options); 64 | 65 | // Send a new data every seconds for 10 seconds then close 66 | const interval = setInterval(() => { 67 | reply.sse({event: "test", data: index}); 68 | if (!(index % 10)) { 69 | reply.sse(); 70 | clearInterval(interval); 71 | } 72 | }, 1000); 73 | }); 74 | ``` 75 | 76 | The `options` are used only for the first call, subsequent ignore it. 77 | 78 | You could specify: 79 | 80 | * `strings` that well be sent directly 81 | * `buffers` that will be converted beck to strings, utf8 encoded 82 | * `objects` that will be stringified with the use of `"fast-safe-stringify"` 83 | * `streams` that are readables, and could deals with objectMode or not 84 | 85 | ## Options 86 | 87 | * `idGenerator`: generate the event id, defaulting to a number incrementing, from 1 88 | * `event`: can be a string for the event name, or a function to compute the event name 89 | 90 | ### idGenerator 91 | 92 | It must be a function that will be called with the event in parameter, and must return a string that will be the 93 | `id` of the SSE, or it could be `null` if no id is needed. 94 | 95 | Using a function: 96 | 97 | ```javascript 98 | reply.sse("message", { 99 | idGenerator: (event) => { 100 | // Retrieve the event name using the key myIdentifiant or use the timestamp if not exists … 101 | return event.myIdentifiant || (new Date()).getTime(); 102 | } 103 | }); 104 | ``` 105 | 106 | It will transmit: 107 | 108 | > id: 1504624133267 109 | > 110 | > data: message 111 | 112 | Do not display `id`, so pass null: 113 | 114 | ```javascript 115 | reply.sse("message", {idGenerator: null}); 116 | ``` 117 | 118 | It will transmit: 119 | 120 | > data: message 121 | 122 | ### event 123 | 124 | It could be: 125 | * a function, called with the event in parameter and return a string that will be used, or a string if the event 126 | name doest not change. The event will be retrieved by the browser using `.on("eventName", […])`. 127 | * nothing if you do not want a name, and events could be retrieved in the browser with the generic 128 | `.on("message", […])`. 129 | 130 | ```javascript 131 | reply.sse({myEventName: "myEvent", hello: "world"}, { 132 | event: (event) => { 133 | // Retrieve the event name using the key myEventName … 134 | const name = event.myEventName; 135 | // … delete it from the object … 136 | delete event.myEventName; 137 | // then return the name 138 | return name; 139 | } 140 | }); 141 | ``` 142 | 143 | It will transmit: 144 | 145 | > id: 1 146 | > 147 | > event: MyEvent 148 | > 149 | > data: {"hello":"world"} 150 | -------------------------------------------------------------------------------- /example.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const fastify = require("fastify")(); 4 | const PassThrough = require("stream").PassThrough; 5 | const Fs = require("fs"); 6 | 7 | fastify 8 | .register(require("./index")) 9 | .after( (err) => { 10 | if (err) { 11 | throw err; 12 | } 13 | } 14 | ); 15 | 16 | fastify.get("/sse", (request, reply) => { 17 | reply.sse("toto"); 18 | 19 | setTimeout(() => { 20 | reply.sse({data: "titi au ski", event: "test"}); 21 | reply.sse(); 22 | }, 500); 23 | }); 24 | 25 | fastify.get("/sse2", (request, reply) => { 26 | const read = new PassThrough({objectMode: true}); 27 | let index = 0; 28 | 29 | reply.sse(read); 30 | 31 | const id = setInterval(() => { 32 | read.write({event: "test", index}); 33 | 34 | index += 1; 35 | 36 | if (!(index % 10)) { 37 | read.end(); 38 | clearInterval(id); 39 | } 40 | }, 1000); 41 | }); 42 | 43 | fastify.route({ 44 | handler: (request, reply) => { 45 | reply.sse(Fs.createReadStream("./package.json")); 46 | }, 47 | method: "GET", 48 | url: "/sse3" 49 | }); 50 | 51 | fastify.get("/", (request, reply) => { 52 | reply.send({hello: "world"}); 53 | }); 54 | 55 | fastify.listen(3000, (err) => { 56 | if (err) { 57 | throw err; 58 | } 59 | console.log(`server listening on ${fastify.server.address().port}`); 60 | }); 61 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* 4 | * Based on https://github.com/mtharrison/susie 5 | */ 6 | 7 | const fastifyPlugin = require("fastify-plugin"); 8 | const Stream = require("stream"); 9 | const safeStringify = require("fast-safe-stringify"); 10 | const PassThrough = Stream.PassThrough; 11 | const Readable = Stream.Readable; 12 | const Transform = Stream.Transform; 13 | 14 | const endl = "\r\n"; 15 | const sseParams = Symbol("sse"); 16 | 17 | /** 18 | * Convert an object 19 | * 20 | * @param {Object} event The event to convert to string 21 | * 22 | * @return {string} The string to send to the browser 23 | */ 24 | const stringifyEvent = (event) => { 25 | let ret = ""; 26 | 27 | for (const key of ["id", "event", "data"]) { 28 | if (event.hasOwnProperty(key)) { 29 | let value = event[key]; 30 | if (value instanceof Buffer) { 31 | value = value.toString(); 32 | } 33 | if ("object" === typeof value) { 34 | value = safeStringify(value); 35 | } 36 | ret += key + ": " + value + endl; 37 | } 38 | } 39 | 40 | return ret + endl; 41 | }; 42 | 43 | /** 44 | * Write a string to the Stream 45 | * 46 | * @param {string|null} event The event that need to be converted to string 47 | * @param {string|Buffer|Object|null} event.data If it contains data, the stream needs to continue 48 | * @param {PassThrough} stream The stream to write data to 49 | * @param {function} stream.write Writable stream function to write data to it 50 | * @param {function} stream.end To close the stream (and so the connection) 51 | */ 52 | const writeEvent = (event, stream) => { 53 | if (event.data) { 54 | stream.write(stringifyEvent(event)); 55 | } else { 56 | stream.write(stringifyEvent({data: "", event: "end"})); 57 | stream.end(); 58 | } 59 | }; 60 | 61 | /** 62 | * This will configure the self parameter with the options 63 | * 64 | * @param {Object} self Object that need to be configured 65 | * @param {Object} options Options to specify 66 | * @param {function} idGenerator Callback function that will generate the event id 67 | */ 68 | const initOptions = (self, options, idGenerator) => { 69 | if (null !== options.idGenerator) { 70 | self.idGenerator = options.idGenerator || idGenerator; 71 | } 72 | if ("function" !== typeof self.idGenerator && null !== options.idGenerator) { 73 | throw new Error("Option idGenerator must be a function or null"); 74 | } 75 | 76 | if ("function" === typeof options.event) { 77 | self.eventGenerator = true; 78 | } 79 | if ("function" === typeof options.event || "string" === typeof options.event) { 80 | self.event = options.event; 81 | return; 82 | } 83 | 84 | self.event = null; 85 | }; 86 | 87 | /** 88 | * Class in charge of converting a stream (in object mode or not) to an object with keys event, id and data 89 | * 90 | * @param {Object} options Options of the transform. Only idGenerator and event are recognised 91 | * @param {function} [options.idGenerator] Function that will generate the event id, or null if none needed 92 | * @param {function|string} [options.event] Event name (string) or function that generate event name 93 | * @param {boolean} [options.objectMode = false] Is this stream work accept object in input? 94 | * @constructor 95 | */ 96 | class EventTransform extends Transform { 97 | constructor(options, objectMode) { 98 | super({objectMode}); 99 | 100 | options = options || {}; 101 | 102 | this.id = 0; 103 | const idGenerator = () => this.id += 1; 104 | 105 | initOptions(this, options, idGenerator); 106 | } 107 | 108 | /** 109 | * Do no call this, it's internal Stream transform function 110 | * 111 | * @param {Object} chunk The data that arrived 112 | * @param {string} encoding Data encoding. Not used here (SSE always in utf8) 113 | * @param {function} callback Function needed to be called after conversion is done 114 | * @private 115 | */ 116 | _transform(chunk, encoding, callback) { 117 | const event = {}; 118 | 119 | if (this.idGenerator) { 120 | event.id = this.idGenerator(chunk); 121 | } 122 | if (this.event) { 123 | event.event = this.eventGenerator ? this.event(chunk) : this.event; 124 | } 125 | event.data = chunk; 126 | 127 | this.push(stringifyEvent(event)); 128 | 129 | callback(); 130 | } 131 | 132 | /** 133 | * Do no call this, it's internal Stream transform function 134 | * 135 | * @param {function} callback Needed to be called at end of working 136 | * @private 137 | */ 138 | _flush(callback) { 139 | this.push(stringifyEvent({data: "", event: "end"})); 140 | 141 | callback(); 142 | } 143 | } 144 | 145 | /** 146 | * Decorators 147 | * 148 | * @param {fastify} instance 149 | * @param {function} instance.decorate 150 | * @param {function} instance.decorateReply 151 | * @param {Object} instance.sse 152 | * @param {Object} opts 153 | * @param {function} next 154 | */ 155 | module.exports = fastifyPlugin((instance, opts, next) => { 156 | 157 | instance.decorateReply("sse", 158 | /** 159 | * Function called when new data should be send 160 | * 161 | * @param {string|Readable|Object} chunk The data to send. Could be a Readable Stream, a string or an Object 162 | * @param {Object} options Options read for the first time, and specifying idGenerator and event 163 | * @param {function|null} [options.idGenerator] Generate the event id 164 | * @param {string|function} [options.event] Generate the event name 165 | */ 166 | function (chunk, options) { 167 | let streamTransform; 168 | 169 | const send = (stream) => { 170 | this.type("text/event-stream") 171 | .header("content-encoding", "identity") 172 | .send(stream); 173 | }; 174 | 175 | const sse = this[sseParams] = this[sseParams] || {id: 0}; 176 | 177 | if (chunk instanceof Readable) { 178 | // handle a stream arg 179 | 180 | sse.mode = "stream"; 181 | 182 | if (chunk._readableState.objectMode) { 183 | // Input stream is in object mode, so pipe the input to the passthrough then to the transform 184 | 185 | const through = new EventTransform(options, true); 186 | streamTransform = new PassThrough(); 187 | through.pipe(streamTransform); 188 | chunk.pipe(through); 189 | } else { 190 | // Input is not in object mode, so pipe the input to the transform 191 | 192 | streamTransform = new EventTransform(options, false); 193 | chunk.pipe(streamTransform); 194 | } 195 | 196 | send(streamTransform); 197 | return; 198 | } 199 | 200 | // handle a first object arg 201 | 202 | if (!sse.stream) { 203 | options = options || {}; 204 | const idGenerator = () => sse.id += 1; 205 | 206 | streamTransform = new PassThrough(); 207 | sse.stream = streamTransform; 208 | sse.mode = "object"; 209 | 210 | initOptions(sse, options, idGenerator); 211 | 212 | send(streamTransform); 213 | } else { 214 | // already have an object stream flowing, just write next event 215 | streamTransform = sse.stream; 216 | } 217 | 218 | const event = {}; 219 | if (sse.idGenerator) { 220 | event.id = sse.idGenerator(chunk); 221 | } 222 | 223 | if (sse.event) { 224 | event.event = sse.eventGenerator ? sse.event(chunk) : sse.event; 225 | } 226 | event.data = chunk; 227 | 228 | writeEvent(event, streamTransform); 229 | }); 230 | 231 | next(); 232 | }, ">=0.x"); 233 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fastify-sse", 3 | "version": "1.0.0", 4 | "description": "Provide Server-Sent Events to Fastify", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "tap test/*.js --coverage --100", 8 | "test:coverage": "tap test/*.js --coverage --100 --coverage-report=html" 9 | }, 10 | "bugs": { 11 | "url": "https://github.com/lolo32/fastify-sse/issues" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/lolo32/fastify-sse.git" 16 | }, 17 | "homepage": "https://github.com/lolo32/fastify-sse.git#readme", 18 | "author": "Lolo_32", 19 | "license": "MIT", 20 | "keywords": [ 21 | "Fastify", 22 | "server-sent events", 23 | "sse" 24 | ], 25 | "devDependencies": { 26 | "fastify": "^2", 27 | "request": "^2.81.0", 28 | "tap": "^10.7.2" 29 | }, 30 | "dependencies": { 31 | "fast-safe-stringify": "^1.2.0", 32 | "fastify-plugin": "^1" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /test/not_streams.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-confusing-arrow */ 2 | 3 | "use strict"; 4 | 5 | const fastifySse = require("../index"); 6 | 7 | const fastifyModule = require("fastify"); 8 | const test = require("tap").test; 9 | const request = require("request"); 10 | 11 | test("reply.sse could send strings", (t) => { 12 | t.plan(7); 13 | 14 | const fastify = fastifyModule(); 15 | fastify.register(fastifySse).after((err) => { 16 | t.error(err); 17 | }); 18 | 19 | const data = "hello: world"; 20 | 21 | fastify.get("/", (request, reply) => { 22 | reply.sse(data); 23 | reply.sse(); 24 | }); 25 | 26 | fastify.listen(0, (err) => { 27 | t.error(err); 28 | 29 | request({ 30 | method: "GET", 31 | uri: `http://localhost:${fastify.server.address().port}` 32 | }, (err, response, body) => { 33 | t.error(err); 34 | t.strictEqual(response.statusCode, 200); 35 | t.strictEqual(response.headers["content-type"], "text/event-stream"); 36 | t.strictEqual(response.headers["content-encoding"], "identity"); 37 | t.equal(body, `id: 1\r\ndata: ${data}\r\n\r\nevent: end\r\ndata: \r\n\r\n`); 38 | t.end(); 39 | fastify.close(); 40 | }); 41 | }); 42 | }); 43 | 44 | test("reply.sse could send objects", (t) => { 45 | t.plan(7); 46 | 47 | const fastify = fastifyModule(); 48 | fastify.register(fastifySse).after( (err) => { 49 | t.error(err); 50 | }); 51 | 52 | const data = {hello: "world"}; 53 | 54 | fastify.get("/", (request, reply) => { 55 | reply.sse(data); 56 | reply.sse(); 57 | }); 58 | 59 | fastify.listen(0, (err) => { 60 | t.error(err); 61 | 62 | request({ 63 | method: "GET", 64 | uri: `http://localhost:${fastify.server.address().port}` 65 | }, (err, response, body) => { 66 | t.error(err); 67 | t.strictEqual(response.statusCode, 200); 68 | t.strictEqual(response.headers["content-type"], "text/event-stream"); 69 | t.strictEqual(response.headers["content-encoding"], "identity"); 70 | t.equal(body, `id: 1\r\ndata: ${JSON.stringify(data)}\r\n\r\nevent: end\r\ndata: \r\n\r\n`); 71 | t.end(); 72 | fastify.close(); 73 | }); 74 | }); 75 | }); 76 | 77 | test("reply.sse could send event name \"hello\"", (t) => { 78 | t.plan(7); 79 | 80 | const fastify = fastifyModule(); 81 | fastify.register(fastifySse).after( (err) => { 82 | t.error(err); 83 | }); 84 | 85 | const data = {hello: "world"}; 86 | 87 | fastify.get("/", (request, reply) => { 88 | reply.sse(data, {event: "hello"}); 89 | reply.sse(); 90 | }); 91 | 92 | fastify.listen(0, (err) => { 93 | t.error(err); 94 | 95 | request({ 96 | method: "GET", 97 | uri: `http://localhost:${fastify.server.address().port}` 98 | }, (err, response, body) => { 99 | t.error(err); 100 | t.strictEqual(response.statusCode, 200); 101 | t.strictEqual(response.headers["content-type"], "text/event-stream"); 102 | t.strictEqual(response.headers["content-encoding"], "identity"); 103 | t.equal(body, `id: 1\r\nevent: hello\r\ndata: ${JSON.stringify(data)}\r\n\r\nevent: end\r\ndata: \r\n\r\n`); 104 | t.end(); 105 | fastify.close(); 106 | }); 107 | }); 108 | }); 109 | 110 | test("reply.sse could send event name generated by function", (t) => { 111 | t.plan(7); 112 | 113 | const fastify = fastifyModule(); 114 | fastify.register(fastifySse).after( (err) => { 115 | t.error(err); 116 | }); 117 | 118 | const data = {hello: "world"}; 119 | 120 | fastify.get("/", (request, reply) => { 121 | reply.sse(data, {event: (event) => event ? event.hello : undefined}); 122 | reply.sse(); 123 | }); 124 | 125 | fastify.listen(0, (err) => { 126 | t.error(err); 127 | 128 | request({ 129 | method: "GET", 130 | uri: `http://localhost:${fastify.server.address().port}` 131 | }, (err, response, body) => { 132 | t.error(err); 133 | t.strictEqual(response.statusCode, 200); 134 | t.strictEqual(response.headers["content-type"], "text/event-stream"); 135 | t.strictEqual(response.headers["content-encoding"], "identity"); 136 | t.equal(body, `id: 1\r\nevent: world\r\ndata: ${JSON.stringify(data)}\r\n\r\nevent: end\r\ndata: \r\n\r\n`); 137 | t.end(); 138 | fastify.close(); 139 | }); 140 | }); 141 | }); 142 | 143 | test("reply.sse could generate id by function", (t) => { 144 | t.plan(7); 145 | 146 | const fastify = fastifyModule(); 147 | fastify.register(fastifySse).after( (err) => { 148 | t.error(err); 149 | }); 150 | 151 | const data = {hello: "world", num: 4}; 152 | 153 | fastify.get("/", (request, reply) => { 154 | reply.sse(data, {idGenerator: (event) => event ? event.num * 5 : undefined}); 155 | reply.sse(); 156 | }); 157 | 158 | fastify.listen(0, (err) => { 159 | t.error(err); 160 | 161 | request({ 162 | method: "GET", 163 | uri: `http://localhost:${fastify.server.address().port}` 164 | }, (err, response, body) => { 165 | t.error(err); 166 | t.strictEqual(response.statusCode, 200); 167 | t.strictEqual(response.headers["content-type"], "text/event-stream"); 168 | t.strictEqual(response.headers["content-encoding"], "identity"); 169 | t.equal(body, `id: ${4 * 5}\r\ndata: ${JSON.stringify(data)}\r\n\r\nevent: end\r\ndata: \r\n\r\n`); 170 | t.end(); 171 | fastify.close(); 172 | }); 173 | }); 174 | }); 175 | 176 | test("reply.sse does not want id", (t) => { 177 | t.plan(7); 178 | 179 | const fastify = fastifyModule(); 180 | fastify.register(fastifySse).after( (err) => { 181 | t.error(err); 182 | }); 183 | 184 | const data = {hello: "world"}; 185 | 186 | fastify.get("/", (request, reply) => { 187 | reply.sse(data, {idGenerator: null}); 188 | reply.sse(); 189 | }); 190 | 191 | fastify.listen(0, (err) => { 192 | t.error(err); 193 | 194 | request({ 195 | method: "GET", 196 | uri: `http://localhost:${fastify.server.address().port}` 197 | }, (err, response, body) => { 198 | t.error(err); 199 | t.strictEqual(response.statusCode, 200); 200 | t.strictEqual(response.headers["content-type"], "text/event-stream"); 201 | t.strictEqual(response.headers["content-encoding"], "identity"); 202 | t.equal(body, `data: ${JSON.stringify(data)}\r\n\r\nevent: end\r\ndata: \r\n\r\n`); 203 | t.end(); 204 | fastify.close(); 205 | }); 206 | }); 207 | }); 208 | 209 | test("reply.sse throw an error if idGenerator is not valid", (t) => { 210 | t.plan(4); 211 | 212 | const fastify = fastifyModule(); 213 | fastify.register(fastifySse).after( (err) => { 214 | t.error(err); 215 | }); 216 | 217 | const data = {hello: "world"}; 218 | 219 | fastify.get("/", (request, reply) => { 220 | t.throws(() => reply.sse(data, {idGenerator: true}), new Error("Option idGenerator must be a function or null")); 221 | reply.send(); 222 | }); 223 | 224 | fastify.listen(0, (err) => { 225 | t.error(err); 226 | 227 | request({ 228 | method: "GET", 229 | uri: `http://localhost:${fastify.server.address().port}` 230 | }, (err) => { 231 | t.error(err); 232 | t.end(); 233 | fastify.close(); 234 | }); 235 | }); 236 | }); 237 | -------------------------------------------------------------------------------- /test/sse_exists.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-confusing-arrow */ 2 | 3 | "use strict"; 4 | 5 | const fastifySse = require("../index"); 6 | 7 | const fastifyModule = require("fastify"); 8 | const test = require("tap").test; 9 | const request = require("request"); 10 | 11 | test("reply.sse exists", (t) => { 12 | t.plan(7); 13 | 14 | const data = {hello: "world"}; 15 | 16 | const fastify = fastifyModule(); 17 | fastify.register(fastifySse).after( (err) => { 18 | t.error(err); 19 | }); 20 | 21 | fastify.get("/", (request, reply) => { 22 | t.ok(reply.sse); 23 | reply.send(data); 24 | }); 25 | 26 | fastify.listen(0, (err) => { 27 | t.error(err); 28 | 29 | request({ 30 | method: "GET", 31 | uri: `http://localhost:${fastify.server.address().port}` 32 | }, (err, response, body) => { 33 | t.error(err); 34 | t.strictEqual(response.statusCode, 200); 35 | t.strictEqual(response.headers["content-length"], `${body.length}`); 36 | t.deepEqual(JSON.parse(body), data); 37 | t.end(); 38 | fastify.close(); 39 | }); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /test/stream.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-confusing-arrow */ 2 | 3 | "use strict"; 4 | 5 | const fastifySse = require("../index"); 6 | 7 | const fastifyModule = require("fastify"); 8 | const test = require("tap").test; 9 | const request = require("request"); 10 | const PassThrough = require("stream").PassThrough; 11 | 12 | test("reply.sse could send Readable stream in object mode", (t) => { 13 | t.plan(7); 14 | 15 | const fastify = fastifyModule(); 16 | fastify.register(fastifySse).after( (err) => { 17 | t.error(err); 18 | }); 19 | 20 | const data = {hello: "world"}; 21 | 22 | fastify.get("/", (request, reply) => { 23 | const pass = new PassThrough({objectMode: true}); 24 | reply.sse(pass); 25 | pass.write(data); 26 | pass.end(); 27 | }); 28 | 29 | fastify.listen(0, (err) => { 30 | t.error(err); 31 | 32 | request({ 33 | method: "GET", 34 | uri: `http://localhost:${fastify.server.address().port}` 35 | }, (err, response, body) => { 36 | t.error(err); 37 | t.strictEqual(response.statusCode, 200); 38 | t.strictEqual(response.headers["content-type"], "text/event-stream"); 39 | t.strictEqual(response.headers["content-encoding"], "identity"); 40 | t.equal(body, `id: 1\r\ndata: ${JSON.stringify(data)}\r\n\r\nevent: end\r\ndata: \r\n\r\n`); 41 | t.end(); 42 | fastify.close(); 43 | }); 44 | }); 45 | }); 46 | 47 | test("reply.sse could send Readable stream in byte mode", (t) => { 48 | t.plan(7); 49 | 50 | const fastify = fastifyModule(); 51 | fastify.register(fastifySse).after( (err) => { 52 | t.error(err); 53 | }); 54 | 55 | const data = "hello: world"; 56 | 57 | fastify.get("/", (request, reply) => { 58 | const pass = new PassThrough({objectMode: false}); 59 | reply.sse(pass); 60 | pass.write(data); 61 | pass.end(); 62 | }); 63 | 64 | fastify.listen(0, (err) => { 65 | t.error(err); 66 | 67 | request({ 68 | method: "GET", 69 | uri: `http://localhost:${fastify.server.address().port}` 70 | }, (err, response, body) => { 71 | t.error(err); 72 | t.strictEqual(response.statusCode, 200); 73 | t.strictEqual(response.headers["content-type"], "text/event-stream"); 74 | t.strictEqual(response.headers["content-encoding"], "identity"); 75 | t.equal(body, `id: 1\r\ndata: ${data}\r\n\r\nevent: end\r\ndata: \r\n\r\n`); 76 | t.end(); 77 | fastify.close(); 78 | }); 79 | }); 80 | }); 81 | 82 | test("reply.sse with streams can generate id", (t) => { 83 | t.plan(7); 84 | 85 | const fastify = fastifyModule(); 86 | fastify.register(fastifySse).after( (err) => { 87 | t.error(err); 88 | }); 89 | 90 | const data = {hello: "world", num: 4}; 91 | 92 | fastify.get("/", (request, reply) => { 93 | const pass = new PassThrough({objectMode: true}); 94 | reply.sse(pass, {idGenerator: (event) => event ? event.num * 5 : undefined}); 95 | pass.write(data); 96 | pass.end(); 97 | }); 98 | 99 | fastify.listen(0, (err) => { 100 | t.error(err); 101 | 102 | request({ 103 | method: "GET", 104 | uri: `http://localhost:${fastify.server.address().port}` 105 | }, (err, response, body) => { 106 | t.error(err); 107 | t.strictEqual(response.statusCode, 200); 108 | t.strictEqual(response.headers["content-type"], "text/event-stream"); 109 | t.strictEqual(response.headers["content-encoding"], "identity"); 110 | t.equal(body, `id: ${4 * 5}\r\ndata: ${JSON.stringify(data)}\r\n\r\nevent: end\r\ndata: \r\n\r\n`); 111 | t.end(); 112 | fastify.close(); 113 | }); 114 | }); 115 | }); 116 | 117 | test("reply.sse with streams can ignore id", (t) => { 118 | t.plan(7); 119 | 120 | const fastify = fastifyModule(); 121 | fastify.register(fastifySse).after( (err) => { 122 | t.error(err); 123 | }); 124 | 125 | const data = {hello: "world", num: 4}; 126 | 127 | fastify.get("/", (request, reply) => { 128 | const pass = new PassThrough({objectMode: true}); 129 | reply.sse(pass, {idGenerator: null}); 130 | pass.write(data); 131 | pass.end(); 132 | }); 133 | 134 | fastify.listen(0, (err) => { 135 | t.error(err); 136 | 137 | request({ 138 | method: "GET", 139 | uri: `http://localhost:${fastify.server.address().port}` 140 | }, (err, response, body) => { 141 | t.error(err); 142 | t.strictEqual(response.statusCode, 200); 143 | t.strictEqual(response.headers["content-type"], "text/event-stream"); 144 | t.strictEqual(response.headers["content-encoding"], "identity"); 145 | t.equal(body, `data: ${JSON.stringify(data)}\r\n\r\nevent: end\r\ndata: \r\n\r\n`); 146 | t.end(); 147 | fastify.close(); 148 | }); 149 | }); 150 | }); 151 | 152 | test("reply.sse with streams can specify static events", (t) => { 153 | t.plan(7); 154 | 155 | const fastify = fastifyModule(); 156 | fastify.register(fastifySse).after( (err) => { 157 | t.error(err); 158 | }); 159 | 160 | const data = {hello: "world", num: 4}; 161 | 162 | fastify.get("/", (request, reply) => { 163 | const pass = new PassThrough({objectMode: true}); 164 | reply.sse(pass, {event: "test"}); 165 | pass.write(data); 166 | pass.end(); 167 | }); 168 | 169 | fastify.listen(0, (err) => { 170 | t.error(err); 171 | 172 | request({ 173 | method: "GET", 174 | uri: `http://localhost:${fastify.server.address().port}` 175 | }, (err, response, body) => { 176 | t.error(err); 177 | t.strictEqual(response.statusCode, 200); 178 | t.strictEqual(response.headers["content-type"], "text/event-stream"); 179 | t.strictEqual(response.headers["content-encoding"], "identity"); 180 | t.equal(body, `id: 1\r\nevent: test\r\ndata: ${JSON.stringify(data)}\r\n\r\nevent: end\r\ndata: \r\n\r\n`); 181 | t.end(); 182 | fastify.close(); 183 | }); 184 | }); 185 | }); 186 | 187 | test("reply.sse with streams can generate dynamic events", (t) => { 188 | t.plan(7); 189 | 190 | const fastify = fastifyModule(); 191 | fastify.register(fastifySse).after( (err) => { 192 | t.error(err); 193 | }); 194 | 195 | const data = {hello: "world", name: "test function"}; 196 | 197 | fastify.get("/", (request, reply) => { 198 | const pass = new PassThrough({objectMode: true}); 199 | reply.sse(pass, {event: (event) => event ? event.name : undefined}); 200 | pass.write(data); 201 | pass.end(); 202 | }); 203 | 204 | fastify.listen(0, (err) => { 205 | t.error(err); 206 | 207 | request({ 208 | method: "GET", 209 | uri: `http://localhost:${fastify.server.address().port}` 210 | }, (err, response, body) => { 211 | t.error(err); 212 | t.strictEqual(response.statusCode, 200); 213 | t.strictEqual(response.headers["content-type"], "text/event-stream"); 214 | t.strictEqual(response.headers["content-encoding"], "identity"); 215 | t.equal(body, `id: 1\r\nevent: test function\r\ndata: ${JSON.stringify(data)}\r\n\r\nevent: end\r\ndata: \r\n\r\n`); 216 | t.end(); 217 | fastify.close(); 218 | }); 219 | }); 220 | }); 221 | 222 | -------------------------------------------------------------------------------- /test/two_events.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-confusing-arrow */ 2 | 3 | "use strict"; 4 | 5 | const fastifySse = require("../index"); 6 | 7 | const fastifyModule = require("fastify"); 8 | const PassThrough = require("stream").PassThrough; 9 | const request = require("request"); 10 | const test = require("tap").test; 11 | 12 | test("reply.sse reply to 2 SSE simultaneously", (t) => { 13 | t.plan(12); 14 | 15 | const fastify = fastifyModule(); 16 | fastify.register(fastifySse).after( (err) => { 17 | t.error(err); 18 | }); 19 | 20 | const data = "hello: world"; 21 | 22 | fastify.get("/", (request, reply) => { 23 | reply.sse(data); 24 | reply.sse(); 25 | }); 26 | 27 | fastify.get("/stream", (request, reply) => { 28 | const pass = new PassThrough({objectMode: true}); 29 | reply.sse(pass); 30 | pass.write({data}); 31 | pass.end(); 32 | }); 33 | 34 | fastify.listen(0, (err) => { 35 | t.error(err); 36 | 37 | let nbSse = 2; 38 | 39 | request({ 40 | method: "GET", 41 | uri: `http://localhost:${fastify.server.address().port}` 42 | }, (err, response, body) => { 43 | t.error(err); 44 | t.strictEqual(response.statusCode, 200); 45 | t.strictEqual(response.headers["content-type"], "text/event-stream"); 46 | t.strictEqual(response.headers["content-encoding"], "identity"); 47 | t.equal(body, `id: 1\r\ndata: ${data}\r\n\r\nevent: end\r\ndata: \r\n\r\n`); 48 | nbSse -= 1; 49 | if (0 === nbSse) { 50 | t.end(); 51 | fastify.close(); 52 | } 53 | }); 54 | 55 | request({ 56 | method: "GET", 57 | uri: `http://localhost:${fastify.server.address().port}/stream` 58 | }, (err, response, body) => { 59 | t.error(err); 60 | t.strictEqual(response.statusCode, 200); 61 | t.strictEqual(response.headers["content-type"], "text/event-stream"); 62 | t.strictEqual(response.headers["content-encoding"], "identity"); 63 | t.equal(body, `id: 1\r\ndata: ${JSON.stringify({data})}\r\n\r\nevent: end\r\ndata: \r\n\r\n`); 64 | nbSse -= 1; 65 | if (0 === nbSse) { 66 | t.end(); 67 | fastify.close(); 68 | } 69 | }); 70 | }); 71 | }); 72 | --------------------------------------------------------------------------------