10
\$\begingroup\$

I am an experienced Java programmer, but fairly new to JavaScript.

I am creating a chat library in JavaScript, and have it working, but want to know if I am going about this correctly, and following correct JavaScript coding standards.

I have a SDK object, LiveChatConnection class, and LiveChatListener and Credentials interfaces (I know classes and interfaces do not exist in JavaScript, but this seems to be how to create them).

To declare methods I am using

this.myMethod = function() {...};

I have seen other libraries put methods on the class prototype

MyClass.prototype.myMethod = function() {...}

or declare them like,

myMethod : function() {...}

Not sure which is best.

So my SDK will be packaged in a sdk.js file on my website, then users should be able to import and use it in their web pages.

Not sure if my code makes sense from a usage perspective, or if I'm missing anything else.

var SDK = {};

function Credentials() {
    this.host = "";
    this.app = "";
    this.url = "";
    this.applicationId = "";
}


/**
 * Listener interface for a LiveChatConnection.
 * This gives asynchronous notification when a channel receives a message, or notice.
 */
function LiveChatListener() {
    /**
     * A user message was received from the channel.
     */
    this.message = function(message) {};

    /**
     * An informational message was received from the channel.
     * Such as a new user joined, private request, etc.
     */ 
    this.info = function(message) {};

    /**
     * An error message was received from the channel.
     * This could be an access error, or message failure.
     */ 
    this.error = function(message) {};

    /**
     * Notification that the connection was closed.
     */
    this.closed = function() {};

    /**
     * The channels users changed (user joined, left, etc.)
     * This contains a comma separated values (CSV) list of the current channel users.
     * It can be passed to the SDKConnection.getUsers() API to obtain the UserConfig info for the users.
     */
    this.updateUsers = function(usersCSV) {};

    /**
     * The channels users changed (user joined, left, etc.)
     * This contains a HTML list of the current channel users.
     * It can be inserted into an HTML document to display the users.
     */
    this.updateUsersXML = function(usersXML) {};
}

/**
 * Connection class for a Live Chat, or chatroom connection.
 * A live chat connection is different than an SDKConnection as it is asynchronous,
 * and uses web sockets for communication.
 */
