Building a Simple Web Server with Ruby 2.0+

The Ruby language can be utilized for a variety of different purposes. The most popular of these is as a web scripting language. The entry point to these applications, the web server, is not something that is usually built from the ground up. Most applications have the lines gem 'puma' or gem 'unicorn' in their Gemfile. Those are high quality web servers that were built and are maintained by very talented people, but how do they work?

The aforementioned web servers are just Ruby gems written by Rubyists. While they are awesome gems, they are not magic. A basic web server can be written to satisfy some very simple requests.

GET Basic

The job of a web server is to take an incoming request, decide what it means and respond accordingly. Essentially, we want to read some text, process it, and send some other text as response. To create a very simple server, the built in Socket library will be utilized.

The goal of this tiny server will be to listen to localhost requests on port 8080.

require 'socket'

server = TCPServer.new(8080)

loop do
  request = server.accept
  request.puts 'hello world'
  request.close
end

And there it is! A ridiculously tiny web server written in Ruby. Dissecting this code, we see how simple the Socket library makes this task.

server = TCPServer.new(8080)

This line makes a new TCPServer instance that will accept requests to port 8080 and defaults to localhost as the host. The important method on the TCPServer instance is accept.

loop do

Since this server is meant to respond to any request on port 8080, an infinite loop is created so that more than one request can be fulfilled before the process exits.

request = server.accept
request.puts 'hello world'
request.close

Here is the real logic of the server. The accept method returns a new instance of TCPSocket which can be used to communicate with a client. A response string, 'hello world', is then printed to the client and the connection is closed.

Saving this code to example_server.rb, the server can be started with ruby example_server.rb in a console.

Using curl to test this server results in:

$: curl localhost:8080
hello world
curl: (56) Recv failure: Connection reset by peer

Alright, alright, the 'hello world' has been printed to the console but it looks like there might be an issue. Connection reset by peer, what the heck does that mean? It means that the server did not send enough information to the client, particularly about when to close the connection. The client is expecting more data than this very naive server is providing. The client expects header data.

Look Ma, no Head(er)!

An HTTP header consists of many colon-separated key-value pairs deliminated by a return and newline combination. Everything from a status code to response size can be returned in an HTTP header.

For this simple example, a status code, Content-Type, Content-Length, and Connection will be added to the response header.

require 'socket'

server = TCPServer.new(8080)

loop do
  request = server.accept

  response = 'hello world'

  header = "HTTP/1.1 200 OK\r\n"
  header += "Content-Type: text/plain\r\n"
  header += "Content-Length: #{ response.bytesize }\r\n"
  header += "Connection: close\r\n"

  request.puts header
  request.puts "\r\n"
  request.puts response

  request.close
end

With this additional information, a client using this server will know the type, size, and connection status of its request. This will enable the client to respect the content type, parse the result intelligently and close the connection.

An interesting detail about HTTP headers is the way they end. A single line consisting of a return newline, request.puts "\r\n", is how the client knows that the HTTP header is finished.

Note: If a server like this is created for any real requests, hard-coded response headers is not the best idea.

Head’s up

Now, the same curl localhost:8080 test to the new and improved server returns no error:

$: curl localhost:8080
hello world

The new headers are visible via curl localhost:8080 -I:

$: curl localhost:8080 -I
HTTP/1.1 200 OK
Content-Type: text/plain
Content-Length: 11
Connection: close

The server is now responding in a sensible way, but a problem still exists. If more than one request is made, the first request will be processed and all others will wait.

This implementation is not adequate for concurrent request handling, but another built in solution is readily available.

Threads the Needle

An easy way to achieve basic concurrency with this server is via Threads. Out of the box Ruby uses the Matz’s Ruby Interpreter or MRI. MRI is not capable of true concurrency (due to the Global Interpreter Lock) but built in threads will be just fine for this small server.

This example can be extended to use threads by wrapping the responsibility of the request in a block:

require 'socket'

server = TCPServer.new(8080)

loop do
  Thread.new(server.accept) do |request|
    response = 'hello world'

    header = "HTTP/1.1 200 OK\r\n"
    header += "Content-Type: text/plain\r\n"
    header += "Content-Length: #{ response.bytesize }\r\n"
    header += "Connection: close\r\n"

    request.puts header
    request.puts "\r\n"
    request.puts response

    request.close
  end
end

Voila! A simple threaded Ruby server!

Note: As with the previous solution, this might not be fit for a production environment since every single request spawns a new thread within a single process.

What now?

Now, this simple server will fulfill concurrent requests in a way that will make sense to its consumers. However, it always responds with 'hello world', regardless of the request data. To fetch and parse the incoming data, request.gets is available:

require 'socket'

server = TCPServer.new(8080)

loop do
  Thread.new(server.accept) do |request|
    response = 'hello world'

    request_input = request.gets
    # handle request information

    header = "HTTP/1.1 200 OK\r\n"
    header += "Content-Type: text/plain\r\n"
    header += "Content-Length: #{ response.bytesize }\r\n"
    header += "Connection: close\r\n"

    request.puts header
    request.puts "\r\n"
    request.puts response

    request.close
  end
end

Other improvements would be header configuration, incoming request parsing, and thread pooling.

Spending a thousand hours iterating on this code will certainly improve its quality, but it still might not allow this simple server stack up to the leaders in the space. I would recommend using a more battle tested web server for an actual production application. At least some of those awesome servers’ mysteries have been explained.

To see even more improvements, check out part 2!