9
\$\begingroup\$

I'm working on rewriting the Cardshifter HTML Client with vanilla JavaScript, the original HTML Client is written with Angular but I really wanted to stay away from anything npm and just go back to basics. Note that I have no desire to use libraries like jQuery, Underscore, etc. unless absolutely necessary.

This is the landing page where a user selects a server to connect to and login. I would like feedback on any and all facets of the code, especially if I'm using anti-patterns that I could avoid using throughout the rest of the client. All the sections are documented so I won't spend time explaining what everything does here.

If you want to skim over the trivial things, the primary code files I would like feedback on are:

  • sections/login/login.js

  • server_interface/server_interface.js

  • utils/loadHtml.js

Here is an animated GIF that shows some of the functionality, namely checking whether a given server can offer a valid WebSocket connection. (note that the alert showing the user name has since been removed, it was for debugging purposes.

check-server-connection

Directory structure

Here is how my files are structured at the moment. I have a few other directories for images and such that I excluded because they are not used yet.

index.html
global.js
sections/
  login/
    login.html
    login.js
  top_navbar/
    top_navbar.html
  server_interface/
    server_interface.js
  styles/
    cardshifter.css
  utils/
    formatDate.js
    loadHtml.js
    logDebugMessage.js

Code

index.html

<!DOCTYPE html>
<html>
    <head>
        <title>Cardshifter</title>
        <!-- Bootstrap -->
        <link href="http://maxcdn.bootstrapcdn.com/bootstrap/3.3.0/css/bootstrap.min.css" rel="stylesheet" />

        <!-- Local styles -->
        <link rel="stylesheet" href="styles/cardshifter.css" />

        <!-- Local JavaScript -->
        <script src="global.js"></script>
        <script src="server_interface/server_interface.js"></script>
        <script src="utils/loadHtml.js"></script>
        <script src="utils/formatDate.js"></script>
        <script src="utils/logDebugMessage.js"></script>

        <!-- Local Section JavaScript -->
        <script src="sections/login/login.js"></script>

        <!-- Favicon links -->
        <link rel="apple-touch-icon" sizes="57x57" href="images/favicon/apple-icon-57x57.png" />
        <link rel="apple-touch-icon" sizes="60x60" href="images/favicon/apple-icon-60x60.png" />
        <link rel="apple-touch-icon" sizes="72x72" href="images/favicon/apple-icon-72x72.png" />
        <link rel="apple-touch-icon" sizes="76x76" href="images/favicon/apple-icon-76x76.png" />
        <link rel="apple-touch-icon" sizes="114x114" href="images/favicon/apple-icon-114x114.png" />
        <link rel="apple-touch-icon" sizes="120x120" href="images/favicon/apple-icon-120x120.png" />
        <link rel="apple-touch-icon" sizes="144x144" href="images/favicon/apple-icon-144x144.png" />
        <link rel="apple-touch-icon" sizes="152x152" href="images/favicon/apple-icon-152x152.png" />
        <link rel="apple-touch-icon" sizes="180x180" href="images/favicon/apple-icon-180x180.png" />
        <link rel="icon" type="image/png" sizes="192x192"  href="images/favicon/android-icon-192x192.png" />
        <link rel="icon" type="image/png" sizes="32x32" href="images/favicon/favicon-32x32.png" />
        <link rel="icon" type="image/png" sizes="96x96" href="images/favicon/favicon-96x96.png" />
        <link rel="icon" type="image/png" sizes="16x16" href="images/favicon/favicon-16x16.png" />
        <link rel="manifest" href="images/favicon/manifest.json" />
        <meta name="msapplication-TileColor" content="#ffffff" />
        <meta name="msapplication-TileImage" content="images/favicon/ms-icon-144x144.png" />
        <meta name="theme-color" content="#ffffff" />
    </head>
    <body>
        <!-- Top navigation bar -->
        <div id="top_navbar_container">
            <script>
                const navbarContainerId = "top_navbar_container";
                const navbarFilePath = "sections/top_navbar/top_navbar.html";
                loadHtml(navbarContainerId, navbarFilePath)
                .then(function() {
                    if (DEBUG) { 
                        logDebugMessage(`"${navbarFilePath}" loaded OK!`); 
                    }
                });
            </script>
        </div>

        <div class="csh-body">
            <div id="login_container">
                <script>
                    const loginContainerId = "login_container";
                    const loginFilePath = "sections/login/login.html";
                    loadHtml(loginContainerId, loginFilePath)
                    .then(function() {
                        loginHandler();
                        if (DEBUG) {
                            logDebugMessage(`"${loginFilePath}" loaded OK!`);
                        } 
                    });
                </script>
            </div>
        </div>
    </body>
</html>

global.js

/*
 * This file is for global values to be used throughout the site.
 */
'use strict';

/*
 * Setting to `true` will log messages in the browser console
 * to help in debugging and keeping track of what is happening on the page.
 * This should be set to `false` on the public client.
 */
const DEBUG = true;

/*
 * Port number used for WebSocket.
 */
const WS_PORT = 4243;

/*
 * List of game server names and WebSocket URIs.
 */
const GAME_SERVERS = {
    "localhost" : `ws://127.0.0.1:${WS_PORT}`,
    "dwarftowers.com" : `ws://dwarftowers.com:${WS_PORT}`,
    "zomis.net" : `ws://stats.zomis.net:${WS_PORT}`,
    "Other" : ""
};

/**
 * Default date format for the application.
 * @type String
 */
const DEFAULT_DATE_FORMAT = "yyyy/MM/dd hh:mm:ss";

sections

sections/login/login.html

<div id="login">
    <h4>Please log in to continue.</h4>
    <form name="login_form" id="login_form" class="login-form">
        <div id="login_server_select_container" class="form-group">
            <label for="login_server_list" aria-label="Server">Server:</label>
            <select name="login_server_list" id="login_server_list" class="form-control">
            </select>
            <div id="login_server_other_container" class="form-group" style="display : none">
                <label for="login_server_other_input">Other Server:</label>
                <input name="login_server_other_input" id="login_server_other_input" type="text" class="form-control" />
                <input type="button" name="test_login_server_other" id="test_login_server_other" class="btn" value="Test connection" />
            </div>
            <input readonly name="server_loading_display" id="server_connecting" class="form-control" style="background-color: #DDD; display: none" />
            <label for="login_secure">Is secure server:</label>
            <input name="login_secure" id="login_secure" type="checkbox" value="secure" />
            <span id="login_server_connection_status" class="label" style="display: block; text-align: left"></span>
        </div>
        <div id="login_username_container" class="form-group">
            <label for="login_username">Username:</label>
            <input name="login_username" id="login_username" type="text" class="form-control" placeholder="Enter name..." />
        </div>
        <div class="form-group">
            <input type="button" name="login_submit" id="login_submit" type="button" class="btn btn-success" value="Log in" />
        </div>
    </form>
</div>

sections/login/login.js

/* global GAME_SERVERS, DEBUG, CardshifterServerAPI, DEFAULT_DATE_FORMAT */

const loginHandler = function() {
    const serverSelectContainer = document.getElementById("login_server_select_container");
    const serverSelect = serverSelectContainer.querySelector("#login_server_list");
    const serverOtherInputContainer = serverSelectContainer.querySelector("#login_server_other_container");
    const serverLoading = serverSelectContainer.querySelector("#server_connecting");
    const connStatusMsg = serverSelectContainer.querySelector("#login_server_connection_status");
    let currentServerHasValidConnection = null;

    /**
     * Adds options to the server selection based on GAME_SERVERS global.
     * @returns {undefined}
     */
    const populateServerSelect = function() {
        for (let key in GAME_SERVERS) {
            if (GAME_SERVERS.hasOwnProperty(key)) {
                const option = document.createElement("option");
                option.text = key;
                option.value = GAME_SERVERS[key];
                serverSelect.add(option);
            }
        }
    };

    /**
     * Tests the WebSocket connection to a server and displays a message on the page
     * to give the user information about the connection status.
     * @returns {undefined}
     */
    const testWebsocketConnection = function() {
        const serverUri = serverSelect.value;
        const isSecure = false;

        let msgText = "";

        if (serverUri) {
            displayConnStatus("connecting", serverUri);
            /**
             * Test WebSocket connection and display status if successful.
             * @returns {undefined}
             */
            const onReady = function() {
                makeServerSelectReadWrite();
                msgText = displayConnStatus("success", serverUri);
                if (DEBUG) { logDebugMessage(msgText); }
                currentServerHasValidConnection = true;
            };
            /**
             * Test WebSocket connection and display status if failed.
             * @returns {undefined}
             */
            const onError = function() {
                makeServerSelectReadWrite();
                msgText = displayConnStatus("failure", serverUri);
                if (DEBUG) { logDebugMessage(msgText); }
                currentServerHasValidConnection = false;
            };
            CardshifterServerAPI.init(serverUri, isSecure, onReady, onError);
            makeServerSelectReadOnly(serverUri);
        }
        else {
            displayConnStatus("unknown", serverUri);
        }
    };

    /**
     * Displays connection status in the page.
     * @param {string} status - Keyword representing the connection status
     * @param {type} serverUri - The URI of the server the client is connecting to
     * @returns {String} - The message text, largely for debug purposes
     */
    const displayConnStatus = function(status, serverUri) {
        let msgText = "";
        switch (status.toLowerCase()) {
            case "connecting":
                msgText = 
                    `<h5>Connecting to server...</h5>` + 
                    `<pre class='bg-warning'>` + 
                        `Address: ${serverUri}` + 
                        `\n${formatDate(new Date())}` +
                    `</pre>`;
                connStatusMsg.className = "label label-warning";
                connStatusMsg.innerHTML = msgText;
                break;
            case "success":
                msgText =
                    `<h5>WebSocket connection OK.</h5>\n` +
                    `<pre class='bg-success'>`+ 
                        `Address: ${serverUri}` +
                        `\n${formatDate(new Date())}` +
                    `</pre>`;
                connStatusMsg.innerHTML = msgText;
                connStatusMsg.className = "label label-success";
                break;
            case "failure":
                msgText = 
                    `<h5>WebSocket connection FAILED.</h5>\n` +
                    `<pre class='bg-danger'>`+ 
                        `Address: ${serverUri}` +
                        `\n${formatDate(new Date())}` +
                    `</pre>`;
                connStatusMsg.innerHTML = msgText;
                connStatusMsg.className = "label label-danger";
                break;
            case "unknown":
            default:
                msgText = `<h5>Unknown connection status...</h5>`;
                connStatusMsg.innerHTML = msgText;
                connStatusMsg.className = "label label-default";
                break;
        }
        return msgText;
    };

    /**
     * Hides the `select` element and shows a read-only `input` instead.
     * @param {string} serverUri
     * @returns {undefined}
     */
    const makeServerSelectReadOnly = function(serverUri) {
        const selector = document.getElementById("login_server_list");
        const connecting = document.getElementById("server_connecting");
        selector.style.display = "none";
        connecting.style.display = "block";
        connecting.value = `Connecting to ${serverUri}...`;
    };

    /**
     * Makes the server `select` element visible and hides the read-only `input`
     * @returns {undefined}
     */
    const makeServerSelectReadWrite = function() {
        const selector = document.getElementById("login_server_list");
        const connecting = document.getElementById("server_connecting");
        selector.style.display = "block";
        connecting.style.display = "none";
    };

    /**
     * Displays an input field for server address if "Other" server is selected.
     * @returns {undefined}
     */
    const handleServerSelectChanges = function() {
        if (serverSelect.value) {
            serverOtherInputContainer.style.display = "none";
        }
        else {
            serverOtherInputContainer.style.display = "block";
        }
    };

    /**
     * Attempts to login to game server.
     * @returns {undefined}
     */
    const tryLogin = function() {
        const username = document.getElementById("login_username").value;
        if (!username) {
            displayNoUsernameWarning();
        }
        else {
            const isSecure = false;
            var loggedIn = null;

            let serverUri = serverSelect.value;
            if (!serverUri) {
                serverUri = document.getElementById("login_server_other_input").value;
            }



            /**
             * Short-circuit login attempt if we've already found that the connection not valid.
             * @type String
             */
            if (!currentServerHasValidConnection) {
                const msg = "Websocket error(error 1)";
                console.log(msg);
                displayLoginFailureWarning(msg);
            }

            /**
             * Attempt to log in once the WebSocket connection is ready.
             * @returns {undefined}
             */
            const onReady = function() {
                let login = new CardshifterServerAPI.messageTypes.LoginMessage(username);

                /**
                 * Listens for a welcome message from the game server, and stores user values in the browser.
                 * @param {Object} welcome
                 * @returns {undefined}
                 */
                const messageListener = function(welcome) {
                    const SUCCESS = 200;
                    const SUCCESS_MESSAGE = "OK";
                    if(welcome.status === SUCCESS && welcome.message === SUCCESS_MESSAGE) {
                        localStorage.setItem("username", username);
                        localStorage.setItem("id", welcome.userId);
                        localStorage.setItem("playerIndex", null);
                        localStorage.setItem("game", { "id" : null, "mod" : null });                           
                    }
                    else {
                        console.log(`${new Date()} server message: ${welcome.message}`);
                        loggedIn = false;
                    }
                };

                try {
                    CardshifterServerAPI.setMessageListener(messageListener, ["loginresponse"]);
                    CardshifterServerAPI.sendMessage(login);
                }
                catch(error) {
                    const msg = "LoginMessage error(error 2)";
                    if (DEBUG) { logDebugMessage(`${msg} ${error}`); }
                    displayLoginFailureWarning(msg, error);
                    loggedIn = false;
                }
            };

            /**
             * Log error if the connection fails
             * @returns {undefined}
             */
            const onError = function() {
                const msg = "Websocket error(error 1)";
                if (DEBUG) { logDebugMessage(msg); }
                displayLoginFailureWarning(msg);
                loggedIn = false;
            };

            CardshifterServerAPI.init(serverUri, isSecure, onReady, onError);
        }
    };



    /**
     * Displays a warning if no username is entered.
     * @returns {undefined}
     */
    const displayNoUsernameWarning = function() {
        const container = document.getElementById("login_username_container");
        if (!container.querySelector("#login_username_missing_msg")) {
            const msg = document.createElement("span");
            msg.id = "login_username_missing_msg";
            msg.className = "label label-danger";
            msg.innerHTML = "Please enter a username.";
            container.appendChild(msg);
        }
    };

    const displayLoginFailureWarning = function(message, error) {
        const container = document.getElementById("login_username_container");
        const warning = document.createElement("span");
        warning.id = "login_failure_msg";
        warning.className = "label label-danger";
        warning.style = "display: block; text-align: left;";
        warning.innerHTML = `<h5>Login failed: ${message}</h5>`;
        if (error) {
            warning.innerHTML += `<pre>${error}</pre>`;
        }
        container.appendChild(warning);
    };

    const testOtherServerConnection = function() {
        const otherServerInput = document.getElementById("login_server_other_input");
        const otherServerUri = otherServerInput.value;
        const isSecure = false;

        /**
         * Test WebSocket connection and display status if successful.
         * @returns {undefined}
         */
        const onReady = function() {
            makeServerSelectReadWrite();
            msgText = displayConnStatus("success", otherServerUri);
            if (DEBUG) { logDebugMessage(msgText); }
            currentServerHasValidConnection = true;
        };
        /**
         * Test WebSocket connection and display status if failed.
         * @returns {undefined}
         */
        const onError = function() {
            makeServerSelectReadWrite();
            msgText = displayConnStatus("failure", otherServerUri);
            if (DEBUG) { logDebugMessage(msgText); }
            currentServerHasValidConnection = false;
        };
        CardshifterServerAPI.init(otherServerUri, isSecure, onReady, onError);
        makeServerSelectReadOnly();
        displayConnStatus("connecting", otherServerUri);
    };

    /**
     * IIFE to setup the login handling for the page it is loaded in.
     * @type undefined
     */
    const runLoginHandler = function() {
        populateServerSelect();
        document.getElementById("login_server_list").addEventListener("change", handleServerSelectChanges, false);
        document.getElementById("login_server_list").addEventListener("change", testWebsocketConnection, false);
        document.getElementById("login_submit").addEventListener("click", tryLogin, false);
        document.getElementById("test_login_server_other").addEventListener("click", testOtherServerConnection, false);
        testWebsocketConnection();
    }();
};

sections/top_navbar/top_navbar.html

<nav id="top_navbar" class="navbar navbar-inverse">
    <div class="container-fluid">

        <div class="navbar-header">
            <!-- TODO fix this logic -->
            <div class="navbar-brand csh-top-link">Cardshifter</div>
        </div>

        <form class="navbar-form">

            <ul class ="navbar-form navbar-left" style="margin-top: 8px;">
                <li class="dropdown">
                    <a href="#" class="dropdown-toggle csh-dropdown-link" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
                        Mods
                        <span class="caret"></span></a>
                    <ul class="dropdown-menu">
                        <li class="cyborg-font">Cyborg Chronicles</li>
                        <li class="cyborg-font"><a href=#>Game rules</a></li>
                        <li class="cyborg-font"><a href=#>Cards</a></li>

                        <li role="separator" class="divider"></li>

                        <li class="mythos-font">Mythos</li>
                        <li class="mythos-font"><a href=#>Game rules</a></li>
                        <li class="mythos-font"><a href=#>Cards</a></li>
                    </ul>
                </li>
            </ul>
            <ul class ="navbar-form navbar-left" style="margin-top: 8px;">
                <li class="dropdown">
                    <a href="#" class="dropdown-toggle csh-dropdown-link" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
                        Help
                        <span class="caret"></span></a>
                </li>
            </ul>
            <ul class ="navbar-form navbar-left" style="margin-top: 8px;">
                <li class="dropdown">
                    <a href="#" class="dropdown-toggle csh-dropdown-link" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
                        About
                        <span class="caret"></span></a>
                </li>
            </ul>

            <div class="form-group navbar-form navbar-left">
                <input name="disconnect_websocket" id="disconnect_websocket" type="button" value="Log Out" class="btn btn-navbar csh-button" />
            </div>
            <div class="form-group navbar-form navbar-left">
                <input name="display_console" id="display_console" type="button" value="Console" class="btn btn-navbar csh-button" />
            </div>

        </form>

    </div>
</nav>

server_interface

server_interface/server_interface.js

"use strict";

// checks if the string begins with either ws:// or wss://
const wsProtocolFinder = /ws(s)*:\/\//;

/*
 * Enum for WebSocket ready state constants.
 * @enum {number}
 */
const readyStates = {
    CONNECTING : 0,
    OPEN : 1,
    CLOSING : 2,
    CLOSED : 3
};

const MAIN_LOBBY = 1;

let eventTypes = [];

/**
* The base class Message for all the other message types
* to inherit from.
*
* TODO: Would it just be easier to set the `.command` property
* individually for each card type?
* 
* @param {string} command - The command of the message.
*/
const Message = function(command) {
    this.command = command;
};

/**
* The exception that is thrown when the code is trying to
* interact with the API when the API has not been
* initialized with `.init` yet.
* 
* @param {string} message - Informational message about the exception.
*/
const NotInitializedException = function(message) {
    this.name = "NotInitializedException";
    this.message = message || "";
};

/**
* The exception that is thrown when the code is telling the
* API to interact with the socket when the socket is not
* ready to accept any information.
* 
* @param {string} message - Informational message about the exception.
* @param {number} readyState - Ready state constant from WebSocket API, https://developer.mozilla.org/en-US/docs/Web/API/WebSocket
*/
const SocketNotReadyException = function(message, readyState) {
    this.name = "SocketNotReadyException";
    this.message = message || "";
    this.readyState = readyState;
};

/*
 * Returns all the keys of an object and its inherited keys.
 * This is used so `JSON.stringify` can get the `.command` of a message.
 * 
 * @param {Object} obj - The object to flatten
 * @return {Object} - a new Object, containing obj's keys and inherited keys
 * @source http://stackoverflow.com/questions/8779249/how-to-stringify-inherited-objects-to-json
*/
const flatten = function(obj) {
    let result = Object.create(obj);
    for(let key in result) {
        // TODO this assignment is weird, why is `result[key]` being assigned to its own value?
        result[key] = result[key];
    }
    return result;
};

/*
 * Singleton object to handle communication via WebSocket between the client
 * and the game server.
 */
const CardshifterServerAPI = {
    socket: null,
    messageTypes: {
        /*
         * Incoming login message.
         * A login message from a client to add a user to the available users on the server.
         * This login message is required before any other action or message can be performed between a client and a server.
         * @constructor
         * @param {string} username - The incoming user name passed from client to server, not null
         * @example Message: <code>{ "command":"login","username":"JohnDoe" }</code>
         */
        LoginMessage : function(username) {
            this.username = username;
        },

        /*
         * Request available targets for a specific action to be performed by an entity.
         * These in-game messages request a list of all available targets for a given action and entity.
         * The client uses this request in order to point out targets (hopefully with a visual aid such as highlighting targets)
         * that an entity (such as a creature card, or a player) can perform an action on (for example attack or enchant a card).
         * @constructor
         * @param {number} gameId - The Id of this game currently being played
         * @param {number} id - The Id of this entity which requests to perform an action
         * @param {string} action - The name of this action requested to be performed
         */
        RequestTargetsMessage : function(gameId, id, action) {
            this.gameId = gameId;
            this.id = id;
            this.action = action;
        },

        /*
         * Make a specific type of request to the server.
         * This is used to request an action from the server which requires server-side information.
         * @constructor
         * @param {string} request - This request
         * @param {string} message - The message accompanying this request
         */
        ServerQueryMessage : function(request, message) {
            this.request = request;
            this.message = message;

            this.toString = function() {
                return `ServerQueryMessage: Request${this.request} message: ${this.message}`;
            };
        },


        /*
         * Request to start a new game.
         * This is sent from the Client to the Server when this player invites another player (including AI) 
         * to start a new game of a chosen type.
         * @constructor
         * @param opponent - The Id of the player entity being invited by this player
         * @param gameType - The type / mod of the game chosen by this player
         */
        StartGameRequest : function(opponent, gameType) {
            this.opponent = opponent;
            this.gameType = gameType;
        },

        /*
         * Serialize message from JSON to byte.
         * Primarily used for libGDX client.
         * Constructor.
         * @param type - This message type
         */
        TransformerMessage : function(type) {
            this.type = type;
        },

        /*
         * Message for a game entity to use a certain ability.
         * Game entities (e.g., cards, players) may have one or more ability actions that they can perform.
         * Certain abilities can have multiple targets, hence the use of an array.
         * @constructor
         * Used for multiple target actions.
         *
         * @param gameId - This current game
         * @param entity - This game entity performing an action
         * @param action - This action
         * @param targets - The set of multiple targets affected by this action
         */
        UseAbilityMessage : function(gameId, id, action, targets) {
            this.gameId = gameId;
            this.id = id;
            this.action = action;
            this.targets = targets;

            this.toString = function() {
                return ``
                    + `UseAbilityMessage`
                    + `[id=${this.id},`
                    + `action=${this.action},`
                    + `gameId=${this.gameId}`
                    + `targets=${this.targets.toString()}]`
                ;
            };
        },

        /*
         * Chat message in game lobby.
         * These are messages printed to the game lobby which are visible to all users present at the time the message is posted.
         * @constructor
         * @param {string} message - The content of this chat message
         */
        ChatMessage : function(message) {
            this.chatId = MAIN_LOBBY;
            this.message = message;

            this.toString = function() {
                // TODO where does that `from` param/var come from?
                return `ChatMessage [chatId=${chatId}, message=${message}, from=${from}]`;
            };
        },

        /*
         * Request to invite a player to start a new game.
         * @constructor
         * @param id - The Id of this invite request
         * @param {string} name - The name of the player being invited
         * @param gameType - The game type of this invite request
         */
        InviteRequest : function(id, name, gameType) {
            this.id = id;
            this.name = name;
            this.gameType = gameType;
        },

        /*
         * Response to an InviteRequest message.
         * @constructor
         * @param inviteId - Id of this incoming InviteRequest message
         * @param {boolean} accepted - Whether or not the InviteRequest is accepted
         */
        InviteResponse : function(inviteId, accepted) {
            this.inviteId = inviteId;
            this.accepted = accepted;
        },

        /*
         * Player configuration for a given game.
         * @constructor
         * @param gameId - This game
         * @param {string} modName - The mod name for this game
         * @param {Map} configs - Map of player name and applicable player configuration
         */
        PlayerConfigMessage : function(gameId, modName, configs) {
            this.gameId = gameId;
            this.modName = modName;
            this.configs = configs;

            this.toString = function() {
                return ``
                    + `PlayerConfigMessage{`
                    + `configs=${configs}, `
                    + `gameId=${gameId}, `
                    + `modName='${modName}'`
                    + `}`
                ;
            };
        }
    },
    /*
     * Initializes the API for use.
     *
     * This sets up all the message types to inherit the main `Message` class, and sets
     * up the websocket that will be used to communicate to the server, and to recieve
     * information from the server.
     * 
     * @param {string} server - The server address to connect to
     * @param {boolean} isSecure - Whether to use SSL for the connection (NOT IMPLEMENTED)
     * @param onReady - Function to assign to `socket.onopen`
     * @param onError - Function to assign to `socket.onerror`
     */
    init : function(server, isSecure, onReady, onError) {
        let types = this.messageTypes;
        // TODO find out why this unused variable is here
        let self = this; // for the events

        types.LoginMessage.prototype = new Message("login");
        types.RequestTargetsMessage.prototype = new Message("requestTargets");
        types.ServerQueryMessage.prototype = new Message("query");
        types.StartGameRequest.prototype = new Message("startgame");
        types.TransformerMessage.prototype = new Message("serial");
        types.UseAbilityMessage.prototype = new Message("use");
        types.ChatMessage.prototype = new Message("chat");
        types.InviteRequest.prototype = new Message("inviteRequest");
        types.InviteResponse.prototype = new Message("inviteResponse");
        types.PlayerConfigMessage.prototype = new Message("playerconfig");
        NotInitializedException.prototype = new Error();
        SocketNotReadyException.prototype = new Error();

         // secure websocket is wss://, rather than ws://
        const secureAddon = (isSecure ? "s" : "");
         // if the protocol is not found in the string, store the correct protocol (is secure?)
        const protocolAddon = (wsProtocolFinder.test(server) ? "" : `ws${secureAddon}://`);

        let socket = new WebSocket(protocolAddon + server);

        socket.onopen = onReady;

        socket.onerror = function() {
            onError();
            this.socket = null;
        };

        this.socket = socket;
    },

    /**
    * Sends a message to the server
    *
    * @param {Object} message - The message to send
    * @error SocketNotReadyException - The socket is not ready to be used
    * @error NotInitializedException - The API has not yet been initialized
    */
    sendMessage : function(message) {
        const socket = this.socket;
        // TODO find out why this unused variable is here
        let self = this;
        if (socket) {
            if (socket.readyState === readyStates.OPEN) {
                this.socket.send(JSON.stringify(flatten(message)));
            } 
            else {
                throw new SocketNotReadyException("The Websocket is not ready to be used.", socket.readyState);
            }
        } 
        else {
            throw new NotInitializedException("The API has not yet been initialized.");
        }
    },

    /**
    * Sets an event listener for when the server sends a message and
    * the message type is one of the types in types
    *
    * @param listener - The function to fire when a message of types is received
    * @param {string[]} types - (OPTIONAL) Only fire the listener when the message type is in this array
    * @param {Object} timeout - (OPTIONAL) The function(.ontimeout) to call after MS(.ms) of no reply
    *
    * TODO: Maybe a timeout will be needed? Pass in a function and a MS count.
    */
    setMessageListener : function(listener, types, timeout) {
        eventTypes = types;

        this.socket.onmessage = function(message) {
            var data = JSON.parse(message.data);
            if (eventTypes) {
                if(eventTypes.indexOf(data.command) !== -1) { // if contains
                    listener(data);
                }
            } 
            else {
                listener(data);
            }
        };
    },

    /**
    * Adds types to the types to listen for in the message event listener
    *
    * @param {string[]} types - The types to add
    */
    addEventTypes : function(types) {
        eventTypes = eventTypes.concat(types);
    },

    /**
    * Removes the message event listener
    */
    removeMessageListener : function() {
        this.socket.onmessage = null;
    }
};

utils

utils/formatDate.js

/* global DEFAULT_DATE_FORMAT */

/**
 * Formats a Date object based on a format string, e.g., "yyyy/MM/dd hh:mm:ss"
 * Original source: 
 * https://dzone.com/articles/javascript-formatdate-function
 * Original source modified to fix a few bugs and modernize.
 * 
 * @param {Date} date - the Date to format
 * @param {String} formatString - the format string to use
 * @returns {String} - the formatted date
 */
const formatDate = function (date, formatString=DEFAULT_DATE_FORMAT) {
    if(date instanceof Date) {
        const months = new Array("Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec");
        const yyyy = date.getFullYear();
        const yy = yyyy.toString().slice(-2);
        const M = date.getMonth() + 1;
        const MM = M < 10 ? `0${M}` : M;
        const MMM = months[M - 1];
        const d = date.getDate();
        const dd = d < 10 ? `0${d}` : d;
        const h = date.getHours();
        const hh = h < 10 ? `0${h}` : h;
        const m = date.getMinutes();
        const mm = m < 10 ? `0${m}` : m;
        const s = date.getSeconds();
        const ss = s < 10 ? `0${s}` : s;
        formatString = formatString.replace(/yyyy/, yyyy);
        formatString = formatString.replace(/yy/, yy);
        formatString = formatString.replace(/MMM/, MMM);
        formatString = formatString.replace(/MM/, MM);
        formatString = formatString.replace(/M/, M);
        formatString = formatString.replace(/dd/, dd);
        formatString = formatString.replace(/d/, d);
        formatString = formatString.replace(/hh/, hh);
        formatString = formatString.replace(/h/, h);
        formatString = formatString.replace(/mm/, mm);
        formatString = formatString.replace(/m/, m);
        formatString = formatString.replace(/ss/, ss);
        formatString = formatString.replace(/s/, s);
        return formatString;
    } else {
        return "";
    }
};

utils/loadHtml.js

/* global fetch, DEBUG */
"use strict";

/*
 * Replicates the functionality of jQuery's `load` function, 
 * used to load some HTML from another file into the current one.
 * 
 * Based on this Stack Overflow answer:
 * https://stackoverflow.com/a/38132775/3626537
 * And `fetch` documentation:
 * https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch
 * 
 * @param {string} parentElementId - The ID of the DOM element to load into
 * @param {string} htmlFilePath - The path of the HTML file to load
 */
const loadHtml = function (parentElementId, filePath) {
    const init = {
        method: "GET",
        headers: { "Content-Type": "text/html" },
        mode: "cors",
        cache: "default"
    };
    // Return Promise from `fetch` allows to use `.then` after call.
    return fetch(filePath, init)
    .then(function (response) {
        return response.text();
    })
    .then(function (body) {
        // Replace `#` char in case the function gets called `querySelector` or jQuery style
        if (parentElementId.startsWith("#")) {
            parentElementId.replace("#", "");
        }
        document.getElementById(parentElementId).innerHTML = body;
        if (DEBUG) {
            console.log(`File "${filePath}" loaded into element ID "${parentElementId}"`);
        }
    })
    .catch(function(err) {
        throw new FailureToLoadHTMLException(
            `Could not load "${filePath} ` + 
            `into element ID "${parentElementId}"` +
            `\n${err}`
        );
    });
};

const FailureToLoadHTMLException = function(message) {
    this.name = "FailureToLoadHTMLException";
    this.message = message;
    this.stack = (new Error()).stack;
};

FailureToLoadHTMLException.prototype = new Error;

utils/logDebugMessage.js

/* global DEFAULT_DATE_FORMAT */

/**
 * Log a debug message to the browser's JavaScript console.
 * @param {String} msg
 * @param {String} dateFormat
 * @returns {undefined}
 */
const logDebugMessage = function(msg, dateFormat=DEFAULT_DATE_FORMAT) {
    const timestamp = new Date();
    console.log(`DEBUG | ${formatDate(timestamp, dateFormat)} | ${msg}`);
};
\$\endgroup\$
  • 2
    \$\begingroup\$ I have no desire to use libraries like jQuery, Underscore, etc. you must be having a lot of free time ;-] \$\endgroup\$ – t3chb0t Mar 24 '18 at 21:28
  • 3
    \$\begingroup\$ I'm not in a hurry or anything, I do want to prove as a personal challenge that you can, in fact, write a good and dynamic web app with just regular old JavaScript :) \$\endgroup\$ – Phrancis Mar 24 '18 at 21:34
  • 3
    \$\begingroup\$ Of course you can. Those libraries are just components built out of regular old JS. It's just how much of the wheel you want to reinvent. \$\endgroup\$ – Hosch250 Mar 24 '18 at 22:37
