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.
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.
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
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
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
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
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:
#!/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
# ...
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
#!/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
Loading the Rack application from another file
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
# ./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
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
3. Rack middleware
It's time to talk about the last (official) component of the Rack ecosystem: 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
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
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
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
Let me now correct what I said earlier:
Behaves as a Rack application BUT with the addition that it has received the
Rack applicationnext 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 Rack application as lean as possible.
Move middleware usage from the server to config.ru
# ./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
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
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 forr
acku
p) - 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:
- rack and its protocol specification
- Rack::Request and Rack::Response that come in handy when it comes to writing Rack applications and middleware,
- Rack::Builder for its DSL,
- rackup and at least one Rack 3-compatible server such as Puma,
- and the source code of some middleware.
Thank you for reading!
Younes SERRAJ