testplane
Version:
Tests framework based on mocha and wdio
397 lines • 18 kB
JavaScript
;
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