Assignment 4: Realtime

Due: Friday, March 22, 2019 @ 11:59pm

Introduction

tooslow

With a new chatroom now in place, Club Penguin HQ has finally been able to resume operations. However, agents out in the field have started to complain about messages arriving too slowly, especially in crucial life-or-death situations. You now need to upgrade your chatroom to send messages in real-time, so that the agents of Club Penguin HQ can communicate with each other at blazing speeds!

To do this you will still use Node, but now you will also use socket.io to have sockets delivering real-time messages in your chatroom.

Requirements

Starting with your existing chatroom as a template, build a real-time chatroom that does not have to poll the server for new messages. Remove any existing refresh/AJAX update logic that you have in your chatroom -- it will no longer be necessary. Messages will now be delivered to your client via events fired by socket.io.

Your new chatroom should also show, for each room, a list of the users currently in that room. Users should be removed from the list when they close their browser window. Users must also be able to change their nickname, and all other users in the same room should see this updated change.

You must also implement one additional real-time feature of your choice, and any other features you can add would be welcome!

Setup

Install the project folder by running:

cs132_install assignment4_dev

This will create an empty directory realtime in /gpfs/data/course/cs1320/CS_LOGIN.

Install socket.io by running:

npm install socket.io

Now, create a server.js file, import socket.io, and set it up with your Express app. This will require a few modifications to your existing setup code; most notably, you will need to manually create an HTTP server, and listen on that instead of on the app:

const http = require('http'); // this is new
const express = require('express');
const app = express();
const server = http.createServer(app); // this is new

// add socket.io
const io = require('socket.io').listen(server);

// your server code here

// changed from *app*.listen(8080);
server.listen(8080);

