April 13, 2012

Learning How to Serve Up Static Files with Node


So I consider myself a Level 2 node.js/CoffeeScript beginner. I'm learning out in the open. Some of the things I come across are interesting from the perspective of someone climbing the learning curve. To gain experience, I wanted to share some things I've learned about one of the most basic functions of any web server: Serving static files.

Serving static files is something you might take for granted if you come from a background where the "website" is hosted on top of IIS or Apache. I never really gave it a lot of attention, but whatever framework I was using was silently, faithfully, serving up all my images, icons, style sheets, and scripts.

Node gives you pretty low-level access to the HTTP server. So when you make an HTTP application in Node -- you're technically writing the server. So this means you have to be responsible for handling all aspects of the web request and every web request including all those cool static files.

Writing it All from Scratch


So you might start the Node server up in the typical fashion.

http = require 'http'

...

server = http.createServer serve_file
port = 3000
server.listen port
console.log "The static file server is on port #{port}"


The function serve_file is the callback that will handle the request.

serve_file = (request, response) ->
  console.log "Serving #{request.url}"
  context = { request, response }
  fs.readFile 'public' + request.url, after_file_is_read(context)


The first thing the static file server has to do is actually read the file. We use the asynchronous version of the standard readFile method. I'm bundling up the request and response objects into something I'm calling context. You'll see how I'm handling this object (notice I'm intentionally not using the word class) in subsequent method calls.

after_file_is_read = (context) ->
  (error, data) ->
    console.log "The requested file has been read"
    context =  context extends {error, data}
    respond_with_file(context) or respond_with_error(context)


The method after_file_is_read isn't the actual callback. It's a method that is invoked immediately passing the context and returns a function (the real callback) and captures the current context in a closure. This helps prevent the Christmas tree effect with nested anonymous callbacks. Essentially the sequence can be followed by looking at functions defined on the left side of the code.

I'm probably being a little too clever (and maybe not in the good way), but something that is just too cool to pass up (during my experimentation phase) is to use the extends keyword to add the additional values of error and data into the "context". Essentially, extends adds two additional properties (error and data) onto the exsisting object that already has a request and response properties.

Now here's where it get's really clever. I call into the respond_with_file function. But what if there's an error? Shouldn't I be protecting the call to respond_with_file with an if statement and calling respond_with_error in an else block?

respond_with_file = (context) ->
  if context.error then return undefined
  console.log "Responding with the file"
  {response, data} = context
  response.writeHead 200, {"Content-Type":"text/html"}
  response.end data
  @


I could, but instead, I'm turning the more conventional approach inside out. I'm making the absence of an error a pre-condition for executing the main body of the respond_with_file method. The function returns undefined if the precondition is not met. This let's me compose the functions using the or operator as I've done.

Notice the "Content-Type" header. There's a bug (a.k.a. an unimplemented enhancement) here.

The respond_with_error function does what you might expect. Based on the error received from readFile, it returns either a 404 or 500 response with the error message.

Now start the application up and browse on over to http://localhost:3000.


Ugh error! Sorry no default handling for index.html. Ok, browse over to http://localhost:3000/index.html.


Success!! We're serving up a static HTML page!

With the source I included an image. Browse on over to http://localhost/bunny.png


Ummm...that doesn't look anything like a bunny. Remember the "Content-Type". Well, you have to deliver the right one based on the media type you're serving. Otherwise, the browser has every right to be confused.

Now the Easier, More Powerful, More Complete, More Extensible Way


Wouldn't it be nice if there was a library already built that let us do all that web server stuff? But it can't get in the way like some BAFx would. Well there is of course. ConnectJS.

All that code turns into this:

connect = require 'connect'

server = connect()
server.use connect.static('public')

port = 3001
server.listen port

console.log "Connect is listening at #{port}"


The primary difference being that now it's a much more complete and robust solution. Browse over to http://localhost:3001/bunny.png


It works by adding a concept Connect calls middleware. I think of middleware as a plugin. Essentially, all the middleware objects that are attached to the connect() object are run, and when one of them handles the request it stops. There is existing middleware for a lot of things including authentication, logging, and many many more. In the code above you can see we're attaching the static middleware that serves up static content in the 'public' directory.

What's more, middleware in the context of Connect isn't some heavyweight component technology. It's simply a function and a callback. Very elegant. Very powerful. Very simple. Writing a connect compliant middleware component is for another time (but it won't be a long post).

The source for these examples is on GitHub and the annotated source is the GitHub page.

What's Next

I got plenty of XP from this exercise, but not quite enough to level up. Still need to tackle:

  • Talking to a persistence store
  • Handling authentication of users
  • Logging

Two out of three are handled by standard Connect middleware.

Then I might be ready for level 3. Just enough to start fighting some of the bigger monsters, and getting into bigger trouble.


No comments:

Post a Comment