What is Rack?

Rack, the protocol

The Rack protocol standardizes how Rack servers (Puma, Thin, Unicorn, ...), Rack applications (Ruby on Rails-based applications, Sinatra-based applications, ...) and Rack middleware communicate with one another.

Rack overview

In this article, we'll see in detail what each of these components is and how it works. Before we do, let's see what the rack gem is.

Rack, the gem

Let's start with what it's not: it's not a requirement. Writing a Rack server/application/middleware does NOT require using the rack gem. As long as they abide by the Rack protocol, they are Rack servers/applications/middleware.

So what's in the rack gem? Just a set of useful tools to ease development. We'll dig in later, let's focus on the Rack protocol for now.

Learn by doing: building Rack 3 programs from scratch

There's no better way to deeply understand things than to re-build them yourself, so let's build a dummy program for each component of the Rack system: a server, an application, and a middleware.

1. Rack server

What is a Rack server?

In a nutshell, a Rack server is an HTTP server that has two responsibilities:

  • Handling the TCP sockets: listening, accepting, closing, ...
  • Speaking the HTTP protocol: reading HTTP requests and formatting responses in an HTTP manner.

It is not responsible for the business logic (what the Web application does) which is delegated to a Rack application. Rack servers and Rack applications communicate using a Rack env which will be presented shortly.

Dummy HTTP server

Let's write a dummy server with a full socket lifecycle management.

Dummy HTTP server

At its essence, it looks something like this:

#!/usr/bin/env ruby

require 'socket'

class DummyRackServer
  attr_reader :hostname, :port

  def initialize(hostname: 'localhost', port: 9292)
    @hostname = hostname
    @port = port
  end

  def run
    server = TCPServer.new(hostname, port)
    begin
      loop do
        client = server.accept
        begin
          # TODO read HTTP request
          # TODO send response
        ensure
          client.close
        end
      end
    ensure
      server.close
    end
  end
end

dummy_rack_server = DummyRackServer.new(
  hostname: 'localhost',
  port: 9292
)

dummy_rack_server.run

Read it on github

The read request/send response is voluntarily oversimplified, we'll add Rack env manipulation and Rack application execution later.

Now that you have the main code structure in mind, let's fill in the blanks:

#!/usr/bin/env ruby

require 'socket'
require 'stringio'

class DummyHttpRequestReader
  def initialize(socket:)
    @socket = socket
  end

  def read
    [request_line, headers, body]
  end

  def request_line
    return @request_line if defined?(@request_line)

    @request_line = socket.gets.strip
  end

  def headers
    return @headers if defined?(@headers)

    @headers = []
    loop do
      line = socket.gets.strip
      break if line.length.zero?
      @headers << line
    end
    @headers
  end

  def body
    return @body if defined?(@body)

    buffer = StringIO.new
    begin
      loop do
        chunk = socket.read_nonblock(4096)
        buffer << chunk
        break if chunk.size < 4096
      end
    rescue IO::EAGAINWaitReadable
    end
    buffer
    @body = buffer.to_s
  end

  private

  attr_reader :socket
end

class DummyRackServer
  attr_reader :hostname, :port

  def initialize(hostname: 'localhost', port: 9292)
    @hostname = hostname
    @port = port
  end

  def run
    server = TCPServer.new(hostname, port)
    begin
      loop do
        client = server.accept
        begin
          request_line, headers, body = DummyHttpRequestReader.new(socket: client).read
          client.write(build_response)
        ensure
          client.close
        end
      end
    ensure
      server.close
    end
  end

  private

  def build_response
    <<~EOF
      HTTP/1.1 200 OK

      <html><body>hello, world</body></html>
    EOF
  end
end

dummy_rack_server = DummyRackServer.new(
  hostname: 'localhost',
  port: 9292
)

dummy_rack_server.run

Read it on github

This server listens on port 9292, accepts a single client at a time, reads the request in a not-super-smart way, and always responds with a static, hard-coded html page (hello, word). This is not yet a Rack server because it still misses some things, but we achieved a first milestone. Let's continue with building the env hash.

Building a Rack env