Then, on each page of your site that needs to communicate with the server in real-time (probably just room.html, if that's what you called it), you need to add the socket.io client-side script to the head:

<script src="/socket.io/socket.io.js"></script>
<script src="/path/to/room.js"></script>

Note that /socket.io/socket.io.js is automatically served by socket.io, and you don't need to copy this script file anywhere.

Helpful Code References

Using socket.io

Socket.io functions as an EventEmitter, which means you attach .on() handlers. The events are transported back and forth between a client and a server. Come up with a reasonable set of events, and then emit them from either the client or the server to indicate state changes.

We would like you to broadcast when users join, leave, and change nicknames.

Below is one possible set of events:

Sent from Client to Server

Sent from Server to Client


Note that we don't include a leave event because the user closing their browser window (the disconnect event) works as an implicit leave.

To send an event from server to client, you first obtain an individual socket object, or a collection of socket objects (like io.sockets for all connected users), and then call the .emit(...) method. The first parameter is the name of the message, and any subsequent parameters are sent as function arguments:

// send to one user
const socket = ...; // obtained through some trickery, you'll see later
socket.emit('myCoolEvent', param1, param2);

// send to all users
io.sockets.emit('myOtherEvent', paramA, paramB, paramC);

On the client-side, you obtain your socket by calling io.connect() (generally with no parameters) once at the top of your script. You can then assign event handlers in the normal Node-y way:

const socket = io.connect();

// lots of app code

socket.on('myCoolEvent', function(param1, param2){
    // this code is run when the server emits an event to us
});

Sending events in reverse works the exact same way: setup event handlers on the server on individual sockets with socket.on(...), and then call socket.emit(...) from the client side. It's also worth noting that socket.io supports callbacks (neat!) -- just pass a function as your last parameter to emit(...).

Generally speaking, if the client will be sending events to the server, you register your handlers for those events inside the io.sockets.on('connection', ...) handler, since it's the first time you get to interact with the new socket object.

More detailed examples can be found in the socket.io docs.

Wiring Things Up

The first order of business is to setup a server-side handler for what happens when a new user connects. One strategy is to keep a global table of users somewhere along with their nicknames.

let users = [];
io.sockets.on('connection', function(socket){
    // handle a newly-connected socket
    users.push(socket);

    // add event handlers for this user
    socket.on('disconnect', function(){
        const idx = users.indexOf(socket);
        users.splice(idx, 1);
    });
});

This will be insufficient, however, because we actually want to keep track of users in different rooms, not users in general. It turns out that socket.io has a feature called "rooms" which is perfect for this. It also does most of the bookkeeping on our behalf, so we don't need to maintain our own users table. Awesome.

What we'll do is create an event called "join" that a user will call immediately after joining a chatroom. We'll use this as an indication that the user should be added to the room. Our new connection handler looks like this:

io.sockets.on('connection', function(socket){
    // clients emit this when they join new rooms
    socket.on('join', function(roomName, nickname, callback){
        socket.join(roomName); // this is a socket.io method
        socket.nickname = nickname; // yay JavaScript! see below

        // get a list of messages currently in the room, then send it back
        const messages = [...];
        callback(messages);
    });

    // this gets emitted if a user changes their nickname
    socket.on('nickname', function(nickname){
        socket.nickname = nickname;
        // broadcast update to room! (see below)
    });

    // the client emits this when they want to send a message
    socket.on('message', function(message){
        // process an incoming message (don't forget to broadcast it to everyone!)

        // note that you somehow need to determine what room this is in
        // io.of(namespace).adapter.rooms[socket.id] may be of some help, or you
        // could consider adding another custom property to the socket object.

        // Note that io.sockets.adapter.sids is a hash mapping
        // from room name to true for all rooms that the socket is in.
        // The first member of the list is always the socket itself,
        // and each successive element is a room the socket is in,
        // So, to get the room name without adding another custom property,
        // you could do something like this:

        const roomName = Object.keys(io.sockets.adapter.sids[socket.id])[1];

        // then send the message to users!
    });

    // the client disconnected/closed their browser window
    socket.on('disconnect', function(){
        // Leave the room!
    });

    // an error occured with sockets
    socket.on('error', function(){
        // Don't forget to handle errors!
        // Maybe you can try to notify users that an error occured and log the error as well.
    });

});

IMPORTANT NOTE: this code exploits a very unique feature of JavaScript. Note that we just assign nicknames to socket objects, even though socket.io does not have a nickname property for sockets. This works, though, because objects in JavaScript are really just hash tables under the hood. The moral of the story is, you can assign any property to any object, anywhere, in JavaScript, and you can then go back and get that property later.

On the client side, we might have something like this:

// inside <script type="text/javascript"></script>
const socket = io.connect();

// fired when the page has loaded
$(document).ready(function(){
    // handle incoming messages
    socket.on('message', function(nickname, message, time){
        // display a newly-arrived message
    });

    // handle room membership changes
    // you may want to consider having separate handlers for members joining, leaving, and changing their nickname
    socket.on('membershipChanged', function(members){
        // display the new member list
    });

    // get the nickname
    let nickname = prompt('Enter a nickname:');

    // join the room
    socket.emit('join', meta('roomName'), nickname, function(messages){
        // process the list of messages the server sent back
    });
});

Whenever a new message comes in on the server side, after determining the room name, you can send it out to all of the users in the room like so:

io.sockets.in(roomName).emit('message', nickname, message, time);

Finally, whenever someone leaves a room, joins a room, or changes their nickname, you'll want to send a membership update to everyone in the room. This can be accomplished with something like this.

function broadcastMemberJoined(roomName, nickname) {
    // send them out
    io.sockets.in(roomName).emit('newMember', nickname);
}
    

There will undoubtedly be some remaining rough edges (when a new user joins, do they immediately see a list of other users in the room? do they see past messages?), but there isn't much more to it than that. Make sure that you preserve your existing database code, we still want messages to be stored.

Other Niceties

We expect you to implement at least one extra feature in addition to the minimum requirements, and any features beyond that can be implemented for extra credit. Below are a few ideas. We'll also be willing to give credit for novel features that are not in the list below, as long as they take advantage of the real-time power of socket.io. Feel free to ask on Piazza if you are not sure whether your feature is sufficient. Think about what features you can add that would improve users' experiences, and be creative!

Please document all extra features (including those from assignment 3) in your README!

Handing In

Before handing in your assignment, make sure you have a package.json file with all dependencies listed. Make sure you've also filled in a README.md file with all the extra features you chose to implement (you can use any format you like for your README, but Markdown is highly recommended). The only way that you can receive extra credit for additional features is if you list them in the README and describe briefly to us how to test that feature (if applicable)!

To hand in your assignment, from your project directory, run:

cs132_handin assignment4_dev

That's it!