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:
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.
$ 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:
class App def call(env) [200, { "content-type" => "text/plain" }, ["Hello World"]] end end
And config.ru
with:
require_relative "app" run App.new
We can run this app using the default WEBrick server by running:
$ bundle exec rackup
The server will run on port 9292
. We can verify this with a curl
command.
$ 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:
$ 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!
$ 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.
$ 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:
class HomeController def index self.response = [200, {}, ["I'm Home!"]] end end
Similarly, in Roda:
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!