Node HTTP Servers for Static File Serving

One of the most fundamental uses of an HTTP server is to serve static files to a user's browser, like CSS, JavaScript, or image files. Beyond normal browser usage, there are thousands of other reasons you'd need to serve a static files, like for downloading music or scientific data. Either way, you'll need to come up with a simple way to let the user download these files from your server.

One simple way to do this is to create a Node HTTP server. As you probably know, Node.js excels at handling I/O-intensive tasks, which makes it a natural choice here. You can choose to create your own simple HTTP server from the base http module that's shipped with Node, or you can use the popular serve-static package, which provides many common features of a static file server.

The end goal of our static server is to let the user specify a file path in the URL and have that file returned as the contents of the page. However, the user shouldn't be able to specify just any path on our server, otherwise a malicious user could try to take advantage of a misconfigured system and steal sensitve information. A simple attack might look like this: localhost:8080/etc/shadow. Here the attacker would be requesting the /etc/shadow file. To prevent these kinds of attacks, we should be able to tell the server to only allow the user to download certain files, or only files from certain directories (like /var/www/my-website/public).

Creating your own

This section is meant for those of you needing a more custom option, or for those wanting to learn how static servers (or just servers in general) work. If you have a fairly common use-case, then you'd be better off moving on to the next section and start working directly with the serve-static module.

While creating your own server from the http module takes a bit of work, it can be very rewarding in showing you how servers work underneath, which includes trade-offs for performance and security concerns that need to be taken in to account. Although, it is fairly easily create your own custom Node static server using only the built-in http module, so we don't have to dive too deep in to the internals of an HTTP server.

Obviously the http module isn't going to be as easy to use as something like Express, but it's a great starting point as an HTTP server. Here I'll show you how to create a simple static HTTP server, which you can then add on to and customize to your liking.

Let's start by just initializing and running our HTTP server:

"use strict";

var http = require('http');

var staticServe = function(req, res) {  
    res.statusCode = 200;
    res.write('ok');
    return res.end();
};

var httpServer = http.createServer(staticServe);

httpServer.listen(8080);  

If you run this code and navigate to localhost:8080 in your browser then all you'll see is 'ok' on the screen. This code handles all requests to the address localhost:8080. Even for non-root paths like localhost:8080/some/url/path you'll still get the same response. So every request received by the server is handled by the staticServe function, which is where the bulk of our static server logic will be.

The next step is to get a file path from the user, which we acquire by using the URL path. It would probably be a bad idea to let the user specify an absolute path on our system for a few reasons:

  • The server shouldn't reveal details of the underlying operating system
  • The user should be limited in the files they can download so they can't try and access sensitive files, like /etc/shadow.
  • The URL shouldn't require redundant parts of the file path (like the root of the directory: /var/www/my-website/public/...)

Given these requirements, we need to specify a base path for the server and then use the given URL as a relative path off of the base. To achieve this, we can use the .resolve() and .join() funtions from Node's path module:

"use strict";

var path = require('path');  
var http = require('http');

var staticBasePath = './static';

var staticServe = function(req, res) {  
    var fileLoc = path.resolve(staticBasePath);
    fileLoc = path.join(fileLoc, req.url);

    res.statusCode = 200;

    res.write(fileLoc);
    return res.end();
};

var httpServer = http.createServer(staticServe);

httpServer.listen(8080);  

Here we construct the full file path using a base path, staticBasePath, and the given URL, which we then print to the user.

Now if you navigate to the same localhost:8080/some/url/path URL, you should see the following text printed to the browser:

/Users/scott/Projects/static-server/static/some/url/path

Note: Your file path will likely be different than mine, depending on your OS, username, and project path. The most important take-away is the last few directories shown (static/some/url/path).

Just using the .resolve() and .join() methods, we're able to restrict the user to only accessing files within the ./static directory. Even if you try to refer to a parent directory using .. you won't be able to access any parent directories outside of 'static', so our other data is safe.

Now that we've restricted the path to only return files in the given directory, we can start serving up the actual files. To do this, we'll simply use the fs.readFile() method to load the file contents.

In order to better serve up the contents, all we have to do is send the file to the user using the res.write(content) method, much like we did with the file path earlier. If we can't find the requested file, we'll instead return a 404 error.

"use strict";

var fs = require('fs');  
var path = require('path');  
var http = require('http');

var staticBasePath = './static';

var staticServe = function(req, res) {  
    var fileLoc = path.resolve(staticBasePath);
    fileLoc = path.join(fileLoc, req.url);

    fs.readFile(fileLoc, function(err, data) {
        if (err) {
            res.writeHead(404, 'Not Found');
            res.write('404: File Not Found!');
            return res.end();
        }

        res.statusCode = 200;

        res.write(data);
        return res.end();
    });
};

var httpServer = http.createServer(staticServe);

httpServer.listen(8080);  

