Nodejs Book: Chapter 3

In the previous chapter we made a basic file server, but it’s still missing some basic functionality compared to other file servers. Specifically, serving index files in a directory, and serving directory listings. Before we add this functionality into our source code, we need make a few modifications to our work folder.

We’re going to install a library for looping asynchronously conveniently called “async”. Install it with the command below.

$ npm install async

The next change we need to make is if we’re going to test directory listings. We need to have files and directories to list. For simplicity, we’re going to make an “images” directory and through a few PNG files in there for testing. Our directory listing should look something like the following.

+ public/
| - index.html
| - layout.css
| - + images/
| --- blender_forest.PNG
| --- closer.PNG
| --- forest_pic.PNG
| --- insideRiver.PNG
| --- left_click.PNG
| --- motion_list.PNG
| --- onTheSide.PNG
| --- position_list.PNG
| --- river_01.PNG
| --- river.PNG
| --- rotation_list.PNG

With that out of the way, the updated code for our server is as follows.

// File: directory.js
"use strict";

const fs = require("fs");
const http = require("http");
const mime = require("mime");
const async = require("async");

const PATH = "public";

const server = http.createServer();
server.on("request", handleRequest);
server.listen(8080, handleListen);


function handleRequest(req, res) {

    fs.stat(PATH + req.url, function( err, stats ) {
        if( err ) {

            res.writeHead(404, {"Content-Type" : "text/plain"});
            return res.end("File Not Found");

        }

        if( stats.isFile() ) {

            res.writeHead(200, {"Content-Type" : mime.lookup(req.url) });
            let stream = fs.createReadStream(PATH + req.url);
            stream.pipe(res);

            stream.on("end", function() {
                console.log("Sent: %s", req.url);
            });

        } else if( stats.isDirectory() ) {

            if(req.url[req.url.length - 1] !== "/") {

                res.writeHead(302, { "Location" : req.url + "/" });
                return res.end();

            }

            fs.readdir(PATH + req.url, function(err, files) {
                if(err) {
                    throw err;
                }

                if(files.indexOf("index.html") !== -1) {

                    res.writeHead(200, {"Content-Type" : "text/html" });
                    let stream = fs.createReadStream(PATH + req.url + "index.html");
                    stream.pipe(res);

                    stream.on("end", function() {
                        console.log("Sent: %s", req.url + "index.html");
                    });

                    return;
                }

                let f_list = [];
                let d_list = [];

                async.eachSeries(files, function(file, nextFile) {

                    fs.stat(PATH + req.url + file, function( err, stats ) {
                        if(err) {
                            throw err;
                        }

                        if(stats.isFile()) {

                            f_list.push(file);

                        } else if(stats.isDirectory()) {

                            d_list.push(file);

                        }

                        nextFile();
                    });

                }, function() {

                    f_list.sort();
                    d_list.sort();

                    res.writeHead(200, {"Content-Type" : "text/html" });
                    res.write("<!DOCTYPE HTML>");
                    res.write("<html>");
                    res.write("<head>");
                    res.write("<meta charset=\"utf-8\">");
                    res.write("<title>Index</title>");
                    res.write("</head>");
                    res.write("<body>");
                    res.write("<h1>"+req.url+"</h1>");
                    res.write("<ul>");

                    d_list.forEach(function(d){

                        res.write("<li>");
                        res.write("<a href=\"" + d + "/\">");
                        res.write(d + "/");
                        res.write("</a>");
                        res.write("</li>");

                    });

                    f_list.forEach(function(f){

                        res.write("<li>");
                        res.write("<a href=\"" + f + "\">");
                        res.write(f);
                        res.write("</a>");
                        res.write("</li>");

                    });

                    res.write("</ul>");
                    res.write("</body>");
                    res.end("</html>");

                });

            });

        } else {

            res.writeHead(404, {"Content-Type" : "text/plain"});
            return res.end("File Not Found");

        }

    });


}

function handleListen( ) {

    console.log("Http server is listening on port: 8080");

}

Now if we run our code, we should now see the following when we request http://192.168.1.16:8080, we should get

And if we request http://192.168.1.16:8080/images/ we should now get
a directory listing to access our files.

