ws.js


import { app, isObject, toNumber, trace } from "./app"
import { $on } from "./dom"
import { emit } from "./events"

/**
 * Websockets client
 * @param {object} [options] - config params
 * @param {string} [options.path=/] - connect url path name
 * @param {object} [options.query] - query parameters to send on connect in the url
 * @param {number} [options.retry_timeout=250] - ms between retries to connect
 * @param {number} [options.retry_factor] - multiplier for retry timeout
 * @param {number} [options.max_timeout=30000] - ms for max timeout on retries, once reached restart with retry_timeout
 * @param {number} [options.max_retries] - how many retries to perform until connected
 * @param {number} [options.max_pending=10] - how many request to keep in pending queue until connected
 * @param {number} [options.ping_interval=300000] - ms between pings
 * @class
 *
 * @example
 * app.$ready(async () => {
 *
 *   app.ws = new app.WS({ path: "/ws" });
 *   app.ws.connect();
 *   app.ws.on("ws:message", (data) => { ...});
 *
 * });
 * ....
 *
 * app.ws.send("/route/action")
 * app.ws.send({ path: "/some/path", data: { ...} })
 */

class WS {
    path = "/"
    query = {}
    retry_timeout = 500
    retry_factor = 2
    max_timeout = 30000
    max_retries = Infinity
    max_pending = 10
    ping_interval = 300000
    _retries = 0
    _pending = []

    constructor(options)
    {
        for (const p in options) {
            if (p[0] != "_" &&
                this[p] !== undefined &&
                typeof this[p] === typeof options[p]) {
                this[p] = options[p];
            }
        }

        $on(window, "online", this.online.bind(this));
    }

    /**
     * Open a new websocket connection
     */
    connect()
    {
        if (this._timer) {
            clearTimeout(this._timer);
            delete this._timer;
        }
        if (this.disabled) return;

        const host = this.host || window.location.hostname;

        if (navigator.onLine === false && !/^(localhost|127.0.0.1)$/.test(host)) {
            return this.timer(0);
        }

        if (!this.query) this.query = {};
        for (const p in this.headers) {
            if (this.query[p] === undefined) this.query[p] = this.headers[p];
        }

        const port = this.port || window.location.port;
        const proto = this.protocol || window.location.protocol.replace("http", "ws");
        const url = `${proto}//${host}:${port}${this.path}?${this.query ? new URLSearchParams(this.query).toString() : ""}`;

        const ws = this.ws = new WebSocket(url);
        ws.onopen = () => {
            trace("ws.open:", url);
            emit("ws:open", url);
            this._ctime = Date.now();
            this._timeout = toNumber(this.retry_timeout);
            this._retries = 0;
            while (this._pending.length) {
                this.send(this.pending.shift());
            }
            this.ping();
        }
        ws.onclose = () => {
            trace("ws.closed:", url, this._timeout, this._retries);
            this.ws = null;
            emit("ws:close", url);
            if (++this._retries < this.max_retries) this.timer();
        }
        ws.onmessage = (msg) => {
            var data = msg.data;
            if (data === "bye") return this.close(1);
            if (typeof data == "string" && (data[0] == "{" || data[0] == "[")) data = JSON.parse(data);
            trace('ws.message:', data);
            emit("ws:message", data);
            if (data.event) {
                emit(app.event, data.event, data);
            }
        }
        ws.onerror = (err) => {
            trace('ws.error:', url, err);
        }
    }

    /**
     * Restart websocket reconnect timer, increase timeout according to reconnect policy (retry_factor, max_timeout)
     * @param {number} timeout
     */
    timer(timeout)
    {
        clearTimeout(this._timer);
        if (this.disabled) return;
        if (typeof timeout == "number") this._timeout = timeout;
        this._timer = setTimeout(this.connect.bind(this), this._timeout);
        this._timeout *= this._timeout == this.max_timeout ? 0 : toNumber(this.retry_factor);
        this._timeout = toNumber(this._timeout, { min: this.retry_timeout, max: this.max_timeout });
    }

    /**
     * Send a ping and shcedule next one
     */
    ping()
    {
        clearTimeout(this._ping);
        if (this.disabled || !this.ping_interval) return;
        if (this.ws?.readyState === WebSocket.OPEN) {
            this.ws.send(this.ping_path || "/ping");
        }
        this._ping = setTimeout(this.ping.bind(this), this.ping_interval);
    }

    /**
     * Closes and possibly disables WS connection, to reconnect again must delete .disabled property
     * @param {boolean} [disable]
     */
    close(disable)
    {
        this.disabled = disable;
        if (!this.ws) return;
        this.ws.close();
        delete this.ws;
    }

    /**
     * Send a string data or an object
     * @param {object|string} data
     */
    send(data)
    {
        if (this.ws?.readyState != WebSocket.OPEN) {
            if (!this.max_pending || this._pending.length < this.max_pending) {
                this._pending.push(data);
            }
            return;
        }
        if (isObject(data)) {
            if (data.url && data.url[0] == "/") {
                data = data.url;
                if (isObject(data.data)) {
                    data += "?" + new URLSearchParams(data.data).toString();
                }
            } else {
                data = JSON.stringify(data);
            }
        }
        this.ws.send(data);
    }

    /**
     * Check the status of websocket connection, reconnect if needed
     */
    online()
    {
        trace('ws.online:', navigator.onLine, this.ws?.readyState, this.path, this._ctime);
        if (this.ws?.readyState !== WebSocket.OPEN && this._ctime) {
            this.connect();
        }
    }
}

export { WS };
export default WS;