fix: reset websocket on online/offline/active events (#1429)

* fix: reset websocket on online/offline/active events

* minor fixup

* add comments
This commit is contained in:
Nolan Lawson 2019-08-24 13:33:57 -07:00 committed by GitHub
parent 88ab0b929c
commit 7b32c71c93
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 338 additions and 13 deletions

View file

@ -44,7 +44,6 @@
}, },
"dependencies": { "dependencies": {
"@babel/core": "^7.5.5", "@babel/core": "^7.5.5",
"@gamestdio/websocket": "^0.3.2",
"@webcomponents/custom-elements": "^1.2.4", "@webcomponents/custom-elements": "^1.2.4",
"babel-loader": "^8.0.6", "babel-loader": "^8.0.6",
"babel-plugin-transform-react-remove-prop-types": "^0.4.24", "babel-plugin-transform-react-remove-prop-types": "^0.4.24",
@ -156,7 +155,8 @@
"ImageData", "ImageData",
"OffscreenCanvas", "OffscreenCanvas",
"postMessage", "postMessage",
"getComputedStyle" "getComputedStyle",
"WebSocket"
], ],
"ignore": [ "ignore": [
"dist", "dist",

View file

@ -1,4 +1,4 @@
import WebSocketClient from '@gamestdio/websocket' import { WebSocketClient } from '../../_thirdparty/websocket/websocket'
import lifecycle from 'page-lifecycle/dist/lifecycle.mjs' import lifecycle from 'page-lifecycle/dist/lifecycle.mjs'
import { getStreamUrl } from './getStreamUrl' import { getStreamUrl } from './getStreamUrl'
import { EventEmitter } from 'events-light' import { EventEmitter } from 'events-light'
@ -11,7 +11,9 @@ export class TimelineStream extends EventEmitter {
this._accessToken = accessToken this._accessToken = accessToken
this._timeline = timeline this._timeline = timeline
this._onStateChange = this._onStateChange.bind(this) 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._setupWebSocket()
this._setupEvents() this._setupEvents()
} }
@ -40,7 +42,7 @@ export class TimelineStream extends EventEmitter {
_setupWebSocket () { _setupWebSocket () {
const url = getStreamUrl(this._streamingApi, this._accessToken, this._timeline) const url = getStreamUrl(this._streamingApi, this._accessToken, this._timeline)
const ws = new WebSocketClient(url, null, { backoff: 'fibonacci' }) const ws = new WebSocketClient(url)
ws.onopen = () => { ws.onopen = () => {
if (!this._opened) { if (!this._opened) {
@ -63,12 +65,16 @@ export class TimelineStream extends EventEmitter {
_setupEvents () { _setupEvents () {
lifecycle.addEventListener('statechange', this._onStateChange) 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 () { _teardownEvents () {
lifecycle.removeEventListener('statechange', this._onStateChange) 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 () { _pause () {
@ -95,9 +101,24 @@ export class TimelineStream extends EventEmitter {
console.log('unfrozen') console.log('unfrozen')
this._unpause() 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) { if (online) {
console.log('online forced') console.log('online forced')
this._unpause() this._unpause()
@ -106,4 +127,14 @@ export class TimelineStream extends EventEmitter {
this._pause() 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()
}
}
} }

View file

@ -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.

View file

@ -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
}
}

View file

@ -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

View file

@ -231,11 +231,6 @@
lodash "^4.17.13" lodash "^4.17.13"
to-fast-properties "^2.0.0" 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": "@mrmlnc/readdir-enhanced@^2.2.1":
version "2.2.1" version "2.2.1"
resolved "https://registry.yarnpkg.com/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz#524af240d1a360527b730475ecfa1344aa540dde" resolved "https://registry.yarnpkg.com/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz#524af240d1a360527b730475ecfa1344aa540dde"