@novo-learning/novo-sdk
Version:
SDK for the Novolanguage Speech Analysis API
189 lines (168 loc) • 6.93 kB
text/typescript
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' },
});
}
}