├── .gitignore ├── .mocharc.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── index.ts ├── package.json ├── test └── ssestream_test.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | yarn-error.log 3 | yarn.lock 4 | .idea/ 5 | dist/ 6 | -------------------------------------------------------------------------------- /.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": ["ts-node/register", "source-map-support/register"], 3 | "extension": ["ts", "ts"] 4 | } 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGE LOG 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/). 6 | 7 | ---- 8 | ## [Unreleased] 9 | 10 | ### Added 11 | N/A 12 | 13 | ### Changed 14 | N/A 15 | 16 | ### Deprecated 17 | N/A 18 | 19 | ### Removed 20 | N/A 21 | 22 | ### Fixed 23 | N/A 24 | 25 | ## [1.1.0] - 2020-03-17 26 | 27 | ### Added 28 | 29 | * Add `SseStream#writeMessage` as a typesafe alias to `SseStream#write` 30 | 31 | ### Changed 32 | 33 | * Ported code to TypeScript 34 | 35 | ## [1.0.1] - 2017-12-28 36 | 37 | ### Fixed 38 | * The `retry` field is set properly 39 | 40 | ## [1.0.0] - 2017-11-22 41 | 42 | ### Added 43 | 44 | * First stable release! 45 | 46 | 47 | [Unreleased]: https://github.com/EventSource/node-ssestream/compare/v1.1.0...master 48 | [1.1.0]: https://github.com/EventSource/node-ssestream/compare/v1.0.1...v1.1.0 49 | [1.0.1]: https://github.com/EventSource/node-ssestream/compare/v1.0.0...v1.0.1 50 | [1.0.0]: https://github.com/EventSource/node-ssestream/tree/v1.0.0 51 | 52 | 53 | [aslakhellesoy]: https://github.com/aslakhellesoy 54 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Making changes 4 | 5 | * Use TDD 6 | * Update `CHANGELOG.md` when you make a significant change 7 | 8 | ## Release process 9 | 10 | Update links in `CHANGELOG.md` and commit. Then: 11 | 12 | npm version NEW_VERSION 13 | npm publish --access public 14 | git push && git push --tags 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) EventSource GitHub organisation 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SseStream 2 | 3 | A zero-dependency node stream for writing [Server-Sent Events](https://html.spec.whatwg.org/multipage/server-sent-events.html). 4 | 5 | ## Installation 6 | 7 | ``` 8 | npm install ssestream 9 | ``` 10 | 11 | Or: 12 | 13 | ``` 14 | yarn add ssestream 15 | ``` 16 | 17 | ## Usage 18 | 19 | In a `(req, res)` handler for a [`request`](https://nodejs.org/api/http.html#http_event_request) event, Express [#get](https://expressjs.com/en/4x/api.html#app.get.method) route or similar: 20 | 21 | ```javascript 22 | const SseStream = require('ssestream') 23 | 24 | function (req, res) { 25 | const sse = new SseStream(req) 26 | sse.pipe(res) 27 | 28 | const message = { 29 | data: 'hello\nworld', 30 | } 31 | sse.write(message) 32 | } 33 | ``` 34 | 35 | Properties on `message`: 36 | 37 | * `data` - String or object, which gets turned into JSON 38 | * `event` - (optional) String 39 | * `id` - (optional) String 40 | * `retry` - (optional) number 41 | * `comment` - (optional) String 42 | 43 | ## TypeScript 44 | 45 | The `SseStream#writeMessage(message)` method is a type-safe alias for `SseStream#write(message)`. 46 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import { Transform } from 'stream' 2 | import { IncomingMessage, OutgoingHttpHeaders } from "http" 3 | 4 | function dataString(data: string|object): string { 5 | if (typeof data === 'object') return dataString(JSON.stringify(data)) 6 | return data.split(/\r\n|\r|\n/).map(line => `data: ${line}\n`).join('') 7 | } 8 | 9 | interface Message { 10 | data: string|object 11 | comment?: string, 12 | event?: string, 13 | id?: string, 14 | retry?: number, 15 | } 16 | 17 | interface WriteHeaders { 18 | writeHead?(statusCode: number, headers?: OutgoingHttpHeaders): WriteHeaders 19 | flushHeaders?(): void 20 | } 21 | 22 | export type HeaderStream = NodeJS.WritableStream & WriteHeaders 23 | 24 | /** 25 | * Transforms "messages" to W3C event stream content. 26 | * See https://html.spec.whatwg.org/multipage/server-sent-events.html 27 | * A message is an object with one or more of the following properties: 28 | * - data (String or object, which gets turned into JSON) 29 | * - event 30 | * - id 31 | * - retry 32 | * - comment 33 | * 34 | * If constructed with a HTTP Request, it will optimise the socket for streaming. 35 | * If this stream is piped to an HTTP Response, it will set appropriate headers. 36 | */ 37 | export default class SseStream extends Transform { 38 | constructor(req?: IncomingMessage) { 39 | super({ objectMode: true }) 40 | if (req) { 41 | req.socket.setKeepAlive(true) 42 | req.socket.setNoDelay(true) 43 | req.socket.setTimeout(0) 44 | } 45 | } 46 | 47 | pipe(destination: T, options?: { end?: boolean; }): T { 48 | if (destination.writeHead) { 49 | destination.writeHead(200, { 50 | 'Content-Type': 'text/event-stream; charset=utf-8', 51 | 'Transfer-Encoding': 'identity', 52 | 'Cache-Control': 'no-cache', 53 | Connection: 'keep-alive', 54 | }) 55 | destination.flushHeaders() 56 | } 57 | // Some clients (Safari) don't trigger onopen until the first frame is received. 58 | destination.write(':ok\n\n') 59 | return super.pipe(destination, options) 60 | } 61 | 62 | _transform(message: Message, encoding: string, callback: (error?: (Error | null), data?: any) => void) { 63 | if (message.comment) this.push(`: ${message.comment}\n`) 64 | if (message.event) this.push(`event: ${message.event}\n`) 65 | if (message.id) this.push(`id: ${message.id}\n`) 66 | if (message.retry) this.push(`retry: ${message.retry}\n`) 67 | if (message.data) this.push(dataString(message.data)) 68 | this.push('\n') 69 | callback() 70 | } 71 | 72 | writeMessage(message: Message, encoding?: string, cb?: (error: Error | null | undefined) => void): boolean { 73 | return this.write(message, encoding, cb) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ssestream", 3 | "version": "1.1.0", 4 | "description": "Send Server-Sent Events with a stream", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "repository": "git://github.com/EventSource/node-ssestream.git", 8 | "author": "Aslak Hellesøy", 9 | "license": "MIT", 10 | "devDependencies": { 11 | "@types/eventsource": "^1.1.2", 12 | "@types/mocha": "^7.0.2", 13 | "@types/node": "^13.9.1", 14 | "eventsource": "^1.0.5", 15 | "mocha": "^7.1.0", 16 | "ts-node": "^8.6.2", 17 | "typescript": "^3.8.3" 18 | }, 19 | "scripts": { 20 | "test": "mocha", 21 | "build": "tsc", 22 | "prepublishOnly": "npm run build" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /test/ssestream_test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import { Writable } from 'stream' 3 | import http, { OutgoingHttpHeaders } from 'http' 4 | import EventSource from 'eventsource' 5 | import SseStream, { HeaderStream } from '../index' 6 | import { AddressInfo } from "net" 7 | 8 | const written = (stream: Writable) => new Promise((resolve, reject) => stream.on('error', reject).on('finish', resolve)) 9 | 10 | class Sink extends Writable implements HeaderStream { 11 | private readonly chunks: string[] = [] 12 | 13 | constructor(public readonly writeHead?: (statusCode: number, headers?: OutgoingHttpHeaders) => Sink) { 14 | super({ objectMode: true }) 15 | } 16 | 17 | _write(chunk: any, encoding: string, callback: (error?: Error | null) => void): void { 18 | this.chunks.push(chunk) 19 | callback() 20 | } 21 | 22 | get content() { 23 | return this.chunks.join('') 24 | } 25 | } 26 | 27 | describe('SseStream', () => { 28 | it('writes multiple multiline messages', async () => { 29 | const sse = new SseStream() 30 | const sink = new Sink() 31 | sse.pipe(sink) 32 | sse.writeMessage({ 33 | data: 'hello\nworld', 34 | }) 35 | sse.write({ 36 | data: 'bonjour\nmonde', 37 | }) 38 | sse.end() 39 | await written(sink) 40 | assert.equal( 41 | sink.content, 42 | `:ok 43 | 44 | data: hello 45 | data: world 46 | 47 | data: bonjour 48 | data: monde 49 | 50 | ` 51 | ) 52 | }) 53 | 54 | it('writes object messages as JSON', async () => { 55 | const sse = new SseStream() 56 | const sink = new Sink() 57 | sse.pipe(sink) 58 | sse.writeMessage({ 59 | data: { hello: 'world' }, 60 | }) 61 | sse.end() 62 | await written(sink) 63 | assert.equal(sink.content, ':ok\n\ndata: {"hello":"world"}\n\n') 64 | }) 65 | 66 | it('writes all message attributes', async () => { 67 | const sse = new SseStream() 68 | const sink = new Sink() 69 | sse.pipe(sink) 70 | sse.writeMessage({ 71 | comment: 'jibber jabber', 72 | event: 'tea-time', 73 | id: 'the-id', 74 | retry: 222, 75 | data: 'hello', 76 | }) 77 | sse.end() 78 | await written(sink) 79 | assert.equal( 80 | sink.content, 81 | `:ok 82 | 83 | : jibber jabber 84 | event: tea-time 85 | id: the-id 86 | retry: 222 87 | data: hello 88 | 89 | ` 90 | ) 91 | }) 92 | 93 | it('sets headers on destination when it looks like a HTTP Response', callback => { 94 | const sse = new SseStream() 95 | let sink: Sink 96 | sink = new Sink((status: number, headers: OutgoingHttpHeaders) => { 97 | assert.deepEqual(headers, { 98 | 'Content-Type': 'text/event-stream; charset=utf-8', 99 | 'Transfer-Encoding': 'identity', 100 | 'Cache-Control': 'no-cache', 101 | 'Connection': 'keep-alive', 102 | }) 103 | callback() 104 | return sink 105 | }) 106 | sse.pipe(sink) 107 | }) 108 | 109 | it('allows an eventsource to connect', callback => { 110 | let sse: SseStream 111 | const server = http.createServer((req, res) => { 112 | sse = new SseStream(req) 113 | sse.pipe(res) 114 | }) 115 | server.listen(() => { 116 | const es = new EventSource(`http://localhost:${(server.address() as AddressInfo).port}`) 117 | es.onmessage = e => { 118 | assert.equal(e.data, 'hello') 119 | es.close() 120 | server.close(callback) 121 | } 122 | es.onopen = () => sse.writeMessage({data: 'hello'}) 123 | es.onerror = e => 124 | callback(new Error(`Error from EventSource: ${JSON.stringify(e)}`)) 125 | }) 126 | }) 127 | }) 128 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "target": "es2017", 5 | "sourceMap": true, 6 | "allowJs": false, 7 | "module": "commonjs", 8 | "esModuleInterop": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "outDir": "dist", 12 | "skipLibCheck": true 13 | }, 14 | "include": [ 15 | "src", 16 | "test" 17 | ] 18 | } 19 | --------------------------------------------------------------------------------