Building the Rack env

The next thing a Rack server does after receiving a client request is building a Rack-compliant env hash. Let's do that:

#!/usr/bin/env ruby

require 'socket'
require 'stringio'
require 'logger'

class DummyHttpRequestReader
  def initialize(socket:)
    @socket = socket
  end

  def read
    [request_line, headers, body]
  end

  def request_line
    # ...
  end

  def headers
    # ...
  end

  def body
    # ...
  end

  def request_method
    @request_method ||= request_line.split(' ')[0]
  end

  def url
    @url ||= request_line.split(' ')[1]
  end

  def path
    @path ||= url.split('?', 2)[0]
  end

  def query_string
    @query_string ||= url.split('?', 2)[1]
  end

  def http_protocol
    @request_url ||= request_line.split(' ')[2]
  end

  private

  attr_reader :socket
end

class DummyRackServer
  attr_reader :hostname, :port

  def initialize(hostname: 'localhost', port: 9292)
    @hostname = hostname
    @port = port
  end

  def run
    errors_stream = $stderr
    logger = Logger.new(STDOUT)
    server = TCPServer.new(hostname, port)
    begin
      loop do
        client = server.accept
        request = DummyHttpRequestReader.new(socket: client)
        request.read
        input_stream = StringIO.new(request.body).tap(&:binmode)
        begin
          env = build_env(
            remote_addr: remote_addr_of(client),
            request: request,
            logger: logger,
            input_stream: input_stream,
            errors_stream: errors_stream
          )
          client.write(build_response)
        ensure
          input_stream.close
          client.close
        end
      end
    ensure
      server.close
      logger.close
    end
  end

  private

  def build_env(remote_addr:, request:, logger:, input_stream:, errors_stream:)
    # > The environment must be an unfrozen instance of Hash that includes CGI-like headers. The Rack application is free to modify the environment.
    # > The environment is required to include these variables (adopted from PEP 333), except when they’d be empty, but see below.
    # Source: https://github.com/rack/rack/blob/main/SPEC.rdoc#the-environment-
    # PEP 333: https://peps.python.org/pep-0333/
    env = {
      'REQUEST_METHOD' => request.request_method, # GET, POST, PATCH, ...
      'rack.url_scheme' => 'http', # In real-life, it depends on the request (http, https, ws, ...)
      'REMOTE_ADDR' => remote_addr,

      # The following variables should permit the reconstruction of the full URL. See https://peps.python.org/pep-0333/#url-reconstruction
      'REQUEST_PATH' => request.path,
      'PATH_INFO' => request.url,
      'QUERY_STRING' => request.query_string.to_s,
      'SERVER_PROTOCOL' => request.http_protocol, # HTTP/1.1
      'SCRIPT_NAME' => '',
      'SERVER_NAME' => hostname,
      'SERVER_PORT' => port.to_s,

      # Used, for instance, for Websocket connections. Hijacking spec: https://github.com/rack/rack/blob/main/SPEC.rdoc#hijacking-
      'rack.hijack?' => false,

      # IO objects
      'rack.input' => input_stream, # Allows Rack apps to read the body of the request
      'rack.logger' => logger,
      'rack.errors' => errors_stream,
    }
    request.headers.each do |header|
      name, value = header.split(':', 2)
      env["HTTP_#{name.upcase}"] = value.strip
    end
    env
  end

  def remote_addr_of(client)
    client.peeraddr(false)[3]
  end

  def build_response
    <<~EOF
      HTTP/1.1 200 OK

      <html><body>hello, world</body></html>
    EOF
  end
end

dummy_rack_server = DummyRackServer.new(
  hostname: 'localhost',
  port: 9292
)

dummy_rack_server.run

Read it on github

There are a lot of things in the env that we're not using for the moment such as:

  • an error stream for the Rack application to output error messages.
  • a logger (by default redirected to STDOUT, but it might as well be plugged to a third-party such as Datadog, Sentry, ...)
  • etc.

They are required by the specification so that Rack applications can use them if need be. Let's not focus on them for the moment as we achieved our goal: building a Rack-compliant env.

