From 99fb5f9a0a46f4d94191eb088321943d86524a55 Mon Sep 17 00:00:00 2001 From: Adam Chelminski Date: Tue, 16 Jul 2019 17:16:50 +0200 Subject: [PATCH 1/4] Implement EventSource networking standard --- Libraries/Core/setUpXHR.js | 1 + Libraries/Network/EventSource.js | 439 +++++++++++ .../Network/__tests__/EventSource-test.js | 685 ++++++++++++++++++ 3 files changed, 1125 insertions(+) create mode 100644 Libraries/Network/EventSource.js create mode 100644 Libraries/Network/__tests__/EventSource-test.js diff --git a/Libraries/Core/setUpXHR.js b/Libraries/Core/setUpXHR.js index d108ef3588a899..4648f6c346e97f 100644 --- a/Libraries/Core/setUpXHR.js +++ b/Libraries/Core/setUpXHR.js @@ -38,3 +38,4 @@ polyfillGlobal( 'AbortSignal', () => require('abort-controller/dist/abort-controller').AbortSignal, // flowlint-line untyped-import:off ); +polyfillGlobal('EventSource', () => require('../Network/EventSource')); diff --git a/Libraries/Network/EventSource.js b/Libraries/Network/EventSource.js new file mode 100644 index 00000000000000..cccd2742216cd9 --- /dev/null +++ b/Libraries/Network/EventSource.js @@ -0,0 +1,439 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @flow + */ + +'use strict'; + +const EventTarget = require('event-target-shim'); +const RCTNetworking = require('./RCTNetworking'); + +const EVENT_SOURCE_EVENTS = ['error', 'message', 'open']; + +// char codes +const bom = [239, 187, 191]; // byte order mark +const lf = 10; +const cr = 13; + +const maxRetryAttempts = 5; + +/** + * An RCTNetworking-based implementation of the EventSource web standard. + * + * See https://developer.mozilla.org/en-US/docs/Web/API/EventSource + * https://html.spec.whatwg.org/multipage/server-sent-events.html + * https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events + */ +class EventSource extends EventTarget(...EVENT_SOURCE_EVENTS) { + static CONNECTING: number = 0; + static OPEN: number = 1; + static CLOSED: number = 2; + + // Properties + readyState: number = EventSource.CONNECTING; + url: string; + withCredentials: boolean = false; + + // Event handlers + onerror: ?Function; + onmessage: ?Function; + onopen: ?Function; + + // Buffers for event stream parsing + _isFirstChunk = false; + _discardNextLineFeed = false; + _lineBuf: string = ''; + _dataBuf: string = ''; + _eventTypeBuf: string = ''; + _lastEventIdBuf: string = ''; + + _headers: Object; + _lastEventId: string = ''; + _reconnectIntervalMs: number = 1000; + _requestId: ?number; + _subscriptions: Array<*>; + _trackingName: string = 'unknown'; + _retryAttempts: number = 0; + + /** + * Custom extension for tracking origins of request. + */ + setTrackingName(trackingName: string): EventSource { + this._trackingName = trackingName; + return this; + } + + /** + * Creates a new EventSource + * @param {string} url the URL at which to open a stream + * @param {?Object} eventSourceInitDict extra configuration parameters + */ + constructor(url: string, eventSourceInitDict: ?Object) { + super(); + + if (!url) { + throw new Error('Cannot open an SSE stream on an empty url'); + } + this.url = url; + + this.headers = {'Cache-Control': 'no-store', Accept: 'text/event-stream'}; + if (this._lastEventId) { + this.headers['Last-Event-ID'] = this._lastEventId; + } + + if (eventSourceInitDict) { + if (eventSourceInitDict.headers) { + if (eventSourceInitDict.headers['Last-Event-ID']) { + this._lastEventId = eventSourceInitDict.headers['Last-Event-ID']; + delete eventSourceInitDict.headers['Last-Event-ID']; + } + + for (var headerKey in eventSourceInitDict.headers) { + const header = eventSourceInitDict.headers[headerKey]; + if (header) { + this.headers[headerKey] = header; + } + } + } + + if (eventSourceInitDict.withCredentials) { + this.withCredentials = eventSourceInitDict.withCredentials; + } + } + + this._subscriptions = []; + this._subscriptions.push( + RCTNetworking.addListener('didReceiveNetworkResponse', args => + this.__didReceiveResponse(...args), + ), + ); + this._subscriptions.push( + RCTNetworking.addListener('didReceiveNetworkIncrementalData', args => + this.__didReceiveIncrementalData(...args), + ), + ); + this._subscriptions.push( + RCTNetworking.addListener('didCompleteNetworkResponse', args => + this.__didCompleteResponse(...args), + ), + ); + + this.__connnect(); + } + + close(): void { + if (this._requestId !== null && this._requestId !== undefined) { + RCTNetworking.abortRequest(this._requestId); + } + + // clean up RCTNetworking subscriptions + (this._subscriptions || []).forEach(sub => { + if (sub) { + sub.remove(); + } + }); + this._subscriptions = []; + + this.readyState = EventSource.CLOSED; + } + + __connnect(): void { + if (this.readyState === EventSource.CLOSED) { + // don't attempt to reestablish connection when the source is closed + return; + } + + if (this._lastEventId) { + this.headers['Last-Event-ID'] = this._lastEventId; + } + + RCTNetworking.sendRequest( + 'GET', // EventSource always GETs the resource + this._trackingName, + this.url, + this.headers, + '', // body for EventSource request is always empty + 'text', // SSE is a text protocol + true, // we want incremental events + 0, // there is no timeout defined in the WHATWG spec for EventSource + this.__didCreateRequest.bind(this), + this.withCredentials, + ); + } + + __reconnect(reason: string): void { + this.readyState = EventSource.CONNECTING; + + let errorEventMessage = 'reestablishing connection'; + if (reason) { + errorEventMessage += ': ' + reason; + } + + this.dispatchEvent({type: 'error', data: errorEventMessage}); + if (this._reconnectIntervalMs > 0) { + setTimeout(this.__connnect.bind(this), this._reconnectIntervalMs); + } else { + this.__connnect(); + } + } + + // Internal buffer processing methods + + __processEventStreamChunk(chunk: string): void { + if (this._isFirstChunk) { + if ( + bom.every((charCode, idx) => { + return this._lineBuf.charCodeAt(idx) === charCode; + }) + ) { + // Strip byte order mark from chunk + chunk = chunk.slice(bom.length); + } + this._isFirstChunk = false; + } + + let pos: number = 0; + while (pos < chunk.length) { + if (this._discardNextLineFeed) { + if (chunk.charCodeAt(pos) === lf) { + // Ignore this LF since it was preceded by a CR + ++pos; + } + this._discardNextLineFeed = false; + } + + const curCharCode = chunk.charCodeAt(pos); + if (curCharCode === cr || curCharCode === lf) { + this.__processEventStreamLine(); + + // Treat CRLF properly + if (curCharCode === cr) { + this._discardNextLineFeed = true; + } + } else { + this._lineBuf += chunk.charAt(pos); + } + + ++pos; + } + } + + __processEventStreamLine(): void { + const line = this._lineBuf; + + // clear the line buffer + this._lineBuf = ''; + + // Dispatch the buffered event if this is an empty line + if (line === '') { + this.__dispatchBufferedEvent(); + return; + } + + const colonPos = line.indexOf(':'); + + let field: string; + let value: string; + + if (colonPos === 0) { + // this is a comment line and should be ignored + return; + } else if (colonPos > 0) { + if (line[colonPos + 1] == ' ') { + field = line.slice(0, colonPos); + value = line.slice(colonPos + 2); // ignores the first space from the value + } else { + field = line.slice(0, colonPos); + value = line.slice(colonPos + 1); + } + } else { + field = line; + value = ''; + } + + switch (field) { + case 'event': + // Set the type of this event + this._eventTypeBuf = value; + break; + case 'data': + // Append the line to the data buffer along with an LF (U+000A) + this._dataBuf += value; + this._dataBuf += String.fromCodePoint(lf); + break; + case 'id': + // Update the last seen event id + this._lastEventIdBuf = value; + break; + case 'retry': + // Set a new reconnect interval value + const newRetryMs = parseInt(value, 10); + if (!isNaN(newRetryMs)) { + this._reconnectIntervalMs = newRetryMs; + } + break; + default: + // this is an unrecognized field, so this line should be ignored + } + } + + __dispatchBufferedEvent() { + this._lastEventId = this._lastEventIdBuf; + + // If the data buffer is an empty string, set the event type buffer to + // empty string and return + if (this._dataBuf === '') { + this._eventTypeBuf = ''; + return; + } + + // Dispatch the event + const eventType = this._eventTypeBuf || 'message'; + this.dispatchEvent({ + type: eventType, + data: this._dataBuf.slice(0, -1), // remove the trailing LF from the data + origin: this.url, + lastEventId: this._lastEventId, + }); + + // Reset the data and event type buffers + this._dataBuf = ''; + this._eventTypeBuf = ''; + } + + // RCTNetworking callbacks, exposed for testing + + __didCreateRequest(requestId: number): void { + this._requestId = requestId; + } + + __didReceiveResponse( + requestId: number, + status: number, + responseHeaders: ?Object, + responseURL: ?string, + ): void { + if (requestId !== this._requestId) { + return; + } + + // make the header names case insensitive + for (const entry of Object.entries(responseHeaders)) { + const [key, value] = entry; + delete responseHeaders[key]; + responseHeaders[key.toLowerCase()] = value; + } + + // Handle redirects + if (status === 301 || status === 307) { + if (responseHeaders && responseHeaders.location) { + // set the new URL, set the requestId to null so that request + // completion doesn't attempt a reconnect, and immediately attempt + // reconnecting + this.url = responseHeaders.location; + this._requestId = null; + this.__connnect(); + return; + } else { + this.dispatchEvent({ + type: 'error', + data: 'got redirect with no location', + }); + return this.close(); + } + } + + if (status !== 200) { + this.dispatchEvent({ + type: 'error', + data: 'unexpected HTTP status ' + status, + }); + return this.close(); + } + + if ( + responseHeaders && + responseHeaders['content-type'] !== 'text/event-stream' + ) { + this.dispatchEvent({ + type: 'error', + data: + 'unsupported MIME type in response: ' + + responseHeaders['content-type'], + }); + return this.close(); + } else if (!responseHeaders) { + this.dispatchEvent({ + type: 'error', + data: 'no MIME type in response', + }); + return this.close(); + } + + // reset the connection retry attempt counter + this._retryAttempts = 0; + + // reset the stream processing buffers + this._isFirstChunk = false; + this._discardNextLineFeed = false; + this._lineBuf = ''; + this._dataBuf = ''; + this._eventTypeBuf = ''; + this._lastEventIdBuf = ''; + + this.readyState = EventSource.OPEN; + this.dispatchEvent({type: 'open'}); + } + + __didReceiveIncrementalData( + requestId: number, + responseText: string, + progress: number, + total: number, + ) { + if (requestId !== this._requestId) { + return; + } + + this.__processEventStreamChunk(responseText); + } + + __didCompleteResponse( + requestId: number, + error: string, + timeOutError: boolean, + ): void { + if (requestId !== this._requestId) { + return; + } + + // The spec states: 'Network errors that prevents the connection from being + // established in the first place (e.g. DNS errors), should cause the user + // agent to reestablish the connection in parallel, unless the user agent + // knows that to be futile, in which case the user agent may fail the + // connection.' + // + // We are treating 5 unnsuccessful retry attempts as a sign that attempting + // to reconnect is 'futile'. Future improvements could also add exponential + // backoff. + if (this._retryAttempts < maxRetryAttempts) { + // pass along the error message so that the user sees it as part of the + // error event fired for re-establishing the connection + this._retryAttempts += 1; + this.__reconnect(error); + } else { + this.dispatchEvent({ + type: 'error', + data: 'could not reconnect after ' + maxRetryAttempts + ' attempts', + }); + this.close(); + } + } +} + +module.exports = EventSource; diff --git a/Libraries/Network/__tests__/EventSource-test.js b/Libraries/Network/__tests__/EventSource-test.js new file mode 100644 index 00000000000000..01f9b2020b555c --- /dev/null +++ b/Libraries/Network/__tests__/EventSource-test.js @@ -0,0 +1,685 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @emails oncall+react_native + */ + +'use strict'; +jest.unmock('../../Utilities/Platform'); +const Platform = require('../../Utilities/Platform'); +let requestId = 1; + +function setRequestId(id) { + if (Platform.OS === 'ios') { + return; + } + requestId = id; +} + +let capturedOptions; +jest + .dontMock('event-target-shim') + .setMock('../../BatchedBridge/NativeModules', { + Networking: { + addListener: function() {}, + removeListeners: function() {}, + sendRequest(options, callback) { + capturedOptions = options; + if (typeof callback === 'function') { + // android does not pass a callback + callback(requestId); + } + }, + abortRequest: function() {}, + }, + }); + +const EventSource = require('../EventSource'); + +describe('EventSource', function() { + let eventSource; + let handleOpen; + let handleMessage; + let handleError; + + let requestIdCounter: number = 0; + + const testUrl = 'https://www.example.com/sse'; + + function setupListeners() { + eventSource.onopen = jest.fn(); + eventSource.onmessage = jest.fn(); + eventSource.onerror = jest.fn(); + + handleOpen = jest.fn(); + handleMessage = jest.fn(); + handleError = jest.fn(); + + eventSource.addEventListener('open', handleOpen); + eventSource.addEventListener('message', handleMessage); + eventSource.addEventListener('error', handleError); + } + + function incrementRequestId() { + ++requestIdCounter; + setRequestId(requestIdCounter); + } + + afterEach(() => { + incrementRequestId(); + + if (eventSource) { + eventSource.close(); // will not error if called twice + } + + eventSource = null; + handleOpen = null; + handleMessage = null; + handleError = null; + }); + + it('should pass along the correct request parameters', function() { + eventSource = new EventSource(testUrl); + + expect(capturedOptions.method).toBe('GET'); + expect(capturedOptions.url).toBe(testUrl); + expect(capturedOptions.headers['Accept']).toBe('text/event-stream'); + expect(capturedOptions.headers['Cache-Control']).toBe('no-store'); + expect(capturedOptions.responseType).toBe('text'); + expect(capturedOptions.incrementalUpdates).toBe(true); + expect(capturedOptions.timeout).toBe(0); + expect(capturedOptions.withCredentials).toBe(false); + }); + + it('should transition readyState correctly for successful requests', function() { + eventSource = new EventSource(testUrl); + setupListeners(); + + expect(eventSource.readyState).toBe(EventSource.CONNECTING); + + eventSource.__didReceiveResponse( + requestId, + 200, + {'content-type': 'text/event-stream'}, + testUrl, + ); + expect(eventSource.readyState).toBe(EventSource.OPEN); + + eventSource.close(); + expect(eventSource.readyState).toBe(EventSource.CLOSED); + }); + + it('should call onerror function when server responds with an HTTP error', function() { + eventSource = new EventSource(testUrl); + setupListeners(); + + expect(eventSource.readyState).toBe(EventSource.CONNECTING); + + eventSource.__didReceiveResponse( + requestId, + 404, + {'content-type': 'text/plain'}, + testUrl, + ); + + expect(eventSource.onerror.mock.calls.length).toBe(1); + expect(handleError.mock.calls.length).toBe(1); + expect(eventSource.readyState).toBe(EventSource.CLOSED); + }); + + it('should call onerror on non event-stream responses', function() { + eventSource = new EventSource(testUrl); + setupListeners(); + + expect(eventSource.readyState).toBe(EventSource.CONNECTING); + + eventSource.__didReceiveResponse( + requestId, + 200, + {'content-type': 'text/plain'}, + testUrl, + ); + + expect(eventSource.onerror.mock.calls.length).toBe(1); + expect(handleError.mock.calls.length).toBe(1); + expect(eventSource.readyState).toBe(EventSource.CLOSED); + }); + + it('should call onerror function when request times out', function() { + eventSource = new EventSource(testUrl); + setupListeners(); + + expect(eventSource.readyState).toBe(EventSource.CONNECTING); + + eventSource.__didCompleteResponse(requestId, 'request timed out', true); + + expect(eventSource.onerror.mock.calls.length).toBe(1); + expect(handleError.mock.calls.length).toBe(1); + }); + + it('should call onerror if connection cannot be established', function() { + eventSource = new EventSource(testUrl); + setupListeners(); + + eventSource.__didCompleteResponse(requestId, 'no internet', false); + + expect(eventSource.onerror.mock.calls.length).toBe(1); + expect(handleError.mock.calls.length).toBe(1); + }); + + it('should call onopen function when stream is opened', function() { + eventSource = new EventSource(testUrl); + setupListeners(); + + eventSource.__didReceiveResponse( + requestId, + 200, + {'content-type': 'text/event-stream'}, + testUrl, + ); + expect(eventSource.onopen.mock.calls.length).toBe(1); + expect(handleOpen.mock.calls.length).toBe(1); + }); + + it('should follow HTTP redirects', function() { + eventSource = new EventSource(testUrl); + setupListeners(); + + expect(eventSource.readyState).toBe(EventSource.CONNECTING); + + const redirectUrl = 'https://www.example.com/new_sse'; + eventSource.__didReceiveResponse( + requestId, + 301, + {location: redirectUrl}, + testUrl, + ); + + const oldRequestId = requestId; + incrementRequestId(); + + eventSource.__didCompleteResponse(oldRequestId, null, false); + + // state should be still connecting, but another request + // should have been sent with the new redirect URL + expect(eventSource.readyState).toBe(EventSource.CONNECTING); + expect(eventSource.onopen.mock.calls.length).toBe(0); + expect(handleOpen.mock.calls.length).toBe(0); + + expect(capturedOptions.url).toBe(redirectUrl); + + eventSource.__didReceiveResponse( + requestId, + 200, + {'content-type': 'text/event-stream'}, + redirectUrl, + ); + + // the stream should now have opened + expect(eventSource.readyState).toBe(EventSource.OPEN); + expect(eventSource.onopen.mock.calls.length).toBe(1); + expect(handleOpen.mock.calls.length).toBe(1); + + eventSource.close(); + expect(eventSource.readyState).toBe(EventSource.CLOSED); + }); + + it('should call onmessage when receiving an unnamed event', function() { + eventSource = new EventSource(testUrl); + setupListeners(); + + eventSource.__didReceiveResponse( + requestId, + 200, + {'content-type': 'text/event-stream'}, + testUrl, + ); + + eventSource.__didReceiveIncrementalData( + requestId, + 'data: this is an event\n\n', + 0, + 0, // these parameters are not used by the EventSource + ); + + expect(eventSource.onmessage.mock.calls.length).toBe(1); + expect(handleMessage.mock.calls.length).toBe(1); + + const event = eventSource.onmessage.mock.calls[0][0]; + + expect(event.data).toBe('this is an event'); + }); + + it('should handle events with multiple lines of data', function() { + eventSource = new EventSource(testUrl); + setupListeners(); + + eventSource.__didReceiveResponse( + requestId, + 200, + {'content-type': 'text/event-stream'}, + testUrl, + ); + + eventSource.__didReceiveIncrementalData( + requestId, + 'data: this is an event\n' + + 'data:with multiple lines\n' + // should not strip the 'w' + 'data: but it should come in as one event\n' + + '\n', + 0, + 0, + ); + + expect(eventSource.onmessage.mock.calls.length).toBe(1); + expect(handleMessage.mock.calls.length).toBe(1); + + const event = eventSource.onmessage.mock.calls[0][0]; + + expect(event.data).toBe( + 'this is an event\nwith multiple lines\nbut it should come in as one event', + ); + }); + + it('should call appropriate handler when receiving a named event', function() { + eventSource = new EventSource(testUrl); + setupListeners(); + + const handleCustomEvent = jest.fn(); + eventSource.addEventListener('custom', handleCustomEvent); + + eventSource.__didReceiveResponse( + requestId, + 200, + {'content-type': 'text/event-stream'}, + testUrl, + ); + + eventSource.__didReceiveIncrementalData( + requestId, + 'event: custom\n' + 'data: this is a custom event\n' + '\n', + 0, + 0, + ); + + expect(eventSource.onmessage.mock.calls.length).toBe(0); + expect(handleMessage.mock.calls.length).toBe(0); + + expect(handleCustomEvent.mock.calls.length).toBe(1); + + const event = handleCustomEvent.mock.calls[0][0]; + expect(event.data).toBe('this is a custom event'); + }); + + it('should receive multiple events', function() { + eventSource = new EventSource(testUrl); + setupListeners(); + + const handleCustomEvent = jest.fn(); + eventSource.addEventListener('custom', handleCustomEvent); + + eventSource.__didReceiveResponse( + requestId, + 200, + {'content-type': 'text/event-stream'}, + testUrl, + ); + + eventSource.__didReceiveIncrementalData( + requestId, + 'event: custom\n' + + 'data: this is a custom event\n' + + '\n' + + '\n' + + 'data: this is a normal event\n' + + 'data: with multiple lines\n' + + '\n' + + 'data: this is a normal single-line event\n\n', + 0, + 0, + ); + expect(handleCustomEvent.mock.calls.length).toBe(1); + + expect(eventSource.onmessage.mock.calls.length).toBe(2); + expect(handleMessage.mock.calls.length).toBe(2); + }); + + it('should handle messages sent in separate chunks', function() { + eventSource = new EventSource(testUrl); + setupListeners(); + + eventSource.__didReceiveResponse( + requestId, + 200, + {'content-type': 'text/event-stream'}, + testUrl, + ); + + eventSource.__didReceiveIncrementalData(requestId, 'data: this is ', 0, 0); + + eventSource.__didReceiveIncrementalData( + requestId, + 'a normal event\n', + 0, + 0, + ); + + eventSource.__didReceiveIncrementalData( + requestId, + 'data: sent as separate ', + 0, + 0, + ); + + eventSource.__didReceiveIncrementalData(requestId, 'chunks\n\n', 0, 0); + + expect(eventSource.onmessage.mock.calls.length).toBe(1); + expect(handleMessage.mock.calls.length).toBe(1); + + const event = eventSource.onmessage.mock.calls[0][0]; + + expect(event.data).toBe('this is a normal event\nsent as separate chunks'); + }); + + it('should forward server-sent errors', function() { + eventSource = new EventSource(testUrl); + setupListeners(); + + const handleCustomEvent = jest.fn(); + eventSource.addEventListener('custom', handleCustomEvent); + + eventSource.__didReceiveResponse( + requestId, + 200, + {'content-type': 'text/event-stream'}, + testUrl, + ); + + eventSource.__didReceiveIncrementalData( + requestId, + 'event: error\n' + 'data: the server sent this error\n\n', + 0, + 0, + ); + + expect(eventSource.onerror.mock.calls.length).toBe(1); + expect(handleError.mock.calls.length).toBe(1); + + const event = eventSource.onerror.mock.calls[0][0]; + + expect(event.data).toBe('the server sent this error'); + }); + + it('should ignore comment lines', function() { + eventSource = new EventSource(testUrl); + setupListeners(); + + eventSource.__didReceiveResponse( + requestId, + 200, + {'content-type': 'text/event-stream'}, + testUrl, + ); + + eventSource.__didReceiveIncrementalData( + requestId, + 'data: this is an event\n' + + ": don't mind me\n" + // this line should be ignored + 'data: on two lines\n' + + '\n', + 0, + 0, + ); + + expect(eventSource.onmessage.mock.calls.length).toBe(1); + expect(handleMessage.mock.calls.length).toBe(1); + + const event = eventSource.onmessage.mock.calls[0][0]; + + expect(event.data).toBe('this is an event\non two lines'); + }); + + it('should properly set lastEventId based on server message', function() { + eventSource = new EventSource(testUrl); + setupListeners(); + + eventSource.__didReceiveResponse( + requestId, + 200, + {'content-type': 'text/event-stream'}, + testUrl, + ); + + eventSource.__didReceiveIncrementalData( + requestId, + 'data: this is an event\n' + 'id: with an id\n' + '\n', + 0, + 0, + ); + + expect(eventSource.onmessage.mock.calls.length).toBe(1); + expect(handleMessage.mock.calls.length).toBe(1); + + const event = eventSource.onmessage.mock.calls[0][0]; + + expect(event.data).toBe('this is an event'); + expect(eventSource._lastEventId).toBe('with an id'); + }); + + it('should properly set reconnect interval based on server message', function() { + eventSource = new EventSource(testUrl); + setupListeners(); + + eventSource.__didReceiveResponse( + requestId, + 200, + {'content-type': 'text/event-stream'}, + testUrl, + ); + + eventSource.__didReceiveIncrementalData( + requestId, + 'data: this is an event\n' + 'retry: 5000\n' + '\n', + 0, + 0, + ); + + expect(eventSource.onmessage.mock.calls.length).toBe(1); + expect(handleMessage.mock.calls.length).toBe(1); + + let event = eventSource.onmessage.mock.calls[0][0]; + + expect(event.data).toBe('this is an event'); + expect(eventSource._reconnectIntervalMs).toBe(5000); + + // NaN should not change interval + eventSource.__didReceiveIncrementalData( + requestId, + 'data: this is another event\n' + 'retry: five\n' + '\n', + 0, + 0, + ); + + expect(eventSource.onmessage.mock.calls.length).toBe(2); + expect(handleMessage.mock.calls.length).toBe(2); + + event = eventSource.onmessage.mock.calls[1][0]; + + expect(event.data).toBe('this is another event'); + expect(eventSource._reconnectIntervalMs).toBe(5000); + }); + + it('should handle messages with non-ASCII characters', function() { + eventSource = new EventSource(testUrl); + setupListeners(); + + eventSource.__didReceiveResponse( + requestId, + 200, + {'content-type': 'text/event-stream'}, + testUrl, + ); + + // flow doesn't like emojis: https://github.com/facebook/flow/issues/4219 + // so we have to add it programatically + const emoji = String.fromCodePoint(128526); + + eventSource.__didReceiveIncrementalData( + requestId, + `data: ${emoji}\n\n`, + 0, + 0, + ); + + expect(eventSource.onmessage.mock.calls.length).toBe(1); + expect(handleMessage.mock.calls.length).toBe(1); + + const event = eventSource.onmessage.mock.calls[0][0]; + + expect(event.data).toBe(emoji); + }); + + it('should properly pass along withCredentials option', function() { + eventSource = new EventSource(testUrl, {withCredentials: true}); + expect(capturedOptions.withCredentials).toBeTruthy(); + + eventSource = new EventSource(testUrl); + expect(capturedOptions.withCredentials).toBeFalsy(); + }); + + it('should properly pass along extra headers', function() { + eventSource = new EventSource(testUrl, { + headers: {'Custom-Header': 'some value'}, + }); + + // make sure the default headers are passed in + expect(capturedOptions.headers['Accept']).toBe('text/event-stream'); + expect(capturedOptions.headers['Cache-Control']).toBe('no-store'); + + // make sure the custom header was passed in; + expect(capturedOptions.headers['Custom-Header']).toBe('some value'); + }); + + it('should properly pass along configured lastEventId', function() { + eventSource = new EventSource(testUrl, { + headers: {'Last-Event-ID': 'my id'}, + }); + + // make sure the default headers are passed in + expect(capturedOptions.headers['Accept']).toBe('text/event-stream'); + expect(capturedOptions.headers['Cache-Control']).toBe('no-store'); + expect(capturedOptions.headers['Last-Event-ID']).toBe('my id'); + + // make sure the event id was also set on the event source + expect(eventSource._lastEventId).toBe('my id'); + }); + + it('should reconnect gracefully and properly pass lastEventId', async function() { + eventSource = new EventSource(testUrl); + setupListeners(); + + // override reconnection time interval so this test can run quickly + eventSource._reconnectIntervalMs = 0; + + eventSource.__didReceiveResponse( + requestId, + 200, + {'content-type': 'text/event-stream'}, + testUrl, + ); + + eventSource.__didReceiveIncrementalData( + requestId, + 'data: this is an event\n' + 'id: 42\n\n', + 0, + 0, + ); + + expect(eventSource.readyState).toBe(EventSource.OPEN); + expect(eventSource.onmessage.mock.calls.length).toBe(1); + expect(handleMessage.mock.calls.length).toBe(1); + + let event = eventSource.onmessage.mock.calls[0][0]; + + expect(event.data).toBe('this is an event'); + + const oldRequestId = requestId; + incrementRequestId(); + + eventSource.__didCompleteResponse(oldRequestId, null, false); // connection closed + expect(eventSource.onerror.mock.calls.length).toBe(1); + expect(eventSource.readyState).toBe(EventSource.CONNECTING); + + // lastEventId should have been captured and sent on reconnect + expect(capturedOptions.headers['Last-Event-ID']).toBe('42'); + + eventSource.__didReceiveResponse( + requestId, + 200, + {'content-type': 'text/event-stream'}, + testUrl, + ); + + eventSource.__didReceiveIncrementalData( + requestId, + 'data: this is another event\n\n', + 0, + 0, + ); + + expect(eventSource.onmessage.mock.calls.length).toBe(2); + expect(handleMessage.mock.calls.length).toBe(2); + + event = eventSource.onmessage.mock.calls[1][0]; + + expect(event.data).toBe('this is another event'); + }); + + it('should stop attempting to reconnect after five failed attempts', function() { + eventSource = new EventSource(testUrl); + setupListeners(); + + // override reconnection time interval so this test can run quickly + eventSource._reconnectIntervalMs = 0; + + let oldRequestId = requestId; + incrementRequestId(); + eventSource.__didCompleteResponse(oldRequestId, 'request timed out', true); + expect(eventSource.onerror.mock.calls.length).toBe(1); + expect(eventSource.readyState).toBe(EventSource.CONNECTING); + + oldRequestId = requestId; + incrementRequestId(); + eventSource.__didCompleteResponse(oldRequestId, 'no internet', false); + expect(eventSource.onerror.mock.calls.length).toBe(2); + expect(eventSource.readyState).toBe(EventSource.CONNECTING); + + oldRequestId = requestId; + incrementRequestId(); + eventSource.__didCompleteResponse(oldRequestId, null, false); // connection closed + expect(eventSource.onerror.mock.calls.length).toBe(3); + expect(eventSource.readyState).toBe(EventSource.CONNECTING); + + oldRequestId = requestId; + incrementRequestId(); + eventSource.__didCompleteResponse(oldRequestId, 'in the subway', false); + expect(eventSource.onerror.mock.calls.length).toBe(4); + expect(eventSource.readyState).toBe(EventSource.CONNECTING); + + oldRequestId = requestId; + incrementRequestId(); + eventSource.__didCompleteResponse(oldRequestId, 'airplane mode', false); + expect(eventSource.onerror.mock.calls.length).toBe(5); + expect(eventSource.readyState).toBe(EventSource.CONNECTING); + + oldRequestId = requestId; + incrementRequestId(); + eventSource.__didCompleteResponse(oldRequestId, 'no service', false); + expect(eventSource.onerror.mock.calls.length).toBe(6); + expect(eventSource.readyState).toBe(EventSource.CLOSED); + }); +}); From f4e05337c8cc5532e2dfe3ea468a67751c86e51e Mon Sep 17 00:00:00 2001 From: Adam Chelminski Date: Thu, 18 Jul 2019 17:45:28 +0200 Subject: [PATCH 2/4] fix test --- Libraries/Network/__tests__/EventSource-test.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Libraries/Network/__tests__/EventSource-test.js b/Libraries/Network/__tests__/EventSource-test.js index 01f9b2020b555c..313cbdd62b9ca3 100644 --- a/Libraries/Network/__tests__/EventSource-test.js +++ b/Libraries/Network/__tests__/EventSource-test.js @@ -36,6 +36,11 @@ jest }, abortRequest: function() {}, }, + PlatformConstants: { + getConstants() { + return {}; + }, + }, }); const EventSource = require('../EventSource'); From 959e06bf05f6d8fdfc45f0138dcedab833924fd1 Mon Sep 17 00:00:00 2001 From: Adam Chelminski Date: Sat, 20 Jul 2019 10:04:13 +0200 Subject: [PATCH 3/4] fix flow errors and linter warnings --- Libraries/Network/EventSource.js | 27 ++++++++++--------- .../Network/__tests__/EventSource-test.js | 6 ++--- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/Libraries/Network/EventSource.js b/Libraries/Network/EventSource.js index cccd2742216cd9..eefb5fdc5a2f71 100644 --- a/Libraries/Network/EventSource.js +++ b/Libraries/Network/EventSource.js @@ -52,7 +52,7 @@ class EventSource extends EventTarget(...EVENT_SOURCE_EVENTS) { _eventTypeBuf: string = ''; _lastEventIdBuf: string = ''; - _headers: Object; + _headers: {[key: string]: any} = {}; _lastEventId: string = ''; _reconnectIntervalMs: number = 1000; _requestId: ?number; @@ -81,9 +81,10 @@ class EventSource extends EventTarget(...EVENT_SOURCE_EVENTS) { } this.url = url; - this.headers = {'Cache-Control': 'no-store', Accept: 'text/event-stream'}; + this._headers['Cache-Control'] = 'no-store'; + this._headers.Accept = 'text/event-stream'; if (this._lastEventId) { - this.headers['Last-Event-ID'] = this._lastEventId; + this._headers['Last-Event-ID'] = this._lastEventId; } if (eventSourceInitDict) { @@ -96,7 +97,7 @@ class EventSource extends EventTarget(...EVENT_SOURCE_EVENTS) { for (var headerKey in eventSourceInitDict.headers) { const header = eventSourceInitDict.headers[headerKey]; if (header) { - this.headers[headerKey] = header; + this._headers[headerKey] = header; } } } @@ -149,14 +150,14 @@ class EventSource extends EventTarget(...EVENT_SOURCE_EVENTS) { } if (this._lastEventId) { - this.headers['Last-Event-ID'] = this._lastEventId; + this._headers['Last-Event-ID'] = this._lastEventId; } RCTNetworking.sendRequest( 'GET', // EventSource always GETs the resource this._trackingName, this.url, - this.headers, + this._headers, '', // body for EventSource request is always empty 'text', // SSE is a text protocol true, // we want incremental events @@ -244,7 +245,7 @@ class EventSource extends EventTarget(...EVENT_SOURCE_EVENTS) { // this is a comment line and should be ignored return; } else if (colonPos > 0) { - if (line[colonPos + 1] == ' ') { + if (line[colonPos + 1] === ' ') { field = line.slice(0, colonPos); value = line.slice(colonPos + 2); // ignores the first space from the value } else { @@ -322,11 +323,13 @@ class EventSource extends EventTarget(...EVENT_SOURCE_EVENTS) { return; } - // make the header names case insensitive - for (const entry of Object.entries(responseHeaders)) { - const [key, value] = entry; - delete responseHeaders[key]; - responseHeaders[key.toLowerCase()] = value; + if (responseHeaders) { + // make the header names case insensitive + for (const entry of Object.entries(responseHeaders)) { + const [key, value] = entry; + delete responseHeaders[key]; + responseHeaders[key.toLowerCase()] = value; + } } // Handle redirects diff --git a/Libraries/Network/__tests__/EventSource-test.js b/Libraries/Network/__tests__/EventSource-test.js index 313cbdd62b9ca3..902d5bd01439e4 100644 --- a/Libraries/Network/__tests__/EventSource-test.js +++ b/Libraries/Network/__tests__/EventSource-test.js @@ -92,7 +92,7 @@ describe('EventSource', function() { expect(capturedOptions.method).toBe('GET'); expect(capturedOptions.url).toBe(testUrl); - expect(capturedOptions.headers['Accept']).toBe('text/event-stream'); + expect(capturedOptions.headers.Accept).toBe('text/event-stream'); expect(capturedOptions.headers['Cache-Control']).toBe('no-store'); expect(capturedOptions.responseType).toBe('text'); expect(capturedOptions.incrementalUpdates).toBe(true); @@ -562,7 +562,7 @@ describe('EventSource', function() { }); // make sure the default headers are passed in - expect(capturedOptions.headers['Accept']).toBe('text/event-stream'); + expect(capturedOptions.headers.Accept).toBe('text/event-stream'); expect(capturedOptions.headers['Cache-Control']).toBe('no-store'); // make sure the custom header was passed in; @@ -575,7 +575,7 @@ describe('EventSource', function() { }); // make sure the default headers are passed in - expect(capturedOptions.headers['Accept']).toBe('text/event-stream'); + expect(capturedOptions.headers.Accept).toBe('text/event-stream'); expect(capturedOptions.headers['Cache-Control']).toBe('no-store'); expect(capturedOptions.headers['Last-Event-ID']).toBe('my id'); From 0934abb73614b819d52892a9f14de8230d1adb56 Mon Sep 17 00:00:00 2001 From: Adam Chelminski Date: Tue, 10 Sep 2019 13:32:14 +0200 Subject: [PATCH 4/4] remove EventSource code, and expose Networking API --- Libraries/Core/setUpXHR.js | 1 - Libraries/Network/EventSource.js | 442 ----------- .../Network/__tests__/EventSource-test.js | 690 ------------------ .../react-native-implementation.js | 3 + 4 files changed, 3 insertions(+), 1133 deletions(-) delete mode 100644 Libraries/Network/EventSource.js delete mode 100644 Libraries/Network/__tests__/EventSource-test.js diff --git a/Libraries/Core/setUpXHR.js b/Libraries/Core/setUpXHR.js index 4648f6c346e97f..d108ef3588a899 100644 --- a/Libraries/Core/setUpXHR.js +++ b/Libraries/Core/setUpXHR.js @@ -38,4 +38,3 @@ polyfillGlobal( 'AbortSignal', () => require('abort-controller/dist/abort-controller').AbortSignal, // flowlint-line untyped-import:off ); -polyfillGlobal('EventSource', () => require('../Network/EventSource')); diff --git a/Libraries/Network/EventSource.js b/Libraries/Network/EventSource.js deleted file mode 100644 index eefb5fdc5a2f71..00000000000000 --- a/Libraries/Network/EventSource.js +++ /dev/null @@ -1,442 +0,0 @@ -/** - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @format - * @flow - */ - -'use strict'; - -const EventTarget = require('event-target-shim'); -const RCTNetworking = require('./RCTNetworking'); - -const EVENT_SOURCE_EVENTS = ['error', 'message', 'open']; - -// char codes -const bom = [239, 187, 191]; // byte order mark -const lf = 10; -const cr = 13; - -const maxRetryAttempts = 5; - -/** - * An RCTNetworking-based implementation of the EventSource web standard. - * - * See https://developer.mozilla.org/en-US/docs/Web/API/EventSource - * https://html.spec.whatwg.org/multipage/server-sent-events.html - * https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events - */ -class EventSource extends EventTarget(...EVENT_SOURCE_EVENTS) { - static CONNECTING: number = 0; - static OPEN: number = 1; - static CLOSED: number = 2; - - // Properties - readyState: number = EventSource.CONNECTING; - url: string; - withCredentials: boolean = false; - - // Event handlers - onerror: ?Function; - onmessage: ?Function; - onopen: ?Function; - - // Buffers for event stream parsing - _isFirstChunk = false; - _discardNextLineFeed = false; - _lineBuf: string = ''; - _dataBuf: string = ''; - _eventTypeBuf: string = ''; - _lastEventIdBuf: string = ''; - - _headers: {[key: string]: any} = {}; - _lastEventId: string = ''; - _reconnectIntervalMs: number = 1000; - _requestId: ?number; - _subscriptions: Array<*>; - _trackingName: string = 'unknown'; - _retryAttempts: number = 0; - - /** - * Custom extension for tracking origins of request. - */ - setTrackingName(trackingName: string): EventSource { - this._trackingName = trackingName; - return this; - } - - /** - * Creates a new EventSource - * @param {string} url the URL at which to open a stream - * @param {?Object} eventSourceInitDict extra configuration parameters - */ - constructor(url: string, eventSourceInitDict: ?Object) { - super(); - - if (!url) { - throw new Error('Cannot open an SSE stream on an empty url'); - } - this.url = url; - - this._headers['Cache-Control'] = 'no-store'; - this._headers.Accept = 'text/event-stream'; - if (this._lastEventId) { - this._headers['Last-Event-ID'] = this._lastEventId; - } - - if (eventSourceInitDict) { - if (eventSourceInitDict.headers) { - if (eventSourceInitDict.headers['Last-Event-ID']) { - this._lastEventId = eventSourceInitDict.headers['Last-Event-ID']; - delete eventSourceInitDict.headers['Last-Event-ID']; - } - - for (var headerKey in eventSourceInitDict.headers) { - const header = eventSourceInitDict.headers[headerKey]; - if (header) { - this._headers[headerKey] = header; - } - } - } - - if (eventSourceInitDict.withCredentials) { - this.withCredentials = eventSourceInitDict.withCredentials; - } - } - - this._subscriptions = []; - this._subscriptions.push( - RCTNetworking.addListener('didReceiveNetworkResponse', args => - this.__didReceiveResponse(...args), - ), - ); - this._subscriptions.push( - RCTNetworking.addListener('didReceiveNetworkIncrementalData', args => - this.__didReceiveIncrementalData(...args), - ), - ); - this._subscriptions.push( - RCTNetworking.addListener('didCompleteNetworkResponse', args => - this.__didCompleteResponse(...args), - ), - ); - - this.__connnect(); - } - - close(): void { - if (this._requestId !== null && this._requestId !== undefined) { - RCTNetworking.abortRequest(this._requestId); - } - - // clean up RCTNetworking subscriptions - (this._subscriptions || []).forEach(sub => { - if (sub) { - sub.remove(); - } - }); - this._subscriptions = []; - - this.readyState = EventSource.CLOSED; - } - - __connnect(): void { - if (this.readyState === EventSource.CLOSED) { - // don't attempt to reestablish connection when the source is closed - return; - } - - if (this._lastEventId) { - this._headers['Last-Event-ID'] = this._lastEventId; - } - - RCTNetworking.sendRequest( - 'GET', // EventSource always GETs the resource - this._trackingName, - this.url, - this._headers, - '', // body for EventSource request is always empty - 'text', // SSE is a text protocol - true, // we want incremental events - 0, // there is no timeout defined in the WHATWG spec for EventSource - this.__didCreateRequest.bind(this), - this.withCredentials, - ); - } - - __reconnect(reason: string): void { - this.readyState = EventSource.CONNECTING; - - let errorEventMessage = 'reestablishing connection'; - if (reason) { - errorEventMessage += ': ' + reason; - } - - this.dispatchEvent({type: 'error', data: errorEventMessage}); - if (this._reconnectIntervalMs > 0) { - setTimeout(this.__connnect.bind(this), this._reconnectIntervalMs); - } else { - this.__connnect(); - } - } - - // Internal buffer processing methods - - __processEventStreamChunk(chunk: string): void { - if (this._isFirstChunk) { - if ( - bom.every((charCode, idx) => { - return this._lineBuf.charCodeAt(idx) === charCode; - }) - ) { - // Strip byte order mark from chunk - chunk = chunk.slice(bom.length); - } - this._isFirstChunk = false; - } - - let pos: number = 0; - while (pos < chunk.length) { - if (this._discardNextLineFeed) { - if (chunk.charCodeAt(pos) === lf) { - // Ignore this LF since it was preceded by a CR - ++pos; - } - this._discardNextLineFeed = false; - } - - const curCharCode = chunk.charCodeAt(pos); - if (curCharCode === cr || curCharCode === lf) { - this.__processEventStreamLine(); - - // Treat CRLF properly - if (curCharCode === cr) { - this._discardNextLineFeed = true; - } - } else { - this._lineBuf += chunk.charAt(pos); - } - - ++pos; - } - } - - __processEventStreamLine(): void { - const line = this._lineBuf; - - // clear the line buffer - this._lineBuf = ''; - - // Dispatch the buffered event if this is an empty line - if (line === '') { - this.__dispatchBufferedEvent(); - return; - } - - const colonPos = line.indexOf(':'); - - let field: string; - let value: string; - - if (colonPos === 0) { - // this is a comment line and should be ignored - return; - } else if (colonPos > 0) { - if (line[colonPos + 1] === ' ') { - field = line.slice(0, colonPos); - value = line.slice(colonPos + 2); // ignores the first space from the value - } else { - field = line.slice(0, colonPos); - value = line.slice(colonPos + 1); - } - } else { - field = line; - value = ''; - } - - switch (field) { - case 'event': - // Set the type of this event - this._eventTypeBuf = value; - break; - case 'data': - // Append the line to the data buffer along with an LF (U+000A) - this._dataBuf += value; - this._dataBuf += String.fromCodePoint(lf); - break; - case 'id': - // Update the last seen event id - this._lastEventIdBuf = value; - break; - case 'retry': - // Set a new reconnect interval value - const newRetryMs = parseInt(value, 10); - if (!isNaN(newRetryMs)) { - this._reconnectIntervalMs = newRetryMs; - } - break; - default: - // this is an unrecognized field, so this line should be ignored - } - } - - __dispatchBufferedEvent() { - this._lastEventId = this._lastEventIdBuf; - - // If the data buffer is an empty string, set the event type buffer to - // empty string and return - if (this._dataBuf === '') { - this._eventTypeBuf = ''; - return; - } - - // Dispatch the event - const eventType = this._eventTypeBuf || 'message'; - this.dispatchEvent({ - type: eventType, - data: this._dataBuf.slice(0, -1), // remove the trailing LF from the data - origin: this.url, - lastEventId: this._lastEventId, - }); - - // Reset the data and event type buffers - this._dataBuf = ''; - this._eventTypeBuf = ''; - } - - // RCTNetworking callbacks, exposed for testing - - __didCreateRequest(requestId: number): void { - this._requestId = requestId; - } - - __didReceiveResponse( - requestId: number, - status: number, - responseHeaders: ?Object, - responseURL: ?string, - ): void { - if (requestId !== this._requestId) { - return; - } - - if (responseHeaders) { - // make the header names case insensitive - for (const entry of Object.entries(responseHeaders)) { - const [key, value] = entry; - delete responseHeaders[key]; - responseHeaders[key.toLowerCase()] = value; - } - } - - // Handle redirects - if (status === 301 || status === 307) { - if (responseHeaders && responseHeaders.location) { - // set the new URL, set the requestId to null so that request - // completion doesn't attempt a reconnect, and immediately attempt - // reconnecting - this.url = responseHeaders.location; - this._requestId = null; - this.__connnect(); - return; - } else { - this.dispatchEvent({ - type: 'error', - data: 'got redirect with no location', - }); - return this.close(); - } - } - - if (status !== 200) { - this.dispatchEvent({ - type: 'error', - data: 'unexpected HTTP status ' + status, - }); - return this.close(); - } - - if ( - responseHeaders && - responseHeaders['content-type'] !== 'text/event-stream' - ) { - this.dispatchEvent({ - type: 'error', - data: - 'unsupported MIME type in response: ' + - responseHeaders['content-type'], - }); - return this.close(); - } else if (!responseHeaders) { - this.dispatchEvent({ - type: 'error', - data: 'no MIME type in response', - }); - return this.close(); - } - - // reset the connection retry attempt counter - this._retryAttempts = 0; - - // reset the stream processing buffers - this._isFirstChunk = false; - this._discardNextLineFeed = false; - this._lineBuf = ''; - this._dataBuf = ''; - this._eventTypeBuf = ''; - this._lastEventIdBuf = ''; - - this.readyState = EventSource.OPEN; - this.dispatchEvent({type: 'open'}); - } - - __didReceiveIncrementalData( - requestId: number, - responseText: string, - progress: number, - total: number, - ) { - if (requestId !== this._requestId) { - return; - } - - this.__processEventStreamChunk(responseText); - } - - __didCompleteResponse( - requestId: number, - error: string, - timeOutError: boolean, - ): void { - if (requestId !== this._requestId) { - return; - } - - // The spec states: 'Network errors that prevents the connection from being - // established in the first place (e.g. DNS errors), should cause the user - // agent to reestablish the connection in parallel, unless the user agent - // knows that to be futile, in which case the user agent may fail the - // connection.' - // - // We are treating 5 unnsuccessful retry attempts as a sign that attempting - // to reconnect is 'futile'. Future improvements could also add exponential - // backoff. - if (this._retryAttempts < maxRetryAttempts) { - // pass along the error message so that the user sees it as part of the - // error event fired for re-establishing the connection - this._retryAttempts += 1; - this.__reconnect(error); - } else { - this.dispatchEvent({ - type: 'error', - data: 'could not reconnect after ' + maxRetryAttempts + ' attempts', - }); - this.close(); - } - } -} - -module.exports = EventSource; diff --git a/Libraries/Network/__tests__/EventSource-test.js b/Libraries/Network/__tests__/EventSource-test.js deleted file mode 100644 index 902d5bd01439e4..00000000000000 --- a/Libraries/Network/__tests__/EventSource-test.js +++ /dev/null @@ -1,690 +0,0 @@ -/** - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @format - * @emails oncall+react_native - */ - -'use strict'; -jest.unmock('../../Utilities/Platform'); -const Platform = require('../../Utilities/Platform'); -let requestId = 1; - -function setRequestId(id) { - if (Platform.OS === 'ios') { - return; - } - requestId = id; -} - -let capturedOptions; -jest - .dontMock('event-target-shim') - .setMock('../../BatchedBridge/NativeModules', { - Networking: { - addListener: function() {}, - removeListeners: function() {}, - sendRequest(options, callback) { - capturedOptions = options; - if (typeof callback === 'function') { - // android does not pass a callback - callback(requestId); - } - }, - abortRequest: function() {}, - }, - PlatformConstants: { - getConstants() { - return {}; - }, - }, - }); - -const EventSource = require('../EventSource'); - -describe('EventSource', function() { - let eventSource; - let handleOpen; - let handleMessage; - let handleError; - - let requestIdCounter: number = 0; - - const testUrl = 'https://www.example.com/sse'; - - function setupListeners() { - eventSource.onopen = jest.fn(); - eventSource.onmessage = jest.fn(); - eventSource.onerror = jest.fn(); - - handleOpen = jest.fn(); - handleMessage = jest.fn(); - handleError = jest.fn(); - - eventSource.addEventListener('open', handleOpen); - eventSource.addEventListener('message', handleMessage); - eventSource.addEventListener('error', handleError); - } - - function incrementRequestId() { - ++requestIdCounter; - setRequestId(requestIdCounter); - } - - afterEach(() => { - incrementRequestId(); - - if (eventSource) { - eventSource.close(); // will not error if called twice - } - - eventSource = null; - handleOpen = null; - handleMessage = null; - handleError = null; - }); - - it('should pass along the correct request parameters', function() { - eventSource = new EventSource(testUrl); - - expect(capturedOptions.method).toBe('GET'); - expect(capturedOptions.url).toBe(testUrl); - expect(capturedOptions.headers.Accept).toBe('text/event-stream'); - expect(capturedOptions.headers['Cache-Control']).toBe('no-store'); - expect(capturedOptions.responseType).toBe('text'); - expect(capturedOptions.incrementalUpdates).toBe(true); - expect(capturedOptions.timeout).toBe(0); - expect(capturedOptions.withCredentials).toBe(false); - }); - - it('should transition readyState correctly for successful requests', function() { - eventSource = new EventSource(testUrl); - setupListeners(); - - expect(eventSource.readyState).toBe(EventSource.CONNECTING); - - eventSource.__didReceiveResponse( - requestId, - 200, - {'content-type': 'text/event-stream'}, - testUrl, - ); - expect(eventSource.readyState).toBe(EventSource.OPEN); - - eventSource.close(); - expect(eventSource.readyState).toBe(EventSource.CLOSED); - }); - - it('should call onerror function when server responds with an HTTP error', function() { - eventSource = new EventSource(testUrl); - setupListeners(); - - expect(eventSource.readyState).toBe(EventSource.CONNECTING); - - eventSource.__didReceiveResponse( - requestId, - 404, - {'content-type': 'text/plain'}, - testUrl, - ); - - expect(eventSource.onerror.mock.calls.length).toBe(1); - expect(handleError.mock.calls.length).toBe(1); - expect(eventSource.readyState).toBe(EventSource.CLOSED); - }); - - it('should call onerror on non event-stream responses', function() { - eventSource = new EventSource(testUrl); - setupListeners(); - - expect(eventSource.readyState).toBe(EventSource.CONNECTING); - - eventSource.__didReceiveResponse( - requestId, - 200, - {'content-type': 'text/plain'}, - testUrl, - ); - - expect(eventSource.onerror.mock.calls.length).toBe(1); - expect(handleError.mock.calls.length).toBe(1); - expect(eventSource.readyState).toBe(EventSource.CLOSED); - }); - - it('should call onerror function when request times out', function() { - eventSource = new EventSource(testUrl); - setupListeners(); - - expect(eventSource.readyState).toBe(EventSource.CONNECTING); - - eventSource.__didCompleteResponse(requestId, 'request timed out', true); - - expect(eventSource.onerror.mock.calls.length).toBe(1); - expect(handleError.mock.calls.length).toBe(1); - }); - - it('should call onerror if connection cannot be established', function() { - eventSource = new EventSource(testUrl); - setupListeners(); - - eventSource.__didCompleteResponse(requestId, 'no internet', false); - - expect(eventSource.onerror.mock.calls.length).toBe(1); - expect(handleError.mock.calls.length).toBe(1); - }); - - it('should call onopen function when stream is opened', function() { - eventSource = new EventSource(testUrl); - setupListeners(); - - eventSource.__didReceiveResponse( - requestId, - 200, - {'content-type': 'text/event-stream'}, - testUrl, - ); - expect(eventSource.onopen.mock.calls.length).toBe(1); - expect(handleOpen.mock.calls.length).toBe(1); - }); - - it('should follow HTTP redirects', function() { - eventSource = new EventSource(testUrl); - setupListeners(); - - expect(eventSource.readyState).toBe(EventSource.CONNECTING); - - const redirectUrl = 'https://www.example.com/new_sse'; - eventSource.__didReceiveResponse( - requestId, - 301, - {location: redirectUrl}, - testUrl, - ); - - const oldRequestId = requestId; - incrementRequestId(); - - eventSource.__didCompleteResponse(oldRequestId, null, false); - - // state should be still connecting, but another request - // should have been sent with the new redirect URL - expect(eventSource.readyState).toBe(EventSource.CONNECTING); - expect(eventSource.onopen.mock.calls.length).toBe(0); - expect(handleOpen.mock.calls.length).toBe(0); - - expect(capturedOptions.url).toBe(redirectUrl); - - eventSource.__didReceiveResponse( - requestId, - 200, - {'content-type': 'text/event-stream'}, - redirectUrl, - ); - - // the stream should now have opened - expect(eventSource.readyState).toBe(EventSource.OPEN); - expect(eventSource.onopen.mock.calls.length).toBe(1); - expect(handleOpen.mock.calls.length).toBe(1); - - eventSource.close(); - expect(eventSource.readyState).toBe(EventSource.CLOSED); - }); - - it('should call onmessage when receiving an unnamed event', function() { - eventSource = new EventSource(testUrl); - setupListeners(); - - eventSource.__didReceiveResponse( - requestId, - 200, - {'content-type': 'text/event-stream'}, - testUrl, - ); - - eventSource.__didReceiveIncrementalData( - requestId, - 'data: this is an event\n\n', - 0, - 0, // these parameters are not used by the EventSource - ); - - expect(eventSource.onmessage.mock.calls.length).toBe(1); - expect(handleMessage.mock.calls.length).toBe(1); - - const event = eventSource.onmessage.mock.calls[0][0]; - - expect(event.data).toBe('this is an event'); - }); - - it('should handle events with multiple lines of data', function() { - eventSource = new EventSource(testUrl); - setupListeners(); - - eventSource.__didReceiveResponse( - requestId, - 200, - {'content-type': 'text/event-stream'}, - testUrl, - ); - - eventSource.__didReceiveIncrementalData( - requestId, - 'data: this is an event\n' + - 'data:with multiple lines\n' + // should not strip the 'w' - 'data: but it should come in as one event\n' + - '\n', - 0, - 0, - ); - - expect(eventSource.onmessage.mock.calls.length).toBe(1); - expect(handleMessage.mock.calls.length).toBe(1); - - const event = eventSource.onmessage.mock.calls[0][0]; - - expect(event.data).toBe( - 'this is an event\nwith multiple lines\nbut it should come in as one event', - ); - }); - - it('should call appropriate handler when receiving a named event', function() { - eventSource = new EventSource(testUrl); - setupListeners(); - - const handleCustomEvent = jest.fn(); - eventSource.addEventListener('custom', handleCustomEvent); - - eventSource.__didReceiveResponse( - requestId, - 200, - {'content-type': 'text/event-stream'}, - testUrl, - ); - - eventSource.__didReceiveIncrementalData( - requestId, - 'event: custom\n' + 'data: this is a custom event\n' + '\n', - 0, - 0, - ); - - expect(eventSource.onmessage.mock.calls.length).toBe(0); - expect(handleMessage.mock.calls.length).toBe(0); - - expect(handleCustomEvent.mock.calls.length).toBe(1); - - const event = handleCustomEvent.mock.calls[0][0]; - expect(event.data).toBe('this is a custom event'); - }); - - it('should receive multiple events', function() { - eventSource = new EventSource(testUrl); - setupListeners(); - - const handleCustomEvent = jest.fn(); - eventSource.addEventListener('custom', handleCustomEvent); - - eventSource.__didReceiveResponse( - requestId, - 200, - {'content-type': 'text/event-stream'}, - testUrl, - ); - - eventSource.__didReceiveIncrementalData( - requestId, - 'event: custom\n' + - 'data: this is a custom event\n' + - '\n' + - '\n' + - 'data: this is a normal event\n' + - 'data: with multiple lines\n' + - '\n' + - 'data: this is a normal single-line event\n\n', - 0, - 0, - ); - expect(handleCustomEvent.mock.calls.length).toBe(1); - - expect(eventSource.onmessage.mock.calls.length).toBe(2); - expect(handleMessage.mock.calls.length).toBe(2); - }); - - it('should handle messages sent in separate chunks', function() { - eventSource = new EventSource(testUrl); - setupListeners(); - - eventSource.__didReceiveResponse( - requestId, - 200, - {'content-type': 'text/event-stream'}, - testUrl, - ); - - eventSource.__didReceiveIncrementalData(requestId, 'data: this is ', 0, 0); - - eventSource.__didReceiveIncrementalData( - requestId, - 'a normal event\n', - 0, - 0, - ); - - eventSource.__didReceiveIncrementalData( - requestId, - 'data: sent as separate ', - 0, - 0, - ); - - eventSource.__didReceiveIncrementalData(requestId, 'chunks\n\n', 0, 0); - - expect(eventSource.onmessage.mock.calls.length).toBe(1); - expect(handleMessage.mock.calls.length).toBe(1); - - const event = eventSource.onmessage.mock.calls[0][0]; - - expect(event.data).toBe('this is a normal event\nsent as separate chunks'); - }); - - it('should forward server-sent errors', function() { - eventSource = new EventSource(testUrl); - setupListeners(); - - const handleCustomEvent = jest.fn(); - eventSource.addEventListener('custom', handleCustomEvent); - - eventSource.__didReceiveResponse( - requestId, - 200, - {'content-type': 'text/event-stream'}, - testUrl, - ); - - eventSource.__didReceiveIncrementalData( - requestId, - 'event: error\n' + 'data: the server sent this error\n\n', - 0, - 0, - ); - - expect(eventSource.onerror.mock.calls.length).toBe(1); - expect(handleError.mock.calls.length).toBe(1); - - const event = eventSource.onerror.mock.calls[0][0]; - - expect(event.data).toBe('the server sent this error'); - }); - - it('should ignore comment lines', function() { - eventSource = new EventSource(testUrl); - setupListeners(); - - eventSource.__didReceiveResponse( - requestId, - 200, - {'content-type': 'text/event-stream'}, - testUrl, - ); - - eventSource.__didReceiveIncrementalData( - requestId, - 'data: this is an event\n' + - ": don't mind me\n" + // this line should be ignored - 'data: on two lines\n' + - '\n', - 0, - 0, - ); - - expect(eventSource.onmessage.mock.calls.length).toBe(1); - expect(handleMessage.mock.calls.length).toBe(1); - - const event = eventSource.onmessage.mock.calls[0][0]; - - expect(event.data).toBe('this is an event\non two lines'); - }); - - it('should properly set lastEventId based on server message', function() { - eventSource = new EventSource(testUrl); - setupListeners(); - - eventSource.__didReceiveResponse( - requestId, - 200, - {'content-type': 'text/event-stream'}, - testUrl, - ); - - eventSource.__didReceiveIncrementalData( - requestId, - 'data: this is an event\n' + 'id: with an id\n' + '\n', - 0, - 0, - ); - - expect(eventSource.onmessage.mock.calls.length).toBe(1); - expect(handleMessage.mock.calls.length).toBe(1); - - const event = eventSource.onmessage.mock.calls[0][0]; - - expect(event.data).toBe('this is an event'); - expect(eventSource._lastEventId).toBe('with an id'); - }); - - it('should properly set reconnect interval based on server message', function() { - eventSource = new EventSource(testUrl); - setupListeners(); - - eventSource.__didReceiveResponse( - requestId, - 200, - {'content-type': 'text/event-stream'}, - testUrl, - ); - - eventSource.__didReceiveIncrementalData( - requestId, - 'data: this is an event\n' + 'retry: 5000\n' + '\n', - 0, - 0, - ); - - expect(eventSource.onmessage.mock.calls.length).toBe(1); - expect(handleMessage.mock.calls.length).toBe(1); - - let event = eventSource.onmessage.mock.calls[0][0]; - - expect(event.data).toBe('this is an event'); - expect(eventSource._reconnectIntervalMs).toBe(5000); - - // NaN should not change interval - eventSource.__didReceiveIncrementalData( - requestId, - 'data: this is another event\n' + 'retry: five\n' + '\n', - 0, - 0, - ); - - expect(eventSource.onmessage.mock.calls.length).toBe(2); - expect(handleMessage.mock.calls.length).toBe(2); - - event = eventSource.onmessage.mock.calls[1][0]; - - expect(event.data).toBe('this is another event'); - expect(eventSource._reconnectIntervalMs).toBe(5000); - }); - - it('should handle messages with non-ASCII characters', function() { - eventSource = new EventSource(testUrl); - setupListeners(); - - eventSource.__didReceiveResponse( - requestId, - 200, - {'content-type': 'text/event-stream'}, - testUrl, - ); - - // flow doesn't like emojis: https://github.com/facebook/flow/issues/4219 - // so we have to add it programatically - const emoji = String.fromCodePoint(128526); - - eventSource.__didReceiveIncrementalData( - requestId, - `data: ${emoji}\n\n`, - 0, - 0, - ); - - expect(eventSource.onmessage.mock.calls.length).toBe(1); - expect(handleMessage.mock.calls.length).toBe(1); - - const event = eventSource.onmessage.mock.calls[0][0]; - - expect(event.data).toBe(emoji); - }); - - it('should properly pass along withCredentials option', function() { - eventSource = new EventSource(testUrl, {withCredentials: true}); - expect(capturedOptions.withCredentials).toBeTruthy(); - - eventSource = new EventSource(testUrl); - expect(capturedOptions.withCredentials).toBeFalsy(); - }); - - it('should properly pass along extra headers', function() { - eventSource = new EventSource(testUrl, { - headers: {'Custom-Header': 'some value'}, - }); - - // make sure the default headers are passed in - expect(capturedOptions.headers.Accept).toBe('text/event-stream'); - expect(capturedOptions.headers['Cache-Control']).toBe('no-store'); - - // make sure the custom header was passed in; - expect(capturedOptions.headers['Custom-Header']).toBe('some value'); - }); - - it('should properly pass along configured lastEventId', function() { - eventSource = new EventSource(testUrl, { - headers: {'Last-Event-ID': 'my id'}, - }); - - // make sure the default headers are passed in - expect(capturedOptions.headers.Accept).toBe('text/event-stream'); - expect(capturedOptions.headers['Cache-Control']).toBe('no-store'); - expect(capturedOptions.headers['Last-Event-ID']).toBe('my id'); - - // make sure the event id was also set on the event source - expect(eventSource._lastEventId).toBe('my id'); - }); - - it('should reconnect gracefully and properly pass lastEventId', async function() { - eventSource = new EventSource(testUrl); - setupListeners(); - - // override reconnection time interval so this test can run quickly - eventSource._reconnectIntervalMs = 0; - - eventSource.__didReceiveResponse( - requestId, - 200, - {'content-type': 'text/event-stream'}, - testUrl, - ); - - eventSource.__didReceiveIncrementalData( - requestId, - 'data: this is an event\n' + 'id: 42\n\n', - 0, - 0, - ); - - expect(eventSource.readyState).toBe(EventSource.OPEN); - expect(eventSource.onmessage.mock.calls.length).toBe(1); - expect(handleMessage.mock.calls.length).toBe(1); - - let event = eventSource.onmessage.mock.calls[0][0]; - - expect(event.data).toBe('this is an event'); - - const oldRequestId = requestId; - incrementRequestId(); - - eventSource.__didCompleteResponse(oldRequestId, null, false); // connection closed - expect(eventSource.onerror.mock.calls.length).toBe(1); - expect(eventSource.readyState).toBe(EventSource.CONNECTING); - - // lastEventId should have been captured and sent on reconnect - expect(capturedOptions.headers['Last-Event-ID']).toBe('42'); - - eventSource.__didReceiveResponse( - requestId, - 200, - {'content-type': 'text/event-stream'}, - testUrl, - ); - - eventSource.__didReceiveIncrementalData( - requestId, - 'data: this is another event\n\n', - 0, - 0, - ); - - expect(eventSource.onmessage.mock.calls.length).toBe(2); - expect(handleMessage.mock.calls.length).toBe(2); - - event = eventSource.onmessage.mock.calls[1][0]; - - expect(event.data).toBe('this is another event'); - }); - - it('should stop attempting to reconnect after five failed attempts', function() { - eventSource = new EventSource(testUrl); - setupListeners(); - - // override reconnection time interval so this test can run quickly - eventSource._reconnectIntervalMs = 0; - - let oldRequestId = requestId; - incrementRequestId(); - eventSource.__didCompleteResponse(oldRequestId, 'request timed out', true); - expect(eventSource.onerror.mock.calls.length).toBe(1); - expect(eventSource.readyState).toBe(EventSource.CONNECTING); - - oldRequestId = requestId; - incrementRequestId(); - eventSource.__didCompleteResponse(oldRequestId, 'no internet', false); - expect(eventSource.onerror.mock.calls.length).toBe(2); - expect(eventSource.readyState).toBe(EventSource.CONNECTING); - - oldRequestId = requestId; - incrementRequestId(); - eventSource.__didCompleteResponse(oldRequestId, null, false); // connection closed - expect(eventSource.onerror.mock.calls.length).toBe(3); - expect(eventSource.readyState).toBe(EventSource.CONNECTING); - - oldRequestId = requestId; - incrementRequestId(); - eventSource.__didCompleteResponse(oldRequestId, 'in the subway', false); - expect(eventSource.onerror.mock.calls.length).toBe(4); - expect(eventSource.readyState).toBe(EventSource.CONNECTING); - - oldRequestId = requestId; - incrementRequestId(); - eventSource.__didCompleteResponse(oldRequestId, 'airplane mode', false); - expect(eventSource.onerror.mock.calls.length).toBe(5); - expect(eventSource.readyState).toBe(EventSource.CONNECTING); - - oldRequestId = requestId; - incrementRequestId(); - eventSource.__didCompleteResponse(oldRequestId, 'no service', false); - expect(eventSource.onerror.mock.calls.length).toBe(6); - expect(eventSource.readyState).toBe(EventSource.CLOSED); - }); -}); diff --git a/Libraries/react-native/react-native-implementation.js b/Libraries/react-native/react-native-implementation.js index 10b92a98b31399..013eb97d377359 100644 --- a/Libraries/react-native/react-native-implementation.js +++ b/Libraries/react-native/react-native-implementation.js @@ -238,6 +238,9 @@ module.exports = { get NativeEventEmitter() { return require('../EventEmitter/NativeEventEmitter'); }, + get Networking() { + return require('../Network/RCTNetworking'); + }, get PanResponder() { return require('../Interaction/PanResponder'); },