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:
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.
Fill in app.rb
with the following:
And config.ru
with:
We can run this app using the default WEBrick server by running:
The server will run on port 9292
. We can verify this with a curl
command.
That's got the basic app running!
Changing Web Servers
WEBrick is a development-only server, so let's swap it out for Puma:
Now try running rackup
again. You'll see it has automatically detected Puma in the bundle and started that instead of WEBrick!
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.
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:
Similarly, in Roda:
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!