Note: The above env is a minimal one. In production-ready servers, you usually find way more things in there.

Preparing the response interface

The next things a Rack server is supposed to do after building the env is to run a Rack application, collect the response, format it as required by the HTTP protocol, and finally send it to the client. Let's skip the running a Rack application part for the moment, we'll get back to it in a short moment. Instead, let's stay at the server level and prepare how the Rack server will build the HTTP response:

Preparing the response interface

#!/usr/bin/env ruby

require 'socket'
require 'stringio'
require 'logger'

class DummyHttpRequestReader
  # ...
end

class DummyRackServer
  attr_reader :hostname, :port

  def initialize(hostname: 'localhost', port: 9292)
    @hostname = hostname
    @port = port
  end

  def run
    errors_stream = $stderr
    logger = Logger.new(STDOUT)
    server = TCPServer.new(hostname, port)
    begin
      loop do
        # ...
        begin
          env = build_env(...)
          response = build_response(
            env: env,
            status: 200,
            headers: {
              'content-type' => 'text/html; charset=utf-8',
              'content-location' => 'https://younes.codes'
            },
            body: [
              '<html><body>',
              'hello, world',
              '</body></html>'
            ]
          )
          client.write(response)
        ensure
          input_stream.close
          client.close
        end
      end
    ensure
      server.close
      logger.close
    end
  end

  private

  def build_env(remote_addr:, request:, logger:, input_stream:, errors_stream:)
    # Identical to the previous example
  end

  STATUS_MESSAGE = {
    200 => 'OK',
    201 => 'CREATED',
    404 => 'NOT FOUND',
    422 => 'UNPROCESSABLE ENTITY',
    500 => 'INTERNAL ERROR'
    # ...
  }

  def build_response(env:, status: 200, headers: [], body: [])
    response = [
      [
        env.fetch('SERVER_PROTOCOL', 'HTTP/1.1'),
        status,
        STATUS_MESSAGE.fetch(status, 'UNKNOWN')
      ].join(' ')
    ]
    headers.each { |name, value| response.push("#{name}: #{value}") }
    response.push('')
    response.concat(body)
    response.join("\r\n")
  end
end

# ...

Read it on github

Although we still have a hard-coded response, we now have an interface for something to provide a status, a hash of headers and an array of body parts. This something is of course intended to be a Rack application.

2. Rack application

What is a Rack application? It's a ruby object that responds to the #call method. It can be a lambda/proc, or some class instance that responds to #call.

Its #call method takes an env parameter and returns an array as a result.

This array must contain exactly three elements that will form the HTTP response:

  • First, an integer representing the HTTP status code,
  • Second, a hash containing HTTP headers with lower-case string keys,
  • Third, an array of strings that'll be concatenated to compose the body.

Dummy Rack application

In its simplest form, a Rack application looks like this:

dummy_rack_application = -> (env) do
  [200, {}, ["hello, world"]]
end

Here is the Rack application that will replace our previously hard-coded response:

dummy_rack_application = -> (env) do
  status_code = 200
  headers = {
    'content-type' => 'text/html; charset=utf-8',
    'content-location' => 'https://younes.codes'
  }
  body = [
    '<html><body>',
    'hello, world',
    '</body></html>'
  ]
  [status_code, headers, body]
end

Moving from a server with a built-in response to calling a Rack application

Rack application

#!/usr/bin/env ruby

require 'socket'
require 'stringio'
require 'logger'

class DummyHttpRequestReader
  # ...
end

class DummyRackServer
  attr_reader :hostname, :port, :rack_application

  def initialize(hostname: 'localhost', port: 9292, rack_application:)
    @hostname = hostname
    @port = port
    @rack_application = rack_application
  end

  def run
    errors_stream = $stderr
    logger = Logger.new(STDOUT)
    server = TCPServer.new(hostname, port)
    begin
      loop do
        # ...
        begin
          env = build_env(...)
          status, headers, body = rack_application.call(env)
          response = build_response(
            env: env,
            status: status,
            headers: headers,
            body: body
          )
          client.write(response)
        ensure
          input_stream.close
          client.close
        end
      end
    ensure
      server.close
      logger.close
    end
  end

  # ...
