Curious First Look at Socket.IO and node.js

published:
2011.08.17
topics:
javascript
node

Being fairly new to node.js, and even newer to the use of Socket.IO, I had a very interesting morning exploring some of the behaviors of Socket.IO. I thought I'd share the things that raised my eyebrows. There were some good lessons hidden in a basic Hello World example. There was also a really interesting question posed by the feature of Socket.IO that allows for a callback to be executed as an acknowledgement that a sent socket message was received.

I don't want to spend a ton of time on getting started with node.js or learning Socket.IO. But you should know that I'm just talking about a default, easy to replicate install of node and io. All I basically did is brew install node, curl http://npmjs.org/install.sh | sh, npm install socket.io and I'm off writing realtime code.

Hello World Has Surprise Lessons

The first thing I noticed is that it is a bit surprising — even alarming — how little you need to get a hello world example working. Let me show you the example and then explain the alarming part.

So after installing node and io, I just made a folder with a couple files:

$ ls -a
./          ../         index.html  server.js

The file server.js is for node.js, and we'll run it as the HTTP and WebSockets server. Here are the contents:

// server.js
var app = require('http').createServer(handler)
  , io = require('socket.io').listen(app)
  , fs = require('fs')

// Start an HTTP server on port 8080
app.listen(8080);

function handler(req, res) {
    // Hardcode *all* HTTP requests to this server to serve up index.html
    fs.readFile(
        __dirname + '/index.html',
        function (err, data) {
            if (err) {
                res.writeHead(500);
                return res.end('Error loading index.html');
            }

            res.writeHead(200);
            res.end(data);
        }
    );
}

// After any socket connects, SEND it a custom 'news' event
io.sockets.on(
    'connection',
    function (socket) {
        socket.emit('news', {hello: 'world'});
    }
);

The file index.html will be served up statically over HTTP to a connecting browser. Here are the contents:

<html>
<head>
    <script src="/socket.io/socket.io.js"></script>
    <script>
        // Make a socket connection and wait to RECEIVE custom 'news' event
        var socket = io.connect('http://localhost');
        socket.on(
            'news',
            function (data) {
                console.log(data);
            }
        );
    </script>
</head>
<body>
    <p>index.html</p>
</body>
</html>

So now I can start the server:

$ node server.js

And if I open my browser and visit http://localhost:8080/ (or http://localhost:8080/sdfsdafasdf/ for that matter), I will see a boring page that just says "index.html" on it. However, if I open the JavaScript console I will see that the object {hello: "world:} has been logged. So, the server has successfully sent me an object over a socket connection after the HTTP connection that served up the page.

Now if you're really paying attention closely, you should have some questions right now. I know that I did.

Question 1.

The server is running on port 8080, but I don't have to specify a port for io.connect('http://localhost')?

It would appear that both the HTTP server and the socket server are accepting connections on the same port, so io.connect('http://localhost') simply assumes the same port as the HTTP connection. That's actually the behavior one should expect. You could in fact browse to http://localhost:8080/ and receive a page that calls io.connect('http://localhost:3000') and gets socket data from a different node server. Give it a try if you'd like to convince yourself. I confirmed this works.

Indeed, after checking out the Socket.IO wiki, I found information on the Socket.IO protocol itself, which describes the initial handshake between browser and Socket.IO over HTTP. Worth reading. I think that Socket.IO HTTP URIs are defined such that the [namespace] part of the schema could actually let a programmer accidentally create a collision between files being served to the browser over HTTP and sockets being connected via JS calls. For example, if my Socket.IO namespace is chat and I have a /chat folder on my web site on the same domain, it sure seems like things could break?

Question 2.

How the heck can my HTML page load the script /socket.io/socket.io.js when the only two files in my folder for my server are server.js and index.html?

Well, this is the part that alarmed me a bit. When you require('socket.io') in your node server, now your HTTP server suddenly routes requests to http://localhost/socket.io to a folder in the Socket.IO install package. This is very clever, and handy, but you have to dig around in the wiki a bit to find this documented, so it comes off feeling a bit like dark magic.

I felt like this was an important node.js lesson to learn, as I think it is fair to assume other node.js packages may use this convention.

Weird and Wonderful World of Acknowledgement Callbacks

Socket.IO has this feature where when you send a message, you can also define a callback function that will execute when your message has been successfully received on the other end of the socket connection. To make it leaps and bounds more useful, the recipient of your message can pass arguments to your callback! Let me show you a basic example, similar to the official example on the io site.

Client index.html code:

// index.html -- this code executes in the local browser client

/* see previous example for full HTML */

// Make a socket connection and SEND custom 'set name' event
var socket = io.connect('http://localhost');
socket.emit(
    'set name',
    'zach',
    
    // This function will be called when server receives
    // this event, and the response argument will be defined
    // by the server
    function (response) {
        if (response.error) {
            // Oh no, I cannot set to that name.
        } else {
            // Awesome, I can set to that name!
        }
        
        console.log(response);
    }
);

