blob: 823b8c27b9a7b1fd18e3484fd1a9cdfd384f2a35 [file] [log] [blame]
idillondfb9f1f2023-01-23 10:32:51 -05001/*
2 * Copyright (C) 2022 Savoir-faire Linux Inc.
3 *
4 * This program is free software; you can redistribute it and/or modify
5 * it under the terms of the GNU Affero General Public License as
6 * published by the Free Software Foundation; either version 3 of the
7 * License, or (at your option) any later version.
8 *
9 * This program is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 * GNU Affero General Public License for more details.
13 *
14 * You should have received a copy of the GNU Affero General Public
15 * License along with this program. If not, see
16 * <https://www.gnu.org/licenses/>.
17 */
18
19import { WebSocketMessage, WebSocketMessageTable, WebSocketMessageType } from 'jami-web-common';
20
21type WebSocketCallback<T extends WebSocketMessageType> = (data: WebSocketMessageTable[T]) => void;
22
23type WebSocketCallbacks = {
24 [key in WebSocketMessageType]: Set<WebSocketCallback<key>>;
25};
26
27const buildWebSocketCallbacks = (): WebSocketCallbacks => {
28 const webSocketCallback = {} as WebSocketCallbacks;
29 for (const messageType of Object.values(WebSocketMessageType)) {
30 // TODO: type this properly to prevent mistakes
31 // The end result of the function is still typed properly
32 webSocketCallback[messageType] = new Set() as any;
33 }
34 return webSocketCallback;
35};
36
37export class WebSocketClient {
38 private webSocket: WebSocket | null = null;
39 private accessToken: string | null = null;
40 private callbacks: WebSocketCallbacks = buildWebSocketCallbacks();
41 private reconnectionTimeout: ReturnType<typeof setTimeout> | null = null;
42
43 // Queue of messages waiting for connection
44 // TODO: Add timeout (or something else) for messages that should be discarded when it takes too long
45 private sendQueue: Parameters<WebSocketClient['send']>[] = [];
46
47 connect(apiUrl: string, accessToken: string) {
48 if (this.accessToken === accessToken) {
49 if (this.webSocket?.readyState === WebSocket.OPEN || this.webSocket?.readyState === WebSocket.CONNECTING) {
50 return; // Connection is already OK
51 }
52 } else {
53 // Clean up to make sure messages for previous token won't be sent or received accidentaly
54 this.disconnect();
55 }
56
57 this.accessToken = accessToken;
58 const url = new URL(apiUrl);
59 url.protocol = 'ws:';
60 url.searchParams.set('accessToken', accessToken);
61
62 this.webSocket = new WebSocket(url);
63 const webSocket = this.webSocket;
64
65 webSocket.onopen = () => {
66 console.debug('WebSocket connected');
67
68 // Send queued messages
69 const oldSendQueue = this.sendQueue;
70 this.sendQueue = [];
71 oldSendQueue.forEach(([type, data]) => this.send(type, data));
72 };
73
74 webSocket.onclose = (event) => {
75 // Involuntary disconnection. See 'disconnect' method to disconnect voluntarily.
76 console.debug(`WebSocket has been involuntarily disconnected (code: ${event.code}). Reconnecting...`);
77 this.reconnectionTimeout = setTimeout(() => this.connect(apiUrl, accessToken), 2000);
78 };
79
80 webSocket.onmessage = <T extends WebSocketMessageType>(event: MessageEvent<string>) => {
81 const messageString = event.data;
82 console.debug('WebSocket received message', messageString);
83
84 const message: WebSocketMessage<T> = JSON.parse(messageString);
85 if (!message.type || !message.data) {
86 console.warn('WebSocket message is not a valid WebSocketMessage (missing type or data fields)');
87 return;
88 }
89
90 if (!Object.values(WebSocketMessageType).includes(message.type)) {
91 console.warn(`Invalid WebSocket message type: ${message.type}`);
92 return;
93 }
94
95 const callbacksForType = this.callbacks[message.type];
96 for (const callback of callbacksForType) {
97 callback(message.data);
98 }
99 };
100
101 webSocket.onerror = (event: Event) => {
102 console.error('WebSocket errored', event);
103 };
104 }
105
106 disconnect() {
107 // Cancel any previous reconnection attempt
108 if (this.reconnectionTimeout) {
109 clearTimeout(this.reconnectionTimeout);
110 this.reconnectionTimeout = null;
111 }
112
113 this.sendQueue = [];
114 this.accessToken = null;
115
116 if (this.webSocket) {
117 this.webSocket.onclose = (event) =>
118 console.debug(`WebSocket has been voluntarily disconnected (code: ${event.code})`);
119 this.webSocket.close();
120 this.webSocket = null;
121 }
122 }
123
124 bind<T extends WebSocketMessageType>(type: T, callback: (data: WebSocketMessageTable[T]) => void) {
125 const callbacksForType = this.callbacks[type];
126 callbacksForType.add(callback);
127 }
128
129 unbind<T extends WebSocketMessageType>(type: T, callback: (data: WebSocketMessageTable[T]) => void) {
130 const callbacksForType = this.callbacks[type];
131 callbacksForType.delete(callback);
132 }
133
134 send<T extends WebSocketMessageType>(type: T, data: WebSocketMessageTable[T]) {
135 if (!this.webSocket || this.webSocket.readyState !== WebSocket.OPEN) {
136 this.sendQueue.push([type, data]);
137 return;
138 }
139 this.webSocket.send(JSON.stringify({ type, data }));
140 }
141}