Tutorial: Creating a chat with Hydna and jQuery, part two

This tutorial will add improvements to the very basic chat from 'Creating a chat with Hydna and jQuery, part one'. We'll introduce a JSON protocol, add nicknames to participants, and display messages as users leave/join the chat.

If you get stuck, please don't hesitate to contact support@hydna.com.

Prerequisites

  • You should have completed part one of the tutorial (we'll use code from the previous part as foundation)
  • Be familiar with JSON
  • You should have glanced at the documentation of Behaviors

Getting started

We've once again used our domain, tutorials.hydna.net, in the code below. Remember to replace that with the name of the domain you created in part one.

Initial setup

Copy the contents of chat-tutorial-part-one (created in part one) to a directory named chat-tutorial-part-two.

The directory layout should look like:

chat-tutorial-part-two/
    index.html
    script/
        app.js
    style/
        app.css

The HTML

We can leave index.html as it was.

The CSS

We can leave style/app.css as it was.

The Application

We are going to introduce a number changes to how the application works.

Let's start at the end — what script/app.js will look like:

$(document).ready(function() {
    var output = $('#output');

    // New in part two
    var nick = prompt("Enter your desired nickname:", null);
    while (!nick) {
        nick = prompt("You must enter a nickname:", null);
    }

    output.append($('<div/>', { html: 'Connecting ...' }));

    // NOTE: You should replace 'tutorials.hydna.net' with your own domain.
    // Updated in part two
    var channel = new HydnaChannel('tutorials.hydna.net/chat?' + nick, 're');

    channel.onopen = function(event) {
        output.append($('<div/>', { html: 'You are now connected!' }));
    };

    channel.onclose = function(event) {
        var text = "Channel closed: " + event.reason;
        output.append($('<div/>', { html: text }));
    };

    // New in part two
    channel.onsignal = function(event) {
        var packet;
        try {
            packet = JSON.parse(event.data);
        } catch (e) {
            console.log("Unable to parse data.");
            return;
        }

        if (packet.action == 'message') {
            var message = escapeHTML(packet.message);
            var nick = escapeHTML(packet.nick);
            var text = nick + ": " + message;
            output.append($('<div/>', { html: text }));
        } else if (packet.action == 'join') {
            var nick = escapeHTML(packet.nick);
            var text = nick + " just joined the chat!";
            output.append($('<div/>', { html: text }));
        } else if (packet.action == 'leave') {
            var nick = escapeHTML(packet.nick);
            var text = nick + " has left the chat.";
            output.append($('<div/>', { html: text }));
        } else {
            console.log("Got an unknown packet.");
        }

        // Scroll to bottom of output when new messages are appended.
        output.prop('scrollTop', output.prop('scrollHeight'));
    };

    $('#input input').focus();

    $('form').submit(function(event) {
        event.preventDefault();

        var input = $('input', this);
        if (input.val()) {
            try {
                // Updated for part two
                var packet = JSON.stringify({
                    command: 'message',
                    message: input.val()
                });
                channel.emit(packet);
                input.val('');
            } catch(e) {
                alert("Cannot send messages while disconnected.");
            }
        }
    });
});

// Escape input to prevent XSS attacks. Note that we're only escaping
// characters that are potentially dangerous for this specific tutorial.
function escapeHTML(value) {
    var entities = {
        "&": "&amp;",
        "<": "&lt;",
        ">": "&gt;",
        '"': "&quot;",
        "'": "&apos;"
    };

    function escapeCharacter(c) {
        return entities[c];
    }

    return String(value).replace(/[&<>]/g, escapeCharacter);
}

(Be mindful to change tutorials.hydna.net to the name of your domain if you are, gods forbid, copy-pasting the code!)

So what changed? Let's go through the important parts.

A JSON protocol

In part one we sent messages as plain text. We've replaced that with our own JSON-based protocol.

The reason for using a protocol, as opposed to just sending text, is to be able to distinguish between different types of messages (regular chat messages, notifications when a user joins the chat, and notifications when a user leaves the chat).

Nicknames

Let's start looking at the changes we introduced to the code. Before the script does anything else it asks the user to enter a nickname:

var nick = prompt("Enter your desired nickname:", null);
while (!nick) {
    nick = prompt("You must enter a nickname:", null);
}

Open the channel with a token

Next we changed our code to include a token when we open the channel. And instead of asking for write (w) permission, we ask for permission to emit (e) signals to the channel.

What previously looked like (again, substitute tutorials.hydna.net for your own domain):

var channel = new HydnaChannel('tutorials.hydna.net/chat', 'rw');

Now looks like this:

var channel = new HydnaChannel('tutorials.hydna.net/chat?' + nick, 're');

Notice how we've appended the "nick" after the ? in the URI of the channel. And how we've changed mode from rw to re.

We left onopen and onclose untouched.

Listen for signals instead of messages

We removed the onmessage handler entirely.

In it's stead, we bound an event listener to onsignal.

channel.onsignal = function(event) {
    var packet;
    try {
        packet = JSON.parse(event.data);
    } catch (e) {
        console.log("Unable to parse data.");
        return;
    }

    if (packet.action == 'message') {
        var message = escapeHTML(packet.message);
        var nick = escapeHTML(packet.nick);
        var text = nick + ": " + message;
        output.append($('<div/>', { html: text }));
    } else if (packet.action == 'join') {
        var nick = escapeHTML(packet.nick);
        var text = nick + " just joined the chat!";
        output.append($('<div/>', { html: text }));
    } else if (packet.action == 'leave') {
        var nick = escapeHTML(packet.nick);
        var text = nick + " has left the chat.";
        output.append($('<div/>', { html: text }));
    } else {
        console.log("Got an unknown packet.");
    }

    // Scroll to bottom of output when new messages are appended.
    output.prop('scrollTop', output.prop('scrollHeight'));
};

What this does is listen for signals from Hydna. When a signal arrives, it is parsed using JSON.parse() and formatted to make sense before we append it to the chat box.

Emit JSON instead of sending text

In the previous version we sent data like so:

channel.send(input.val());

We're now creating a JSON packet and emitting it to the channel instead:

var packet = JSON.stringify({
    command: 'message',
    message: input.val()
});
channel.emit(packet);

Testing the application without behaviors

If you open index.html now you should be presented with a prompt asking you for a nickname. When you enter a nickname and klick "Ok" you'll see:

Connecting ...
You are now connected!

... like in the previous version. But if you send a chat message you'll notice that nothing appears. This is because we're using channel.emit() now. Signals emitted are sent to a Behaviors instead of other users on the channel.

Attaching a Behavior to the channel

We've updated our code to send the nickname as a token when opening the /chat channel, and to emit chat messages as signals instead of being sent as messages. This gives us the ability to define a behavior that instructs Hydna how to behave under these circumstances.

To define a behavior, navigate to the control panel of your domain. Further down, you'll see a tab called "Behaviors". In that tab you'll find a code editor.

The online editor in the domain control panel.

Enter the following code into the editor:

behavior('/chat', {
    open: function(event) {
        var nick = event.token;
        if (!nick) {
            event.deny("You must supply a nickname to join the chat!");
        }

        event.connection.set('nick', nick);

        var packet = JSON.stringify({
            action: 'join',
            nick: nick
        });
        event.channel.emit(packet);
    },
    close: function(event) {
        event.connection.get('nick', function(err, nick)  {
            if (!err && nick !== null) {
                var packet = JSON.stringify({
                    action: 'leave',
                    nick: nick
                });
                event.channel.emit(packet);
            }
        });
    },
    emit: function(event) {
        var packet = JSON.parse(event.data);

        if (packet.command == 'message') {
            event.connection.get('nick', function(err, nick)  {
                if (!err && nick !== null) {
                    var messagePacket = JSON.stringify({
                        action: 'message',
                        nick: nick,
                        message: packet.message
                    });
                    event.channel.emit(messagePacket);
                }
            });
        } else {
            // ignore other commands for now
        }
    }
});

Click on the button labeled "Save & deploy". You should see a green text that says "Your behaviors were successfully deployed ✓".

You've deployed a behavior! The code you entered in the editor has been saved and sent to your domain, and is now running. Let's break it down.

What we've done is attach three event handlers to the channel /chat. This is done with the behavior-function behavior(path, handlers). handlers is an object containing mappings between an event and a handler. We're bidning handlers to the events open, close, and emit.

First we defined our open handler. This handler is run every time a client tries to open the channel /chat on your domain.

We instruct Hydna to look at the token sent with the request to open the channel (the nickname we're sending after the ? in the URL when we're opening the channel), we deny the open if no nick was given. We store the nickname on the connection with event.connection.set(), create a JSON packet and emit it to the channel.

All users on the channel will receive the emitted signal as we attached an event listener to channel.onemit in app.js.

open: function(event) {
    var nick = event.token;
    if (!nick) {
        event.deny("You must supply a nickname to join the chat!");
    }

    event.connection.set('nick', nick);

    var packet = JSON.stringify({
        action: 'join',
        nick: nick
    });
    event.channel.emit(packet);
},

When a user closes the channel (by closing the browser, for example) the handler attached to the event close will be triggered.

This time we retrieve the nickname set on the connection from which the event is triggered (the connection closing the channel) with event.connection.get() and, if a nickname had been set, create a JSON package that we emit to the channel to let the other participants know that someone has left the chat.

close: function(event) {
    event.connection.get('nick', function(err, nick)  {
        if (!err && nick !== null) {
            var packet = JSON.stringify({
                action: 'leave',
                nick: nick
            });
            event.channel.emit(packet);
        }
    });
},

The final event handler we're adding is triggered when a user emits a signal (the JSON packet we emit from the form with chan.emit() in app.js).

We parse the data-part of the signal, check if the "command" corresponds to "message", find the nickname of the client from whom the signal originates, and, if we find a nickname, emit a JSON package containing the nickname and the message to the channel.

emit: function(event) {
    var packet = JSON.parse(event.data);

    if (packet.command == 'message') {
        event.connection.get('nick', function(err, nick)  {
            if (!err && nick !== null) {
                var messagePacket = JSON.stringify({
                    action: 'message',
                    nick: nick,
                    message: packet.message
                });
                event.channel.emit(messagePacket);
            }
        });
    } else {
        // ignore other commands for now
    }
}

Phew! Let's find out if this works.

Try it out

Open index.html in your preferred browser. You should now be prompted to enter a nickname. If you enter a nickname and click "Ok" you should, again, see something similar to:

Connecting ...
You are now connected!

Now, if you send a chat message your chosen nickname should be displayed to the left of the message:

Connecting ...
You are now connected!
Strange: I love bacon!

If you open index.html in a new browser window, and enter another nickname when prompted, you should see an announcment that a user with that nickname has joined the chat. You'll also see when participants leave the chat:

Connecting ...
You are now connected!
Strange: I love bacon!
JFD just joined the chat!
JFD: Hello world!
Strange: Ehh, hello?
JFD has left the chat.

And that's part two of the tutorial. We hope you're still having fun! Please send an email to support@hydna.com if you've discovered any errors, found a section particularly unclear, or if something went wrong.

Next step

A valid question is "Couldn't we just have kept the nicknames local and sent them along with the message instead of doing stuff in Behaviors?". We could. Sort-of. And it would actually require less code.

What we would be missing out on is the ability to determine when a user leaves the room.

And we now have a state on the server. Something we'll exploit in the next part of the series when we further improve upon the chat by implementing a few "commands".