Server server.js code:

// server.js -- this code executes on the remote server

/* see previous example for io = require('socket.io') etc. */

// After any socket connects, wait to RECEIVE a custom 'set name' event
io.sockets.on(
    'connection',
    function (socket) {
        socket.on(
            'set name',
            // callbackfn is the sender's callback function
            // and when I call callbackfn with an argument
            // it will be executed by the sender with that argument
            // and it will not be actually executed here
            function (name, callbackfn) {
                var response = {};
                
                if (name == 'zach') {
                    response.error = 'Name is taken.';
                } else {
                    response.ok = 'Name is available.';
                }
                
                callbackfn(response);
                
                console.log(name);
            }
        );
    }
);

So if I start the server, and I open up the page in my browser, my server's console is going to log 'zach' and my browser's console is going to log the object {error: "Name is taken."}.

Again, I bet you have some questions. I had some really major questions after seeing this.

Question 1.

Wait wait wait... so even though the server calls callbackfn({error: 'Name is taken.'});... the function callbackfn is actually executed in the client browser instead?! With the argument {error: 'Name is taken.'}?!

Yes, that's exactly right. Even though the server is very clearly receiving a function as an argument, and it is very clearly calling that function ... the function actually executes in the client browser and not on the server.

Now functionality-wise, this is awesome because it means that on the client I don't have to send a 'set name' event and then listen for a 'did name get set' event which tells me if my 'set name' event was successful. That's really, really nice. And it is specifically the fact that the remote server supplies the argument for my local client message-received callback that makes this possible. If the remote server couldn't supply the callback argument it would have to send the client that 'did name get set' event so that it could tell the client something about what it did with the client's message.

Question 2.

Are you sure? Can we double check that callbackfn doesn't execute on the server?

I had my doubts, too, given the syntax. I think the syntax sugar being used here is really clever, but presents some readability and cognition issues. More on that in a minute. First, consider this new example:

// index.html -- this code executes in the local browser client

/* see previous example for full HTML */

// Make a socket connection and SEND custom 'set name' event
var socket = io.connect('http://localhost');
socket.emit(
    'set name',
    'zach',
    
    // This function will be called when server receives
    // this event, and the response argument will be defined
    // by the server
    function (response) {
        if (response.error) {
            // Oh no, I cannot set to that name.
        } else {
            // Awesome, I can set to that name!
        }
        
        // MODIFY RESPONSE OBJECT
        response.newName = 'bob';                                       // ★
        
        console.log(response);
    }
);
// server.js -- this code executes on the remote server

/* see previous example for io = require('socket.io') etc. */

// After any socket connects, wait to RECEIVE a custom 'set name' event
io.sockets.on(
    'connection',
    function (socket) {
        socket.on(
            'set name',
            // callbackfn is the sender's callback function
            // and when I call callbackfn with an argument
            // it will be executed by the sender with that argument
            // and it will not be actually executed here
            function (name, callbackfn) {
                var response = {};
                
                if (name == 'zach') {
                    response.error = 'Name is taken.';
                } else {
                    response.ok = 'Name is available.';
                }
                
                callbackfn(response);
                
                // WAS RESPONSE OBJECT MODIFIED?
                console.log(response.newName);                          // ★
                
                console.log(name);
            }
        );
    }
);

Now, because of the way JavaScript works, if callbackfn was executed on the server, then the server's response object would be modified. However, if you run this code, you will see that response.newName is undefined on the server. So truly, callbackfn is being executed only in the client browser.

Again, this is undoubtably clever syntax shorthand. There's no denying that. However, I think it has the potential to confuse people or to encourage people to try things that will not work. I'd like to suggest a different syntax considering that the sender can only define a single acknowledgement callback, and considering that the recipient can only ever call that one acknowledgement callback from the sender.

It could work something like this:

// server.js -- this code executes on the remote server

/* see previous example for io = require('socket.io') etc. */

// After any socket connects, wait to RECEIVE a custom 'set name' event
io.sockets.on(
    'connection',
    function (socket) {
        socket.on(
            'set name',
            function (name) {
                var response = {};
                
                if (name == 'zach') {
                    response.error = 'Name is taken.';
                } else {
                    response.ok = 'Name is available.';
                }
                
                // THIS WOULD CAUSE THE SENDER'S ACKNOWLEDGEMENT CALLBACK
                // TO EXECUTE WITH ARGUMENT: response
                socket.emitReply(response);                             // ★
                                
                console.log(name);
            }
        );
    }
);

This syntax of socket.emitReply(response); makes it much more clear that you are going to cause code to execute on the sender's end of the socket rather than on the recipient's end. It also makes it clear that something is going to be crossing the network. I'm actually not even sure with the other syntax of callbackfn(response); whether that call is blocking or asynchronous?

Anyway, that's it for now. Let me know your thoughts! I'm digging into this stuff hard now because Node KO 2011 is almost here, and I'm on team Watermelon Sauce.