├── .gitignore ├── .flowconfig ├── .babelrc ├── jest.config.js ├── babel.config.js ├── package.json ├── README.md └── src ├── EventSource.js └── __tests__ └── EventSource-test.js /.gitignore: -------------------------------------------------------------------------------- 1 | lib/ 2 | node_modules/ -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | 3 | [include] 4 | 5 | [libs] 6 | 7 | [lints] 8 | 9 | [options] 10 | 11 | [strict] 12 | 13 | [untyped] 14 | .*/node_modules/**/.* 15 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-flow"], 3 | "plugins": [ 4 | "transform-flow-strip-types", 5 | "@babel/plugin-proposal-class-properties" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | 'testRegex': 'src/__tests__/.*-test\\.js$', 5 | 'testPathIgnorePatterns': [ 6 | '/node_modules/', 7 | ], 8 | 'testEnvironment': 'node', 9 | }; 10 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | // babel.config.js 2 | module.exports = { 3 | presets: [ 4 | [ 5 | '@babel/preset-env', 6 | { 7 | targets: { 8 | node: 'current', 9 | }, 10 | }, 11 | ], 12 | ], 13 | }; 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rn-eventsource", 3 | "version": "1.0.0", 4 | "description": "An EventSource implementation built on top of React Native's low-level Networking API", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/adamchel/rn-eventsource.git" 8 | }, 9 | "main": "lib/EventSource.js", 10 | "scripts": { 11 | "build": "babel src/ -d lib/ --ignore '**/__tests__'", 12 | "prepare": "npm run build", 13 | "test": "jest src", 14 | "flow": "flow" 15 | }, 16 | "author": "Adam Chelminski", 17 | "license": "MIT", 18 | "peerDependencies": { 19 | "react-native": "^0.62.0-rc.1" 20 | }, 21 | "devDependencies": { 22 | "@babel/cli": "^7.8.3", 23 | "@babel/core": "^7.8.3", 24 | "@babel/plugin-proposal-class-properties": "^7.8.3", 25 | "@babel/preset-env": "^7.8.3", 26 | "@babel/preset-flow": "^7.8.3", 27 | "babel-jest": "^25.1.0", 28 | "babel-plugin-transform-flow-strip-types": "^6.22.0", 29 | "flow-bin": "^0.117.0", 30 | "jest": "^25.1.0", 31 | "react-native": "^0.62.0-rc.1" 32 | }, 33 | "dependencies": { 34 | "event-target-shim": "^5.0.1" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rn-eventsource 2 | 3 | This package that implements the [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) web standard using low-level React Native networking primitives. 4 | 5 | There are several `EventSource` polyfills today, but none of them satisfy the following three goals: 6 | 7 | * Don't depend on the Node.js standard library 8 | - The Node.js standard library isn't supported by React Native. 9 | * Don't depend on a native module 10 | - This makes it harder to work with simple Expo-based apps. 11 | * Don't implement with XmlHttpRequest 12 | - Existing polyfills that use XmlHttpRequest are not optimal for streaming sources because they cache the entire stream of data until the request is over. 13 | 14 | Thanks to the low-level network primitives exposed in React Native 0.62, it became possible to build this native `EventSource` implementation for React Native. See [this thread in react-native-community](https://github.com/react-native-community/discussions-and-proposals/issues/99#issue-404506330) for a longer discussion around the motivations of this implementation. 15 | 16 | ## Usage 17 | 18 | Install the package in your React Native project with: 19 | 20 | ```bash 21 | npm install --save rn-eventsource 22 | ``` 23 | 24 | To import the library in your project: 25 | ```js 26 | const EventSource = require('rn-eventsource'); 27 | ``` 28 | 29 | Once imported, you can use it like any other `EventSource`. See the [MDN Documentation](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) for more usage examples. 30 | ```js 31 | const ticker = new EventSource('https://www.example.com/stream?token=blah'); 32 | 33 | ticker.onmessage = (message) => { 34 | console.log(message.data) 35 | } 36 | ``` 37 | -------------------------------------------------------------------------------- /src/EventSource.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2020 Adam Chelminski 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy 5 | * of this software and associated documentation files (the "Software"), to 6 | * deal in the Software without restriction, including without limitation the 7 | * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 8 | * sell copies of the Software, and to permit persons to whom the Software is 9 | * furnished to do so, subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in 12 | * all copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 19 | * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 20 | * IN THE SOFTWARE. 21 | * 22 | * @format 23 | * @flow 24 | */ 25 | 26 | 'use strict'; 27 | 28 | import EventTarget from 'event-target-shim'; 29 | import { Networking } from 'react-native'; 30 | 31 | const EVENT_SOURCE_EVENTS = ['error', 'message', 'open']; 32 | 33 | // char codes 34 | const bom = [239, 187, 191]; // byte order mark 35 | const lf = 10; 36 | const cr = 13; 37 | 38 | const maxRetryAttempts = 5; 39 | /** 40 | * An RCTNetworking-based implementation of the EventSource web standard. 41 | * 42 | * See https://developer.mozilla.org/en-US/docs/Web/API/EventSource 43 | * https://html.spec.whatwg.org/multipage/server-sent-events.html 44 | * https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events 45 | */ 46 | class EventSource extends (EventTarget(...EVENT_SOURCE_EVENTS): any) { 47 | static CONNECTING: number = 0; 48 | static OPEN: number = 1; 49 | static CLOSED: number = 2; 50 | 51 | // Properties 52 | readyState: number = EventSource.CONNECTING; 53 | url: string; 54 | withCredentials: boolean = false; 55 | 56 | // Event handlers 57 | onerror: ?Function; 58 | onmessage: ?Function; 59 | onopen: ?Function; 60 | 61 | // Buffers for event stream parsing 62 | _isFirstChunk = false; 63 | _discardNextLineFeed = false; 64 | _lineBuf: string = ''; 65 | _dataBuf: string = ''; 66 | _eventTypeBuf: string = ''; 67 | _lastEventIdBuf: string = ''; 68 | 69 | _headers: {[key: string]: any} = {}; 70 | _lastEventId: string = ''; 71 | _reconnectIntervalMs: number = 1000; 72 | _requestId: ?number; 73 | _subscriptions: Array<*>; 74 | _trackingName: string = 'unknown'; 75 | _retryAttempts: number = 0; 76 | 77 | /** 78 | * Custom extension for tracking origins of request. 79 | */ 80 | setTrackingName(trackingName: string): EventSource { 81 | this._trackingName = trackingName; 82 | return this; 83 | } 84 | 85 | /** 86 | * Creates a new EventSource 87 | * @param {string} url the URL at which to open a stream 88 | * @param {?Object} eventSourceInitDict extra configuration parameters 89 | */ 90 | constructor(url: string, eventSourceInitDict: ?Object) { 91 | super(); 92 | 93 | if (!url) { 94 | throw new Error('Cannot open an SSE stream on an empty url'); 95 | } 96 | this.url = url; 97 | 98 | this._headers['Cache-Control'] = 'no-store'; 99 | this._headers.Accept = 'text/event-stream'; 100 | if (this._lastEventId) { 101 | this._headers['Last-Event-ID'] = this._lastEventId; 102 | } 103 | 104 | if (eventSourceInitDict) { 105 | if (eventSourceInitDict.headers) { 106 | if (eventSourceInitDict.headers['Last-Event-ID']) { 107 | this._lastEventId = eventSourceInitDict.headers['Last-Event-ID']; 108 | delete eventSourceInitDict.headers['Last-Event-ID']; 109 | } 110 | 111 | for (var headerKey in eventSourceInitDict.headers) { 112 | const header = eventSourceInitDict.headers[headerKey]; 113 | if (header) { 114 | this._headers[headerKey] = header; 115 | } 116 | } 117 | } 118 | 119 | if (eventSourceInitDict.withCredentials) { 120 | this.withCredentials = eventSourceInitDict.withCredentials; 121 | } 122 | } 123 | 124 | this._subscriptions = []; 125 | this._subscriptions.push( 126 | Networking.addListener('didReceiveNetworkResponse', args => 127 | this.__didReceiveResponse(...args), 128 | ), 129 | ); 130 | this._subscriptions.push( 131 | Networking.addListener('didReceiveNetworkIncrementalData', args => 132 | this.__didReceiveIncrementalData(...args), 133 | ), 134 | ); 135 | this._subscriptions.push( 136 | Networking.addListener('didCompleteNetworkResponse', args => 137 | this.__didCompleteResponse(...args), 138 | ), 139 | ); 140 | 141 | this.__connnect(); 142 | } 143 | 144 | close(): void { 145 | if (this._requestId !== null && this._requestId !== undefined) { 146 | Networking.abortRequest(this._requestId); 147 | } 148 | 149 | // clean up Networking subscriptions 150 | (this._subscriptions || []).forEach(sub => { 151 | if (sub) { 152 | sub.remove(); 153 | } 154 | }); 155 | this._subscriptions = []; 156 | 157 | this.readyState = EventSource.CLOSED; 158 | } 159 | 160 | __connnect(): void { 161 | if (this.readyState === EventSource.CLOSED) { 162 | // don't attempt to reestablish connection when the source is closed 163 | return; 164 | } 165 | 166 | if (this._lastEventId) { 167 | this._headers['Last-Event-ID'] = this._lastEventId; 168 | } 169 | 170 | Networking.sendRequest( 171 | 'GET', // EventSource always GETs the resource 172 | this._trackingName, 173 | this.url, 174 | this._headers, 175 | '', // body for EventSource request is always empty 176 | 'text', // SSE is a text protocol 177 | true, // we want incremental events 178 | 0, // there is no timeout defined in the WHATWG spec for EventSource 179 | this.__didCreateRequest.bind(this), 180 | this.withCredentials, 181 | ); 182 | } 183 | 184 | __reconnect(reason: string): void { 185 | this.readyState = EventSource.CONNECTING; 186 | 187 | let errorEventMessage = 'reestablishing connection'; 188 | if (reason) { 189 | errorEventMessage += ': ' + reason; 190 | } 191 | 192 | this.dispatchEvent({type: 'error', data: errorEventMessage}); 193 | if (this._reconnectIntervalMs > 0) { 194 | setTimeout(this.__connnect.bind(this), this._reconnectIntervalMs); 195 | } else { 196 | this.__connnect(); 197 | } 198 | } 199 | 200 | // Internal buffer processing methods 201 | 202 | __processEventStreamChunk(chunk: string): void { 203 | if (this._isFirstChunk) { 204 | if ( 205 | bom.every((charCode, idx) => { 206 | return this._lineBuf.charCodeAt(idx) === charCode; 207 | }) 208 | ) { 209 | // Strip byte order mark from chunk 210 | chunk = chunk.slice(bom.length); 211 | } 212 | this._isFirstChunk = false; 213 | } 214 | 215 | let pos: number = 0; 216 | while (pos < chunk.length) { 217 | if (this._discardNextLineFeed) { 218 | if (chunk.charCodeAt(pos) === lf) { 219 | // Ignore this LF since it was preceded by a CR 220 | ++pos; 221 | } 222 | this._discardNextLineFeed = false; 223 | } 224 | 225 | const curCharCode = chunk.charCodeAt(pos); 226 | if (curCharCode === cr || curCharCode === lf) { 227 | this.__processEventStreamLine(); 228 | 229 | // Treat CRLF properly 230 | if (curCharCode === cr) { 231 | this._discardNextLineFeed = true; 232 | } 233 | } else { 234 | this._lineBuf += chunk.charAt(pos); 235 | } 236 | 237 | ++pos; 238 | } 239 | } 240 | 241 | __processEventStreamLine(): void { 242 | const line = this._lineBuf; 243 | 244 | // clear the line buffer 245 | this._lineBuf = ''; 246 | 247 | // Dispatch the buffered event if this is an empty line 248 | if (line === '') { 249 | this.__dispatchBufferedEvent(); 250 | return; 251 | } 252 | 253 | const colonPos = line.indexOf(':'); 254 | 255 | let field: string; 256 | let value: string; 257 | 258 | if (colonPos === 0) { 259 | // this is a comment line and should be ignored 260 | return; 261 | } else if (colonPos > 0) { 262 | if (line[colonPos + 1] === ' ') { 263 | field = line.slice(0, colonPos); 264 | value = line.slice(colonPos + 2); // ignores the first space from the value 265 | } else { 266 | field = line.slice(0, colonPos); 267 | value = line.slice(colonPos + 1); 268 | } 269 | } else { 270 | field = line; 271 | value = ''; 272 | } 273 | 274 | switch (field) { 275 | case 'event': 276 | // Set the type of this event 277 | this._eventTypeBuf = value; 278 | break; 279 | case 'data': 280 | // Append the line to the data buffer along with an LF (U+000A) 281 | this._dataBuf += value; 282 | this._dataBuf += String.fromCodePoint(lf); 283 | break; 284 | case 'id': 285 | // Update the last seen event id 286 | this._lastEventIdBuf = value; 287 | break; 288 | case 'retry': 289 | // Set a new reconnect interval value 290 | const newRetryMs = parseInt(value, 10); 291 | if (!isNaN(newRetryMs)) { 292 | this._reconnectIntervalMs = newRetryMs; 293 | } 294 | break; 295 | default: 296 | // this is an unrecognized field, so this line should be ignored 297 | } 298 | } 299 | 300 | __dispatchBufferedEvent() { 301 | this._lastEventId = this._lastEventIdBuf; 302 | 303 | // If the data buffer is an empty string, set the event type buffer to 304 | // empty string and return 305 | if (this._dataBuf === '') { 306 | this._eventTypeBuf = ''; 307 | return; 308 | } 309 | 310 | // Dispatch the event 311 | const eventType = this._eventTypeBuf || 'message'; 312 | this.dispatchEvent({ 313 | type: eventType, 314 | data: this._dataBuf.slice(0, -1), // remove the trailing LF from the data 315 | origin: this.url, 316 | lastEventId: this._lastEventId, 317 | }); 318 | 319 | // Reset the data and event type buffers 320 | this._dataBuf = ''; 321 | this._eventTypeBuf = ''; 322 | } 323 | 324 | // Networking callbacks, exposed for testing 325 | 326 | __didCreateRequest(requestId: number): void { 327 | this._requestId = requestId; 328 | } 329 | 330 | __didReceiveResponse( 331 | requestId: number, 332 | status: number, 333 | responseHeaders: ?Object, 334 | responseURL: ?string, 335 | ): void { 336 | if (requestId !== this._requestId) { 337 | return; 338 | } 339 | 340 | if (responseHeaders) { 341 | // make the header names case insensitive 342 | for (const entry of Object.entries(responseHeaders)) { 343 | const [key, value] = entry; 344 | delete responseHeaders[key]; 345 | responseHeaders[key.toLowerCase()] = value; 346 | } 347 | } 348 | 349 | // Handle redirects 350 | if (status === 301 || status === 307) { 351 | if (responseHeaders && responseHeaders.location) { 352 | // set the new URL, set the requestId to null so that request 353 | // completion doesn't attempt a reconnect, and immediately attempt 354 | // reconnecting 355 | this.url = responseHeaders.location; 356 | this._requestId = null; 357 | this.__connnect(); 358 | return; 359 | } else { 360 | this.dispatchEvent({ 361 | type: 'error', 362 | data: 'got redirect with no location', 363 | }); 364 | return this.close(); 365 | } 366 | } 367 | 368 | if (status !== 200) { 369 | this.dispatchEvent({ 370 | type: 'error', 371 | data: 'unexpected HTTP status ' + status, 372 | }); 373 | return this.close(); 374 | } 375 | 376 | if ( 377 | responseHeaders && 378 | responseHeaders['content-type'] !== 'text/event-stream' 379 | ) { 380 | this.dispatchEvent({ 381 | type: 'error', 382 | data: 383 | 'unsupported MIME type in response: ' + 384 | responseHeaders['content-type'], 385 | }); 386 | return this.close(); 387 | } else if (!responseHeaders) { 388 | this.dispatchEvent({ 389 | type: 'error', 390 | data: 'no MIME type in response', 391 | }); 392 | return this.close(); 393 | } 394 | 395 | // reset the connection retry attempt counter 396 | this._retryAttempts = 0; 397 | 398 | // reset the stream processing buffers 399 | this._isFirstChunk = false; 400 | this._discardNextLineFeed = false; 401 | this._lineBuf = ''; 402 | this._dataBuf = ''; 403 | this._eventTypeBuf = ''; 404 | this._lastEventIdBuf = ''; 405 | 406 | this.readyState = EventSource.OPEN; 407 | this.dispatchEvent({type: 'open'}); 408 | } 409 | 410 | __didReceiveIncrementalData( 411 | requestId: number, 412 | responseText: string, 413 | progress: number, 414 | total: number, 415 | ) { 416 | if (requestId !== this._requestId) { 417 | return; 418 | } 419 | 420 | this.__processEventStreamChunk(responseText); 421 | } 422 | 423 | __didCompleteResponse( 424 | requestId: number, 425 | error: string, 426 | timeOutError: boolean, 427 | ): void { 428 | if (requestId !== this._requestId) { 429 | return; 430 | } 431 | 432 | // The spec states: 'Network errors that prevents the connection from being 433 | // established in the first place (e.g. DNS errors), should cause the user 434 | // agent to reestablish the connection in parallel, unless the user agent 435 | // knows that to be futile, in which case the user agent may fail the 436 | // connection.' 437 | // 438 | // We are treating 5 unnsuccessful retry attempts as a sign that attempting 439 | // to reconnect is 'futile'. Future improvements could also add exponential 440 | // backoff. 441 | if (this._retryAttempts < maxRetryAttempts) { 442 | // pass along the error message so that the user sees it as part of the 443 | // error event fired for re-establishing the connection 444 | this._retryAttempts += 1; 445 | this.__reconnect(error); 446 | } else { 447 | this.dispatchEvent({ 448 | type: 'error', 449 | data: 'could not reconnect after ' + maxRetryAttempts + ' attempts', 450 | }); 451 | this.close(); 452 | } 453 | } 454 | } 455 | 456 | module.exports = EventSource; 457 | -------------------------------------------------------------------------------- /src/__tests__/EventSource-test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2020 Adam Chelminski 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy 5 | * of this software and associated documentation files (the "Software"), to 6 | * deal in the Software without restriction, including without limitation the 7 | * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 8 | * sell copies of the Software, and to permit persons to whom the Software is 9 | * furnished to do so, subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in 12 | * all copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 19 | * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 20 | * IN THE SOFTWARE. 21 | * 22 | * @format 23 | */ 24 | 25 | 'use strict'; 26 | 27 | let requestId = 1; 28 | 29 | function setRequestId(id) { 30 | requestId = id; 31 | } 32 | 33 | let capturedOptions; 34 | jest 35 | .setMock('react-native', { 36 | Networking: { 37 | addListener: function() {}, 38 | removeListeners: function() {}, 39 | sendRequest( 40 | method, 41 | _, 42 | url, 43 | headers, 44 | __, 45 | responseType, 46 | incrementalUpdates, 47 | timeout, 48 | callback, 49 | withCredentials 50 | ) { 51 | capturedOptions = { 52 | method, 53 | url, 54 | headers, 55 | responseType, 56 | incrementalUpdates, 57 | timeout, 58 | withCredentials 59 | } 60 | if (typeof callback === 'function') { 61 | // android does not pass a callback 62 | callback(requestId); 63 | } 64 | }, 65 | abortRequest: function() {}, 66 | }, 67 | PlatformConstants: { 68 | getConstants() { 69 | return {}; 70 | }, 71 | }, 72 | }); 73 | 74 | const EventSource = jest.requireActual('../EventSource'); 75 | const EventTarget = jest.requireActual('event-target-shim'); 76 | 77 | describe('EventSource', function() { 78 | let eventSource; 79 | let handleOpen; 80 | let handleMessage; 81 | let handleError; 82 | 83 | let requestIdCounter = 1; 84 | 85 | const testUrl = 'https://www.example.com/sse'; 86 | 87 | function setupListeners() { 88 | eventSource.onopen = jest.fn(); 89 | eventSource.onmessage = jest.fn(); 90 | eventSource.onerror = jest.fn(); 91 | 92 | handleOpen = jest.fn(); 93 | handleMessage = jest.fn(); 94 | handleError = jest.fn(); 95 | 96 | eventSource.addEventListener('open', handleOpen); 97 | eventSource.addEventListener('message', handleMessage); 98 | eventSource.addEventListener('error', handleError); 99 | } 100 | 101 | function incrementRequestId() { 102 | ++requestIdCounter; 103 | setRequestId(requestIdCounter); 104 | } 105 | 106 | afterEach(() => { 107 | incrementRequestId(); 108 | 109 | if (eventSource) { 110 | eventSource.close(); // will not error if called twice 111 | } 112 | 113 | eventSource = null; 114 | handleOpen = null; 115 | handleMessage = null; 116 | handleError = null; 117 | }); 118 | 119 | it('should pass along the correct request parameters', function() { 120 | eventSource = new EventSource(testUrl); 121 | 122 | expect(capturedOptions.method).toBe('GET'); 123 | expect(capturedOptions.url).toBe(testUrl); 124 | expect(capturedOptions.headers.Accept).toBe('text/event-stream'); 125 | expect(capturedOptions.headers['Cache-Control']).toBe('no-store'); 126 | expect(capturedOptions.responseType).toBe('text'); 127 | expect(capturedOptions.incrementalUpdates).toBe(true); 128 | expect(capturedOptions.timeout).toBe(0); 129 | expect(capturedOptions.withCredentials).toBe(false); 130 | }); 131 | 132 | it('should transition readyState correctly for successful requests', function() { 133 | eventSource = new EventSource(testUrl); 134 | setupListeners(); 135 | 136 | expect(eventSource.readyState).toBe(EventSource.CONNECTING); 137 | 138 | eventSource.__didReceiveResponse( 139 | requestId, 140 | 200, 141 | {'content-type': 'text/event-stream'}, 142 | testUrl, 143 | ); 144 | expect(eventSource.readyState).toBe(EventSource.OPEN); 145 | 146 | eventSource.close(); 147 | expect(eventSource.readyState).toBe(EventSource.CLOSED); 148 | }); 149 | 150 | it('should call onerror function when server responds with an HTTP error', function() { 151 | eventSource = new EventSource(testUrl); 152 | setupListeners(); 153 | 154 | expect(eventSource.readyState).toBe(EventSource.CONNECTING); 155 | 156 | eventSource.__didReceiveResponse( 157 | requestId, 158 | 404, 159 | {'content-type': 'text/plain'}, 160 | testUrl, 161 | ); 162 | 163 | expect(eventSource.onerror.mock.calls.length).toBe(1); 164 | expect(handleError.mock.calls.length).toBe(1); 165 | expect(eventSource.readyState).toBe(EventSource.CLOSED); 166 | }); 167 | 168 | it('should call onerror on non event-stream responses', function() { 169 | eventSource = new EventSource(testUrl); 170 | setupListeners(); 171 | 172 | expect(eventSource.readyState).toBe(EventSource.CONNECTING); 173 | 174 | eventSource.__didReceiveResponse( 175 | requestId, 176 | 200, 177 | {'content-type': 'text/plain'}, 178 | testUrl, 179 | ); 180 | 181 | expect(eventSource.onerror.mock.calls.length).toBe(1); 182 | expect(handleError.mock.calls.length).toBe(1); 183 | expect(eventSource.readyState).toBe(EventSource.CLOSED); 184 | }); 185 | 186 | it('should call onerror function when request times out', function() { 187 | eventSource = new EventSource(testUrl); 188 | setupListeners(); 189 | 190 | expect(eventSource.readyState).toBe(EventSource.CONNECTING); 191 | 192 | eventSource.__didCompleteResponse(requestId, 'request timed out', true); 193 | 194 | expect(eventSource.onerror.mock.calls.length).toBe(1); 195 | expect(handleError.mock.calls.length).toBe(1); 196 | }); 197 | 198 | it('should call onerror if connection cannot be established', function() { 199 | eventSource = new EventSource(testUrl); 200 | setupListeners(); 201 | 202 | eventSource.__didCompleteResponse(requestId, 'no internet', false); 203 | 204 | expect(eventSource.onerror.mock.calls.length).toBe(1); 205 | expect(handleError.mock.calls.length).toBe(1); 206 | }); 207 | 208 | it('should call onopen function when stream is opened', function() { 209 | eventSource = new EventSource(testUrl); 210 | setupListeners(); 211 | 212 | eventSource.__didReceiveResponse( 213 | requestId, 214 | 200, 215 | {'content-type': 'text/event-stream'}, 216 | testUrl, 217 | ); 218 | expect(eventSource.onopen.mock.calls.length).toBe(1); 219 | expect(handleOpen.mock.calls.length).toBe(1); 220 | }); 221 | 222 | it('should follow HTTP redirects', function() { 223 | eventSource = new EventSource(testUrl); 224 | setupListeners(); 225 | 226 | expect(eventSource.readyState).toBe(EventSource.CONNECTING); 227 | 228 | const redirectUrl = 'https://www.example.com/new_sse'; 229 | eventSource.__didReceiveResponse( 230 | requestId, 231 | 301, 232 | {location: redirectUrl}, 233 | testUrl, 234 | ); 235 | 236 | eventSource.__didCompleteResponse(requestId, null, false); 237 | 238 | // state should be still connecting, but another request 239 | // should have been sent with the new redirect URL 240 | expect(eventSource.readyState).toBe(EventSource.CONNECTING); 241 | expect(eventSource.onopen.mock.calls.length).toBe(0); 242 | expect(handleOpen.mock.calls.length).toBe(0); 243 | 244 | expect(capturedOptions.url).toBe(redirectUrl); 245 | 246 | eventSource.__didReceiveResponse( 247 | requestId, 248 | 200, 249 | {'content-type': 'text/event-stream'}, 250 | redirectUrl, 251 | ); 252 | 253 | // the stream should now have opened 254 | expect(eventSource.readyState).toBe(EventSource.OPEN); 255 | expect(eventSource.onopen.mock.calls.length).toBe(1); 256 | expect(handleOpen.mock.calls.length).toBe(1); 257 | 258 | eventSource.close(); 259 | expect(eventSource.readyState).toBe(EventSource.CLOSED); 260 | }); 261 | 262 | it('should call onmessage when receiving an unnamed event', function() { 263 | eventSource = new EventSource(testUrl); 264 | setupListeners(); 265 | 266 | eventSource.__didReceiveResponse( 267 | requestId, 268 | 200, 269 | {'content-type': 'text/event-stream'}, 270 | testUrl, 271 | ); 272 | 273 | eventSource.__didReceiveIncrementalData( 274 | requestId, 275 | 'data: this is an event\n\n', 276 | 0, 277 | 0, // these parameters are not used by the EventSource 278 | ); 279 | 280 | expect(eventSource.onmessage.mock.calls.length).toBe(1); 281 | expect(handleMessage.mock.calls.length).toBe(1); 282 | 283 | const event = eventSource.onmessage.mock.calls[0][0]; 284 | 285 | expect(event.data).toBe('this is an event'); 286 | }); 287 | 288 | it('should handle events with multiple lines of data', function() { 289 | eventSource = new EventSource(testUrl); 290 | setupListeners(); 291 | 292 | eventSource.__didReceiveResponse( 293 | requestId, 294 | 200, 295 | {'content-type': 'text/event-stream'}, 296 | testUrl, 297 | ); 298 | 299 | eventSource.__didReceiveIncrementalData( 300 | requestId, 301 | 'data: this is an event\n' + 302 | 'data:with multiple lines\n' + // should not strip the 'w' 303 | 'data: but it should come in as one event\n' + 304 | '\n', 305 | 0, 306 | 0, 307 | ); 308 | 309 | expect(eventSource.onmessage.mock.calls.length).toBe(1); 310 | expect(handleMessage.mock.calls.length).toBe(1); 311 | 312 | const event = eventSource.onmessage.mock.calls[0][0]; 313 | 314 | expect(event.data).toBe( 315 | 'this is an event\nwith multiple lines\nbut it should come in as one event', 316 | ); 317 | }); 318 | 319 | it('should call appropriate handler when receiving a named event', function() { 320 | eventSource = new EventSource(testUrl); 321 | setupListeners(); 322 | 323 | const handleCustomEvent = jest.fn(); 324 | eventSource.addEventListener('custom', handleCustomEvent); 325 | 326 | eventSource.__didReceiveResponse( 327 | requestId, 328 | 200, 329 | {'content-type': 'text/event-stream'}, 330 | testUrl, 331 | ); 332 | 333 | eventSource.__didReceiveIncrementalData( 334 | requestId, 335 | 'event: custom\n' + 'data: this is a custom event\n' + '\n', 336 | 0, 337 | 0, 338 | ); 339 | 340 | expect(eventSource.onmessage.mock.calls.length).toBe(0); 341 | expect(handleMessage.mock.calls.length).toBe(0); 342 | 343 | expect(handleCustomEvent.mock.calls.length).toBe(1); 344 | 345 | const event = handleCustomEvent.mock.calls[0][0]; 346 | expect(event.data).toBe('this is a custom event'); 347 | }); 348 | 349 | it('should receive multiple events', function() { 350 | eventSource = new EventSource(testUrl); 351 | setupListeners(); 352 | 353 | const handleCustomEvent = jest.fn(); 354 | eventSource.addEventListener('custom', handleCustomEvent); 355 | 356 | eventSource.__didReceiveResponse( 357 | requestId, 358 | 200, 359 | {'content-type': 'text/event-stream'}, 360 | testUrl, 361 | ); 362 | 363 | eventSource.__didReceiveIncrementalData( 364 | requestId, 365 | 'event: custom\n' + 366 | 'data: this is a custom event\n' + 367 | '\n' + 368 | '\n' + 369 | 'data: this is a normal event\n' + 370 | 'data: with multiple lines\n' + 371 | '\n' + 372 | 'data: this is a normal single-line event\n\n', 373 | 0, 374 | 0, 375 | ); 376 | expect(handleCustomEvent.mock.calls.length).toBe(1); 377 | 378 | expect(eventSource.onmessage.mock.calls.length).toBe(2); 379 | expect(handleMessage.mock.calls.length).toBe(2); 380 | }); 381 | 382 | it('should handle messages sent in separate chunks', function() { 383 | eventSource = new EventSource(testUrl); 384 | setupListeners(); 385 | 386 | eventSource.__didReceiveResponse( 387 | requestId, 388 | 200, 389 | {'content-type': 'text/event-stream'}, 390 | testUrl, 391 | ); 392 | 393 | eventSource.__didReceiveIncrementalData(requestId, 'data: this is ', 0, 0); 394 | 395 | eventSource.__didReceiveIncrementalData( 396 | requestId, 397 | 'a normal event\n', 398 | 0, 399 | 0, 400 | ); 401 | 402 | eventSource.__didReceiveIncrementalData( 403 | requestId, 404 | 'data: sent as separate ', 405 | 0, 406 | 0, 407 | ); 408 | 409 | eventSource.__didReceiveIncrementalData(requestId, 'chunks\n\n', 0, 0); 410 | 411 | expect(eventSource.onmessage.mock.calls.length).toBe(1); 412 | expect(handleMessage.mock.calls.length).toBe(1); 413 | 414 | const event = eventSource.onmessage.mock.calls[0][0]; 415 | 416 | expect(event.data).toBe('this is a normal event\nsent as separate chunks'); 417 | }); 418 | 419 | it('should forward server-sent errors', function() { 420 | eventSource = new EventSource(testUrl); 421 | setupListeners(); 422 | 423 | const handleCustomEvent = jest.fn(); 424 | eventSource.addEventListener('custom', handleCustomEvent); 425 | 426 | eventSource.__didReceiveResponse( 427 | requestId, 428 | 200, 429 | {'content-type': 'text/event-stream'}, 430 | testUrl, 431 | ); 432 | 433 | eventSource.__didReceiveIncrementalData( 434 | requestId, 435 | 'event: error\n' + 'data: the server sent this error\n\n', 436 | 0, 437 | 0, 438 | ); 439 | 440 | expect(eventSource.onerror.mock.calls.length).toBe(1); 441 | expect(handleError.mock.calls.length).toBe(1); 442 | 443 | const event = eventSource.onerror.mock.calls[0][0]; 444 | 445 | expect(event.data).toBe('the server sent this error'); 446 | }); 447 | 448 | it('should ignore comment lines', function() { 449 | eventSource = new EventSource(testUrl); 450 | setupListeners(); 451 | 452 | eventSource.__didReceiveResponse( 453 | requestId, 454 | 200, 455 | {'content-type': 'text/event-stream'}, 456 | testUrl, 457 | ); 458 | 459 | eventSource.__didReceiveIncrementalData( 460 | requestId, 461 | 'data: this is an event\n' + 462 | ": don't mind me\n" + // this line should be ignored 463 | 'data: on two lines\n' + 464 | '\n', 465 | 0, 466 | 0, 467 | ); 468 | 469 | expect(eventSource.onmessage.mock.calls.length).toBe(1); 470 | expect(handleMessage.mock.calls.length).toBe(1); 471 | 472 | const event = eventSource.onmessage.mock.calls[0][0]; 473 | 474 | expect(event.data).toBe('this is an event\non two lines'); 475 | }); 476 | 477 | it('should properly set lastEventId based on server message', function() { 478 | eventSource = new EventSource(testUrl); 479 | setupListeners(); 480 | 481 | eventSource.__didReceiveResponse( 482 | requestId, 483 | 200, 484 | {'content-type': 'text/event-stream'}, 485 | testUrl, 486 | ); 487 | 488 | eventSource.__didReceiveIncrementalData( 489 | requestId, 490 | 'data: this is an event\n' + 'id: with an id\n' + '\n', 491 | 0, 492 | 0, 493 | ); 494 | 495 | expect(eventSource.onmessage.mock.calls.length).toBe(1); 496 | expect(handleMessage.mock.calls.length).toBe(1); 497 | 498 | const event = eventSource.onmessage.mock.calls[0][0]; 499 | 500 | expect(event.data).toBe('this is an event'); 501 | expect(eventSource._lastEventId).toBe('with an id'); 502 | }); 503 | 504 | it('should properly set reconnect interval based on server message', function() { 505 | eventSource = new EventSource(testUrl); 506 | setupListeners(); 507 | 508 | eventSource.__didReceiveResponse( 509 | requestId, 510 | 200, 511 | {'content-type': 'text/event-stream'}, 512 | testUrl, 513 | ); 514 | 515 | eventSource.__didReceiveIncrementalData( 516 | requestId, 517 | 'data: this is an event\n' + 'retry: 5000\n' + '\n', 518 | 0, 519 | 0, 520 | ); 521 | 522 | expect(eventSource.onmessage.mock.calls.length).toBe(1); 523 | expect(handleMessage.mock.calls.length).toBe(1); 524 | 525 | let event = eventSource.onmessage.mock.calls[0][0]; 526 | 527 | expect(event.data).toBe('this is an event'); 528 | expect(eventSource._reconnectIntervalMs).toBe(5000); 529 | 530 | // NaN should not change interval 531 | eventSource.__didReceiveIncrementalData( 532 | requestId, 533 | 'data: this is another event\n' + 'retry: five\n' + '\n', 534 | 0, 535 | 0, 536 | ); 537 | 538 | expect(eventSource.onmessage.mock.calls.length).toBe(2); 539 | expect(handleMessage.mock.calls.length).toBe(2); 540 | 541 | event = eventSource.onmessage.mock.calls[1][0]; 542 | 543 | expect(event.data).toBe('this is another event'); 544 | expect(eventSource._reconnectIntervalMs).toBe(5000); 545 | }); 546 | 547 | it('should handle messages with non-ASCII characters', function() { 548 | eventSource = new EventSource(testUrl); 549 | setupListeners(); 550 | 551 | eventSource.__didReceiveResponse( 552 | requestId, 553 | 200, 554 | {'content-type': 'text/event-stream'}, 555 | testUrl, 556 | ); 557 | 558 | // flow doesn't like emojis: https://github.com/facebook/flow/issues/4219 559 | // so we have to add it programatically 560 | const emoji = String.fromCodePoint(128526); 561 | 562 | eventSource.__didReceiveIncrementalData( 563 | requestId, 564 | `data: ${emoji}\n\n`, 565 | 0, 566 | 0, 567 | ); 568 | 569 | expect(eventSource.onmessage.mock.calls.length).toBe(1); 570 | expect(handleMessage.mock.calls.length).toBe(1); 571 | 572 | const event = eventSource.onmessage.mock.calls[0][0]; 573 | 574 | expect(event.data).toBe(emoji); 575 | }); 576 | 577 | it('should properly pass along withCredentials option', function() { 578 | eventSource = new EventSource(testUrl, {withCredentials: true}); 579 | expect(capturedOptions.withCredentials).toBeTruthy(); 580 | 581 | eventSource = new EventSource(testUrl); 582 | expect(capturedOptions.withCredentials).toBeFalsy(); 583 | }); 584 | 585 | it('should properly pass along extra headers', function() { 586 | eventSource = new EventSource(testUrl, { 587 | headers: {'Custom-Header': 'some value'}, 588 | }); 589 | 590 | // make sure the default headers are passed in 591 | expect(capturedOptions.headers.Accept).toBe('text/event-stream'); 592 | expect(capturedOptions.headers['Cache-Control']).toBe('no-store'); 593 | 594 | // make sure the custom header was passed in; 595 | expect(capturedOptions.headers['Custom-Header']).toBe('some value'); 596 | }); 597 | 598 | it('should properly pass along configured lastEventId', function() { 599 | eventSource = new EventSource(testUrl, { 600 | headers: {'Last-Event-ID': 'my id'}, 601 | }); 602 | 603 | // make sure the default headers are passed in 604 | expect(capturedOptions.headers.Accept).toBe('text/event-stream'); 605 | expect(capturedOptions.headers['Cache-Control']).toBe('no-store'); 606 | expect(capturedOptions.headers['Last-Event-ID']).toBe('my id'); 607 | 608 | // make sure the event id was also set on the event source 609 | expect(eventSource._lastEventId).toBe('my id'); 610 | }); 611 | 612 | it('should reconnect gracefully and properly pass lastEventId', async function() { 613 | eventSource = new EventSource(testUrl); 614 | setupListeners(); 615 | 616 | // override reconnection time interval so this test can run quickly 617 | eventSource._reconnectIntervalMs = 0; 618 | 619 | eventSource.__didReceiveResponse( 620 | requestId, 621 | 200, 622 | {'content-type': 'text/event-stream'}, 623 | testUrl, 624 | ); 625 | 626 | eventSource.__didReceiveIncrementalData( 627 | requestId, 628 | 'data: this is an event\n' + 'id: 42\n\n', 629 | 0, 630 | 0, 631 | ); 632 | 633 | expect(eventSource.readyState).toBe(EventSource.OPEN); 634 | expect(eventSource.onmessage.mock.calls.length).toBe(1); 635 | expect(handleMessage.mock.calls.length).toBe(1); 636 | 637 | let event = eventSource.onmessage.mock.calls[0][0]; 638 | 639 | expect(event.data).toBe('this is an event'); 640 | 641 | const oldRequestId = requestId; 642 | incrementRequestId(); 643 | 644 | eventSource.__didCompleteResponse(oldRequestId, null, false); // connection closed 645 | expect(eventSource.onerror.mock.calls.length).toBe(1); 646 | expect(eventSource.readyState).toBe(EventSource.CONNECTING); 647 | 648 | // lastEventId should have been captured and sent on reconnect 649 | expect(capturedOptions.headers['Last-Event-ID']).toBe('42'); 650 | 651 | eventSource.__didReceiveResponse( 652 | requestId, 653 | 200, 654 | {'content-type': 'text/event-stream'}, 655 | testUrl, 656 | ); 657 | 658 | eventSource.__didReceiveIncrementalData( 659 | requestId, 660 | 'data: this is another event\n\n', 661 | 0, 662 | 0, 663 | ); 664 | 665 | expect(eventSource.onmessage.mock.calls.length).toBe(2); 666 | expect(handleMessage.mock.calls.length).toBe(2); 667 | 668 | event = eventSource.onmessage.mock.calls[1][0]; 669 | 670 | expect(event.data).toBe('this is another event'); 671 | }); 672 | 673 | it('should stop attempting to reconnect after five failed attempts', function() { 674 | eventSource = new EventSource(testUrl); 675 | setupListeners(); 676 | 677 | // override reconnection time interval so this test can run quickly 678 | eventSource._reconnectIntervalMs = 0; 679 | 680 | let oldRequestId = requestId; 681 | incrementRequestId(); 682 | eventSource.__didCompleteResponse(oldRequestId, 'request timed out', true); 683 | expect(eventSource.onerror.mock.calls.length).toBe(1); 684 | expect(eventSource.readyState).toBe(EventSource.CONNECTING); 685 | 686 | oldRequestId = requestId; 687 | incrementRequestId(); 688 | eventSource.__didCompleteResponse(oldRequestId, 'no internet', false); 689 | expect(eventSource.onerror.mock.calls.length).toBe(2); 690 | expect(eventSource.readyState).toBe(EventSource.CONNECTING); 691 | 692 | oldRequestId = requestId; 693 | incrementRequestId(); 694 | eventSource.__didCompleteResponse(oldRequestId, null, false); // connection closed 695 | expect(eventSource.onerror.mock.calls.length).toBe(3); 696 | expect(eventSource.readyState).toBe(EventSource.CONNECTING); 697 | 698 | oldRequestId = requestId; 699 | incrementRequestId(); 700 | eventSource.__didCompleteResponse(oldRequestId, 'in the subway', false); 701 | expect(eventSource.onerror.mock.calls.length).toBe(4); 702 | expect(eventSource.readyState).toBe(EventSource.CONNECTING); 703 | 704 | oldRequestId = requestId; 705 | incrementRequestId(); 706 | eventSource.__didCompleteResponse(oldRequestId, 'airplane mode', false); 707 | expect(eventSource.onerror.mock.calls.length).toBe(5); 708 | expect(eventSource.readyState).toBe(EventSource.CONNECTING); 709 | 710 | oldRequestId = requestId; 711 | incrementRequestId(); 712 | eventSource.__didCompleteResponse(oldRequestId, 'no service', false); 713 | expect(eventSource.onerror.mock.calls.length).toBe(6); 714 | expect(eventSource.readyState).toBe(EventSource.CLOSED); 715 | }); 716 | }); 717 | --------------------------------------------------------------------------------