Chapter 2. Doing Interesting Things

The programming trends of the last few years have made it progressively easier to write more complex applications with ease. It’s important that we don’t lose that, but Node is specifically focused on solving the problem of building network applications—that is, applications that do a lot of input/output (I/O). Let’s build a few I/O-type apps and see how easy it is to do this with Node in a way that really scales.

Building a Chat Server

In a world that’s increasingly real-time, what is more real-time than chat? So where should we begin? Let’s start with a TCP-based chat server we can connect to with Telnet. Not only is it a simple place to start, but it’s also something we can write 100% in Node.

The first thing we need to do is include the TCP libraries from Node and create a new TCP server (see Example 2-1).

Example 2-1. Creating a new TCP server

var net = require('net')

var chatServer = net.createServer()

chatServer.on('connection', function(client) {
  client.write('Hi!\n');
  client.write('Bye!\n');

  client.end()
})

chatServer.listen(9000)

First, we include the net module. This contains all the TCP stuff for Node. From that, we can create a TCP server by calling the net.createServer() method. Now that we have a server, we want it to do stuff. So we add an event listener by using the on() method. Whenever the connection event happens, the event listener will call the function we gave it. A connection event happens when a new client connects to the server.

The connection event passes us a reference to the TCP socket for our new client when it calls our callback function. We named this reference client. By calling client.write(), we can send messages to the newly connected client. To start with, we just say “Hi!” and then “Bye!”, and we call the client.end() method, which closes the connection. It’s simple, but it’s a starting point for our chat server. Finally, we need to call listen() so Node knows which port to listen on. Let’s test it.

We can test our new server by connecting to it with the Telnet program, which is installed on most operating systems.[2] First, we need to start our server by calling node with the filename. Then we can connect by opening a Telnet connection to localhost on port 9000, as we specified in our Node program. See Example 2-2.

Example 2-2. Connecting to a Node TCP server with Telnet

Console Window 1
----------------
Enki:~ $ node chat.js
Chat server started


Console Window 2
----------------
Last login: Tue Jun  7 20:35:14 on ttys000
Enki:~ $ telnet 127.0.0.1 9000
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
Hi!
Bye!
Connection closed by foreign host.
Enki:~ $

So far we’ve made a server that clients can connect to, and we’ve sent them a message before kicking them out, but it’s hardly a chat server. Let’s add a few more things. First, we should make sure we can get messages from the clients, as shown in Example 2-3.

Example 2-3. Listening to each connection

var net = require('net')

var chatServer = net.createServer()

chatServer.on('connection', function(client) {
  client.write('Hi!\n');

  client.on('data', function(data) {
    console.log(data)
  })

})

chatServer.listen(9000)

Here we’ve added another event listener, and this time it’s client.on(). Notice how we’ve added the event listener in the scope of the connection callback function. Doing this means we have access to the client that is passed to that event. The listener we’ve added is for an event called data. This is the event that is called each time client sends some data to the server. We’ve had to lose the client.end(), though. If we closed the connection to the client, how could we listen for new data? Now whenever we send data to the server, it will be outputted to the console. Let’s try that in Example 2-4.

Example 2-4. Sending data to the server from Telnet

Console 1
-------------

Enki:~ $ node chat.js 
Chat server started
<Buffer 48 65 6c 6c 6f 2c 20 79 6f 75 72 73 65 6c 66 0d 0a>

Console 2
------------
Enki:~ $ telnet 127.0.0.1 9000
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
Hi!
Hello, yourself

What has happened here? We ran the server and connected to it with Telnet. The server said “Hi!” and we responded with “Hello, yourself”. At this point, Node spat out a bunch of seeming gibberish in a data type you’ve never seen before. Because JavaScript doesn’t have a good way to deal with binary data, Node added one. It’s called Buffer, and it lets the server represent binary data. Node doesn’t know what kind of data Telnet sent, so Node simply stores the data as binary until we ask for it in some other kind of encoding. The sequence of letters and numbers is actually bytes in hex (see Buffers in Chapter 4 for more on this). Each byte represents one of the letters or characters in the string “Hello, yourself”. We can use the toString() method to translate Buffer data into a regular string again if we want, or we can just pass it around as it is because TCP and Telnet understand the binary, too.

