@anycable/core
Version:
AnyCable JavaScript client library core functionality
190 lines (146 loc) • 4.17 kB
JavaScript
/*eslint n/no-unsupported-features/es-syntax: ["error", {version: "14.0"}] */
import { StaleConnectionError } from '../protocol/index.js'
import { NoopLogger } from '../logger/index.js'
const defaults = {
maxMissingPings: 2,
maxReconnectAttempts: Infinity
}
const now = () => Date.now()
export const backoffWithJitter = (interval, opts) => {
opts = opts || {}
let { backoffRate, jitterRatio, maxInterval } = opts
backoffRate = backoffRate || 2
if (jitterRatio === undefined) jitterRatio = 0.5
return attempts => {
let left = interval * backoffRate ** attempts
let right = left * backoffRate
let delay = left + (right - left) * Math.random()
let deviation = 2 * (Math.random() - 0.5) * jitterRatio
delay = delay * (1 + deviation)
if (maxInterval && maxInterval < delay) delay = maxInterval
return delay
}
}
export class Monitor {
constructor({ pingInterval, ...opts }) {
this.pingInterval = pingInterval
if (!this.pingInterval) {
throw Error(`Incorrect pingInterval is provided: ${pingInterval}`)
}
opts = Object.assign({}, defaults, opts)
this.strategy = opts.reconnectStrategy
if (!this.strategy) {
throw Error('Reconnect strategy must be provided')
}
this.maxMissingPings = opts.maxMissingPings
this.maxReconnectAttempts = opts.maxReconnectAttempts
this.logger = opts.logger || new NoopLogger()
this.state = 'pending_connect'
this.attempts = 0
this.disconnectedAt = now()
}
watch(target) {
this.target = target
this.initListeners()
}
reconnectNow() {
if (
this.state === 'connected' ||
this.state === 'pending_connect' ||
this.state === 'closed'
) {
return false
}
this.cancelReconnect()
this.state = 'pending_connect'
this.target.connect().catch(err => {
this.logger.info('Failed at reconnecting: ' + err)
})
return true
}
initListeners() {
this.unbind = []
this.unbind.push(
this.target.on('connect', () => {
this.attempts = 0
this.pingedAt = now()
this.state = 'connected'
this.cancelReconnect()
this.startPolling()
})
)
this.unbind.push(
this.target.on('disconnect', () => {
this.disconnectedAt = now()
this.state = 'disconnected'
this.stopPolling()
this.scheduleReconnect()
})
)
this.unbind.push(
this.target.on('close', () => {
this.disconnectedAt = now()
this.state = 'closed'
this.cancelReconnect()
this.stopPolling()
})
)
this.unbind.push(
this.target.on('keepalive', () => {
this.pingedAt = now()
})
)
this.unbind.push(() => {
this.cancelReconnect()
this.stopPolling()
})
}
dispose() {
delete this.target
if (this.unbind) {
this.unbind.forEach(clbk => clbk())
}
delete this.unbind
}
startPolling() {
if (this.pollId) {
clearTimeout(this.pollId)
}
let pollDelay =
this.pingInterval + (Math.random() - 0.5) * this.pingInterval * 0.5
this.pollId = setTimeout(() => {
this.checkStale()
if (this.state === 'connected') this.startPolling()
}, pollDelay)
}
stopPolling() {
if (this.pollId) {
clearTimeout(this.pollId)
}
}
checkStale() {
let diff = now() - this.pingedAt
if (diff > this.maxMissingPings * this.pingInterval) {
this.logger.warn(`Stale connection: ${diff}ms without pings`)
this.state = 'pending_disconnect'
this.target.disconnected(new StaleConnectionError())
}
}
scheduleReconnect() {
if (this.attempts >= this.maxReconnectAttempts) {
this.target.close()
return
}
let delay = this.strategy(this.attempts)
this.attempts++
this.logger.info(`Reconnecting in ${delay}ms (${this.attempts} attempt)`)
this.state = 'pending_reconnect'
this.reconnnectId = setTimeout(() => this.reconnectNow(), delay)
}
cancelReconnect() {
if (this.reconnnectId) {
clearTimeout(this.reconnnectId)
delete this.reconnnectId
}
}
}