Great! Now we have a primitive static file server.

There are still quite a few improvements we can make on this code, like file caching, adding more HTTP headers, and security checks. We'll briefly take a look at some of these in the next few sub-sections.

Caching

The simplest caching technique is to just use unbounded in-memory caching. This is a good starting point, but it shouldn't be used in production (you can't always just cache everything in memory). All we need to do here is create a plain JavaScript object to hold the contents from files we've previously loaded. Then on subsequent file requests, we can check if the file has already been loaded using the file path as the lookup key. If data exists in the cache object for the given key then we return the saved content, otherwise we open the file as before:

"use strict";

var fs = require('fs');  
var path = require('path');  
var http = require('http');

var staticBasePath = './static';

var cache = {};

var staticServe = function(req, res) {  
    var fileLoc = path.resolve(staticBasePath);
    fileLoc = path.join(fileLoc, req.url);

    // Check the cache first...
    if (cache[fileLoc] !== undefined) {
        res.statusCode = 200;

        res.write(cache[fileLoc]);
        return res.end();
    }

    // ...otherwise load the file
    fs.readFile(fileLoc, function(err, data) {
        if (err) {
            res.writeHead(404, 'Not Found');
            res.write('404: File Not Found!');
            return res.end();
        }

        // Save to the cache
        cache[fileLoc] = data;

        res.statusCode = 200;

        res.write(data);
        return res.end();
    });
};

var httpServer = http.createServer(staticServe);

httpServer.listen(8080);  

As I mentioned, we probably shouldn't just let the cache be unbounded, otherwise we may take up all of the system memory. A better approach would be to use a smarter cache algorithm, like lru-cache, which implements the least recently used cache concept. This way if a file isn't requested for a while, then it is removed from the cache and memory is conserved.

Streams

Another big improvement we can make is to load the file contents using streams instead of fs.readFile(). The problem with fs.readFile() is that it needs to load and buffer all of the file contents before it can be sent to the user.

Using a stream, on the other hand, we can send the file contents to the user as it's being loaded from disk, byte by byte. Since we don't have to wait for the entire file to load, this reduces both the time it takes to respond to the user's request and the memory it takes to handle the request since we don't need to load the entire file at once.

Using a non-stream approach like fs.readFile() can get especially costly for us if the user has a slow connection, which would mean we'd have to keep the file contents in memory even longer. With streams, we don't have this problem since the data is only loaded and sent from the file system as fast as the user's connection can accept it. This concept is called back pressure.

A simple example that implements streaming is given here:

"use strict";

var fs = require('fs');  
var path = require('path');  
var http = require('http');

var staticBasePath = './static';

var cache = {};

var staticServe = function(req, res) {  
    var fileLoc = path.resolve(staticBasePath);
    fileLoc = path.join(fileLoc, req.url);

        var stream = fs.createReadStream(fileLoc);

        // Handle non-existent file
        stream.on('error', function(error) {
            res.writeHead(404, 'Not Found');
            res.write('404: File Not Found!');
            res.end();
        });

        // File exists, stream it to user
        res.statusCode = 200;
        stream.pipe(res);
};

var httpServer = http.createServer(staticServe);

httpServer.listen(8080);  

Note that this doesn't add any caching like we showed earlier. If you want to include it, all you need to do is add a listener to the stream's data event and incrementally save the chunks to the cache. I'll leave this up to you to implement on your own :)

serve-static

If you need a static file server for production use, there are a few other options you may want to consider instead of writing your own from scratch. Nginx is one of the best options out there, but if your use-case requires you to use Node for whatever reason, or you have something against Nginx, then the serve-static module also works very well.

The great thing about this module, in my opinion, is that it can also be used as middleware for the popular web framework, Express. This seems to be the use-case for most people since they usually need to serve dynamic content along with their static files anyway.

First, if you want to use it a stand-alone server, you can use the http module with serve-static and finalhandler in just a few lines like this:

var http = require('http');  
var finalhandler = require('finalhandler');  
var serveStatic = require('serve-static');

var staticBasePath = './static';

var serve = serveStatic(staticBasePath, {'index': false});

var server = http.createServer(function(req, res){  
    var done = finalhandler(req, res);
    serve(req, res, done);
})

server.listen(8080);  

Otherwise if you're using Express, then all you need to do is add it as middleware:

var express = require('express')  
var serveStatic = require('serve-static')

var staticBasePath = './static';

var app = express()

app.use(serveStatic(staticBasePath, {'index': false}))  
app.listen(8080)  

Conclusion

In this article I've presented a few options for running a static file server with Node.js. Keep in mind that there are still more options out there than what I've mentioned here.

For example, there are a few other similar modules, like node-static and http-server. I just didn't use them here since serve-static is much more widely used, and therefore probably more stable. Just know that there are other options that are worth checking out.

If you have any other improvements to make the static file servers faster, feel free to post them in the comments!