function LiveChatConnection(credentials) {
    this.debug = false;
    this.channel = null;
    this.user = null;
    this.credentials = credentials;
    this.socket = null;
    this.listener = null;
    this.keepAlive = false;
    this.keepAliveInterval = null;

    /**
     * Connection to the live chat server channel.
     * Validate the user credentials.
     * This call is asynchronous, any error or success with be sent as a separate message to the listener.
     */
    this.connect = function(channel, user) {
        if (this.credentials == null) {
            throw "Mising credentials";
        }
        this.channel = channel;
        this.user = user;
        var host = "ws://" + this.credentials.host + this.credentials.app + "/live/chat";
        if ('WebSocket' in window) {
            this.socket = new WebSocket(host);
        } else if ('MozWebSocket' in window) {
            this.socket = new MozWebSocket(host);
        } else {
            this.socket = new WebSocket(host);
            //throw 'Error: WebSocket is not supported by this browser.';
        }

        this.listener.connection = this;
        var self = this;

        this.socket.onopen = function () {
            if (self.user == null) {
                self.socket.send("connect " + self.channel.id + " " + self.credentials.applicationId);
            } else {
                self.socket.send(
                        "connect " + self.channel.id + " " + self.user.user + " " + self.user.token + " " + self.credentials.applicationId);                        
            }
            self.setKeepAlive(this.keepAlive);
        };

        this.socket.onclose = function () {
            self.listener.message("Info: Closed");
            self.listener.closed();
        };

        this.socket.onmessage = function (message) {
            user = "";
            data = message.data;
            text = data;
            index = text.indexOf(':');
            if (index != -1) {
                user = text.substring(0, index);
                data = text.substring(index + 2, text.length);
            }
            if (user == "Online-xml") {
                self.listener.updateUsersXML(data);
                return;
            }
            if (user == "Online") {
                self.listener.updateUsers(data);
                return;
            }

            if (self.keepAlive && user == "Info" && text.contains("pong")) {
                return;
            }
            if (user == "Info") {
                self.listener.info(text);
                return;
            }
            if (user == "Error") {
                self.listener.error(text);
                return;
            }
            self.listener.message(text);
        };
    };

    /**
     * Sent a text message to the channel.
     * This call is asynchronous, any error or success with be sent as a separate message to the listener.
     * Note, the listener will receive its own messages.
     */
    this.sendMessage = function(message) {
        this.checkSocket();
        this.socket.send(message);
    };

    /**
     * Accept a private request.
     * This is also used by an operator to accept the top of the waiting queue.
     * This can also be used by a user to chat with the channel bot.
     * This call is asynchronous, any error or success with be sent as a separate message to the listener.
     */
    this.accept = function() {
        this.checkSocket();
        this.socket.send("accept");
    };

    /**
     * Test the connection.
     * A pong message will be returned, this message will not be broadcast to the channel.
     * This call is asynchronous, any error or success with be sent as a separate message to the listener.
     */
    this.ping = function() {
        this.checkSocket();
        this.socket.send("ping");
    };

    /**
     * Exit from the current private channel.
     * This call is asynchronous, any error or success with be sent as a separate message to the listener.
     */
    this.exit = function() {
        this.checkSocket();
        this.socket.send("exit");
    };

    /**
     * Request a private chat session with a user.
     * This call is asynchronous, any error or success with be sent as a separate message to the listener.
     */
    this.pvt = function(user) {
        this.checkSocket();
        this.socket.send("pvt: " + user);
    };

    /**
     * Disconnect from the channel.
     */
    this.disconnect = function() {
        this.setKeepAlive(false);
        if (this.socket != null) {
            this.socket.disconnect();
        }
    };

    this.checkSocket = function() {
        if (this.socket == null) {
            throw "Not connected";
        }
    };

    this.toggleKeepAlive = function() {
        setKeepAlive(!this.keepAlive);
    }

    this.setKeepAlive = function(keepAlive) {
        this.keepAlive = keepAlive;
        if (!keepAlive && this.keepAliveInterval != null) {
            clearInterval(this.keepAliveInterval);
        } else if (keepAlive && this.keepAliveInterval == null) {
            this.keepAliveInterval = setInterval(
                    function() {
                        this.ping()
                    },
                    600000);
        }
    }
}

SDK.chime = function() {
    var sound = new Audio('chime.wav');
    sound.play();
}

SDK.url = "/botlibre/rest/botlibre";
SDK.tts = function(text) {
    try {
        var url = SDK.url + '/form-speak?&text=';
        url = url + encodeURIComponent(text);

        var request = new XMLHttpRequest();
        request.onreadystatechange = function() {
            if (request.readyState != 4) return;
            if (request.status != 200) {
                console.log('Error: Speech web request failed');
                return;
            }
            var audio = new Audio(request.responseText);
            audio.mediaGroup = 'voice';
            audio.play();
        }

        request.open('GET', url, true);
        request.send();
    } catch (error) {
        console.log('Error: Speech web request failed');
    }
}
\$\endgroup\$

1 Answer 1

10
\$\begingroup\$

I'm glad to see I'm not the only one who finds "interfaces" in JavaScript useful. Yes, the language doesn't support interfaces, but there is value in defining them anyway if you expect third party code to integrate with it.

Interfaces in JavaScript

There is no official vehicle for defining interfaces in JavaScript. That being said, when someone sees a constructor function, such as your LiveChatListener function, they expect it to be an instantiable, usable class. I recommend using an Object Literal to define your interface, since it really is only for reference, and is not actually usable.

/**
 * Listener interface for a LiveChatConnection.
 * This gives asynchronous notification when a channel receives a message, or notice.
 */
