therungg / therun-frontend

Frontend for therun.gg
https://therun-fr-therun.vercel.app
MIT License
25 stars 8 forks source link

Implement reconnect mechanism for websockets #18

Closed therungg closed 1 year ago

therungg commented 1 year ago

Right now, the websockets have no reconnect mechanism. AWS' websockets hang up (always!!) after 10 minutes on no messages, and after 2 hours regardless.

This means the live page stops working (timers just keep running and the page needs to be refreshed) after 2 hours.

Implement a reconnect when the server hangs up.

zoglo commented 1 year ago

When I was building the live comparison for waifus tournament (after Livesplit changes with custom splits I did abandon it), I wrote a heartbeat for testing purposes.

Basically listening to the websocket connection and it's events.

Was native JS tho so might look into it.

zoglo commented 1 year ago

Example ES6 plugin below, I didn't finish coding it but the logic is in _initHandlers


import {extend} from "../core/options"
import options from "../data/routes.json"

/*import { Sortable, Swap } from 'sortablejs/modular/sortable.core.esm'
Sortable.mount(new Swap())*/

export class theRunDashboard
{
    constructor(options)
    {
        this.options = extend(true,
            {
                selector: '#runners',
                runners: [],
                start: '#startws',
                close: '#closews',
                socket: {
                    keepalive: 9,
                    createTimeout: 500,
                },
                console: {
                    start:      ['color: GreenYellow'],
                    error:      ['color: Red'],
                    update:     ['color: Aqua'],
                    info:       ['color: DarkGray'],
                    end:        ['color: Magenta']
                }
            }, options ||{})

        this.selector = document.querySelector(this.options.selector)

        this.start    = document.querySelector(this.options.start)
        this.close    = document.querySelector(this.options.close)
        this.runners  = this.options.runners

        if (
            !this.selector ||
            !this.start ||
            !this.close ||
            !this.runners.length
        )
            return

        this.contents = []

        this.sockets  = []
        this.timeouts = []
        this.timers   = []

        this.abort = false

        this._init()
    }

    _init()
    {
        this._bindButtons()
    }

    _bindButtons()
    {
        this.start.addEventListener('click', () => {
            this._initPlayers()
        })

        this.close.addEventListener('click', () => {
            this._clearSockets()
        })
    }

    _initPlayers()
    {
        this.runners.forEach((runner, index, collection) => {
            setTimeout(() => {
                this._createWebSocket(runner, index)
                this._createRunner(runner, index)
            }, index * this.options.socket.createTimeout);
        })
    }

    _setPlayers()
    {
        this.runners.forEach((runner, index, collection) => {
            this._createRunner(runner, index)
        })
    }

    _createRunner(runner, index)
    {
        let template = document.createElement('template')

        template.innerHTML = `<div class="item" data-item-index="${index}">
                                <div class="inside">
                                  <span class="name">${runner}</span>
                                  <span class="timer" data-timer="${runner}"></span>
                                  <span class="split" data-split="${runner}"></span>
                                </div>
                              </div>`

        this.contents[index] = this.selector.appendChild(template.content.firstChild)
    }

    _createWebSocket(runner, index)
    {
        let socket = new WebSocket(options.websocket.route + runner);

        this.sockets[index] = socket

        this._initHandlers(socket, runner, index)
    }

    _initHandlers(socket, runner, index)
    {
        socket.onclose = (evt) => {
            if (this.abort)
                return

            console.log('%cClose event: socket for %s closed: %s', this.options.console.error.join(';'), runner, evt.reason);
            this._resetSocket(index)
            this._createWebSocket(runner, index)
        }

        socket.onerror = (evt) => {
            if (this.abort)
                return

            console.log('%cError event: socket for %s closed: %s', this.options.console.error.join(';'), runner, evt.reason);
            this._resetSocket(index)
            this._createWebSocket(runner, index)
        }

        socket.onopen = (evt) => {
            console.log('%cListening to: %s', this.options.console.start.join(';'), runner);
            this._heartBeat(socket, runner, index);
        }

        socket.onmessage = (evt) => {
            this._analyzeData(evt, runner, index)
        }
    }

    _setCurrentTime(index, ctime)
    {
        if (this.timers[index])
            clearInterval(this.timers[index])

        const timer = this.contents[index].querySelector('.timer')

        // ToDo: create timer that updates
        setInterval(this._setCurrentTimeInMs(timer, ctime), 1000);
    }

    _setCurrentTimeInMs(timer, cms)
    {
        const ms = Math.floor(cms % 60).toString().padStart(2,'0')
        const s = Math.floor((cms / 1000) % 60).toString().padStart(2,'0')
        const m = Math.floor((cms / 1000 / 60) % 60).toString().padStart(2,'0')
        const h = Math.floor((cms / 1000 / 60 / 60) % 24).toString().padStart(2,'0')

        timer.innerHTML = `${h}:${m}:${s}:${ms}`;
    }

    _convertDate(time)
    {
        const date = new Date(time);
        const h = date.getHours();
        const m = "0" + date.getMinutes();
        const s = "0" + date.getSeconds();

        const ms = "0" + date.getMilliseconds();

        return h.pad(2) + ':' + m.substring(-2).padStart(2) + ':' + s.substring(-2).padStart(2) + ':' + ms.substring(-2).padStart(2);
    }

    _analyzeData(evt, runner, index)
    {
        const data = JSON.parse(evt.data)

        if (data.message)
            return

        const date    = (new Date()).toUTCString()
        const run = data.run

        this._setCurrentTime(index, run.currentTime)

        console.group('%s: %s', runner, date);

        console.log('%cUpdate for: %s', this.options.console.update.join(';'), runner);

        console.log('User: %s', data.user)
        console.log('CurrentSplit: %s', run.currentSplitIndex)
        console.log('CurrentTime: %s', run.currentTime)
        console.log('CurrentPrediction: %s', run.currentPrediction)
        console.log('Delta: %s', run.delta)
        console.log('PB: %s', run.pb)
        console.log('Run Time: %s', run.gameData.totalRunTime)

        console.log(data);
        console.groupEnd()

        //localStorage.setItem(key, 'Value');
    }

    _heartBeat(socket, runner, index)
    {
        this.timeouts[index] = setInterval(() => {
            //console.log('keepalive for: %s', runner)
            socket.send('ping')
        }, 60000 * this.options.socket.keepalive);
    }

    _resetSocket(index)
    {
        clearInterval(this.timeouts[index])

        delete this.sockets[index]
        delete this.timeouts[index]

        this.contents[index].remove()
        delete this.contents[index]
    }

    _clearSockets()
    {
        this.abort = true

        console.log('%cStopped all sockets', this.options.console.end.join(';'))
        this.sockets.forEach((socket, index) => {
            socket.close()
            this._resetSocket(index)
        })

        this.sockets = []
        this.timeouts = []
        this.contents = []
    }
}