Now that we can get messages from each client, we should let them send each other messages. To do this, we need a way of letting them communicate with each other. It’s great that we can call client.write(), but that works on only one client at a time. What we need is a way to reference other clients. We can do this by creating a list of clients that we want to write data to. Whenever we get a new client, we’ll add it to our list and use the list to communicate between the clients (see Example 2-5).

Example 2-5. Communicating between clients

var net = require('net')

var chatServer = net.createServer(),
    clientList = []

chatServer.on('connection', function(client) {
  client.write('Hi!\n');

  clientList.push(client)

  client.on('data', function(data) {
    for(var i=0;i<clientList.length;i+=1) {
      //write this data to all clients
      clientList[i].write(data)
    }
  })

})

chatServer.listen(9000)

Now when we run it in Example 2-6, we can connect multiple clients to the server to see them sending messages to each other.

Example 2-6. Sending messages between clients

Console 1
------------

Enki:~ $ node chat.js 


Console 2
------------

Enki:~ $ telnet 127.0.0.1 9000
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
Hi!
Hello, yourself
Hello, yourself


Console 3
------------

Enki:~ $ telnet 127.0.0.1 9000
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
Hi!
Hello, yourself

This time, the server isn’t logging any messages it receives, and instead we loop through the array and send them back to the clients. Notice that when the Telnet client in terminal 2 sends a message, it gets sent to the Telnet client in terminal 3, but it also gets sent back to Telnet in terminal 2 as well. This is because when we send the message, we aren’t checking who the sender was; we just send to our entire array of clients. It’s also not clear just by looking at Telnet which messages were things we sent and which were things we received. We can improve on this. In Example 2-7, let’s create a function to send messages to all the clients, and use it to tidy up some of these issues.

Example 2-7. Improving the sending of messages

var net = require('net')

var chatServer = net.createServer(),
    clientList = []

chatServer.on('connection', function(client) {
  client.name = client.remoteAddress + ':' + client.remotePort
  client.write('Hi ' + client.name + '!\n');

  clientList.push(client)

  client.on('data', function(data) {
    broadcast(data, client)
  })

})

function broadcast(message, client) {
  for(var i=0;i<clientList.length;i+=1) {
    if(client !== clientList[i]) {
      clientList[i].write(client.name + " says " + message)
    }
  }
}

chatServer.listen(9000)

The first thing we’ve added to the connection event listener is a command to add a name property to each client. Note how we are able to decorate the client object with additional properties. This is because the closure binds each client object to a specific request. The existing properties of the client are used to create the name, and the client.remoteAddress is the IP address the client is connecting from. The client.remotePort is the TCP port that the client asked the server to send data back to. When multiple clients connect from the same IP, they will each have a unique remotePort. When we issue a greeting to the client, we can now do it using a unique name for that client.

We also extracted the client write loop from the data event listener. We now have a function called broadcast and, using it, we can send a message to all the connected clients. However, this time we pass the client that is sending the message (data) so we can exclude it from getting the message. We also include the sending client name (now that it has one) when sending the message to the other clients. This is a much better version of the server, as shown in Example 2-8.

Example 2-8. Running the improved chat server

Console 1
---------

Enki:~ $ node chat.js

Console 2
---------

Enki:~ $ telnet 127.0.0.1 9000
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
Hi 127.0.0.1:56022!
Hello
127.0.0.1:56018 says Back atcha

Console 3
---------

Enki:~ $ telnet 127.0.0.1 9000
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
Hi 127.0.0.1:56018!
127.0.0.1:56022 says Hello
Back atcha

This is a much friendlier and more useful service. It’s still not perfect, but we are making progress. Note that the exact port numbers used in the names will almost certainly vary for you when you run this example. Different operating systems allow different port ranges, and the assignment will also depend on which ones you are already using, as well as a random factor. You may have already encountered this, but our server has a fatal flaw! If one of the clients disconnects, the server will fail horribly, as demonstrated in Example 2-9.

Example 2-9. Causing the server to fail by disconnecting a client

Console 1
----------

Enki:~ $ node book-chat.js  1

net.js:392 2
    throw new Error('Socket is not writable');
          ^
Error: Socket is not writable
    at Socket._writeOut (net.js:392:11)
    at Socket.write (net.js:378:17)
    at broadcast (/Users/sh1mmer/book-chat.js:21:21)
    at Socket.<anonymous> (/Users/sh1mmer/book-chat.js:13:5)
    at Socket.emit (events.js:64:17)
    at Socket._onReadable (net.js:679:14)
    at IOWatcher.onReadable [as callback] (net.js:177:10)
