"use strict"; // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. Object.defineProperty(exports, "__esModule", { value: true }); const MAX_WEBSOCKET_FAILS = 7; const MIN_WEBSOCKET_RETRY_TIME = 3000; // 3 sec const MAX_WEBSOCKET_RETRY_TIME = 300000; // 5 mins const JITTER_RANGE = 2000; // 2 sec const WEBSOCKET_HELLO = 'hello'; class WebSocketClient { conn; connectionUrl; // responseSequence is the number to track a response sent // via the websocket. A response will always have the same sequence number // as the request. responseSequence; // serverSequence is the incrementing sequence number from the // server-sent event stream. serverSequence; connectFailCount; responseCallbacks; /** * @deprecated Use messageListeners instead */ eventCallback = null; /** * @deprecated Use firstConnectListeners instead */ firstConnectCallback = null; /** * @deprecated Use reconnectListeners instead */ reconnectCallback = null; /** * @deprecated Use missedMessageListeners instead */ missedEventCallback = null; /** * @deprecated Use errorListeners instead */ errorCallback = null; /** * @deprecated Use closeListeners instead */ closeCallback = null; messageListeners = new Set(); firstConnectListeners = new Set(); reconnectListeners = new Set(); missedMessageListeners = new Set(); errorListeners = new Set(); closeListeners = new Set(); connectionId; serverHostname; postedAck; constructor() { this.conn = null; this.connectionUrl = null; this.responseSequence = 1; this.serverSequence = 0; this.connectFailCount = 0; this.responseCallbacks = {}; this.connectionId = ''; this.serverHostname = ''; this.postedAck = false; } // on connect, only send auth cookie and blank state. // on hello, get the connectionID and store it. // on reconnect, send cookie, connectionID, sequence number. initialize(connectionUrl = this.connectionUrl, token, postedAck) { if (this.conn) { return; } if (connectionUrl == null) { console.log('websocket must have connection url'); //eslint-disable-line no-console return; } if (this.connectFailCount === 0) { console.log('websocket connecting to ' + connectionUrl); //eslint-disable-line no-console } if (typeof postedAck != 'undefined') { this.postedAck = postedAck; } // Add connection id, and last_sequence_number to the query param. // We cannot use a cookie because it will bleed across tabs. // We cannot also send it as part of the auth_challenge, because the session cookie is already sent with the request. this.conn = new WebSocket(`${connectionUrl}?connection_id=${this.connectionId}&sequence_number=${this.serverSequence}${this.postedAck ? '&posted_ack=true' : ''}`); this.connectionUrl = connectionUrl; this.conn.onopen = () => { if (token) { this.sendMessage('authentication_challenge', { token }); } if (this.connectFailCount > 0) { console.log('websocket re-established connection'); //eslint-disable-line no-console this.reconnectCallback?.(); this.reconnectListeners.forEach((listener) => listener()); } else if (this.firstConnectCallback || this.firstConnectListeners.size > 0) { this.firstConnectCallback?.(); this.firstConnectListeners.forEach((listener) => listener()); } this.connectFailCount = 0; }; this.conn.onclose = () => { this.conn = null; this.responseSequence = 1; if (this.connectFailCount === 0) { console.log('websocket closed'); //eslint-disable-line no-console } this.connectFailCount++; this.closeCallback?.(this.connectFailCount); this.closeListeners.forEach((listener) => listener(this.connectFailCount)); let retryTime = MIN_WEBSOCKET_RETRY_TIME; // If we've failed a bunch of connections then start backing off if (this.connectFailCount > MAX_WEBSOCKET_FAILS) { retryTime = MIN_WEBSOCKET_RETRY_TIME * this.connectFailCount * this.connectFailCount; if (retryTime > MAX_WEBSOCKET_RETRY_TIME) { retryTime = MAX_WEBSOCKET_RETRY_TIME; } } // Applying jitter to avoid thundering herd problems. retryTime += Math.random() * JITTER_RANGE; setTimeout(() => { this.initialize(connectionUrl, token, postedAck); }, retryTime); }; this.conn.onerror = (evt) => { if (this.connectFailCount <= 1) { console.log('websocket error'); //eslint-disable-line no-console console.log(evt); //eslint-disable-line no-console } this.errorCallback?.(evt); this.errorListeners.forEach((listener) => listener(evt)); }; this.conn.onmessage = (evt) => { const msg = JSON.parse(evt.data); if (msg.seq_reply) { // This indicates a reply to a websocket request. // We ignore sequence number validation of message responses // and only focus on the purely server side event stream. if (msg.error) { console.log(msg); //eslint-disable-line no-console } if (this.responseCallbacks[msg.seq_reply]) { this.responseCallbacks[msg.seq_reply](msg); Reflect.deleteProperty(this.responseCallbacks, msg.seq_reply); } } else if (this.eventCallback || this.messageListeners.size > 0) { // We check the hello packet, which is always the first packet in a stream. if (msg.event === WEBSOCKET_HELLO && (this.missedEventCallback || this.missedMessageListeners.size > 0)) { console.log('got connection id ', msg.data.connection_id); //eslint-disable-line no-console // If we already have a connectionId present, and server sends a different one, // that means it's either a long timeout, or server restart, or sequence number is not found. // Then we do the sync calls, and reset sequence number to 0. if (this.connectionId !== '' && this.connectionId !== msg.data.connection_id) { console.log('long timeout, or server restart, or sequence number is not found.'); //eslint-disable-line no-console this.missedEventCallback?.(); for (const listener of this.missedMessageListeners) { try { listener(); } catch (e) { console.log(`missed message listener "${listener.name}" failed: ${e}`); // eslint-disable-line no-console } } this.serverSequence = 0; } // If it's a fresh connection, we have to set the connectionId regardless. // And if it's an existing connection, setting it again is harmless, and keeps the code simple. this.connectionId = msg.data.connection_id; // Also update the server hostname this.serverHostname = msg.data.server_hostname; } // Now we check for sequence number, and if it does not match, // we just disconnect and reconnect. if (msg.seq !== this.serverSequence) { console.log('missed websocket event, act_seq=' + msg.seq + ' exp_seq=' + this.serverSequence); //eslint-disable-line no-console // We are not calling this.close() because we need to auto-restart. this.connectFailCount = 0; this.responseSequence = 1; this.conn?.close(); // Will auto-reconnect after MIN_WEBSOCKET_RETRY_TIME. return; } this.serverSequence = msg.seq + 1; this.eventCallback?.(msg); this.messageListeners.forEach((listener) => listener(msg)); } }; } /** * @deprecated Use addMessageListener instead */ setEventCallback(callback) { this.eventCallback = callback; } addMessageListener(listener) { this.messageListeners.add(listener); if (this.messageListeners.size > 5) { // eslint-disable-next-line no-console console.warn(`WebSocketClient has ${this.messageListeners.size} message listeners registered`); } } removeMessageListener(listener) { this.messageListeners.delete(listener); } /** * @deprecated Use addFirstConnectListener instead */ setFirstConnectCallback(callback) { this.firstConnectCallback = callback; } addFirstConnectListener(listener) { this.firstConnectListeners.add(listener); if (this.firstConnectListeners.size > 5) { // eslint-disable-next-line no-console console.warn(`WebSocketClient has ${this.firstConnectListeners.size} first connect listeners registered`); } } removeFirstConnectListener(listener) { this.firstConnectListeners.delete(listener); } /** * @deprecated Use addReconnectListener instead */ setReconnectCallback(callback) { this.reconnectCallback = callback; } addReconnectListener(listener) { this.reconnectListeners.add(listener); if (this.reconnectListeners.size > 5) { // eslint-disable-next-line no-console console.warn(`WebSocketClient has ${this.reconnectListeners.size} reconnect listeners registered`); } } removeReconnectListener(listener) { this.reconnectListeners.delete(listener); } /** * @deprecated Use addMissedMessageListener instead */ setMissedEventCallback(callback) { this.missedEventCallback = callback; } addMissedMessageListener(listener) { this.missedMessageListeners.add(listener); if (this.missedMessageListeners.size > 5) { // eslint-disable-next-line no-console console.warn(`WebSocketClient has ${this.missedMessageListeners.size} missed message listeners registered`); } } removeMissedMessageListener(listener) { this.missedMessageListeners.delete(listener); } /** * @deprecated Use addErrorListener instead */ setErrorCallback(callback) { this.errorCallback = callback; } addErrorListener(listener) { this.errorListeners.add(listener); if (this.errorListeners.size > 5) { // eslint-disable-next-line no-console console.warn(`WebSocketClient has ${this.errorListeners.size} error listeners registered`); } } removeErrorListener(listener) { this.errorListeners.delete(listener); } /** * @deprecated Use addCloseListener instead */ setCloseCallback(callback) { this.closeCallback = callback; } addCloseListener(listener) { this.closeListeners.add(listener); if (this.closeListeners.size > 5) { // eslint-disable-next-line no-console console.warn(`WebSocketClient has ${this.closeListeners.size} close listeners registered`); } } removeCloseListener(listener) { this.closeListeners.delete(listener); } close() { this.connectFailCount = 0; this.responseSequence = 1; if (this.conn && this.conn.readyState === WebSocket.OPEN) { this.conn.onclose = () => { }; this.conn.close(); this.conn = null; console.log('websocket closed'); //eslint-disable-line no-console } } sendMessage(action, data, responseCallback) { const msg = { action, seq: this.responseSequence++, data, }; if (responseCallback) { this.responseCallbacks[msg.seq] = responseCallback; } if (this.conn && this.conn.readyState === WebSocket.OPEN) { this.conn.send(JSON.stringify(msg)); } else if (!this.conn || this.conn.readyState === WebSocket.CLOSED) { this.conn = null; this.initialize(); } } userTyping(channelId, parentId, callback) { const data = { channel_id: channelId, parent_id: parentId, }; this.sendMessage('user_typing', data, callback); } updateActiveChannel(channelId, callback) { const data = { channel_id: channelId, }; this.sendMessage('presence', data, callback); } updateActiveTeam(teamId, callback) { const data = { team_id: teamId, }; this.sendMessage('presence', data, callback); } updateActiveThread(isThreadView, channelId, callback) { const data = { thread_channel_id: channelId, is_thread_view: isThreadView, }; this.sendMessage('presence', data, callback); } userUpdateActiveStatus(userIsActive, manual, callback) { const data = { user_is_active: userIsActive, manual, }; this.sendMessage('user_update_active_status', data, callback); } acknowledgePostedNotification(postId, status, reason, postedData) { const data = { post_id: postId, user_agent: window.navigator.userAgent, status, reason, data: postedData, }; this.sendMessage('posted_notify_ack', data); } getStatuses(callback) { this.sendMessage('get_statuses', null, callback); } getStatusesByIds(userIds, callback) { const data = { user_ids: userIds, }; this.sendMessage('get_statuses_by_ids', data, callback); } } exports.default = WebSocketClient;