ruby

The Basics of Rack for Ruby

Ayush Newatia

Ayush Newatia on

The Basics of Rack for Ruby

Rack is the foundation for every popular Ruby web framework in existence. It standardizes an interface between a Ruby application and a web server. This mechanism allows us to pair any Rack-compliant web server (such as Puma, Unicorn, or Falcon) with any Rack-compliant web framework (like Rails, Sinatra, Roda, or Hanami).

Separating the concerns like this is immensely powerful and provides a lot of flexibility. It does, however, also come with limitations.

Rack 2 operated on the assumption that every request must provide a response and close the connection. It made no facility for persistent connections to enable pathways like WebSockets.

Developers had to make use of a hacky escape hatch to take over connections from Rack to implement WebSockets or similar persistent connections.

This all changed with Rack 3. But first, let's backtrack and take a closer look at Rack itself.

A Barebones Rack App

A basic Rack app looks like this:

ruby
class App def call(env) [200, { "Content-Type" => "text/plain" }, ["Hello World"]] end end run App.new

env is a hash containing request-specific information such as HTTP headers. When a request is made, the call method is called, and we return an array representing the response.

The first element is the HTTP response code, in this case 200. The second element is a hash containing any Rack and HTTP response headers we wish to send. Finally, the last element is an array of strings representing the response body.

Let's organize this app into a folder and run it.

bash
$ mkdir rack-demo $ cd rack-demo $ bundle init $ bundle add rack rackup $ touch app.rb $ touch config.ru

Fill in app.rb with the following:

ruby
class App def call(env) [200, { "content-type" => "text/plain" }, ["Hello World"]] end end

And config.ru with:

ruby
require_relative "app" run App.new

We can run this app using the default WEBrick server by running:

bash
$ bundle exec rackup

The server will run on port 9292. We can verify this with a curl command.

bash
$ curl localhost:9292 Hello World

That's got the basic app running!

Changing Web Servers

WEBrick is a development-only server, so let's swap it out for Puma:

bash
$ bundle add puma

Now try running rackup again. You'll see it has automatically detected Puma in the bundle and started that instead of WEBrick!

bash
$ bundle exec rackup Puma starting in single mode... * Puma version: 6.4.2 (ruby 3.2.2-p53) ("The Eagle of Durango") * Min threads: 0 * Max threads: 5 * Environment: development * PID: 45877 * Listening on http://127.0.0.1:9292 * Listening on http://[::1]:9292 Use Ctrl-C to stop

I recommend starting Puma directly instead of using rackup, as that allows us to pass configuration arguments should we want to. The -w 4 below starts Puma using 4 workers, meaning 4 instances of Puma are started up simultaneously.

bash
$ bundle exec puma -w 4 [45968] Puma starting in cluster mode... [45968] * Puma version: 6.4.2 (ruby 3.2.2-p53) ("The Eagle of Durango") [45968] * Min threads: 0 [45968] * Max threads: 5 [45968] * Environment: development [45968] * Master PID: 45968 [45968] * Workers: 4 [45968] * Restarts: () hot () phased [45968] * Listening on http://0.0.0.0:9292 [45968] Use Ctrl-C to stop [45968] - Worker 0 (PID: 45981) booted in 0.0s, phase: 0 [45968] - Worker 1 (PID: 45982) booted in 0.0s, phase: 0 [45968] - Worker 2 (PID: 45983) booted in 0.0s, phase: 0 [45968] - Worker 3 (PID: 45984) booted in 0.0s, phase: 0

This basic app demonstrates the Rack interface. An incoming HTTP request is parsed into the env hash and provided to the application. The application processes the request and supplies an array as the response that the server formats and sends to the client.

Rack Compliance In Frameworks

Every compliant web framework follows the Rack spec under the hood and provides an access point to go down to this level.

In Rails, we can send a Rack response in a controller:

ruby
class HomeController def index self.response = [200, {}, ["I'm Home!"]] end end

Similarly, in Roda:

ruby
route do |r| r.on "home" do r.halt [200, {}, ["I'm Home!"]] end end

Every Rack-compliant framework will have a slightly different syntax for accomplishing this, but since they're all sending Rack responses under the hood, they will have an API for you to access that response.

You can find the full, relatively accessible Rack specification on GitHub.

Wrapping Up

As this demo shows, Rack operates under the assumption that a request comes in, is processed by a web application, and a response is sent back. Throwing persistent connections into the mix totally breaks this model, yet Rack-compliant frameworks like Rails implement WebSockets.

In the next post, part two of a three-part series, we'll cover how to take over connections from Rack so we can hold persistent connections to enable pathways such as WebSockets.

Until then, happy coding!

P.S. If you'd like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!

Ayush Newatia

Ayush Newatia

Guest author Ayush is a freelance Ruby and Rails developer. He's the author of The Rails and Hotwire Codex and part of the Bridgetown core team. He also runs a privacy focused mailing list app called Scattergun.

All articles by Ayush Newatia

Become our next author!

Find out more

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps