// 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 = new Map(); _discardReplyReservations() { // it's ok. All pending reservations disappear into the GC. this._replyMap = new Map(); } _addReservation(replyCode: number, handler: ReplyHandler): void { this._replyMap.set(replyCode, handler); } request(message_: string, requestArgs?: any): Promise { let responseCode = this._nextResponseCode(); return new Promise((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): 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."); } } 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; } 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 { return new Promise((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 { return new Promise((resolve, reject) => { this.connectInternal_() .then((socket) => { this.socket = socket; resolve(); }) .catch((reason) => { reject(reason); }); }); } } export default PiPedalSocket;