Enki:~ $

Console 2
---------

Enki:~ $ telnet 127.0.0.1 9000 3
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
Hi 127.0.0.1:56910!
^]
telnet> quit 4
Connection closed.
Enki:~ $ 

Console 3
---------

Enki:~ $ telnet 127.0.0.1 9000 5
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
Hi 127.0.0.1:56911!
You still there? 6
Connection closed by foreign host. 7
Enki:~ $

We start the server as normal 1 and connect some clients 35, but when the client in Console 2 disconnects 4, we have a bit of a problem. The next time we use broadcast(), in this case when Console 3 sends a message 6, the server tries to write to a disconnected client 2. When the client from Console 2 disconnected 4, its socket stopped being writable or readable. When we try to call write() on a socket that is closed, we get an exception in the Node process. This also causes the disconnection of all the remaining clients 7. Obviously, this is extremely brittle and not acceptable for a server.

We should fix this in two ways. First, we should make sure that when a client disconnects, we remove it from the clients array so it stops getting write() method calls. This will also allow V8 to garbage-collect the socket object and reclaim that memory. Second, we should be a bit more defensive when we write to a socket. We want to make sure that between the last time the socket was written and the current pass, nothing has stopped us from being able to call write(). Happily, Node has easy ways to achieve both of these things. The first is shown in Example 2-10.

Example 2-10. Making the chat server more robust

chatServer.on('connection', function(client) {
  client.name = client.remoteAddress + ':' + client.remotePort
  client.write('Hi ' + client.name + '!\n');

  clientList.push(client)

  client.on('data', function(data) {
    broadcast(data, client)
  })

  client.on('end', function() {
    clientList.splice(clientList.indexOf(client), 1)
  })
})

First, let’s deal with those disconnecting clients. When a client disconnects, we want to be able to remove it from the list of clients. This is easy to achieve with the end event. When a socket disconnects, it fires the end event to indicate that it’s about to close. We can call Array.splice() when this happens to remove the client from the clientList array. Using Array.indexOf(), we are able to find the position of this client. splice() then removes from the array one item, which is the client. Now when the next client uses the broadcast call, the disconnected client will no longer be in the list.

We can still be a bit more defensive, though, as demonstrated in Example 2-11.

Example 2-11. Checking the write status of sockets

function broadcast(message, client) {
  var cleanup = []
  for(var i=0;i<clientList.length;i+=1) {
    if(client !== clientList[i]) {

      if(clientList[i].writable) {
        clientList[i].write(client.name + " says " + message)
      } else {
        cleanup.push(clientList[i])
        clientList[i].destroy()
      }

    }
  }  //Remove dead Nodes out of write loop to avoid trashing loop index 
  for(i=0;i<cleanup.length;i+=1) {
    clientList.splice(clientList.indexOf(cleanup[i]), 1)
  }
}

By adding a check for the write status of the socket during the broadcast call, we can make sure that any sockets that are not available to be written don’t cause an exception. Moreover, we can make sure that any sockets that can’t be written to are closed (using Socket.destroy()) and then removed from the clientList. Note that we don’t remove the sockets from the clientList while we are looping through it, because we don’t want to cause side effects on the current loop we are in. Our server is now much more robust. There is one more thing we should do before we are really ready to deploy it: log the errors (Example 2-12).

Example 2-12. Logging errors

chatServer.on('connection', function(client) {
  client.name = client.remoteAddress + ':' + client.remotePort
  client.write('Hi ' + client.name + '!\n');
  console.log(client.name + ' joined')

  clientList.push(client)

  client.on('data', function(data) {
    broadcast(data, client)
  })

  client.on('end', function() {
    console.log(client.name + ' quit')
    clientList.splice(clientList.indexOf(client), 1)
  })
  
  client.on('error', function(e) {
    console.log(e)
  })
})

By adding a console.log() call to the error event for the client objects, we can ensure that any errors that occur to clients are logged, even as our previous code makes sure that clients throwing errors are not able to cause the server to abort with an exception.



[2] If you are on Windows, we recommend using the free Putty program as a Telnet client.

Get Node: Up and Running now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.