create-broadcast-app
Version:
Create a NextGen TV broadcast app with one command
322 lines (245 loc) • 8.27 kB
text/typescript
/*
* Copyright © 2022 Sinclair Broadcast Group
*
* 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.
*/
/* eslint-disable @typescript-eslint/no-use-before-define */
/* eslint-disable no-use-before-define */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { EventEmitter } from 'events';
import {
format,
InvalidRequest,
JsonRpcPayload,
JsonRpcPayloadError,
JsonRpcPayloadNotification,
JsonRpcPayloadRequest,
JsonRpcPayloadResponse,
MethodNotFound,
parse,
} from 'json-rpc-protocol';
import { ClientOptions, ReconnectOptions } from './client.types';
import { InternalError, MaxReconnectAttemptsExceedError } from './errors';
const RESERVED_EVENTS = [
'error',
'open',
'close',
'message',
'unexpected-response',
'upgrade',
'reopen',
'protocol:error',
'protocol:response',
'protocol:request',
];
export class Client extends EventEmitter {
private client?: WebSocket;
private options: ClientOptions;
private reconnect: ReconnectOptions;
private requestId = 0;
onReOpen?: () => void = undefined;
constructor(public endpoint: string, options?: ClientOptions) {
super();
this.options = {
callTimeout: 5000,
reconnectTimeout: 5000,
reconnectAttempts: Infinity,
waitConnectionTimeout: 5000,
...options,
};
this.reconnect = {
attempted: 0,
timeoutId: null,
};
this.open();
}
open() {
if (this.client) {
this.emit('error', new InternalError("Client exists, can't open a new websocket connection"));
return;
}
this.client = new WebSocket(this.endpoint);
this.client.addEventListener('error', (error) => this.onError(error));
this.client.addEventListener('open', () => this.onOpen());
this.client.addEventListener('close', ({ code, reason }) => this.onClose(code, reason));
this.client.addEventListener('message', (message) => this.onMessage(message));
}
close(code?: number, reason?: string) {
this.cleanupReconnect();
if (!this.client) {
this.emit('error', new InternalError("Client missing, can't close websocket connection"));
return;
}
this.client.close(code, reason);
}
isOpen() {
return !!(this.client && this.client.readyState === this.client.OPEN);
}
call(method: string, params?: any) {
this.requestId += 1;
const id = this.requestId;
let message: string;
try {
message = format.request(id, method, params);
} catch (error) {
this.emit('error', error);
return Promise.reject(error);
}
return this.waitConnection().then(
() =>
new Promise((resolve, reject) => {
this.client?.send(message);
const timeout = window.setTimeout(() => {
reject(new InternalError(`Call on method '${method}' timed out`));
}, this.options.callTimeout);
const protocolResponseHandler = (receivedId: number, result: any) => {
if (receivedId === id) {
cleanupCallEvents();
resolve(result);
}
};
const protocolErrorHandler = (receivedId: number, protocolError: any) => {
if (receivedId === id) {
cleanupCallEvents();
reject(protocolError);
}
};
const closeHandler = () => {
const closeError = new InternalError(
`Call on method ${method} failed, broken connection`
);
cleanupCallEvents();
reject(closeError);
};
const cleanupCallEvents = () => {
clearTimeout(timeout);
this.removeListener('protocol:response', protocolResponseHandler);
this.removeListener('protocol:error', protocolErrorHandler);
this.removeListener('close', closeHandler);
};
this.on('protocol:response', protocolResponseHandler);
this.on('protocol:error', protocolErrorHandler);
this.on('close', closeHandler);
})
);
}
private waitConnection(): Promise<void> {
if (this.isOpen()) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const handler = () => {
clearTimeout(timeoutId);
this.removeListener('open', handler);
if (this.isOpen()) {
resolve();
} else {
reject(new InternalError(`Client gone, can't request`));
}
};
const timeoutId = window.setTimeout(handler, this.options.waitConnectionTimeout);
this.once('open', handler);
});
}
/* Client events */
private onError(error: Event) {
this.emit('error', error);
}
private onOpen() {
this.cleanupReconnect();
if (this.onReOpen) this.onReOpen();
this.emit('open');
}
private onClose(code?: number, reason?: string) {
delete this.client;
this.emit('close', code, reason);
this.performReconnect();
}
private onMessage(message: MessageEvent) {
let parsedMessage;
try {
parsedMessage = parse(message.data);
} catch (error) {
this.emit('error', error);
return;
}
this.handleMessage(parsedMessage as JsonRpcPayload);
}
/* Reconnect */
private performReconnect() {
if (this.reconnect.attempted >= this.options?.reconnectAttempts) {
this.emit(
'error',
new MaxReconnectAttemptsExceedError('Too many failed connection attempts')
);
return;
}
this.reconnect.timeoutId = window.setTimeout(() => {
this.reconnect.attempted += 1;
this.emit('reopen', this.options.reconnectAttempts, this.reconnect.attempted);
this.open();
}, this.options.reconnectTimeout);
}
/* Protocol */
private cleanupReconnect() {
if (this.reconnect.timeoutId) {
clearTimeout(this.reconnect.timeoutId);
}
this.reconnect = {
attempted: 0,
timeoutId: null,
};
}
private handleMessage(payload: JsonRpcPayload) {
if ('method' in payload && RESERVED_EVENTS.includes(payload.method)) {
this.emit('error', new MethodNotFound(`Reserved method call: ${payload.method}`));
return;
}
if ('method' in payload && payload.method === 'org.atsc.notify') {
const { method, params } = payload as JsonRpcPayloadNotification;
this.emit(method, params);
return;
}
const { type } = payload;
switch (type) {
case 'error': {
const { id, error } = payload as JsonRpcPayloadError;
this.emit('protocol:error', id, error);
break;
}
case 'response': {
const { id, result } = payload as JsonRpcPayloadResponse;
this.emit('protocol:response', id, result);
break;
}
case 'request': {
const { method, id, params } = payload as JsonRpcPayloadRequest;
this.emit('protocol:request', method, id, params);
break;
}
case 'notification': {
const { method, params } = payload as JsonRpcPayloadNotification;
this.emit(method, params);
break;
}
default:
this.emit('error', new InvalidRequest(`Unknown protocol message type: ${type}`));
break;
}
}
}