5
\$\begingroup\$

Very neat project! I really like that you are avoiding using a library, it's a great way to get better at JS.

  • I see the if (DEBUG) { logDebugMessage() } pattern several times, it might be worth bringing the conditional inside the logDebugMessage function.

  • formatDate has a ton of repetitive code, I'd recommend taking advantage of .replace's second parameter. There's probably some way to reduce this further...

    const formatDate = function (date, formatString = DEFAULT_DATE_FORMAT) {
      if (!(date instanceof Date)) {
        return ""
      }
    
      const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
      const format = {
        yyyy: date.getFullYear(),
        M: date.getMonth() + 1,
        d: date.getDate(),
        h: date.getHours(),
        m: date.getMinutes(),
        s: date.getSeconds()
      }
      format.yy = format.yyyy.toString().slice(-2);
      format.MM = format.M < 10 ? `0${format.M}` : M;
      format.MMM = months[date.getMonth()];
      format.dd = format.d < 10 ? `0${format.d}` : format.d;
      format.hh = format.h < 10 ? `0${format.h}` : format.h;
      format.mm = format.m < 10 ? `0${format.m}` : format.m;
      format.ss = format.s < 10 ? `0${format.s}` : format.s;
    
      const regex = /yyyy|yy|MMM|MM|M|dd|d|hh|h|mm|m|ss|s/g
      return formatString.replace(regex, s => format[s] || s);
    };
    
  • The original formatDate function will have a problem in May if MMM is included in the format string.

  • It might be worth allowing formatDate to accept no arguments, in which case it uses the current date.

  • loadHtml looks good to me with two exceptions. First, if the passed id starts with #, the # will not be removed as the comment implies it should. Second, if you define a logDebugMessage function, use it! Drop the console.log.

  • In server_interface.js, I'd recommend the source of the flatten method, it explains how it works.

  • Get rid of unused variables. There are plenty of tools which can check if a variable is used, you should get rid of them instead of just commenting about them.

  • Since you are using let and const, you can use Array.prototype.includes instead of Array.prototype.indexOf to check for an element in an array.

  • The ChatMessage toString method won't work. JS isn't C# - it's not possible to drop this and still access the instance variables. Same goes for PlayerConfigMessage.

  • Instead of using the LoginMessage : function(username) pattern, you can use LoginMessage(username).

  • wsProtocolFinder does not check for wss at the start of the string. It will also match wssssss://. The regex should be /^wss?:\/\//.

  • I know the goal of this project is to avoid any npm packages, however you should really consider at least using a linting program (if only installed locally). Pretty much all linting programs could have caught the problems with this mentioned above and can also alert you to the unused variables.

\$\endgroup\$
  • \$\begingroup\$ Nice answer, thank you so much! I use Netbeans as IDE which includes JsHint, but apparently it didn't catch all that. Any suggestions on a linter/plugin I can use? \$\endgroup\$ – Phrancis Mar 25 '18 at 19:15
  • \$\begingroup\$ @Phrancis good question! JSHint has an undef option which should warn you about this, though you may need to enable it. I'm not familiar with Netbeans (VSCode here) so can't really recommend how to set that up easily. \$\endgroup\$ – Gerrit0 Mar 25 '18 at 21:57
  • \$\begingroup\$ Actually, JsHint did warn me, but I realize I ignored it lol. \$\endgroup\$ – Phrancis Mar 25 '18 at 22:28

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.