UNPKG

sse-events

Version:

SSE event source polyfill wrapped in Node.js like EventEmitter with performance fixes and custom api. Compatible with React/React-Native.

454 lines (405 loc) 12.4 kB
// EventSource.js // Original implementation from // https://github.com/remy/polyfills/blob/master/EventSource.js // https://github.com/jordanbyron/react-native-event-source // https://github.com/EventSource/eventsource // const EventEmitter = require('eventemitter3'); const url = require('url'); const reTrim = /^(\s|\u00A0)+|(\s|\u00A0)+$/g; const types = { ON_OPEN: 'open', // readyState goes from CONNECTING -> OPEN ON_ERROR: 'error', // SSE, network or server response errors ON_CLOSE: 'close', // readyState goes to CLOSED ON_STATE: 'state', // readyState has changed ON_TIMEOUT: 'timeout' // server closed connection }; const MessageEvent = (data = null, origin = '', lastEventId = '') => ({ type: 'message', data, lastEventId, origin }); // Interval calculation const randomInterval = (max, min = 0) => Math.floor((Math.random() * (max + 1 - min) + min)); const backoffInterval = (attempt, delay = 100, maxDelay = 120000) => { let current = 1; let prev; if (attempt > current) { prev = 1; current = 2; for (let index = 2; index < attempt; index++) { const next = prev + current; prev = current; current = next; if (delay + (current * 100) > maxDelay) break; } } return delay + randomInterval(current * 100) }; class EventSource extends EventEmitter { constructor({ url, options = {}, params = {}, path = '', reconnectInterval, maxInterval, minInterval, maxAttempts, retryOnNetworkError, retryOnServerError, serverErrorCodes } = {}) { super(); // States this.CONNECTING = 0; this.OPEN = 1; this.CLOSED = 2; // Configuration /** * Reference time interval between re-connection attempts in ms. Actual interval is randomly selected * between 100ms and this value to reduce server request blocking. For retry attempts, following * network errors, actual interval is randomly selected using a progressiver random algorithm with this value * as the min interval. Default to 1000 ms. * @type {Number} */ this.RECONNECT_INTERVAL = reconnectInterval || 1000; /** * Max retry time interval in ms. Default to 15000 ms * @type {Number} */ this.MAX_INTERVAL = maxInterval || 15000; /** * Min retry time interval in ms. Default to 1000 ms * @type {Number} */ this.MIN_INTERVAL = minInterval || 1000; /** * Max number of retry attempts before closing the connection. Negative value means unlimited * attempts. Default to -1 (no limit). * @type {Number} */ this.MAX_ATTEMPTS = maxAttempts || -1; /** * Retry to connect on network or other internal errors. Default to true. * @type {Boolean} */ this.RETRY_ON_NETWORK_ERROR = retryOnNetworkError || true; /** * Retry to connect on server error response. Default to false. * @type {Boolean} */ this.RETRY_ON_SERVER_ERROR = retryOnServerError || false; /** * Server error status codes to retry connection. Default to [502, 503, 504] * @type {Array} */ this.SERVER_ERROR_CODES = serverErrorCodes || [502, 503, 504]; /** * Connection URL * @type {String} */ this.url = url; /** * SSE Connection Options * @type {Object} */ this.options = options; /** * Connection URL params * @type {Object} */ this.params = params; this.path = path; /** * SSE Connection State * @type {CONNECTING|OPENED|CLOSED} */ this.readyState = undefined; this.lastEventId = null; this._pollTimer = null; this._xhr = null; this._attempts = 0; } /** * Adds or edits request headers parameters * @param {Object} [headers={}] Headers params */ setHeaders(headers = {}) { this.options.headers = { ...this.options.headers, ...headers }; } /** * Adds or edits request query parameters * @param {Object} [params={}] Query parameters */ setParams(params = {}) { this.params = { ...this.params, ...params }; } /** * Opens SSE connection to the server, if not opened yet. Only one connection * is open a time, regardeless of how many times this method is called. * @return {this} */ open() { if ( (!this._xhr || this._xhr.readyState === 4 || this._xhr.readyState === 0) && !this._pollTimer ) { this._setReadyState(this.CONNECTING); this._poll(); } return this; } /** * Closes SSE connection with the server, if not closed yet. Also used internally. * @param {Boolean} [error=false] Flag to indicate closing after an error * @return {this} */ close(error = false) { if (this.readyState === this.CLOSED) return; // closes the connection - disabling the polling this._setReadyState(this.CLOSED); clearInterval(this._pollTimer); this._pollTimer = null; this._xhr && this._xhr.abort(); this._attempts = 0; this.emit(types.ON_CLOSE, { type: types.ON_CLOSE, error }); return this; } /** * Close SSE connection (if not closed yet) and remove all event listeners */ destroy() { this.close(); this.removeAllListeners(); } /** * Adds event listener. Listners persist after connection is closed. * @param {String} type Event type * @param {Function} handler Event handler */ addEventListener(type, handler) { if (typeof handler === 'function') { handler._handler = handler; return this.on(type, handler); } } /** * Remove event listener * @param {String} type Event type * @param {Function} handler Event handler */ removeEventListener(type, handler) { return this.removeListener(type, handler); } /** * Initiates an XHR object and attach event handlers * @private */ _poll() { const { CONNECTING, OPEN, CLOSED, options, params } = this; const self = this; try { // force hiding of the error message... insane? if (this.readyState === CLOSED) return; const href = url.parse(`${this.url}${this.path}`, true); href.search = null; href.query = { ...href.query, ...params }; // NOTE: IE7 and upwards support const xhr = new XMLHttpRequest(); xhr.open('GET', url.format(href), true); if (options && options.headers) { Object.keys(options.headers).forEach(key => { xhr.setRequestHeader(key, options.headers[key]); }); } xhr.setRequestHeader('Accept', 'text/event-stream'); xhr.setRequestHeader('Cache-Control', 'no-cache'); // we must make use of this on the server side if we're working with Android - because they don't trigger // readychange until the server connection is closed xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); if (this.lastEventId) xhr.setRequestHeader('Last-Event-ID', this.lastEventId); let cache = ''; xhr.onreadystatechange = function () { if ( this.readyState === 3 || (this.readyState === 4 && this.status === 200) ) { // on success if (self.readyState === CONNECTING && this.status === 200) { // on open self._setReadyState(OPEN); self.emit(types.ON_OPEN, { type: types.ON_OPEN, url: self.url, options: self.options }); // reset retries attempts self._attempts = 0; } // process this.responseText const responseText = this.responseText || ''; let data = []; let parts = responseText.substr(cache.length).split("\n"); let i = 0; let line = ''; let eventType; cache = responseText; // TODO handle 'event' (for buffer name), retry for (; i < parts.length; i++) { line = parts[i].replace(reTrim, ''); if (line.indexOf('event') === 0) { eventType = line.replace(/event:?\s*/, ''); } else if (line.indexOf('retry') === 0) { retry = parseInt(line.replace(/retry:?\s*/, '')); if(!isNaN(retry)) { interval = retry; } } else if (line.indexOf('data') === 0) { data.push(line.replace(/data:?\s*/, '')); } else if (line.indexOf('id:') === 0) { self.lastEventId = line.replace(/id:?\s*/, ''); } else if (line.indexOf('id') === 0) { // this resets the id self.lastEventId = null; } else if (line == '') { if (data.length) { self._emitMessage( eventType || 'message', MessageEvent(data.join('\n'), `${self.url}${self.path}`, self.lastEventId) ); data = []; eventType = undefined; } } } if (this.readyState === 4) { // on server close self.emit(types.ON_TIMEOUT, { type: types.ON_TIMEOUT }); // reconnect ASAP const interval = randomInterval(self.RECONNECT_INTERVAL, 100); self._pollAgain(interval); } } else if (this.readyState === 4) { if (this.status !== 0) { // Server error responses self._emitError(this.status, this.responseText); // Reconnect if required if ( self.RETRY_ON_SERVER_ERROR && self.SERVER_ERROR_CODES.includes(this.status) && (self.MAX_ATTEMPTS < 0 || self._attempts < self.MAX_ATTEMPTS) ) { // Attempt to reconnect self._setReadyState(CONNECTING); self._retry(); return; } // Close connection self.close(true); } } }; xhr.onerror = function(e) { // Select network errors if (this.status === 0) { // Discard errors on retries if (self._attempts === 0) { self._emitError(this.status, this.responseText); } if ( self.RETRY_ON_NETWORK_ERROR && (self.MAX_ATTEMPTS < 0 || self._attempts < self.MAX_ATTEMPTS) ) { // Attempt to reconnect self._setReadyState(CONNECTING); self._retry(); return; } self.close(true); } } xhr.send(); this._xhr = xhr; } catch (e) { // in an attempt to silence the errors self._emitError(0, this.responseText); this.close(true); } } /** * Schedules a _poll() call * @private */ _pollAgain(interval) { this._pollTimer = setTimeout(() => { this._poll(); }, interval); } /** * Schedules a _poll() call with longer and progressive interval * @private */ _retry() { this._attempts += 1; const interval = backoffInterval(this._attempts, this.MIN_INTERVAL, this.MAX_INTERVAL); this._pollAgain(interval); } /** * Sets this readyState property and emits state change event * @private */ _setReadyState(state) { const prevState = this.readyState; if (prevState !== state) { this.readyState = state; this.emit(types.ON_STATE, { type: types.ON_STATE, state, prevState }); } } /** * Tries to parse error message and emits it * @param {Number} status Reponse status * @param {String} message Error message */ _emitError(status, message) { let data = {}; if (status !== 0) { try { data = JSON.parse(message) } catch (e) {}; } this.emit(types.ON_ERROR, { type: types.ON_ERROR, status, message, data }); } /** * Tries to decode message payload and emits it * @param {String} type Message type * @param {Object} message Message content */ _emitMessage(type, message = {}) { let decodedData = message.data; if (message.data) { try { decodedData = JSON.parse(message.data); } catch (e) {} } this.emit(type, { ...message, data: decodedData }); } } EventSource.types = types; module.exports = EventSource;