Files
op-pedal/react/src/PiPedalSocket.tsx
T
2024-09-21 13:18:00 -04:00

314 lines
9.8 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;
export interface PiPedalSocketListener {
onMessageReceived: (header: PiPedalMessageHeader, body: any | null) => void;
onError: (message: string, exception?: Error) => void;
onConnectionLost: () => void;
onReconnect: () => void;
onReconnecting: (retry: number, maxRetries: number) => boolean;
};
class PiPedalSocket {
listener: PiPedalSocketListener;
socket?: WebSocket;
nextResponseCode: number = 0;
url: string;
retrying: boolean = false;
retryCount: number = 0;
retryDelay: number = 0;
totalRetryDelay: number = 0;
constructor(
url: string,
listener: PiPedalSocketListener
) {
this.url = url;
this.listener = listener;
}
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.listener.onMessageReceived(header, body);
}
} catch (error) {
if (this.listener) {
this.listener.onError("Invalid server response. " + error, error as Error);
} else {
throw new PiPedalStateError("Invalid server response.");
}
}
}
handleError(_event: Event): any {
if (this.listener) {
this.listener.onError("Server connection lost.");
} else {
throw new PiPedalStateError("Server connection lost.");
}
}
canReconnect: boolean = false;
handleClose(_event: any): any {
if (this.retrying) {
this.close();
// treat this as a fatal error.
if (this.listener) {
this.listener.onError("Server connection lost.");
} else {
throw new PiPedalStateError("Server connection closed.");
}
return;
}
if (this.canReconnect) {
this.listener.onConnectionLost();
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.close();
}
exitBackgroundState() {
this.isBackground = false;
this._reconnect();
}
reconnect() {
if (this.socket) {
this.close();
}
if (!this.listener.onReconnecting(this.retryCount, MAX_RETRIES)) {
return;
}
++this.retryCount;
this.connectInternal_()
.then((socket) => {
this.socket = socket;
this.retrying = false;
this.listener.onReconnect();
})
.catch(error => {
if (this.totalRetryDelay >= MAX_RETRY_TIME) {
this.listener.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();
this.socket = undefined;
}
} catch (ignored) {
}
this.socket = undefined;
}
connectInternal_(): Promise<WebSocket> {
return new Promise<WebSocket>((resolve, reject) => {
try {
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("Connection closed unexpectedly.");
};
ws.onerror = (evt: Event) => {
ws.onclose = null;
ws.onerror = null;
reject("Failed to connect.");
};
ws.onopen = (event: Event) => {
ws.onerror = self.handleError.bind(self);
ws.onclose = self.handleClose.bind(self);
ws.onopen = null;
resolve(ws);
};
} catch (e: any){
reject("Failed to connect. " + e.toString());
};
});
}
connect(): Promise < void> {
return new Promise<void>((resolve, reject) => {
this.connectInternal_()
.then((socket) => {
this.socket = socket;
resolve();
})
.catch((reason) => {
reject(reason);
});
});
}
}
export default PiPedalSocket;