var ILiveChatListener = {
    /**
     * A user message was received from the channel.
     */
    message: function(message) {},

    /**
     * An informational message was received from the channel.
     * Such as a new user joined, private request, etc.
     */ 
    info: function(message) {},

    /**
     * An error message was received from the channel.
     * This could be an access error, or message failure.
     */ 
    error: function(message) {},

    /**
     * Notification that the connection was closed.
     */
    closed: function() {},

    /**
     * The channels users changed (user joined, left, etc.)
     * This contains a comma separated values (CSV) list of the current channel users.
     * It can be passed to the SDKConnection.getUsers() API to obtain the UserConfig info for the users.
     */
    updateUsers: function(usersCSV) {},

    /**
     * The channels users changed (user joined, left, etc.)
     * This contains a HTML list of the current channel users.
     * It can be inserted into an HTML document to display the users.
     */
    updateUsersXML: function(usersXML) {}
};

I know this is opinion, but I do like the .NET convention of prefixing an interface's name with a capital "I", which I think better communicates that this is an interface. So LiveChatListener becomes ILiveChatListener.

On the other hand, if you intend on having people use the LiveChatListener as a base class of some sort, then what you've created is an abstract base class:

function AbstractLiveChatListener() {

}

AbstractLiveChatListener.prototype = {
    constructor: AbstractLiveChatListener,

    message: function(message) {
        throw new Error("Not Implemented");
    },

    info: function(message) {
        throw new Error("Not Implemented");
    },

    ...
};

I like your idea of just defining the interface, so I would stick with that.

Styles for Writing "Classes" in JavaScript

You are creating public methods and properties for your classes inside the constructor function. While this doesn't hurt anything, it does mean each instance of that class also has brand new instances of Function for each public method. The "best practice" is to define public methods on the Prototype, unless they need access to "private" data.

These are my general style guidelines:

  1. If all properties and methods are public, define the methods on the Prototype, and give the properties default values. This communicates what methods are available and what data the class uses.

    function Point(x, y) {
        this.x = x;
        this.y = y;
    }
    
    Point.prototype = {
        x: 0,
        y: 0,
    
        constructor: Point,
    
        isAbove: function(p) {
            return this.y > p.y;
        },
    
        isBelow: function(p) {
            return this.y < p.y;
        }
    };
    
  2. If a class requires inheritance, then I use the Foo.prototype.bar = function() style for defining methods on the Prototype:

    function Point3D(x, y, z) {
        Point.call(this, x, y);
        this.z = z;
    }
    
    Point3D.prototype = Object.create(Point.prototype);
    
    Point3D.prototype.contains = function(p) {
        return this.x >= p.x
            && this.y >= p.y
            && this.z >= p.z;
    };
    
  3. If a class requires "private" data, then I define methods and properties in the constructor function like you did:

    function Point(x, y) {
        x = x || 0;
        y = y || 0;
    
        var
        getX = function() {
            return x;
        },
        getY = function() {
            return y;
        },
        isAbove = function(p) {
            return y > p.y;
        },
        isBelow = function(p) {
            return y < p.y;
        };
    
        // Define public interface
        this.getX = getX;
        this.getY = getY;
        this.isAbove = isAbove;
        this.isBelow = isBelow;
    }
    

Since you don't require inheritance or "private" data at this point, I would recommend style #1 as I find it easier to read, and less clutter in the code.

Namespaces in JavaScript

You have a global object called "SDK". It is a good idea to put your code in a "namespace" in JavaScript, but the classes you've written also appear to be global. I would recommend using an Immediately Invoked Function Expression (IIFE), which will give you a Function scope to define classes and variables that you only want internal to your library, and selectively expose things publically. Secondly, I would not name it "SDK" as this is very generic and could collide with other people who use this namespace. Make the namespace named after your library. For instance, if you call your lib "Baked Ziti", then the name space would be BakedZiti. (As a side note, baked ziti with sausage sounds pretty good right now.)

(function(global) {

    function Credentials() {
        ...
    }

    var ILiveChatListener = {
        ...
    };

    function LiveChatConnection(...) {
        ...
    }

    // Public Namespace
    global.BakedZiti = {
        Credentials: Credentials,
        ILiveChatListener: ILiveChatListener,
        LiveChatConnection: LiveChatConnection,

        url: "...",

        tts: function() {
            ...
        },
        chime: function() {
            ...
        }
    };

})(this);

Now, you can get a new connection via:

var credentials = new BakedZiti.Credentials(),
    connection = new BakedZiti.LiveChatConnection(credentials);