Most of the code remains the same, except we have added a long condition for when a requested url is a directory. We’ll go over the content one step at a time.

if(req.url[req.url.length - 1] !== "/") {

    res.writeHead(302, { "Location" : req.url + "/" });
    return res.end();

}

When a directory is requested, we want to first make sure there is a trailing
slash on the end. The reason for this is because managing paths becomes much more difficult without it. If we were to serve the “images” directory without the trailing slash, then when a link is clicked, the browser would attempt to request urls “/imagesriver.PNG” as opposed to “/images/river.PNG”. So in the case there is no trailing slash on the end, we simply instruct the browser with a 302 code, to request the same path with a trailing slash.

fs.readdir(PATH + req.url, function(err, files) {
    if(err) {
        throw err;
    }

    ...
});

If the directory requested does have a trailing slash, then the next step is to
list the files inside the directory. We have two options, if there is an “index.html” file in the directory, then we will serve that, otherwise, we will serve the directory listing.

if(files.indexOf("index.html") !== -1) {
        res.writeHead(200, {"Content-Type" : "text/html" });
        let stream = fs.createReadStream(PATH + req.url + "index.html");
        stream.pipe(res);

        stream.on("end", function() {
        console.log("Sent: %s", req.url + "index.html");
    });
    return;
}
In the case there is an index.html file, then we simply read it from the file
system and serve it back to the client browser like we would an ordinary file.
The only difference is the url the client sees is still the directory, and not
the full path with index.html on the end.
let f_list = [];
let d_list = [];

async.eachSeries(files, function(file, nextFile) {

	fs.stat(PATH + req.url + file, function(err, stats) {
		if (err) {
			throw err;
		}

		if (stats.isFile()) {

			f_list.push(file);

		} else if (stats.isDirectory()) {

			if (file !== ".") {
				d_list.push(file);
			}

		}

		nextFile();
	});

}, function() {

	f_list.sort();
	d_list.sort();

	res.writeHead(200, {
		"Content-Type": "text/html"
	});
	res.write("<!DOCTYPE HTML>");
	res.write("<html>");
	res.write("<head>");
	res.write("<meta charset=\"utf-8\">");
	res.write("<title>Index</title>");
	res.write("</head>");
	res.write("<body>");
	res.write("<h1>" + req.url + "</h1>");
	res.write("<ul>");

	d_list.forEach(function(d) {

		res.write("<li>");
		res.write("<a href=\"" + d + "/\">");
		res.write(d + "/");
		res.write("</a>");
		res.write("</li>");

	});

	f_list.forEach(function(f) {

		res.write("<li>");
		res.write("<a href=\"" + f + "\">");
		res.write(f);
		res.write("</a>");
		res.write("</li>");

	});

	res.write("</ul>");
	res.write("</body>");
	res.end("</html>");

});

The last option is by far the most complicated, but not very difficult once broken down. We want to list out the files in the directory. When doing this we want to have any directories listed above normal files for simplicity. But to do this we need to iterate over a list of files and check if each one is a directory or a normal file.

The problem is that the fs.stat function provided by Nodejs is asynchronous so we can’t just use a standard for loop to stat each file sequentially, because each callback wouldn’t take place inside the for loop, being called back at a later time. Luckily the “async” library can help us with this pattern. We can use async.eachSeries, passing an array and a callback, and what do to once the loop has finished into the function. This allows us to make asynchronous calls before calling incrementing to the next object in the array.

Once we’re done we sort the file and directory lists respectively, and from there something interesting happens. We return the content type of “text/html” and then simply start writing html text to the client browser from Nodejs itself. When we read a file or otherwise, there’s no magic that takes place, we’re either sending a buffer(raw binary data), or text. And text simply gets broken down into binary data. So all http, and html really is, is simply text sent to and from computers.

In this case we write a basic header, and a body with list items and links. The result is a dynamically generated page with the current directory listing where the end user on the client browser can quickly and easily navigate and view files over a network.

Now that we have a basic file server working, it would be nice to clean up a bit, since we now have a pretty massive function performing a standard task in our code. In the next chapter we will clean up and modularize our code to make it easier to read.