blob: 823b8c27b9a7b1fd18e3484fd1a9cdfd384f2a35 [file] [log] [blame]
/*
* 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 }));
}
}