diff --git a/package.json b/package.json index b3df248f..b7e80866 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,6 @@ }, "dependencies": { "@babel/core": "^7.5.5", - "@gamestdio/websocket": "^0.3.2", "@webcomponents/custom-elements": "^1.2.4", "babel-loader": "^8.0.6", "babel-plugin-transform-react-remove-prop-types": "^0.4.24", @@ -156,7 +155,8 @@ "ImageData", "OffscreenCanvas", "postMessage", - "getComputedStyle" + "getComputedStyle", + "WebSocket" ], "ignore": [ "dist", diff --git a/src/routes/_api/stream/TimelineStream.js b/src/routes/_api/stream/TimelineStream.js index 4fbab2c9..93de679e 100644 --- a/src/routes/_api/stream/TimelineStream.js +++ b/src/routes/_api/stream/TimelineStream.js @@ -1,4 +1,4 @@ -import WebSocketClient from '@gamestdio/websocket' +import { WebSocketClient } from '../../_thirdparty/websocket/websocket' import lifecycle from 'page-lifecycle/dist/lifecycle.mjs' import { getStreamUrl } from './getStreamUrl' import { EventEmitter } from 'events-light' @@ -11,7 +11,9 @@ export class TimelineStream extends EventEmitter { this._accessToken = accessToken this._timeline = timeline this._onStateChange = this._onStateChange.bind(this) - this._onOnlineForced = this._onOnlineForced.bind(this) + this._onOnline = this._onOnline.bind(this) + this._onOffline = this._onOffline.bind(this) + this._onForcedOnlineStateChange = this._onForcedOnlineStateChange.bind(this) this._setupWebSocket() this._setupEvents() } @@ -40,7 +42,7 @@ export class TimelineStream extends EventEmitter { _setupWebSocket () { const url = getStreamUrl(this._streamingApi, this._accessToken, this._timeline) - const ws = new WebSocketClient(url, null, { backoff: 'fibonacci' }) + const ws = new WebSocketClient(url) ws.onopen = () => { if (!this._opened) { @@ -63,12 +65,16 @@ export class TimelineStream extends EventEmitter { _setupEvents () { lifecycle.addEventListener('statechange', this._onStateChange) - eventBus.on('forcedOnline', this._onOnlineForced) + eventBus.on('forcedOnline', this._onForcedOnlineStateChange) // only happens in tests + window.addEventListener('online', this._onOnline) + window.addEventListener('offline', this._onOffline) } _teardownEvents () { lifecycle.removeEventListener('statechange', this._onStateChange) - eventBus.removeListener('forcedOnline', this._onOnlineForced) + eventBus.removeListener('forcedOnline', this._onForcedOnlineStateChange) // only happens in tests + window.removeEventListener('online', this._onOnline) + window.removeEventListener('offline', this._onOffline) } _pause () { @@ -95,9 +101,24 @@ export class TimelineStream extends EventEmitter { console.log('unfrozen') this._unpause() } + if (event.newState === 'active') { // page is reopened from a background tab + console.log('active') + this._tryToReconnect() + } } - _onOnlineForced (online) { + _onOnline () { + console.log('online') + this._unpause() // if we're not paused, then this is a no-op + this._tryToReconnect() // to be safe, try to reset and reconnect + } + + _onOffline () { + console.log('offline') + this._pause() // in testing, it seems to work better to stop polling when we get this event + } + + _onForcedOnlineStateChange (online) { if (online) { console.log('online forced') this._unpause() @@ -106,4 +127,14 @@ export class TimelineStream extends EventEmitter { this._pause() } } + + _tryToReconnect () { + console.log('websocket readyState', this._ws && this._ws.readyState) + if (this._ws && this._ws.readyState !== WebSocketClient.OPEN) { + // if a websocket connection is not currently open, then reset the + // backoff counter to ensure that fresh notifications come in faster + this._ws.reset() + this._ws.reconnect() + } + } } diff --git a/src/routes/_thirdparty/websocket/LICENSE b/src/routes/_thirdparty/websocket/LICENSE new file mode 100644 index 00000000..37df45a3 --- /dev/null +++ b/src/routes/_thirdparty/websocket/LICENSE @@ -0,0 +1,21 @@ +Copyright (c) 2015 Endel Dreyer + +MIT License: + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/routes/_thirdparty/websocket/backoff.js b/src/routes/_thirdparty/websocket/backoff.js new file mode 100644 index 00000000..5ac2fc70 --- /dev/null +++ b/src/routes/_thirdparty/websocket/backoff.js @@ -0,0 +1,36 @@ +const MAX_DELAY = 60000 // 60 seconds +const INITIAL_DELAY = 100 + +export class Backoff { + constructor (onReady) { + this.attempts = 0 + this.onReady = onReady + } + + backoff () { + const delay = this.fibonacci(++this.attempts) + console.log('websocket delay', delay) + setTimeout(this.onReady, delay) + } + + fibonacci (attempt) { + let current = 1 + + if (attempt > current) { + let prev = 1 + current = 2 + + for (let index = 2; index < attempt; index++) { + const next = prev + current + prev = current + current = next + } + } + + return Math.min(MAX_DELAY, Math.floor(Math.random() * current * INITIAL_DELAY)) + } + + reset () { + this.attempts = 0 + } +} diff --git a/src/routes/_thirdparty/websocket/websocket.js b/src/routes/_thirdparty/websocket/websocket.js new file mode 100644 index 00000000..063cb063 --- /dev/null +++ b/src/routes/_thirdparty/websocket/websocket.js @@ -0,0 +1,242 @@ +// forked from https://github.com/gamestdio/websocket/blob/4111bfa/src/index.js + +import { Backoff } from './backoff' + +export class WebSocketClient { + /** + * @param url DOMString The URL to which to connect; this should be the URL to which the WebSocket server will respond. + * @param protocols DOMString|DOMString[] Either a single protocol string or an array of protocol strings. These strings are used to indicate sub-protocols, so that a single server can implement multiple WebSocket sub-protocols (for example, you might want one server to be able to handle different types of interactions depending on the specified protocol). If you don't specify a protocol string, an empty string is assumed. + * @param options options + */ + constructor (url, protocols = null, options = {}) { + this.url = url + this.protocols = protocols + + this.reconnectEnabled = true + this.listeners = {} + + this.backoff = new Backoff(this.onBackoffReady.bind(this)) + + if (typeof (options.connect) === 'undefined' || options.connect) { + this.open() + } + } + + open (reconnect = false) { + this.isReconnect = reconnect + + // keep binaryType used on previous WebSocket connection + const binaryType = this.ws && this.ws.binaryType + + this.ws = new WebSocket(this.url, this.protocols) + this.ws.onclose = this.onCloseCallback.bind(this) + this.ws.onerror = this.onErrorCallback.bind(this) + this.ws.onmessage = this.onMessageCallback.bind(this) + this.ws.onopen = this.onOpenCallback.bind(this) + + if (binaryType) { + this.ws.binaryType = binaryType + } + } + + /** + * @ignore + */ + onBackoffReady () { + this.open(true) + } + + /** + * @ignore + */ + onCloseCallback (e) { + if (!this.isReconnect && this.listeners.onclose) { + this.listeners.onclose.apply(null, arguments) + } + if (this.reconnectEnabled && e.code < 3000) { + this.backoff.backoff() + } + } + + /** + * @ignore + */ + onErrorCallback () { + if (this.listeners.onerror) { + this.listeners.onerror.apply(null, arguments) + } + } + + /** + * @ignore + */ + onMessageCallback () { + if (this.listeners.onmessage) { + this.listeners.onmessage.apply(null, arguments) + } + } + + /** + * @ignore + */ + onOpenCallback () { + if (this.listeners.onopen) { + this.listeners.onopen.apply(null, arguments) + } + + if (this.isReconnect && this.listeners.onreconnect) { + this.listeners.onreconnect.apply(null, arguments) + } + + this.isReconnect = false + } + + // Unused + // /** + // * The number of bytes of data that have been queued using calls to send() + // * but not yet transmitted to the network. This value does not reset to zero + // * when the connection is closed; if you keep calling send(), this will + // * continue to climb. + // * + // * @type unsigned long + // * @readonly + // */ + // get bufferedAmount () { return this.ws.bufferedAmount } + // + /** + * The current state of the connection; this is one of the Ready state constants. + * @type unsigned short + * @readonly + */ + get readyState () { return this.ws.readyState } + + // Unused + // + // /** + // * A string indicating the type of binary data being transmitted by the + // * connection. This should be either "blob" if DOM Blob objects are being + // * used or "arraybuffer" if ArrayBuffer objects are being used. + // * @type DOMString + // */ + // get binaryType () { return this.ws.binaryType } + // + // set binaryType (binaryType) { this.ws.binaryType = binaryType } + // + // /** + // * The extensions selected by the server. This is currently only the empty + // * string or a list of extensions as negotiated by the connection. + // * @type DOMString + // */ + // get extensions () { return this.ws.extensions } + // + // set extensions (extensions) { this.ws.extensions = extensions } + // + // /** + // * A string indicating the name of the sub-protocol the server selected; + // * this will be one of the strings specified in the protocols parameter when + // * creating the WebSocket object. + // * @type DOMString + // */ + // get protocol () { return this.ws.protocol } + // + // set protocol (protocol) { this.ws.protocol = protocol } + + /** + * Closes the WebSocket connection or connection attempt, if any. If the + * connection is already CLOSED, this method does nothing. + * + * @param code A numeric value indicating the status code explaining why the connection is being closed. If this parameter is not specified, a default value of 1000 (indicating a normal "transaction complete" closure) is assumed. See the list of status codes on the CloseEvent page for permitted values. + * @param reason A human-readable string explaining why the connection is closing. This string must be no longer than 123 bytes of UTF-8 text (not characters). + * + * @return void + */ + close (code, reason) { + if (typeof code === 'undefined') { code = 1000 } + + this.reconnectEnabled = false + + this.ws.close(code, reason) + } + + /** + * Transmits data to the server over the WebSocket connection. + * @param data DOMString|ArrayBuffer|Blob + * @return void + */ + send (data) { this.ws.send(data) } + + /** + * An event listener to be called when the WebSocket connection's readyState changes to CLOSED. The listener receives a CloseEvent named "close". + * @param listener EventListener + */ + set onclose (listener) { this.listeners.onclose = listener } + + get onclose () { return this.listeners.onclose } + + /** + * An event listener to be called when an error occurs. This is a simple event named "error". + * @param listener EventListener + */ + set onerror (listener) { this.listeners.onerror = listener } + + get onerror () { return this.listeners.onerror } + + /** + * An event listener to be called when a message is received from the server. The listener receives a MessageEvent named "message". + * @param listener EventListener + */ + set onmessage (listener) { this.listeners.onmessage = listener } + + get onmessage () { return this.listeners.onmessage } + + /** + * An event listener to be called when the WebSocket connection's readyState changes to OPEN; this indicates that the connection is ready to send and receive data. The event is a simple one with the name "open". + * @param listener EventListener + */ + set onopen (listener) { this.listeners.onopen = listener } + + get onopen () { return this.listeners.onopen } + + /** + * @param listener EventListener + */ + set onreconnect (listener) { this.listeners.onreconnect = listener } + + get onreconnect () { return this.listeners.onreconnect } + + /** + * Reset the backoff function back to initial state + */ + reset () { + console.log('websocket reset') + this.backoff.reset() + } + + /** Reconnect the websocket + * + */ + reconnect () { + console.log('websocket reconnect') + this.onBackoffReady() + } +} + +/** + * The connection is not yet open. + */ +WebSocketClient.CONNECTING = WebSocket.CONNECTING + +/** + * The connection is open and ready to communicate. + */ +WebSocketClient.OPEN = WebSocket.OPEN + +/** + * The connection is in the process of closing. + */ +WebSocketClient.CLOSING = WebSocket.CLOSING + +/** + * The connection is closed or couldn't be opened. + */ +WebSocketClient.CLOSED = WebSocket.CLOSED diff --git a/yarn.lock b/yarn.lock index 9a1ab234..639bff2a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -231,11 +231,6 @@ lodash "^4.17.13" to-fast-properties "^2.0.0" -"@gamestdio/websocket@^0.3.2": - version "0.3.2" - resolved "https://registry.yarnpkg.com/@gamestdio/websocket/-/websocket-0.3.2.tgz#321ba0976ee30fd14e51dbf8faa85ce7b325f76a" - integrity sha512-J3n5SKim+ZoLbe44hRGI/VYAwSMCeIJuBy+FfP6EZaujEpNchPRFcIsVQLWAwpU1bP2Ji63rC+rEUOd1vjUB6Q== - "@mrmlnc/readdir-enhanced@^2.2.1": version "2.2.1" resolved "https://registry.yarnpkg.com/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz#524af240d1a360527b730475ecfa1344aa540dde"