client/client.js

/**
 * Class rapresenting the client connection
 */
class WsClient {
    /**
     * @param {Object} option
     * @param {Url} option.url - Url to websocket server (ws://x.x)
     * @param {WebSocket=} option.websocket - Class rapresent the websocket connection, if you are on nodejs use ws otherwise leave blank
     * @param {*=} authenticationToken - If you want to authenticate the user set this and validate it on server
     */
    constructor({ url, websocket = null, authenticationToken = null }) {
        if (!websocket) websocket = WebSocket;
        if (authenticationToken) this.authenticationToken = authenticationToken;
        /** @private */
        this.config = {
            websocket,
            url,
        };
        /** @private */
        this.callbacks = {};
        /** @private */
        this.intervalCheckAcknowledge = setInterval(() => this.checkAcknowledge(this), 2000);
        /** @private */
        this.isAlive = true;
        this._createConnection();
    }

    /**
     * Start ws connection, you need to call this method to make a connection
     * @return {Promise} resolve the promise when the connection is established
     */
    async connect() {
        return new Promise((resolve) => {
            if (this.getReadyState() == 1) this.send(null, "connection");
            else
                this.connection.onopen = (e) => {
                    this.send(null, "connection");
                    resolve(true);
                };
        });
    }

    /**
     * Send message to server
     * @param {string} channel - Channel name (server need to listen on it with onMessage() )
     * @param {*} data - Data to be sent
     */
    async send(channel, data) {
        let message = { data, channel };
        if (this.authenticationToken) message.token = this.authenticationToken;
        if (!this.totMessage++) message.isInitial = true;
        if (!this.connection.readyState) await waitConnection(this.connection);
        await this.connection.send(JSON.stringify(message));
    }

    /**
     * use this to change your authenticationToken
     */
    setAuthenticationToken(authenticationToken) {
        this.authenticationToken = authenticationToken;
        this.send("reservedChannelWs", {
            action: "setAuthenticationToken",
            token: this.authenticationToken,
        });
    }

    /**
     * @return {Number} Total number of message sent
     */
    getTotMessages() {
        return this.totMessage - 1;
    }

    /**
     * Set the callback to call when the user receive a message
     * @param {string} channel - Channel name to listen to
     * @param {onMessageCallbackClient} callback - Callback where to receive the message
     */
    onMessage(channel, callback) {
        if (typeof channel !== "string") throw new Error("Invalid channel name");
        if (typeof callback !== "function") throw new Error("Callback must be a function");
        if (channel in this.callbacks) throw new Error("This channel is already registered");
        this.callbacks[channel] = callback;
    }

    /**
     * Set the callback to call when the connection goes down
     * @param {function} callback - zero parameter function called when connection is down
     */
    onConnectionFailure(callback) {
        if (typeof callback !== "function") throw new Error("Callback must be a function");
        this.config.onConnectionFailure = callback;
    }

    /**
     * Set the callback to call when the connection comes back online
     * @param {function} callback - zero parameter function called when the connection comes back online
     */
    onConnectionReestablished(callback) {
        if (typeof callback !== "function") throw new Error("Callback must be a function");
        this.config.onConnectionReestablished = callback;
    }

    /**
     * @private
     */
    handleMessage({ data }) {
        if (data === "ping") return this._sendHeartbeat("pong");
        if (data === "ack") return (this.lastAcknowledge = new Date().getTime());
        data = JSON.parse(data);
        if (data?.channel in this.callbacks && data.data) {
            this.callbacks[data.channel](data.data);
        }
    }

    async close() {
        await this.send("reservedChannelWs", { action: "close" });
        this.connection.close();
    }

    getReadyState() {
        return this.connection.readyState;
    }

    /**
     * @private
     */
    checkAcknowledge(self) {
        if (new Date().getTime() - 5000 > self.lastAcknowledge || !self.lastAcknowledge) {
            // two ack packets were lost
            if (self.isAlive) {
                self.isAlive = false;
                self.config.onConnectionFailure?.();
            }
            self._createConnection();
            this.connect();
            console.log("Connection problem");
        }
    }

    /**
     * @private
     */
    async _createConnection() {
        /** @private */
        this.connection = new this.config.websocket(this.config.url);
        this.connection.onmessage = (message) => {
            this.handleMessage(message);
        };
        /** @private */
        this.totMessage = 0;
    }

    /**
     * @private
     */
    async _sendHeartbeat(data) {
        if (!this.connection.readyState) await waitConnection(this.connection);
        this.connection.send(data);
    }
}

/**
 * @private
 */
async function waitConnection(connection) {
    return new Promise((resolve, reject) => {
        let i = 0;
        const listener = setInterval(() => {
            if (connection.readyState) {
                resolve(true);
                return clearInterval(listener);
            }
            if (++i > 20) throw new Error("Cannot send");
        }, 10);
    });
}

module.exports = WsClient;