UNPKG

testplane

Version:

Tests framework based on mocha and wdio

397 lines 18 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.WsConnection = void 0; const consumers_1 = require("node:stream/consumers"); const ws_1 = require("ws"); const error_1 = require("./error"); const utils_1 = require("./utils"); const constants_1 = require("./constants"); var WsConnectionStatus; (function (WsConnectionStatus) { WsConnectionStatus[WsConnectionStatus["DISCONNECTED"] = 0] = "DISCONNECTED"; WsConnectionStatus[WsConnectionStatus["CONNECTING"] = 1] = "CONNECTING"; WsConnectionStatus[WsConnectionStatus["CONNECTED"] = 2] = "CONNECTED"; WsConnectionStatus[WsConnectionStatus["CLOSED"] = 3] = "CLOSED"; })(WsConnectionStatus || (WsConnectionStatus = {})); // Closing WS when its still not connected produces error: // https://github.com/websockets/ws/blob/86eac5b44ac2bff9087ec40c9bd06bc7b4f0da07/lib/websocket.js#L297-L301 const closeWsConnection = (ws) => { if (ws.readyState !== ws.CONNECTING) { ws.close(); } else { ws.once("open", () => { ws.close(); }); } }; class WsConnection { constructor(endpoint, options) { this._onPong = null; this._pingShouldSkip = false; this._pingInterval = null; this._pingSubsequentFails = 0; this._onConnectionCloseFn = null; // Defined, if there is connection attempt at the moment this._wsConnectionStatus = WsConnectionStatus.DISCONNECTED; this._wsConnection = null; this._wsConnectionPromise = null; this._requestId = 0; this._pendingRequests = {}; const { headers, debugFn, retries, timeouts, errors, onMessage } = options; this._endpoint = endpoint; this._requestHeaders = headers; this._debugFn = debugFn || (() => { }); this._retries = retries || { count: 3, baseDelay: 500, factor: 2 }; this._timeouts = timeouts; this._errors = errors; this._onMessage = onMessage; } async getConnectionProperties() { await this._getWsConnection(); return { responseHeaders: this._responseHeaders, }; } /** @description Tries to establish ws connection with timeout */ async _tryToEstablishWsConnection(endpoint) { return new Promise(resolve => { try { const onConnectionCloseFn = () => done(new this._errors.ConnectionTerminated()); if (this._wsConnectionStatus === WsConnectionStatus.CLOSED) { onConnectionCloseFn(); } else { this._onConnectionCloseFn = onConnectionCloseFn; } // eslint-disable-next-line const cdpConnectionInstance = this; const ws = new ws_1.WebSocket(endpoint, { headers: this._requestHeaders }); let isSettled = false; const timeoutId = setTimeout(() => { closeWsConnection(ws); done(new this._errors.ConnectionTimeout({ message: `Couldn't establish WS connection to "${endpoint}" in ${this._timeouts.createSession}ms`, })); }, this._timeouts.createSession).unref(); const onOpen = () => { done(ws); }; const onUnexpectedResponse = async (ws, res) => { closeWsConnection(ws); const reason = await (0, consumers_1.text)(res).catch(() => "Unknown reason"); done(new this._errors.ConnectionEstablishment({ message: `Couldn't establish WS connection to "${endpoint}"\n\tReason: ${reason}`, statusCode: res.statusCode, })); }; const onError = (error) => { closeWsConnection(ws); done(new this._errors.ConnectionEstablishment({ message: `Couldn't establish WS connection to "${endpoint}": ${error}`, })); }; const onClose = () => { done(new this._errors.ConnectionEstablishment({ message: `WS connection to "${endpoint}" unexpectedly closed while establishing`, })); }; const onUpgrade = (res) => { this._responseHeaders = res.headers; this._onResponseHeaders?.(res.headers); }; ws.on("open", onOpen); ws.on("unexpected-response", onUnexpectedResponse); ws.on("error", onError); ws.on("close", onClose); ws.on("upgrade", onUpgrade); // eslint-disable-next-line no-inner-declarations function done(result) { if (isSettled) { return; } cdpConnectionInstance._onConnectionCloseFn = null; isSettled = true; clearTimeout(timeoutId); ws.off("open", onOpen); ws.off("unexpected-response", onUnexpectedResponse); ws.off("error", onError); ws.off("close", onClose); ws.off("upgrade", onUpgrade); resolve(result); } } catch (err) { resolve(err); } }); } /** * @description creates ws connection with retries or returns existing one * @note Concurrent requests with same params produce same ws connection */ async _getWsConnection() { const ws = this._wsConnection; if (this._wsConnectionStatus === WsConnectionStatus.CLOSED) { throw new this._errors.ConnectionTerminated({ message: `Session to ${this._endpoint} was closed` }); } if (this._wsConnectionStatus === WsConnectionStatus.CONNECTING && this._wsConnectionPromise) { return this._wsConnectionPromise; } if (this._wsConnectionStatus === WsConnectionStatus.CONNECTED && ws && ws.readyState === ws.OPEN) { return ws; } if (this._wsConnectionStatus === WsConnectionStatus.CONNECTED && ws && ws.readyState !== ws.OPEN) { this._closeWsConnection("WS connection was in invalid state", WsConnectionStatus.DISCONNECTED); } this._wsConnectionStatus = WsConnectionStatus.CONNECTING; this._wsConnectionPromise = (async () => { try { for (let retriesLeft = this._retries.count || 0; retriesLeft >= 0; retriesLeft--) { const result = await this._tryToEstablishWsConnection(this._endpoint); if (this._wsConnectionStatus === WsConnectionStatus.CLOSED) { if (result instanceof ws_1.WebSocket) { closeWsConnection(result); } throw new this._errors.ConnectionTerminated(); } if (result instanceof ws_1.WebSocket) { this._debugFn(`\u2713 Established WS connection to ${this._endpoint}`); this._wsConnection = result; this._wsConnectionStatus = WsConnectionStatus.CONNECTED; this._pingHealthCheckStart(); const onPing = () => result.pong(); const onMessage = (data, isBinary) => this._onMessage(data, isBinary); const onError = (err) => { if (result === this._wsConnection) { this._closeWsConnection(`An error occured in WS connection: ${err}`, WsConnectionStatus.DISCONNECTED); this._tryToReconnect(); } }; result.on("ping", onPing); result.on("message", onMessage); result.on("error", onError); result.once("close", () => { result.off("ping", onPing); result.off("message", onMessage); result.off("error", onError); if (result === this._wsConnection) { this._closeWsConnection("WS connection was closed unexpectedly", WsConnectionStatus.DISCONNECTED); this._tryToReconnect(); } }); return result; } if (!(result instanceof error_1.WsError) || !result.isRetryable()) { throw result; } this._debugFn(`⟳ ${result.message}; retries left: ${retriesLeft}; endpoint: "${this._endpoint}"`); // Intentionally avoiding wait after timeout if (result instanceof error_1.WsError && !(result instanceof error_1.WsTimeoutError)) { await (0, utils_1.exponentiallyWait)({ baseDelay: this._retries.baseDelay, attempt: this._retries.count - retriesLeft, factor: this._retries.factor, }); } } throw new this._errors.ConnectionEstablishment({ message: `Couldn't establish WS connection to ${this._endpoint} in ${this._retries.count} retries`, }); } catch (err) { if (this._wsConnectionStatus === WsConnectionStatus.CONNECTING) { this._wsConnectionStatus = WsConnectionStatus.DISCONNECTED; this._wsConnectionPromise = null; } throw err; } finally { if (this._wsConnectionStatus !== WsConnectionStatus.CONNECTING) { this._wsConnectionPromise = null; } } })(); return this._wsConnectionPromise; } /** @description Produces connection-"uniq" request ids */ getRequestId() { const id = ++this._requestId; if (this._requestId >= constants_1.WS_MAX_REQUEST_ID) { this._requestId = 0; } return id; } /** @description Performs WS request with timeout */ async makeRequest(requestId, requestMessage) { const ws = await this._getWsConnection(); if (this._wsConnectionStatus === WsConnectionStatus.CLOSED) { throw new this._errors.ConnectionTerminated({ requestId, message: `Couldn't send request because WS connection was manually closed`, }); } return new Promise(resolve => { const pendingRequests = this._pendingRequests; let isSettled = false; const onTimeout = setTimeout(() => { const err = new this._errors.RequestTimeout({ message: `Timed out while waiting for request in ${this._timeouts.request}ms`, requestId, }); done(err); }, this._timeouts.request).unref(); function done(response) { if (isSettled) { return; } isSettled = true; delete pendingRequests[requestId]; clearTimeout(onTimeout); resolve(response); } pendingRequests[requestId] = done; ws.send(requestMessage, error => { if (!error) { this._pingShouldSkip = true; return; } done(new this._errors.ConnectionBreak({ message: `Couldn't send WS request: ${error.message}`, requestId, })); // Proactively closing connection as "send error" is marker that something bad with connection happened if (ws === this._wsConnection) { this._closeWsConnection("WS connection was considered broken as 'send' failed", WsConnectionStatus.DISCONNECTED); this._tryToReconnect(); } }); }); } provideResponseFor(requestId, data) { if (!this._pendingRequests[requestId]) { this._debugFn(`! Received response to request ${requestId}, which is probably timed out already; endpoint: "${this._endpoint}"`); return; } this._pendingRequests[requestId](data); } forceReconnect(sessionAbortMessage) { this._closeWsConnection(sessionAbortMessage, WsConnectionStatus.DISCONNECTED); this._tryToReconnect(); } /** @description Used to abort all pending requests when connection is closed */ _abortPendingRequests(message, isTerminated) { const pendingRequests = this._pendingRequests; const pendingRequestIds = Object.keys(pendingRequests).map(Number); this._pendingRequests = {}; for (const requestId of pendingRequestIds) { if (pendingRequests[requestId]) { pendingRequests[requestId](isTerminated ? new this._errors.ConnectionTerminated({ message, requestId, }) : new this._errors.ConnectionBreak({ message, requestId, })); } } } _closeWsConnection(sessionAbortMessage, status) { const ws = this._wsConnection; if (!ws || this._wsConnectionStatus === WsConnectionStatus.CLOSED) { this._wsConnection = null; return; } this._debugFn(`\u2718 ${sessionAbortMessage}; endpoint: "${this._endpoint}"`); const isClosing = status === WsConnectionStatus.CLOSED; if (isClosing && this._onConnectionCloseFn) { this._onConnectionCloseFn(); } this._wsConnection = null; this._wsConnectionStatus = status; this._abortPendingRequests(`Request was aborted because ${sessionAbortMessage}`, isClosing); this._pingHealthCheckStop(); if (isClosing) { this._onClose?.(); } else { this._onDisconnect?.(); } closeWsConnection(ws); } /** * @description Tries to re-establish connection after network drops * @note Silently gives up after failed retries attempts */ _tryToReconnect() { this._debugFn(`⟳ Trying to reconnect; endpoint: "${this._endpoint}"`); this._getWsConnection() .then(() => { this._onReconnect?.(); this._debugFn(`\u2713 Successfully reconnected to session; endpoint: "${this._endpoint}"`); }) .catch(() => this._debugFn(`\u2718 Couldn't reconnect to session automatically; endpoint: "${this._endpoint}"`)); } /** @description Closes websocket connection, terminating all pending requests */ close() { this._closeWsConnection("Connection was closed manually", WsConnectionStatus.CLOSED); } _pingHealthCheckStop() { this._pingSubsequentFails = 0; if (this._pingInterval) { clearInterval(this._pingInterval); } if (this._wsConnection && this._onPong) { this._wsConnection.off("pong", this._onPong); } } _isWebSocketActive(ws) { return Boolean(ws.readyState !== ws.CLOSED && ws.readyState !== ws.CLOSING && ws === this._wsConnection); } _pingHealthCheckStart() { this._pingHealthCheckStop(); const ws = this._wsConnection; if (!ws || !this._isWebSocketActive(ws)) { return; } this._pingHealthCheckStop(); let isWaitingForPong = false; let pongTimeout; const onPong = (this._onPong = () => { if (isWaitingForPong && this._isWebSocketActive(ws)) { isWaitingForPong = false; this._debugFn(`< PONG; endpoint: "${this._endpoint}"`); clearTimeout(pongTimeout); this._pingSubsequentFails = 0; } }); ws.on("pong", onPong); const pingInterval = (this._pingInterval = setInterval(() => { if (!this._isWebSocketActive(ws)) { clearInterval(pingInterval); return; } if (this._pingShouldSkip) { this._pingShouldSkip = false; return; } pongTimeout = setTimeout(() => { if (isWaitingForPong && this._isWebSocketActive(ws)) { isWaitingForPong = false; this._pingSubsequentFails++; this._debugFn(`! Ping failed(${this._pingSubsequentFails} in a row) in ${constants_1.WS_PING_TIMEOUT}ms`); if (this._pingSubsequentFails >= constants_1.WS_PING_MAX_SUBSEQUENT_FAILS) { this._closeWsConnection(`WS connection was considered broken as ${this._pingSubsequentFails} pings failed in a row`, WsConnectionStatus.DISCONNECTED); this._tryToReconnect(); } } }, constants_1.WS_PING_TIMEOUT).unref(); ws.ping(); this._debugFn(`> PING; endpoint: "${this._endpoint}"`); isWaitingForPong = true; }, constants_1.WS_PING_INTERVAL).unref()); } } exports.WsConnection = WsConnection; //# sourceMappingURL=index.js.map