Move WebSocket client to its own class

- Make the WebSocket client independent from the React's hooks and re-renders. It is intended to make the WebSocket connection more stable and easier to manage.
- Add 'sendQueue' to not lose messages while WebSocket is disconnected. Will need more work: not all messages should be sent after delay.
- Make WebSocketProvider always return a WebSocketClient

Change-Id: If6c967f7f558d90c82f8fd023058492dfc5b8735
diff --git a/client/src/services/WebSocketClient.ts b/client/src/services/WebSocketClient.ts
new file mode 100644
index 0000000..823b8c2
--- /dev/null
+++ b/client/src/services/WebSocketClient.ts
@@ -0,0 +1,141 @@
+/*
+ * Copyright (C) 2022 Savoir-faire Linux Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with this program.  If not, see
+ * <https://www.gnu.org/licenses/>.
+ */
+
+import { WebSocketMessage, WebSocketMessageTable, WebSocketMessageType } from 'jami-web-common';
+
+type WebSocketCallback<T extends WebSocketMessageType> = (data: WebSocketMessageTable[T]) => void;
+
+type WebSocketCallbacks = {
+  [key in WebSocketMessageType]: Set<WebSocketCallback<key>>;
+};
+
+const buildWebSocketCallbacks = (): WebSocketCallbacks => {
+  const webSocketCallback = {} as WebSocketCallbacks;
+  for (const messageType of Object.values(WebSocketMessageType)) {
+    // TODO: type this properly to prevent mistakes
+    // The end result of the function is still typed properly
+    webSocketCallback[messageType] = new Set() as any;
+  }
+  return webSocketCallback;
+};
+
+export class WebSocketClient {
+  private webSocket: WebSocket | null = null;
+  private accessToken: string | null = null;
+  private callbacks: WebSocketCallbacks = buildWebSocketCallbacks();
+  private reconnectionTimeout: ReturnType<typeof setTimeout> | null = null;
+
+  // Queue of messages waiting for connection
+  // TODO: Add timeout (or something else) for messages that should be discarded when it takes too long
+  private sendQueue: Parameters<WebSocketClient['send']>[] = [];
+
+  connect(apiUrl: string, accessToken: string) {
+    if (this.accessToken === accessToken) {
+      if (this.webSocket?.readyState === WebSocket.OPEN || this.webSocket?.readyState === WebSocket.CONNECTING) {
+        return; // Connection is already OK
+      }
+    } else {
+      // Clean up to make sure messages for previous token won't be sent or received accidentaly
+      this.disconnect();
+    }
+
+    this.accessToken = accessToken;
+    const url = new URL(apiUrl);
+    url.protocol = 'ws:';
+    url.searchParams.set('accessToken', accessToken);
+
+    this.webSocket = new WebSocket(url);
+    const webSocket = this.webSocket;
+
+    webSocket.onopen = () => {
+      console.debug('WebSocket connected');
+
+      // Send queued messages
+      const oldSendQueue = this.sendQueue;
+      this.sendQueue = [];
+      oldSendQueue.forEach(([type, data]) => this.send(type, data));
+    };
+
+    webSocket.onclose = (event) => {
+      // Involuntary disconnection. See 'disconnect' method to disconnect voluntarily.
+      console.debug(`WebSocket has been involuntarily disconnected (code: ${event.code}). Reconnecting...`);
+      this.reconnectionTimeout = setTimeout(() => this.connect(apiUrl, accessToken), 2000);
+    };
+
+    webSocket.onmessage = <T extends WebSocketMessageType>(event: MessageEvent<string>) => {
+      const messageString = event.data;
+      console.debug('WebSocket received message', messageString);
+
+      const message: WebSocketMessage<T> = JSON.parse(messageString);
+      if (!message.type || !message.data) {
+        console.warn('WebSocket message is not a valid WebSocketMessage (missing type or data fields)');
+        return;
+      }
+
+      if (!Object.values(WebSocketMessageType).includes(message.type)) {
+        console.warn(`Invalid WebSocket message type: ${message.type}`);
+        return;
+      }
+
+      const callbacksForType = this.callbacks[message.type];
+      for (const callback of callbacksForType) {
+        callback(message.data);
+      }
+    };
+
+    webSocket.onerror = (event: Event) => {
+      console.error('WebSocket errored', event);
+    };
+  }
+
+  disconnect() {
+    // Cancel any previous reconnection attempt
+    if (this.reconnectionTimeout) {
+      clearTimeout(this.reconnectionTimeout);
+      this.reconnectionTimeout = null;
+    }
+
+    this.sendQueue = [];
+    this.accessToken = null;
+
+    if (this.webSocket) {
+      this.webSocket.onclose = (event) =>
+        console.debug(`WebSocket has been voluntarily disconnected (code: ${event.code})`);
+      this.webSocket.close();
+      this.webSocket = null;
+    }
+  }
+
+  bind<T extends WebSocketMessageType>(type: T, callback: (data: WebSocketMessageTable[T]) => void) {
+    const callbacksForType = this.callbacks[type];
+    callbacksForType.add(callback);
+  }
+
+  unbind<T extends WebSocketMessageType>(type: T, callback: (data: WebSocketMessageTable[T]) => void) {
+    const callbacksForType = this.callbacks[type];
+    callbacksForType.delete(callback);
+  }
+
+  send<T extends WebSocketMessageType>(type: T, data: WebSocketMessageTable[T]) {
+    if (!this.webSocket || this.webSocket.readyState !== WebSocket.OPEN) {
+      this.sendQueue.push([type, data]);
+      return;
+    }
+    this.webSocket.send(JSON.stringify({ type, data }));
+  }
+}