end

dummy_rack_application = -> (env) do
  status_code = 200
  headers = {
    'content-type' => 'text/html; charset=utf-8',
    'content-location' => 'https://younes.codes'
  }
  body = [
    '<html><body>',
    'hello, world',
    '</body></html>'
  ]
  [status_code, headers, body]
end

dummy_rack_server = DummyRackServer.new(
  hostname: 'localhost',
  port: 9292,
  rack_application: dummy_rack_application
)

dummy_rack_server.run

Read it on github

Loading the Rack application from another file

config.ru without middleware

A server that embeds a Rack application is not the most useful. We will make it load a Rack application from an external file.

The conventional by-default location to look for a Rack application is ./config.ru.

The goal is thus to read/execute ruby code from a given file, somehow extract from it the Rack application in the form of an object that responds to #call, and feed it to our server DummyRackServer at initialization.

Although it is possible to re-implement this functionality, I think it is time to start using the rack gem. It provides the Rack::Builder class that helps in doing just that.

Rack app example using a .rb file

If you go with loading a ruby file, the convention states that the ruby file must expose a constant that is named after the file (dummy_application.rb -> DummyApplication).

# my_dummy_application.rb

# Depending on how you build things, the constant could either reference a proc/lambda:

MyDummyApplication = -> (env) do
  [200, {}, ["hello, world"]]
end

# Or, if you have a more complex logic to structure, you could write a class that you then instantiate:

class DummyApplication
  def call(env)
    [200, {}, ["hello, world"]]
  end
end
MyDummyApplication = DummyApplication.new

Rack app example using a .ru file

The .ru approach leverages the DSL provided by Rack::Builder. Here, you are expected to call a run method and give it your rack application.

# config.ru

dummy_rack_application = -> (env) do
  [200, {}, ["hello, world"]]
end

run dummy_rack_application

You can learn more about Rack::Builder's DSL by reading its source code.

NOTE: I'm voluntarily skipping anything middleware-related for the moment. We'll get back to this once we've completed the Rack server and application parts.

Using Rack::Builder to load a Rack application

Okay, so let's use Rack::Builder to load a Rack application from ./config.ru:

#!/usr/bin/env ruby

require 'socket'
require 'stringio'
require 'logger'
require 'rack'

class DummyHttpRequestReader
  # ...
end

class DummyRackServer
  # ...
end

dummy_rack_application = Rack::Builder.parse_file('./config.ru')

dummy_rack_server = DummyRackServer.new(
  hostname: 'localhost',
  port: 9292,
  rack_application: dummy_rack_application
)

dummy_rack_server.run

Read it on github

# ./config.ru

dummy_rack_application = -> (env) do
  status_code = 200
  headers = {
    'content-type' => 'text/html; charset=utf-8',
    'content-location' => 'https://younes.codes'
  }
  body = [
    '<html><body>',
    'hello, world',
    '</body></html>'
  ]
  [status_code, headers, body]
end

run dummy_rack_application

Read it on github

Since we started using the rack gem, let's replace some of our code with helpers provided by the rack gem. Here is the full code at this stage:

#!/usr/bin/env ruby

require 'socket'
require 'stringio'
require 'logger'
require 'rack'

# There are breaking changes with Rack 2, make sure we're requiring Rack 3!
raise 'Rack 3 required' unless Rack.release.split('.')[0] == '3'

class DummyHttpRequestReader
  def initialize(socket:)
    @socket = socket
  end

  def read
    [request_line, headers, body]
  end

  def request_line
    return @request_line if defined?(@request_line)

    @request_line = socket.gets.strip
  end

  def headers
    return @headers if defined?(@headers)

    @headers = []
    loop do
      line = socket.gets.strip
      break if line.length.zero?
      @headers << line
    end
    @headers
  end

  def body
    return @body if defined?(@body)

    buffer = StringIO.new
    begin
      loop do
        chunk = socket.read_nonblock(4096)
        buffer << chunk
        break if chunk.size < 4096
      end
    rescue IO::EAGAINWaitReadable
    end
    buffer
    @body = buffer.to_s
  end

  def request_method
    @request_method ||= request_line.split(' ')[0]
  end

  def url
    @url ||= request_line.split(' ')[1]
  end

  def path
    @path ||= url.split('?', 2)[0]
  end

  def query_string
    @query_string ||= url.split('?', 2)[1]
  end

  def http_protocol
    @request_url ||= request_line.split(' ')[2]
  end

  private

  attr_reader :socket
