Static Content Server with plain Nodejs

Static Content Server with plain Nodejs

What does Express do under the hood?

Static server with Nodejs

This tutorial will walk you through few steps how to set up simple HTTP server for static content using only nodejs. We will add basic features as serving requested resource from file or from memory(cache) and responding with error message when no resource is available. In reality you will almost never run any http server in this manner however, it might be very helpful to understand what frameworks like Expressjs do under the hood. It also could serve as a very simple testing tool in your local environment.

Requirements is to have installed nodejs on the system, preferably newer version (12+). The recommended environment is Unix like machine, but it is not necessary. The target audience is JavaScript beginner or UI developer who is curious how does HTTP server work in nodejs.

We will go through the following:

  • setup http server, what is static server
  • adding rules how to read the request
  • finding resources and caching

Lets start with the simplest possible

Http server is a network application which listens to incoming network traffic. It is doing so by acquiring some system resources. Specifically it creates the process in the memory which listens to incoming traffic over the network on the dedicated port. To talk to the http server we need the physical address of the computer and the port which the application acquired. Nodejs provides all necessary functionality to do so. Let's have a look how nodesj does it.

the simplest way how to start and run the most basic HTTP server using Nodejs would be something like this:

node -e "require('http').createServer((req, res) => {res.end('hello world')}).listen(3000)"

Running the above code on Linux machine with node installed will start the server. it can be verified by typing http://localhost:3000 in the browser URL bar. or by typing the following in new terminal window:

> curl http://localhost:3000
// expected response is
hello world

In this basic example we can easily see the building stones. We create an object and call the listen which effectively opens up the connection on the given port and it is waiting for the incoming request complying HTTP protocol. We can test it with netcat sending a text complying with HTTP GET request header.

printf "GET / HTTP/1.1\r\n\r\n" | nc 127.0.0.1 3000
// The expected response is again
HTTP/1.1 200 OK
Date: Tue, 21 Sep 2021 09:59:13 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Content-Length: 11

hello world%

It is a little richer because netcat prints just about everything what is received in the response including response header. curl can do it as well. Go ahead and try using -i flag.

The other main component aside createServer() and listen() is callback passed into createServer. It contains references to request and response objects. Working with these two objects we can interact with our http server.

This article is however not about networking and protocols but tutorial how to build simple static content server using only nodejs and this does not get us too far since it responses with "hello world" to any request. Let's see if we can do better.

Serving response from a file

Let's make one step further in terms of the functionality of our new HTTP server. We are aiming towards the server which can serve static content. The word static here means similar to "static" keyword in JavaScript. It is something which is already known and defined prior the user request. From the web server we usually referred as static content to files like images, icons, CSS files and so on. So let's server user with the content of the file rather then hard coded message.

module.exports = function staticServer() {
  const path = './static_content';
  const port = 3000;

    // create server object as in previous example
    var server = http.createServer(function(req, res){
    const filePath = path + 'index.html';
    fs.readFile(absPath, function(err, data) {
      res.end(data);
    });
    });

  server.listen(port, function() {
    console.log("server listening on port: " + port));
  });
  return server;
};

in addition, create directory and file ./static_content/index.html containing your content:

<html>
  <body>
    <h1>
      Hello, this is very simple
    </h1>
  </body>
</html>

In the above code we define the path where the static content is, in this case it is index.html file we read the file and send the data back to user as a response to client's request. response.end() executes the above with some [default headers]()

Finding and serving requested resource

Next in the quest serving the content based on the user request is finding the requested resource our user is asking. The server looks it up and if it exists it serves the content of the file to client.

module.exports = function staticServer() {
  const path = './static_content';
  const port = 3000;

    // create server object as in previous example
    var server = http.createServer(function(req, res){
    // get the resource from request
    const filePath = path + req.url;
    fs.readFile(absPath, function(err, data) {
      res.end(fileContents);
    });
    });

  server.listen(port, function() {
    console.log("server listening on port: " + port));
  });
  return server;
};

const filePath = path + req.url show how mapping between the requested resource and the actual resource might work. Path is relative path to location where our nodejs app is running and req.url is last bit of the URI identifying what resource user wants.

example.comresource

Caching

