Skip to content

Commit 22a4082

Browse files
DavidLiedleclaude
andcommitted
Add chapter: Build a Rack Server from Scratch
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b81f3a5 commit 22a4082

1 file changed

Lines changed: 345 additions & 0 deletions

File tree

src/rack/server-from-scratch.md

Lines changed: 345 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,345 @@
1+
# Build a Rack Server from Scratch
2+
3+
A Rack server has one job: accept HTTP connections, parse them into a Rack env hash, call your application, and serialize the response back into HTTP. Let's build one.
4+
5+
This isn't a production server. It handles one request at a time, ignores keep-alive, has no TLS, and will fall over under load. It is, however, a real HTTP server that speaks valid HTTP/1.1 and can run actual Rack applications. Understanding it will demystify everything that happens before your application code runs.
6+
7+
## The Structure
8+
9+
A Rack server needs to:
10+
11+
1. Listen on a TCP port
12+
2. Accept connections in a loop
13+
3. Parse the HTTP request into a Rack env hash
14+
4. Call the application with the env
15+
5. Serialize the `[status, headers, body]` response into HTTP
16+
6. Write it to the socket
17+
18+
Let's build each piece.
19+
20+
## Step 1: The TCP Listener
21+
22+
```ruby
23+
require 'socket'
24+
25+
server = TCPServer.new('0.0.0.0', 9292)
26+
puts "Listening on http://localhost:9292"
27+
28+
loop do
29+
client = server.accept
30+
# handle client
31+
client.close
32+
end
33+
```
34+
35+
`TCPServer.new` opens a socket. `server.accept` blocks until a connection arrives, then returns a `TCPSocket` representing that connection. Straightforward.
36+
37+
## Step 2: Parsing the HTTP Request
38+
39+
HTTP requests look like this:
40+
41+
```
42+
GET /path?query=string HTTP/1.1\r\n
43+
Host: localhost:9292\r\n
44+
Accept: text/html\r\n
45+
\r\n
46+
```
47+
48+
We need to parse this into a Rack env hash. The tricky parts are:
49+
- Headers end at a blank line (`\r\n` alone)
50+
- The body follows the blank line, if `Content-Length` is set
51+
- Header names become `HTTP_UPPERCASED_WITH_UNDERSCORES`
52+
53+
```ruby
54+
def parse_request(client)
55+
# Read the request line
56+
request_line = client.gets&.chomp
57+
return nil unless request_line
58+
59+
method, full_path, http_version = request_line.split(' ', 3)
60+
path, query_string = full_path.split('?', 2)
61+
62+
# Read headers until blank line
63+
headers = {}
64+
while (line = client.gets&.chomp) && !line.empty?
65+
name, value = line.split(': ', 2)
66+
headers[name] = value
67+
end
68+
69+
# Read body if Content-Length is present
70+
body = ''
71+
if (length = headers['Content-Length']&.to_i) && length > 0
72+
body = client.read(length)
73+
end
74+
75+
# Build the Rack env
76+
env = {
77+
# Required CGI variables
78+
'REQUEST_METHOD' => method,
79+
'SCRIPT_NAME' => '',
80+
'PATH_INFO' => path,
81+
'QUERY_STRING' => query_string || '',
82+
'SERVER_NAME' => 'localhost',
83+
'SERVER_PORT' => '9292',
84+
'HTTP_VERSION' => http_version,
85+
'SERVER_PROTOCOL' => http_version,
86+
87+
# Rack-specific
88+
'rack.version' => [1, 3],
89+
'rack.input' => StringIO.new(body),
90+
'rack.errors' => $stderr,
91+
'rack.multithread' => false,
92+
'rack.multiprocess' => false,
93+
'rack.run_once' => false,
94+
'rack.url_scheme' => 'http',
95+
}
96+
97+
# Convert HTTP headers to CGI format
98+
headers.each do |name, value|
99+
# Content-Type and Content-Length get special treatment
100+
key = case name
101+
when 'Content-Type' then 'CONTENT_TYPE'
102+
when 'Content-Length' then 'CONTENT_LENGTH'
103+
else "HTTP_#{name.upcase.gsub('-', '_')}"
104+
end
105+
env[key] = value
106+
end
107+
108+
env
109+
end
110+
```
111+
112+
The header name transformation — `Content-Type` becomes `HTTP_CONTENT_TYPE`, `X-Request-Id` becomes `HTTP_X_REQUEST_ID` — is a CGI convention that Rack inherits. It's annoying but consistent.
113+
114+
## Step 3: Serializing the Response
115+
116+
The response is `[status, headers, body]`. We need to turn that into HTTP/1.1 text:
117+
118+
```ruby
119+
STATUS_PHRASES = {
120+
200 => 'OK',
121+
201 => 'Created',
122+
204 => 'No Content',
123+
301 => 'Moved Permanently',
124+
302 => 'Found',
125+
304 => 'Not Modified',
126+
400 => 'Bad Request',
127+
401 => 'Unauthorized',
128+
403 => 'Forbidden',
129+
404 => 'Not Found',
130+
405 => 'Method Not Allowed',
131+
415 => 'Unsupported Media Type',
132+
422 => 'Unprocessable Entity',
133+
500 => 'Internal Server Error',
134+
}.freeze
135+
136+
def send_response(client, status, headers, body)
137+
phrase = STATUS_PHRASES[status] || 'Unknown'
138+
139+
# Status line
140+
client.write("HTTP/1.1 #{status} #{phrase}\r\n")
141+
142+
# Headers
143+
headers.each do |name, value|
144+
client.write("#{name}: #{value}\r\n")
145+
end
146+
147+
# Blank line separating headers from body
148+
client.write("\r\n")
149+
150+
# Body — iterate over whatever the app gave us
151+
body.each do |chunk|
152+
client.write(chunk)
153+
end
154+
155+
# Some body objects need to be closed (file handles, etc.)
156+
body.close if body.respond_to?(:close)
157+
end
158+
```
159+
160+
## Putting It Together
161+
162+
```ruby
163+
# tiny_server.rb
164+
require 'socket'
165+
require 'stringio'
166+
167+
STATUS_PHRASES = {
168+
200 => 'OK', 201 => 'Created', 204 => 'No Content',
169+
301 => 'Moved Permanently', 302 => 'Found',
170+
400 => 'Bad Request', 401 => 'Unauthorized',
171+
403 => 'Forbidden', 404 => 'Not Found',
172+
405 => 'Method Not Allowed', 500 => 'Internal Server Error',
173+
}.freeze
174+
175+
def parse_request(client)
176+
request_line = client.gets&.chomp
177+
return nil unless request_line && !request_line.empty?
178+
179+
method, full_path, http_version = request_line.split(' ', 3)
180+
path, query_string = full_path.split('?', 2)
181+
182+
headers = {}
183+
while (line = client.gets&.chomp) && !line.empty?
184+
name, value = line.split(': ', 2)
185+
headers[name] = value
186+
end
187+
188+
body = ''
189+
if (length = headers['Content-Length']&.to_i) && length > 0
190+
body = client.read(length)
191+
end
192+
193+
env = {
194+
'REQUEST_METHOD' => method,
195+
'SCRIPT_NAME' => '',
196+
'PATH_INFO' => path,
197+
'QUERY_STRING' => query_string || '',
198+
'SERVER_NAME' => 'localhost',
199+
'SERVER_PORT' => '9292',
200+
'SERVER_PROTOCOL' => http_version || 'HTTP/1.1',
201+
'rack.version' => [1, 3],
202+
'rack.input' => StringIO.new(body),
203+
'rack.errors' => $stderr,
204+
'rack.multithread' => false,
205+
'rack.multiprocess' => false,
206+
'rack.run_once' => false,
207+
'rack.url_scheme' => 'http',
208+
}
209+
210+
headers.each do |name, value|
211+
key = case name
212+
when 'Content-Type' then 'CONTENT_TYPE'
213+
when 'Content-Length' then 'CONTENT_LENGTH'
214+
else "HTTP_#{name.upcase.tr('-', '_')}"
215+
end
216+
env[key] = value
217+
end
218+
219+
env
220+
end
221+
222+
def send_response(client, status, headers, body)
223+
phrase = STATUS_PHRASES[status] || 'Unknown'
224+
client.write("HTTP/1.1 #{status} #{phrase}\r\n")
225+
headers.each { |name, value| client.write("#{name}: #{value}\r\n") }
226+
client.write("\r\n")
227+
body.each { |chunk| client.write(chunk) }
228+
body.close if body.respond_to?(:close)
229+
end
230+
231+
def run(app, port: 9292)
232+
server = TCPServer.new('0.0.0.0', port)
233+
puts "TinyServer listening on http://localhost:#{port}"
234+
235+
loop do
236+
client = server.accept
237+
238+
begin
239+
env = parse_request(client)
240+
241+
if env
242+
status, headers, body = app.call(env)
243+
send_response(client, status, headers, body)
244+
end
245+
rescue => e
246+
$stderr.puts "Error handling request: #{e.message}"
247+
$stderr.puts e.backtrace.first(5).join("\n")
248+
249+
error_body = "Internal Server Error\n"
250+
client.write("HTTP/1.1 500 Internal Server Error\r\n")
251+
client.write("Content-Type: text/plain\r\n")
252+
client.write("Content-Length: #{error_body.bytesize}\r\n")
253+
client.write("\r\n")
254+
client.write(error_body)
255+
ensure
256+
client.close
257+
end
258+
end
259+
end
260+
```
261+
262+
## Running It with a Real App
263+
264+
Let's plug in the notes app from the previous chapter:
265+
266+
```ruby
267+
# run_notes.rb
268+
require_relative 'tiny_server'
269+
require_relative 'app' # the NotesApp from the previous chapter
270+
271+
run NotesApp.new, port: 9292
272+
```
273+
274+
```bash
275+
$ ruby run_notes.rb
276+
TinyServer listening on http://localhost:9292
277+
```
278+
279+
```bash
280+
$ curl -s http://localhost:9292/notes
281+
[]
282+
283+
$ curl -s -X POST http://localhost:9292/notes \
284+
-H 'Content-Type: application/json' \
285+
-d '{"content": "It works"}' | jq .
286+
{"id":1,"content":"It works","created":"2026-02-19T12:00:00+00:00"}
287+
288+
$ curl -s http://localhost:9292/notes | jq .
289+
[{"id":1,"content":"It works","created":"2026-02-19T12:00:00+00:00"}]
290+
```
291+
292+
Your handwritten server, running your handwritten app. Real HTTP, real TCP sockets.
293+
294+
## Making It Threaded
295+
296+
The current server handles one request at a time — the next `server.accept` doesn't run until the current request is finished. For a learning tool, fine. For anything resembling concurrent use, we need threads:
297+
298+
```ruby
299+
def run(app, port: 9292)
300+
server = TCPServer.new('0.0.0.0', port)
301+
puts "TinyServer (threaded) on http://localhost:#{port}"
302+
303+
loop do
304+
client = server.accept
305+
306+
Thread.new(client) do |conn|
307+
begin
308+
env = parse_request(conn)
309+
if env
310+
status, headers, body = app.call(env)
311+
send_response(conn, status, headers, body)
312+
end
313+
rescue => e
314+
$stderr.puts "Error: #{e.message}"
315+
ensure
316+
conn.close
317+
end
318+
end
319+
end
320+
end
321+
```
322+
323+
Each connection gets its own thread. The main loop immediately returns to `accept`, ready for the next connection. This is essentially what WEBrick does (minus SSL, keep-alive, virtual host support, and a decade of edge-case handling).
324+
325+
## What We're Not Handling
326+
327+
A production HTTP/1.1 server needs to handle:
328+
329+
- **Keep-alive connections**: HTTP/1.1 keeps connections open by default. Our server closes after every response, which is valid but wasteful.
330+
- **Chunked transfer encoding**: When `Content-Length` is unknown at response time, you can send data in chunks.
331+
- **HTTP pipelining**: Multiple requests on the same connection before any response.
332+
- **Request timeouts**: A client that connects and never sends data will tie up a thread forever.
333+
- **Very large bodies**: We read the entire body into memory. For file uploads, you'd want streaming.
334+
- **SSL/TLS**: Everything above is cleartext.
335+
- **HTTP/2**: A binary protocol with multiplexing; fundamentally different from HTTP/1.1.
336+
337+
Puma, the default Rails server, handles all of these. It's about 10,000 lines of code. Our server is about 80. The gap is instructive — those 9,920 lines are solving real, hard problems. But the core idea — parse a hash, call an object, serialize the result — is in our 80 lines.
338+
339+
## The Moment
340+
341+
Here it is: **the only thing a web server does is build a hash and call your code.** The hash has a few required keys. Your code returns a three-element array. The server turns that array into text and sends it over a socket.
342+
343+
When Puma says it "runs Rack applications," this is what it means. When we say "Rack-compatible server," we mean "a server that knows how to build this specific hash and interpret this specific array." The protocol is simple enough that we just implemented a conforming server in under a hundred lines.
344+
345+
Next: the middleware chain that sits between the server and your app.

0 commit comments

Comments
 (0)