end

class DummyRackServer
  attr_reader :hostname, :port, :rack_application

  def initialize(hostname: 'localhost', port: 9292, rack_application:)
    @hostname = hostname
    @port = port
    @rack_application = rack_application
  end

  def run
    errors_stream = $stderr
    logger = Logger.new(STDOUT)
    server = TCPServer.new(hostname, port)
    begin
      loop do
        client = server.accept
        request = DummyHttpRequestReader.new(socket: client)
        request.read
        input_stream = StringIO.new(request.body).tap(&:binmode)
        begin
          env = build_env(
            remote_addr: remote_addr_of(client),
            request: request,
            logger: logger,
            input_stream: input_stream,
            errors_stream: errors_stream
          )
          status, headers, body = rack_application.call(env)
          response = build_response(
            env: env,
            status: status,
            headers: headers,
            body: body
          )
          client.write(response)
        ensure
          input_stream.close
          client.close
        end
      end
    ensure
      server.close
      logger.close
    end
  end

  private

  def build_env(remote_addr:, request:, logger:, input_stream:, errors_stream:)
    # Use constants defined in rack/constants.rb
    env = {
      Rack::REQUEST_METHOD => request.request_method,
      Rack::REQUEST_PATH => request.path,
      Rack::PATH_INFO => request.url,
      Rack::QUERY_STRING => request.query_string.to_s,
      Rack::SERVER_PROTOCOL => request.http_protocol,
      Rack::SCRIPT_NAME => '',
      Rack::SERVER_NAME => hostname,
      Rack::SERVER_PORT => port.to_s,
      Rack::RACK_IS_HIJACK => false,
      Rack::RACK_INPUT => input_stream,
      Rack::RACK_LOGGER => logger,
      Rack::RACK_ERRORS => errors_stream,
      Rack::RACK_TEMPFILES => [],
      Rack::RACK_URL_SCHEME => 'http',
      'REMOTE_ADDR' => remote_addr,
    }
    request.headers.each do |header|
      name, value = header.split(':', 2)
      env["HTTP_#{name.upcase}"] = value.strip
    end
    env
  end

  def remote_addr_of(client)
    client.peeraddr(false)[3]
  end

  def build_response(env:, status: 200, headers: [], body: [])
    response = [
      [
        env.fetch('SERVER_PROTOCOL', 'HTTP/1.1'),
        status,
        Rack::Utils::HTTP_STATUS_CODES.fetch(status, 'UNKNOWN')
      ].join(' ')
    ]
    # Rack 3 requires that response headers keys be lowercase. Rack::Headers does that for you:
    headers = Rack::Headers.new(headers)
    headers.each { |name, value| response.push("#{name}: #{value}") }
    response.push('')
    response.concat(body)
    response.join("\r\n")
  end
end

dummy_rack_application = Rack::Builder.parse_file('./config.ru')

dummy_rack_server = DummyRackServer.new(
  hostname: 'localhost',
  port: 9292,
  rack_application: dummy_rack_application
)

dummy_rack_server.run

Read it on github

3. Rack middleware

It's time to talk about the last (official) component of the Rack ecosystem: Rack middleware.

Rack middleware

What are Rack middleware

Rack middleware are wrappers of Rack applications. They can run code before the app, after, in parallel (unusual but possible), and can even decide whether or not to execute the Rack app.