Let's make one small addition. The cache. When we server the file from a disk it is not a big deal, as it is pretty quick, however if the file would come from some more time expensive resource we want to keep the content of the file for later requests. Here is a very simple example how it can be implemented:

module.exports = function staticServer() {
  const path = './static_content';
  const port = 3000;

  const cache = {}

    // create server object as in previous example
    var server = http.createServer(function(req, res){
    // get the resource from request
    const filePath = path + req.url;
    if (cache[filePath]) {
      sendFile(res, filePath, cache[filePath]);
    } else {
      fs.readFile(filePath, function(err, data) {
        res.end(fileContents);
      });
    }
    });

  server.listen(port, function() {
    console.log("server listening on port: " + port));
  });
  return server;
};

Basic error handling and wrap up

In this last section we add some simple error handling. In case the user specifies the resource which is not found in the given location of static content or if the resource in not readable we need to notify the user with an error. The standard way of doing it is to return response with code 404 in the response headers. We also might add some explanation in the content.

let 
    fs = require('fs'),
    path = require('path'),
    http = require('http');

const cache = {};

/**
 * lookup content type
 * infer from the extension
 * no extension would resolve in "text/plain"
 */
function lookupContentType(fileName) {
  const ext = fileName.toLowerCase().split('.').slice(1).pop();
  switch (ext) {
    case 'txt':
      return 'text/plain';
    case 'js':
      return 'text/javascript'
    case 'css':
      return 'text/css'
    case 'pdf':
      return 'application/pdf';
    case 'jpg':
    case 'jpeg':
      return 'image/jpeg';
    case 'mp4':
      return 'video/mp4';
    default:
      return ''
  }
}


/**
 * plain 404 response
 */
function send404(res){
    res.writeHead(404, {'Content-Type':'text/plain'});
    res.write('Error 404: resource not found.');
    res.end();
}

/**
 * sending file response
 */
function sendFile(res, filePath, fileContents){
    res.writeHead(200, {"Content-Type": lookupContentType(path.basename(filePath))});
    res.end(fileContents);
}

/**
 * serve static content
 * using cache if possible
 */
function serveStatic(res, cache, absPath) {
  // use cache if there is any
    if (cache[absPath]) {
        sendFile(res, absPath, cache[absPath]);
    } else {
        fs.exists(absPath, function(fileExists) {
      // attempt to read the resource only if it exist
            if (fileExists) {
                fs.readFile(absPath, function(err, data){
          // not able to read the resource
                    if(err) {
                        send404(res);
                    } else {
                        cache[absPath] = data;
                        sendFile(res, absPath, data);
                    }
                });
            } else {
        // resource does not exist
                send404(res);
            }
        });
    }
}

module.exports = function startServer(spec){
  let { path, port } = spec;

    // create server object
    var server = http.createServer(function(req, res){
    // if no resource is specified use index.html
        if(req.url === '/') {
            const filePath = path + 'index.html';
      serveStatic(res, cache, filePath);
        } else {
      const filePath = path + req.url;
      serveStatic(res, cache, filePath);
        }
    });

  server.listen(port, function(){
    console.log("server listening on port: "+port);
  });
  return server;
};

Now we can run it like this:

const startServer = require('./startServer.js')

startServer({ path: './static_content', port: 3000 });

In the above example I added very basic error handling. In the event the resource specified by the user is not found in the static content directory, or it can't be open for reading, the server response with different header with error code 404 and different content explaining what went wrong. In order for a browser to understand better what kind of content we are dealing with, it is also a good idea to include some indication about resource content type. In lookupContentType we can do it just based on the file extension type. Now if we try pdf the browser will have no problem opening pdf file instead downloading it.

Conclusion

This is by no means a robust product, merely a very simplified example how things do work behind the curtain in frameworks like expressjs. We leveraged the nodejs built in library http to run simple http server. We implemented simple routing to find static content in a given location. We also implemented simple in memory caching, content type resolution and basic error handling in case the resource is not found or accessible.

Further reading

If anybody want to build their own server serving static content I would recommend using existing framework. I would also strongly advice looking at least into following topics:

  • session and transaction management
  • caching
  • security, authentication and authorisation

Sources

  1. nodejs/http
  2. netcat
  3. http
  4. status codes
  5. Common MIME types
  6. title image