Packages in JavaScript

You mention that you will have a file called sdk.js that people can include on their sites. I would recommend keeping each class in its own file, then use a package manager like Bower to concatenate and minify the files into something like "sdk.js". Furthermore, you could publish your package on Bower, making it available to anywone via a bower install on the command line during development.

Example Project Structure

I've used this basic folder structure for my JavaScript libraries:

BakedZiti/
    build/
        header.js
        footer.js
    demo/
        index.html
    dist/
        BakedZiti-v1.0.0.concat.js
        BakedZiti-v1.0.0.min.js
        BakedZiti-v1.0.1.concat.js
        BakedZiti-v1.0.1.min.js
        BakedZiti-v2.0.0.concat.js
        BakedZiti-v2.0.0.min.js
    src/
        BakedZiti/
            Credentials.js
            ILiveChatListener.js
            LiveChatConnection.js
        BakedZiti.js
    tests/
        BakedZiti
            LiveChatConnectionTests.js
    bower.json
    Gruntfile.js
    package.json

The demo folder would have a quick and dirty implementation of your library.

The dist directory would have packaged and minified versions of every major release.

The src directory obviously have the raw source files in their unconcatenated and unminified forms, making for easy development and maintenance.

The tests directory would have any JavaScript unit tests, if applicable. As a side note, you could create a factory method that returns a WebSocket object, and you could mock this object in your tests making the LiveChatConnection class testable.

The bower.json file is where you wire your dependencies files together:

{
    "name": "BakedZiti",
    "description": "BakedZiti is a tasty chat library for JavaScript with no dependencies.",
    "version": "2.0.0",
    "homepage": "http://example.com/BakedZiti",
    "authors": [
        "Your Name <[email protected]>"
    ],
    "license": "MIT",
    "repository": { "type": "git", "url": "https://github.com/foo/BakedZiti.git" },
    "main": "dist/BakedZiti-v2.0.js"
}

Then you could use Grunt to build the library (Gruntfile.js) and create the distributable forms:

module.exports = function(grunt) {
    var files = [
        "build/header.js",
        "src/BakedZiti/Credentials.js",
        "src/BakedZiti/ILiveChatListener.js",
        "src/BakedZiti/LiveChatConnection.js"
        "build/footer.js",
    ];

    // Project configuration.
    grunt.initConfig({
        pkg: grunt.file.readJSON('package.json'),

        concat: {
            options: {
                banner: '/*! <%= pkg.name %> <%= grunt.template.today("yyyy-mm-dd") %> */\n'
            },
            main: {
                src: files.main,
                dest: 'dist/<%= pkg.name %>.concat.js'
            },
        },
        min: {
            main: {
                src: 'dist/<%= pkg.name %>.concat.js',
                dest: 'dist/<%= pkg.name %>.min.js',
            }
        }
    });

    // Load the plugin that provides the "concat" task.
    grunt.loadNpmTasks('grunt-contrib-concat');
    // Load the plugin that provides the "min" task.
    grunt.loadNpmTasks('grunt-yui-compressor');
    // Default task(s).
    grunt.registerTask('default', ['concat', 'min']);
};

Then package.json required by Grunt:

{
    "name": "BakedZiti",
    "version": "2.0.0",
    "devDependencies": {
        "grunt": "~0.4.2",
        "grunt-contrib-concat": "~0.1.2",
        "grunt-contrib-jshint": "~0.6.3",
        "grunt-contrib-nodeunit": "~0.2.0",
        "grunt-yui-compressor": "~0.3.3"
    }
}

Then build/header.js:

(function(global) {

And build/footer.js:

    // Public Namespace
    global.BakedZiti = {
        Credentials: Credentials,
        ILiveChatListener: ILiveChatListener,
        LiveChatConnection: LiveChatConnection,

        url: "...",

        tts: function() {
            ...
        },
        chime: function() {
            ...
        }
    };
})(this);

All you need then is to run grunt from the command line to package things up, which you will need to install Node locally.

This really keeps your code organized and allows you to scale your library out as much as you need.

\$\endgroup\$
0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

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