UNPKG

@novo-learning/novo-sdk

Version:

SDK for the Novolanguage Speech Analysis API

189 lines (168 loc) 6.93 kB
import { ConnectionQuality } from '../entities/connection-quality'; import { ConnectionState } from '../entities/connection-state'; import { EventBus } from '../event-bus'; import { ConnectionQualityChangedEvent } from '../events/connection-quality-change.event'; import { ConnectionStateChangedEvent } from '../events/connection-state-change.event'; import { promiseTimeout } from '../utils/timeout.promise'; import { MessageQueue } from './message-queue'; const CLOSED_BY_CLIENT_EVENT_CODE = 4000; const CHECK_CONNECTION_INTERVAL = 20000; // Check every so many milliseconds if the server is still responding const PING_TIMEOUT = 5000; // The maximum time to wat for the server to respond. If the server does not respond within this time, the connection is considered to be closed const LOW_CONNECTION_QUALITY_LIMIT = 1000; // Time maximum time in milliseconds to wait for response of the ping request. When exceeding this limit the connection quality is considered to be low const HIGH_CONNECTION_QUALITY_LIMIT = 200; // When the response of the ping request arrives within this time in milliseconds the connection quality is considered to be high /** * Check the connection quality regularly and thus also provides a keep-a-live mechanism as websockets close after some idle tiem */ export class ConnectionMonitor { private pollingTimer?: number; private pollingPromise?: Promise<void>; private internalConnQuality: ConnectionQuality = 'medium'; private internalState: ConnectionState = 'initial'; constructor( private readonly eventBus: EventBus, private readonly messageQueue: MessageQueue, private readonly websocket: WebSocket, ) { this.websocket.onopen = (): void => { this.state = 'open'; void this.checkConnection(true); }; this.websocket.onclose = (event: CloseEvent): void => { // 4000 is a custom error code for a user initiated close if (this.state === 'open' && event.code !== CLOSED_BY_CLIENT_EVENT_CODE) { console.info('Websocket closed'); if (event.preventDefault) { event.preventDefault(); } this.lostConnection({ reason: 'Websocket closed', closeEvent: { reason: event.reason, code: event.code, }, }); } this.state = 'closed'; }; this.websocket.onerror = (error): void => { if (this.state === 'open') { console.error('Websocket error:', JSON.stringify(error)); this.lostConnection({ error, reason: 'Websocket error', }); } }; } get connectionQuality(): ConnectionQuality { return this.internalConnQuality; } set connectionQuality(quality: ConnectionQuality) { if (this.internalConnQuality === quality) { return; } this.eventBus.dispatch<ConnectionQualityChangedEvent>(ConnectionQualityChangedEvent.type, { quality, previous: this.internalConnQuality, }); this.internalConnQuality = quality; } get state(): ConnectionState { return this.internalState; } private set state(state: ConnectionState) { if (this.state === state) { return; } this.eventBus.dispatch<ConnectionStateChangedEvent>(ConnectionStateChangedEvent.type, { previous: this.internalState, state, }); this.internalState = state; } async checkConnection(force = false): Promise<void> { // TODO: rewrite checkConnection to use setInterval instead of setTimeout // this will reduce complexity as we do not to keep track of the polling timer if (this.state !== 'open') { return; } if (this.pollingPromise && !force) { // Already checking connection return; } if (this.pollingTimer) { clearTimeout(this.pollingTimer); } const promise = this.ping(); this.pollingPromise = promise; // TODO: check if this.connectionQuality can be set wihtin this.getConnectionQuality so we do not need to await this.connectionQuality = await this.getConnectionQuality(promise); if (this.state === 'open') { // Check if alive (not coming back < CHECK_CONNECTION_INTERVAL milliseconds) const isAlive = await promiseTimeout(PING_TIMEOUT, promise); this.pollingPromise = undefined; if (!isAlive) { this.lostConnection({ reason: 'Connection check timed out' }); } else { this.pollingTimer = setTimeout(() => this.checkConnection(), CHECK_CONNECTION_INTERVAL) as unknown as number; } } } private lostConnection(detail: { reason: string; error?: Event; closeEvent?: { reason: string; code: number }; }): void { if (this.pollingTimer) { clearTimeout(this.pollingTimer); } if (this.websocket && this.websocket.readyState <= WebSocket.OPEN) { // Websocket is CONNECTING or OPEN this.websocket.close(detail.closeEvent?.code); } if (this.state === 'closed') { return; } this.eventBus.dispatch<ConnectionStateChangedEvent>(ConnectionStateChangedEvent.type, { state: 'closed', previous: this.state, reason: detail.closeEvent?.reason ?? detail.reason, code: detail.closeEvent?.code ?? -1, }); this.state = 'closed'; } private getConnectionQuality(pingRequestPromise: Promise<void>): Promise<ConnectionQuality> { // Create (and return) a new Promise which gets resolved by either a timer (meaning a low connection quality as the request took too long to return ) // or when the ping request returns a result. In the latter case the time needed for the response to arrive ddetermines if the connection quality is medium or high. return new Promise((resolve) => { const before = new Date(); // Note: this timer causes the warning: // A worker process has failed to exit gracefully and has been force exited. const timer = setTimeout(() => resolve('low'), LOW_CONNECTION_QUALITY_LIMIT); pingRequestPromise .then(() => { // After ping response is obtained: stop the timer (when still running) and determine if the connection quality is high or medium clearTimeout(timer); const duration = new Date().getTime() - before.getTime(); if (duration < HIGH_CONNECTION_QUALITY_LIMIT) { resolve('high'); } else if (duration > LOW_CONNECTION_QUALITY_LIMIT) { // Check if duration > LOW_CONNECTION_QUALITY_LIMIT as otherwise quality is reset to medium resolve('medium'); } }) .catch((_) => { resolve('error'); }); }); } private ping(): Promise<void> { return this.messageQueue.request<void, void>('ping'); } public close(): void { this.lostConnection({ reason: 'Closed by client', closeEvent: { code: CLOSED_BY_CLIENT_EVENT_CODE, reason: 'Connection closed by client' }, }); } }