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.
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 HelloBack 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
net.js:392 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
Trying 127.0.0.1... Connected to localhost. Escape character is '^]'. Hi 127.0.0.1:56910!^]
telnet> quit Connection closed. Enki:~ $ 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:56911! You still there? Connection closed by foreign host. Enki:~ $
We start the server as normal and connect
some clients , but
when the client in Console 2 disconnects , we
have a bit of a problem. The next time we use broadcast()
, in this case when Console 3 sends a
message , the server tries to write to a
disconnected client . When the client from
Console 2 disconnected , 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 . 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.
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.