"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.WebsocketReverseMappingTransport = exports.WebSocketTransport = exports.WebSocketClassProvider = exports.WebSocket = void 0;
const ws_1 = __importDefault(require("ws"));
exports.WebSocket = ws_1.default;
const metrics_1 = require("../metrics");
const util_1 = require("../util");
const streaming_1 = require("./abstract/streaming");
const metrics_2 = require("./metrics");
const logger = (0, util_1.makeLogger)('WebSocketTransport');
class WebSocketClassProvider {
    static set(ctor) {
        this.ctor = ctor;
    }
    static get() {
        return this.ctor;
    }
}
exports.WebSocketClassProvider = WebSocketClassProvider;
WebSocketClassProvider.ctor = ws_1.default;
/**
 * Transport implementation that takes incoming requests, adds them to an [[subscriptionSet]] and,
 * through a WebSocket connection, subscribes to the relevant feeds to populate the cache.
 *
 * @typeParam T - Helper struct type that will be used to pass types to the generic parameters (check [[WebsocketTransportGenerics]])
 */
class WebSocketTransport extends streaming_1.StreamingTransport {
    constructor(config) {
        super();
        this.config = config;
        this.currentUrl = '';
        this.lastMessageReceivedAt = 0;
        this.connectionOpenedAt = 0;
    }
    getSubscriptionTtlFromConfig(adapterSettings) {
        return adapterSettings.WS_SUBSCRIPTION_TTL;
    }
    connectionClosed() {
        return !this.wsConnection || this.wsConnection.readyState === ws_1.default.CLOSED;
    }
    serializeMessage(payload) {
        return typeof payload === 'string' ? payload : JSON.stringify(payload);
    }
    deserializeMessage(data) {
        return JSON.parse(data.toString());
    }
    buildConnectionHandlers(context, connection, connectionReadyResolve) {
        return {
            // Called when the WS connection is opened
            open: async (event) => {
                logger.debug(`Opened websocket connection. (event type ${event.type})`);
                if (this.config.handlers.open) {
                    await this.config.handlers.open(connection, context);
                    logger.debug('Successfully executed connection opened handler');
                }
                connectionReadyResolve(event.target);
            },
            // Called when any message is received by the open connection
            message: async (event) => {
                const parsed = this.deserializeMessage(event.data);
                (0, util_1.censorLogs)(() => logger.trace(`Got ws message: ${event.data}`));
                const providerDataReceived = Date.now();
                const results = this.config.handlers.message(parsed, context)?.map((r) => {
                    const result = r;
                    const partialResponse = r.response;
                    result.response.timestamps = {
                        providerDataStreamEstablishedUnixMs: this.providerDataStreamEstablished,
                        providerDataReceivedUnixMs: providerDataReceived,
                        providerIndicatedTimeUnixMs: partialResponse.timestamps?.providerIndicatedTimeUnixMs,
                    };
                    return result;
                });
                logger.trace(`Writing ${results?.length ?? 0} responses to cache`);
                if (Array.isArray(results) && results.length > 0) {
                    // Updating the last message received time here, to only care about messages we use
                    this.lastMessageReceivedAt = Date.now();
                    await this.responseCache.write(this.name, results);
                }
                // Do this after writing so we get the values to the cache ASAP
                // We're not calculating feedId or subscription because this is only a single message,
                // and it could in theory contain more than one value to set to the cache
                metrics_1.metrics.get('wsMessageTotal').labels({ direction: 'received' }).inc();
            },
            // Called when an error is thrown by the connection
            error: async (event) => {
                (0, util_1.censorLogs)(() => logger.warn(`Error occurred in web socket connection. Error: ${event.error} ; Message: ${event.message}`));
                // Record connection error count
                metrics_1.metrics.get('wsConnectionErrors').labels((0, metrics_2.connectionErrorLabels)(event.message)).inc();
                if (this.config.handlers.error) {
                    this.config.handlers.error(event, context);
                    logger.debug('Successfully executed connection error handler');
                }
            },
            // Called when the WS connection closes for any reason
            close: (event) => {
                // If the connection closed with 1000, it's a usual closure
                const level = event.code === 1000 ? 'debug' : 'info';
                logger[level](`Closed websocket connection. Code: ${event.code} ; reason: ${event.reason?.toString()}`);
                // Record active ws connections by decrementing count on close
                // Using URL in label since connection_key is removed from v3
                metrics_1.metrics.get('wsConnectionActive').dec();
                // Also, register that the connection was closed and the reason why.
                // We need to filter out query params from the URL to avoid having
                // the cardinality of the metric go out of control.
                const filteredUrl = this.currentUrl.split('?')[0];
                metrics_1.metrics.get('wsConnectionClosures').inc({
                    code: event.code,
                    url: filteredUrl,
                });
                if (this.config.handlers.close) {
                    this.config.handlers.close(event, context);
                    logger.debug('Successfully executed connection close handler');
                }
            },
        };
    }
    async establishWsConnection(context, url, options) {
        const [promise, resolve, reject] = (0, util_1.deferredPromise)();
        const ctor = WebSocketClassProvider.get();
        const connection = new ctor(url, undefined, options);
        const handlers = this.buildConnectionHandlers(context, connection, resolve);
        connection.addEventListener('error', handlers.error);
        connection.addEventListener('open', this.rejectionHandler(reject, handlers.open));
        // Attempt to establish the connection
        metrics_1.metrics.get('wsConnectionAttempt').inc();
        try {
            await (0, util_1.timeoutPromise)('WS Open Handler', promise, context.adapterSettings.WS_CONNECTION_OPEN_TIMEOUT);
            // Record active ws connections by incrementing count on open
            metrics_1.metrics.get('wsConnectionActive').inc();
        }
        catch (e) {
            logger.error(`There was an error connecting to the provider websocket`);
            throw e;
        }
        // Now that the connection is established, we can clean up listeners and set the proper ones
        connection.removeAllListeners();
        connection.addEventListener('message', this.rejectionHandler(reject, handlers.message));
        connection.addEventListener('error', handlers.error);
        connection.addEventListener('close', handlers.close);
        return connection;
    }
    async sendMessages(context, subscribes, unsubscribes) {
        const serializedSubscribes = subscribes.map(this.serializeMessage);
        const serializedUnsubscribes = unsubscribes.map(this.serializeMessage);
        const messages = serializedSubscribes.concat(serializedUnsubscribes);
        if (messages.length > 0) {
            logger.debug(`There are ${messages.length} messages to send`);
        }
        for (const message of messages) {
            this.wsConnection?.send(message);
        }
    }
    async streamHandler(context, subscriptions) {
        // New subs && no connection -> connect -> add subs
        // No new subs && no connection -> skip
        // New subs && connection -> add subs
        // No new subs && connection -> unsubs only
        if (!subscriptions.new.length && !this.wsConnection) {
            logger.debug('No entries in subscription set and no established connection, skipping');
            await (0, util_1.sleep)(context.adapterSettings.BACKGROUND_EXECUTE_MS_WS);
            return;
        }
        // We want to check if the URL we calculate is different from the one currently connected.
        // This is because some providers handle subscriptions on the URLs and not through messages.
        const urlFromConfig = await this.config.url(context, subscriptions.desired);
        const urlChanged = this.currentUrl !== urlFromConfig;
        // We want to check that if we have a connection, it hasn't gone stale. That is,
        // since opening it, have we had any activity from the provider.
        const now = Date.now();
        const timeSinceLastMessage = Math.max(0, now - this.lastMessageReceivedAt);
        const timeSinceConnectionOpened = Math.max(0, now - this.connectionOpenedAt);
        const timeSinceLastActivity = Math.min(timeSinceLastMessage, timeSinceConnectionOpened);
        const connectionUnresponsive = timeSinceLastActivity > 0 &&
            timeSinceLastActivity > context.adapterSettings.WS_SUBSCRIPTION_UNRESPONSIVE_TTL;
        let connectionClosed = this.connectionClosed();
        logger.trace(`WS conn staleness info: 
      now: ${now} |
      timeSinceLastMessage: ${timeSinceLastMessage} |
      timeSinceConnectionOpened: ${timeSinceConnectionOpened} |
      timeSinceLastActivity: ${timeSinceLastActivity} |
      subscriptionUnresponsiveTtl: ${context.adapterSettings.WS_SUBSCRIPTION_UNRESPONSIVE_TTL} |
      connectionUnresponsive: ${connectionUnresponsive} |
      `);
        // Check if we should close the current connection
        if (!connectionClosed && (urlChanged || connectionUnresponsive)) {
            if (urlChanged) {
                (0, util_1.censorLogs)(() => logger.debug(`Websocket url has changed from ${this.currentUrl} to ${urlFromConfig}, closing connection...`));
            }
            else {
                (0, util_1.censorLogs)(() => logger.info(`Last message was received ${timeSinceLastMessage} ago, exceeding the threshold of ${context.adapterSettings.WS_SUBSCRIPTION_UNRESPONSIVE_TTL}ms, closing connection...`));
            }
            // Check if connection was opened very recently; if so, wait a bit before continuing.
            // This is so if we just opened the connection and are waiting to receive some messages,
            // we don't close is immediately after and miss the chance to receive them
            if (timeSinceConnectionOpened < 1000) {
                logger.info(`Connection was opened only ${timeSinceConnectionOpened}ms ago, waiting for that to get to 1s before continuing...`);
                await (0, util_1.sleep)(1000 - timeSinceConnectionOpened);
            }
            this.wsConnection?.close(1000);
            connectionClosed = true;
            if (subscriptions.desired.length) {
                (0, util_1.censorLogs)(() => logger.trace(`Connection will be reopened and will subscribe to new and resubscribe to existing: ${JSON.stringify(subscriptions.desired)}`));
            }
        }
        // Check if we need to open a new connection
        if (connectionClosed && subscriptions.desired.length) {
            logger.debug('No established connection and new subscriptions available, connecting to WS');
            const options = this.config.options && (await this.config.options(context, subscriptions.desired));
            this.currentUrl = urlFromConfig;
            // Need to write this now, otherwise there could be messages sent with values before the open handler finishes
            this.providerDataStreamEstablished = Date.now();
            // Local subscriptions array should be empty before the connection is opened
            this.localSubscriptions = [];
            // If the connection was closed, the new subscriptions should be the desired ones
            subscriptions.new = subscriptions.desired;
            // Connect to the provider
            this.wsConnection = await this.establishWsConnection(context, urlFromConfig, options);
            // Now that we successfully opened the connection, we can reset the variables
            connectionClosed = false;
            this.connectionOpenedAt = Date.now();
        }
        // Send messages only if the connection is open
        // Otherwise we could encounter the case where we just closed the connection because there's no desired ones,
        // but without this check we'd attempt to send out all the unsubscribe messages
        if (!connectionClosed) {
            logger.debug('Connection is open, sending subs/unsubs if there are any');
            const builders = this.config.builders;
            if (builders) {
                const { subscribeMessage, unsubscribeMessage } = builders;
                await this.sendMessages(context, subscribeMessage
                    ? subscriptions.new.map((sub) => subscribeMessage(sub, context))
                    : subscriptions.new, unsubscribeMessage
                    ? subscriptions.stale.map((sub) => unsubscribeMessage(sub, context))
                    : subscriptions.stale);
            }
            else {
                logger.trace("This ws transport has no builders configured, so we're not sending any messages");
            }
        }
        // Record WS message and subscription metrics
        (0, metrics_2.recordWsMessageMetrics)(context, subscriptions.new, subscriptions.stale);
        // The background execute loop no longer sleeps between executions, so we have to do it here
        logger.trace(`Websocket handler complete, sleeping for ${context.adapterSettings.BACKGROUND_EXECUTE_MS_WS}ms...`);
        await (0, util_1.sleep)(context.adapterSettings.BACKGROUND_EXECUTE_MS_WS);
        return;
    }
    rejectionHandler(rejectionFn, handler) {
        return async (event) => {
            try {
                await handler(event);
            }
            catch (e) {
                return rejectionFn(e);
            }
        };
    }
}
exports.WebSocketTransport = WebSocketTransport;
/**
 * Transport that wraps the WebSocketTransport in order to provide helper methods for a reverse mapping. This is useful
 * when the Data Provider uses pair IDs that cannot be programmatically translated into request params.
 *
 * Example with `{from: "STETH", to: "USD"} -> "STETHUSD"`:
 * Instead of trying to split the Data Provider ID string based on length, we can use
 * `setReverseMapping("STETHUSD", {from: "STETH", to: "USD"})` to map the Data Provider ID to the request payload, then
 * use `getReverseMapping("STETHUSD")` to get the right input parameters.
 *
 * @typeParam T - Helper struct type that will be used to pass types to the generic parameters (check [[WebsocketTransportGenerics]])
 * @typeParam K - The type for the Data Provider's IDs
 */
class WebsocketReverseMappingTransport extends WebSocketTransport {
    constructor() {
        super(...arguments);
        this.requestMapping = new Map();
    }
    /**
     * Sets the request params mapping for the given value
     *
     * @param value - the Data Provider lookup value to map to `params`
     * @param params - the body of the adapter request
     * @returns the WS message (can be any type as long as the [[WebSocket]] doesn't complain)
     */
    setReverseMapping(value, params) {
        this.requestMapping.set(value, params);
    }
    /**
     * Gets the request params mapping for the given value
     *
     * @param value - the Data Provider lookup value
     * @returns the request parameters for the Data Provider lookup value, if one has been set
     */
    getReverseMapping(value) {
        return this.requestMapping.get(value);
    }
}
exports.WebsocketReverseMappingTransport = WebsocketReverseMappingTransport;
//# sourceMappingURL=websocket.js.map