0319cc0588
Fixes #20
312 lines
9.5 KiB
TypeScript
312 lines
9.5 KiB
TypeScript
// Copyright (c) 2022 Robin Davies
|
|
//
|
|
// 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.
|
|
|
|
import { PiPedalStateError } from './PiPedalError';
|
|
import Utility from './Utility';
|
|
|
|
export type MessageHandler = (header: PiPedalMessageHeader, body: any | null) => void;
|
|
export type ErrorHandler = (message: string, exception?: Error) => void;
|
|
export type ReconnectHandler = () => void;
|
|
export type ReconnectingHandler = (retry: number, maxRetries: number) => void;
|
|
|
|
const MAX_RETRIES = 6;
|
|
const MAX_RETRY_TIME = 90*1000;
|
|
|
|
export type PiPedalMessageHeader = {
|
|
replyTo?: number;
|
|
reply?: number;
|
|
message: string;
|
|
}
|
|
type ReplyHandler = (header: PiPedalMessageHeader, body: any | null) => void;
|
|
|
|
|
|
|
|
|
|
class PiPedalSocket {
|
|
onMessageReceived: MessageHandler;
|
|
onError: ErrorHandler;
|
|
onReconnecting: ReconnectingHandler;
|
|
onReconnect: ReconnectHandler;
|
|
|
|
socket?: WebSocket;
|
|
nextResponseCode: number = 0;
|
|
url: string;
|
|
retrying: boolean = false;
|
|
retryCount: number = 0;
|
|
retryDelay: number = 0;
|
|
totalRetryDelay: number = 0;
|
|
|
|
constructor(
|
|
url: string,
|
|
onMessageReceived: MessageHandler,
|
|
onError: ErrorHandler,
|
|
onReconnecting: ReconnectingHandler,
|
|
onReconnect: ReconnectHandler) {
|
|
this.url = url;
|
|
this.onMessageReceived = onMessageReceived;
|
|
this.onError = onError;
|
|
this.onReconnecting = onReconnecting;
|
|
this.onReconnect = onReconnect;
|
|
}
|
|
|
|
handleOpen(event: Event): any {
|
|
|
|
}
|
|
|
|
sendInternal_(json: string) {
|
|
if (!this.retrying) {
|
|
this.socket?.send(json);
|
|
}
|
|
}
|
|
send(message: string, jsonObject?: any) {
|
|
let msg: any;
|
|
if (jsonObject === undefined) {
|
|
msg = [{ message: message }];
|
|
} else {
|
|
msg = [{ message: message }, jsonObject];
|
|
}
|
|
let json = JSON.stringify(msg);
|
|
this.sendInternal_(json);
|
|
}
|
|
reply(replyTo: number, message: string, jsonObject?: any) {
|
|
if (replyTo !== -1) {
|
|
let msg: any;
|
|
if (jsonObject === undefined) {
|
|
msg = [{ reply: replyTo, message: message }];
|
|
} else {
|
|
msg = [{ reply: replyTo, message: message }, jsonObject];
|
|
}
|
|
let json = JSON.stringify(msg);
|
|
this.sendInternal_(json);
|
|
}
|
|
}
|
|
|
|
_nextResponseCode(): number {
|
|
return ++this.nextResponseCode;
|
|
}
|
|
|
|
_replyMap: Map<number, ReplyHandler> = new Map<number, ReplyHandler>();
|
|
|
|
_discardReplyReservations()
|
|
{
|
|
// it's ok. All pending reservations disappear into the GC.
|
|
this._replyMap = new Map<number, ReplyHandler>();
|
|
}
|
|
|
|
|
|
_addReservation(replyCode: number, handler: ReplyHandler): void {
|
|
this._replyMap.set(replyCode, handler);
|
|
}
|
|
|
|
request<Type = any>(message_: string, requestArgs?: any): Promise<Type> {
|
|
let responseCode = this._nextResponseCode();
|
|
|
|
return new Promise<Type>((resolve, reject) => {
|
|
try {
|
|
this._addReservation(responseCode, (header: PiPedalMessageHeader, jsonObject?: any) => {
|
|
if (header.message === "error") {
|
|
reject(jsonObject + "");
|
|
} else {
|
|
resolve(jsonObject as Type);
|
|
}
|
|
});
|
|
let msg: any;
|
|
if (requestArgs !== undefined) {
|
|
msg = [{ message: message_, replyTo: responseCode }, requestArgs];
|
|
} else {
|
|
msg = [{ message: message_, replyTo: responseCode }];
|
|
}
|
|
let jsonMessage = JSON.stringify(msg);
|
|
this.sendInternal_(jsonMessage);
|
|
} catch (err) {
|
|
reject(err);
|
|
}
|
|
});
|
|
}
|
|
handleMessage(event: MessageEvent<string>): any {
|
|
try {
|
|
let message: any = JSON.parse(event.data);
|
|
if (!Array.isArray(message)) {
|
|
throw new PiPedalStateError("Invalid message received from server.");
|
|
}
|
|
let header = message[0] as PiPedalMessageHeader;
|
|
let body = undefined;
|
|
if (message.length === 2) {
|
|
body = message[1];
|
|
}
|
|
if (header.reply !== undefined) {
|
|
let handler = this._replyMap.get(header.reply);
|
|
if (handler) {
|
|
handler(header, body);
|
|
}
|
|
return;
|
|
} else {
|
|
if (header.message === "error") {
|
|
throw new PiPedalStateError("Server error: " + body);
|
|
}
|
|
this.onMessageReceived(header, body);
|
|
}
|
|
} catch (error) {
|
|
if (this.onError) {
|
|
this.onError("Invalid server response. " + error, error as Error);
|
|
} else {
|
|
throw new PiPedalStateError("Invalid server response.");
|
|
}
|
|
}
|
|
}
|
|
|
|
handleError(_event: Event): any {
|
|
if (this.onError) {
|
|
this.onError("Server connection lost.");
|
|
} else {
|
|
throw new PiPedalStateError("Server connection lost.");
|
|
}
|
|
}
|
|
|
|
canReconnect: boolean = false;
|
|
|
|
handleClose(_event: any): any {
|
|
if (this.retrying) {
|
|
// treat this as a fatal error.
|
|
if (this.onError) {
|
|
this.onError("Server connection lost.");
|
|
} else {
|
|
throw new PiPedalStateError("Server connection closed.");
|
|
}
|
|
return;
|
|
|
|
}
|
|
if (this.canReconnect)
|
|
{
|
|
this._reconnect();
|
|
}
|
|
}
|
|
_reconnect() {
|
|
this._discardReplyReservations();
|
|
this.retrying = true;
|
|
this.retryCount = 0;
|
|
this.socket = undefined;
|
|
this.retryDelay = 10000;
|
|
this.totalRetryDelay = 0;
|
|
|
|
this.reconnect();
|
|
}
|
|
|
|
isBackground: boolean = false;
|
|
|
|
enterBackgroundState()
|
|
{
|
|
this.isBackground = true;
|
|
this.socket?.close();
|
|
|
|
}
|
|
exitBackgroundState()
|
|
{
|
|
this.isBackground = false;
|
|
this._reconnect();
|
|
}
|
|
|
|
reconnect() {
|
|
|
|
if (this.socket)
|
|
{
|
|
this.close();
|
|
}
|
|
|
|
this.onReconnecting(this.retryCount,MAX_RETRIES);
|
|
++this.retryCount;
|
|
|
|
this.connectInternal_()
|
|
.then((socket) => {
|
|
this.socket = socket;
|
|
this.retrying = false;
|
|
this.onReconnect();
|
|
})
|
|
.catch(error => {
|
|
if (this.totalRetryDelay === MAX_RETRY_TIME)
|
|
{
|
|
this.onError("Server connection lost.");
|
|
return;
|
|
} else {
|
|
this.totalRetryDelay += this.retryDelay;
|
|
Utility.delay(this.retryDelay).then(() => this.reconnect());
|
|
this.retryDelay *= 2;
|
|
if (this.retryDelay > 3000) this.retryDelay = 3000;
|
|
}
|
|
});
|
|
}
|
|
|
|
close(): void {
|
|
try {
|
|
if (this.socket)
|
|
{
|
|
this.socket.onclose = null;
|
|
this.socket.onerror = null;
|
|
this.socket.onmessage = null;
|
|
this.socket.onopen = null;
|
|
this.socket?.close();
|
|
}
|
|
} catch (ignored)
|
|
{
|
|
|
|
}
|
|
this.socket = undefined;
|
|
}
|
|
connectInternal_(): Promise<WebSocket> {
|
|
return new Promise<WebSocket>((resolve, reject) => {
|
|
let ws = new WebSocket(this.url);
|
|
let self = this;
|
|
|
|
ws.onmessage = this.handleMessage.bind(this);
|
|
ws.onclose = (event: Event) => {
|
|
ws.onclose = null;
|
|
ws.onerror = null;
|
|
reject("Can't connect to server.");
|
|
};
|
|
ws.onerror = (event: Event) => {
|
|
ws.onclose = null;
|
|
ws.onerror = null;
|
|
reject("Can't connect to server.");
|
|
};
|
|
ws.onopen = (event: Event) => {
|
|
ws.onerror = self.handleError.bind(self);
|
|
ws.onclose = self.handleClose.bind(self);
|
|
ws.onopen = null;
|
|
resolve(ws);
|
|
};
|
|
});
|
|
}
|
|
connect(): Promise<void> {
|
|
return new Promise<void>((resolve, reject) => {
|
|
|
|
this.connectInternal_()
|
|
.then((socket) => {
|
|
this.socket = socket;
|
|
resolve();
|
|
})
|
|
.catch((reason) => {
|
|
reject(reason);
|
|
});
|
|
});
|
|
}
|
|
|
|
}
|
|
|
|
export default PiPedalSocket;
|