What are they useful for? A lot of stuff. Here are some examples:

  • Rack::Deflater: Enables content encoding of http responses.
  • Rack::CommonLogger: Forwards every request to the given +app+, and logs a line in the Apache common log format to the configured logger.
  • Rack::ETag: Automatically sets the etag header on all String bodies.
  • Rack::ContentLength: Sets the content-length header on responses that do not specify a content-length or transfer-encoding header.
  • Rack::Directory: Serves entries below the +root+ given, according to the path info of the Rack request. If a directory is found, the file's contents will be presented in an html based index. If a file is found, the env will be passed to the specified +app+.
  • Rack::ShowExceptions: catches all exceptions raised from the app it wraps. It shows a useful backtrace with the sourcefile and clickable context, the whole Rack environment and the request data. Be careful when you use this on public-facing sites as it could reveal information helpful to attackers.

Descriptions taken from the respective middleware source codes

The middleware listed above are provided by the rack gem itself. Others, such as Rack::Attack, can be imported as well, and you can obviously write yours too.

In its essence, a Rack middleware looks like this:

# ./some_middleware.rb

class SomeMiddleware
  def initialize(app)
    @app = app
  end

  def call(env)
    # Behaves as a Rack application BUT with the addition that it has received the Rack application at initialization,
    # which means it can do stuff before (such as modifying the env for instance), decide to execute or not the app, do stuff after, etc.
    @app.call(env)
  end
end

Dummy Rack middleware

Let's write a dummy middleware for appending an HTML signature comment to Rack applications response body:

# ./useless_signature_middleware.rb

class UselessSignatureMiddleware
  def initialize(app)
    @app = app
  end

  def call(env)
    status, headers, body = @app.call(env)
    body << "<!-- I read https://younes.codes blog posts. It makes me happy -->"
    [status, headers, body]
  end
end

Read it on github

And for now, let's use it from the server:

#!/usr/bin/env ruby

require 'socket'
require 'stringio'
require 'logger'
require 'rack'
require_relative './useless_signature_middleware'

#...

dummy_rack_application = Rack::Builder.parse_file('./config.ru')

wrapped_application = UselessSignatureMiddleware.new(dummy_rack_application)

dummy_rack_server = DummyRackServer.new(
  hostname: 'localhost',
  port: 9292,
  rack_application: wrapped_application
)

dummy_rack_server.run

Read it on github

Now any request that is served by this server will have a response body ending with a useless signature. Yey \o/

How about we add another middleware? Let's do that!

# ./ping_pong_middleware.rb

class PingPongMiddleware
  def initialize(app)
    @app = app
  end

  def call(env)
    if env[Rack::REQUEST_PATH] == '/ping'
      [200, {}, ["pong!"]]
    else
      @app.call(env)
    end
  end
end

Read it on github

This one takes the lead when the /ping path is requested. Instead of passing the request to the next middleware or application, it directly returns a predefined answer.

#!/usr/bin/env ruby

require 'socket'
require 'stringio'
require 'logger'
require 'rack'
require_relative './useless_signature_middleware'
require_relative './ping_pong_middleware'

#...

dummy_rack_application = Rack::Builder.parse_file('./config.ru')

wrapped_application = UselessSignatureMiddleware.new(dummy_rack_application)
wrapped_application = PingPongMiddleware.new(wrapped_application)

# PingPongMiddleware
#   -> UselessSignatureMiddleware
#     -> Rack application

dummy_rack_server = DummyRackServer.new(
  hostname: 'localhost',
  port: 9292,
  rack_application: wrapped_application
)

dummy_rack_server.run

Read it on github

Let me now correct what I said earlier:

Behaves as a Rack application BUT with the addition that it has received the Rack application next application to execute at initialization,
which means it can do stuff before (such as modifying the env for instance), decide to execute or not the next app, do stuff after, etc.
The next app can indiscriminately be a middleware or a Rack application, which means you can play russian dolls all you want.

As you can see, middleware are just a composition tool that lets us isolate specific logic and keep the main Rake application as lean as possible.

Move middleware usage from the server to config.ru

config.ru with two middleware

# ./config.9.ru

app = Rack::Builder.app do
  dummy_rack_application = -> (env) do
    status_code = 200
    headers = {
      'content-type' => 'text/html; charset=utf-8',
      'content-location' => 'https://younes.codes'
    }
    body = [
      '<html><body>',
      'hello, world',
      '</body></html>'
    ]
    [status_code, headers, body]
  end

  use PingPongMiddleware
  use UselessSignatureMiddleware
  run dummy_rack_application
end

run app

Read it on github

We've been using Rack::Builder's DSL #run method to declare the application to execute. Now we also use the #use method to list as many middleware as we want in the order in which we want them to intervene.

Speaking of middleware, there is one I want to emphasize on. When developing a Rack application or middleware, you want to make sure you're compliant with the Rack specification. The rack gem embeds a middleware that does that: Rack::Lint. You can #use it before each middleware/application you want to validate. You can also use it, in development mode, directly in your server:

#!/usr/bin/env ruby

require 'socket'
require 'stringio'
require 'logger'
require 'rack'

raise 'Rack 3 required' unless Rack.release.split('.')[0] == '3'

class DummyHttpRequestReader
  #...
end

class DummyRackServer
  attr_reader :hostname, :port, :rack_application

  def initialize(hostname: 'localhost', port: 9292, rack_application:)
    #...
  end

  def run
    errors_stream = $stderr
    logger = Logger.new(STDOUT)
    server = TCPServer.new(hostname, port)
    begin
      loop do
        client = server.accept
        request = DummyHttpRequestReader.new(socket: client)
        request.read
        input_stream = StringIO.new(request.body).tap(&:binmode)
        begin
          env = build_env(...)
          # Ensure we're compliant with the Rack specification
          status, headers, body = Rack::Lint.new(rack_application).call(env)
          response = build_response(...)
          client.write(response)
        ensure
          input_stream.close
          client.close
        end
      end
    ensure
      server.close
      logger.close
    end
  end

  #...
end

dummy_rack_application = Rack::Builder.parse_file("./config.9.ru")

dummy_rack_server = DummyRackServer.new(
  hostname: 'localhost',
  port: 9292,
  rack_application: dummy_rack_application
)

dummy_rack_server.run

Read it on github

4. Command line interface

So far we've talked about Rack servers, Rack applications, and Rack middleware. Alongside what I called the official components of the Rack ecosystem is one that I didn't mention yet: the rackup gem. Although it is not part of the Rack protocol, rackup is so commonly used when it comes to developing Rack applications that I feel it's important to mention it in this article.

rackup provides a command line interface for running a Rack-compatible application.
Source: https://github.com/rack/rackup

To be more precise, rackup provides a command line interface for running a Rack-compatible application using a Rack-compatible server (Puma, WEBrick, ...).

When executed, and unless told otherwise, it:

  • Expects a config.ru to exist and define the Rack application tu run (ru stands for rackup)
  • Uses Puma for the HTTP server part if it is already installed, or defaults on WEBrick otherwise.

It too uses Rack::Builder for loading the Rack application from either config.ru (or any .rb rackup config file).

I'll let you read the documentation and the source code of rackup if you want to know more about it as it's a bit out of the scope of this article.

$ cat config.ru 
dummy_rack_application = -> (env) do
  status_code = 200
  headers = {
    'content-type' => 'text/html; charset=utf-8',
    'content-location' => 'https://younes.codes'
  }
  body = [
    '<html><body>',
    'hello, world',
    '</body></html>'
  ]
  [status_code, headers, body]
end

run dummy_rack_application
$ rackup
Puma starting in single mode...
* Puma version: 6.1.0 (ruby 3.1.2-p20) ("The Way Up")
*  Min threads: 0
*  Max threads: 5
*  Environment: development
*          PID: 259618
* Listening on http://127.0.0.1:9292
* Listening on http://[::1]:9292
Use Ctrl-C to stop
$ curl http://localhost:9292
<html><body>
hello, world
</body></html>

Conclusion

You now know what Rack servers, Rack applications, and Rack middleware are. You also understand why there is a config.ru file at the root directory of your Ruby on Rails application ;)

You can find the final source code implemented here on GitHub. Again, it's not production-ready, it's overly simplified to ease the understanding of what each component is and how they communicate with each other.

As always, I strongly advise you read some source code:

Thank you for reading!
Younes SERRAJ