Building your own web server might seem daunting, but Ruby's built-in libraries make it surprisingly straightforward. In this comprehensive guide, we'll create a fast, lightweight, and performant web server using nothing but pure Rubyβno external gems or dependencies required.
Why Build Your Own Web Server?
Understanding how web servers work under the hood is crucial for any developer. By building one from scratch, you'll gain insights into:
- HTTP protocol fundamentals
- Socket programming
- Request/response handling
- Performance optimization techniques
- Ruby's networking capabilities
Our final server will be capable of handling multiple concurrent connections, serving static files, and processing dynamic requests efficiently.
Prerequisites
Before we start, ensure you have:
- Ruby 2.7 or higher installed
- Basic understanding of HTTP protocol
- Familiarity with Ruby syntax and concepts
Step 1: Understanding the Basics
A web server's core responsibility is listening for incoming HTTP requests and sending back appropriate responses. At its simplest, a web server:
- Binds to a port and listens for connections
- Accepts incoming client connections
- Reads HTTP requests
- Processes the requests
- Sends back HTTP responses
- Closes the connection
Let's start with the most basic implementation:
# basic_server.rb
require 'socket'
# Create a TCP server socket bound to port 3000
server = TCPServer.new(3000)
puts "Server running on http://localhost:3000"
loop do
# Accept incoming connections
client = server.accept
# Read the request
request = client.gets
puts "Request: #{request}"
# Send a simple HTTP response
response = "HTTP/1.1 200 OK\r\n\r\nHello, World!"
client.puts response
# Close the connection
client.close
end
This basic server can handle one request at a time, but it's far from production-ready. Let's improve it step by step.
Step 2: Parsing HTTP Requests
HTTP requests follow a specific format. A typical GET request looks like:
GET /path HTTP/1.1
Host: localhost:3000
User-Agent: Mozilla/5.0...
Accept: text/html...
Let's create a proper HTTP request parser:
# http_parser.rb
class HTTPRequest
attr_reader :method, :path, :version, :headers, :body
def initialize(raw_request)
lines = raw_request.split("\r\n")
# Parse the request line
request_line = lines.first
@method, @path, @version = request_line.split(' ') if request_line
# Parse headers
@headers = {}
header_lines = lines[1..-1]
header_lines.each do |line|
break if line.empty? # Empty line indicates end of headers
key, value = line.split(': ', 2)
@headers[key.downcase] = value if key && value
end
# Extract body (for POST requests)
body_index = lines.index('')
@body = body_index ? lines[(body_index + 1)..-1].join("\r\n") : ''
end
def get?
@method == 'GET'
end
def post?
@method == 'POST'
end
def valid?
!@method.nil? && !@path.nil? && !@version.nil?
end
end
Step 3: Building HTTP Responses
Similarly, HTTP responses have a standard format:
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 13
Hello, World!
Let's create a response builder:
# http_response.rb
class HTTPResponse
attr_accessor :status_code, :headers, :body
def initialize(status_code = 200, body = '', headers = {})
@status_code = status_code
@body = body
@headers = {
'content-type' => 'text/html',
'content-length' => body.bytesize.to_s,
'connection' => 'close',
'server' => 'RubyServer/1.0'
}.merge(headers)
end
def to_s
status_text = status_message(@status_code)
response = "HTTP/1.1 #{@status_code} #{status_text}\r\n"
@headers.each do |key, value|
response += "#{key.capitalize}: #{value}\r\n"
end
response += "\r\n#{@body}"
response
end
private
def status_message(code)
case code
when 200 then 'OK'
when 201 then 'Created'
when 404 then 'Not Found'
when 405 then 'Method Not Allowed'
when 500 then 'Internal Server Error'
else 'Unknown'
end
end
end
Step 4: Creating the Core Server Class
Now let's build our main server class that ties everything together:
# web_server.rb
require 'socket'
require 'uri'
require 'cgi'
class WebServer
attr_reader :port, :host
def initialize(port = 3000, host = 'localhost')
@port = port
@host = host
@routes = {}
@middleware = []
@static_paths = {}
end
def start
@server = TCPServer.new(@host, @port)
puts "π Server running on http://#{@host}:#{@port}"
puts "Press Ctrl+C to stop"
trap('INT') { shutdown }
loop do
begin
client = @server.accept
handle_request(client)
rescue => e
puts "Error handling request: #{e.message}"
ensure
client&.close
end
end
end
def get(path, &block)
add_route('GET', path, block)
end
def post(path, &block)
add_route('POST', path, block)
end
def static(url_path, file_path)
@static_paths[url_path] = file_path
end
def use(&middleware)
@middleware << middleware
end
private
def add_route(method, path, handler)
@routes["#{method} #{path}"] = handler
end
def handle_request(client)
raw_request = read_request(client)
return unless raw_request
request = HTTPRequest.new(raw_request)
unless request.valid?
send_response(client, HTTPResponse.new(400, 'Bad Request'))
return
end
# Apply middleware
context = { request: request, params: {} }
@middleware.each { |middleware| middleware.call(context) }
response = process_request(request, context)
send_response(client, response)
end
def read_request(client)
request_lines = []
# Read until we get the complete request
while line = client.gets
request_lines << line
break if line.strip.empty? # Empty line indicates end of headers
end
request_lines.join
rescue
nil
end
def process_request(request, context)
# Check for static files first
static_response = handle_static_file(request)
return static_response if static_response
# Look for matching route
route_key = "#{request.method} #{request.path}"
handler = @routes[route_key]
if handler
begin
result = handler.call(request, context[:params])
case result
when String
HTTPResponse.new(200, result)
when Array
status, body, headers = result
HTTPResponse.new(status, body, headers || {})
when HTTPResponse
result
else
HTTPResponse.new(200, result.to_s)
end
rescue => e
puts "Error in route handler: #{e.message}"
HTTPResponse.new(500, "Internal Server Error")
end
else
HTTPResponse.new(404, "Not Found")
end
end
def handle_static_file(request)
return nil unless request.get?
@static_paths.each do |url_path, file_path|
if request.path.start_with?(url_path)
relative_path = request.path[url_path.length..-1]
full_path = File.join(file_path, relative_path)
if File.exist?(full_path) && File.file?(full_path)
content = File.read(full_path)
content_type = guess_content_type(full_path)
return HTTPResponse.new(200, content, {
'content-type' => content_type
})
end
end
end
nil
end
def guess_content_type(file_path)
extension = File.extname(file_path).downcase
case extension
when '.html', '.htm' then 'text/html'
when '.css' then 'text/css'
when '.js' then 'application/javascript'
when '.json' then 'application/json'
when '.png' then 'image/png'
when '.jpg', '.jpeg' then 'image/jpeg'
when '.gif' then 'image/gif'
when '.svg' then 'image/svg+xml'
when '.txt' then 'text/plain'
else 'application/octet-stream'
end
end
def send_response(client, response)
client.write(response.to_s)
rescue
# Client might have disconnected
end
def shutdown
puts "\nπ Shutting down server..."
@server&.close
exit
end
end
Step 5: Adding Concurrency Support
Our current server handles one request at a time, which is inefficient. Let's add threading support to handle multiple concurrent requests:
# Add this method to the WebServer class
def start_threaded
@server = TCPServer.new(@host, @port)
puts "π Threaded server running on http://#{@host}:#{@port}"
puts "Press Ctrl+C to stop"
trap('INT') { shutdown }
loop do
begin
client = @server.accept
# Handle each request in a separate thread
Thread.new(client) do |client_connection|
begin
handle_request(client_connection)
rescue => e
puts "Error in thread: #{e.message}"
ensure
client_connection&.close
end
end
rescue => e
puts "Error accepting connection: #{e.message}"
end
end
end
Step 6: Performance Optimizations
Let's add several performance improvements:
Connection Pooling and Keep-Alive
# Enhanced version with keep-alive support
def handle_request_with_keepalive(client)
loop do
raw_request = read_request(client)
break unless raw_request
request = HTTPRequest.new(raw_request)
break unless request.valid?
context = { request: request, params: {} }
@middleware.each { |middleware| middleware.call(context) }
response = process_request(request, context)
# Check if client wants to keep connection alive
if should_keep_alive?(request, response)
response.headers['connection'] = 'keep-alive'
send_response(client, response)
else
response.headers['connection'] = 'close'
send_response(client, response)
break
end
end
end
private
def should_keep_alive?(request, response)
# Keep alive if client requests it and response is successful
connection_header = request.headers['connection']
connection_header&.downcase == 'keep-alive' && response.status_code < 400
end
Request Parsing Optimization
# Optimized HTTPRequest class
class HTTPRequest
MAX_REQUEST_SIZE = 8192 # 8KB limit
def self.parse_fast(raw_request)
return nil if raw_request.bytesize > MAX_REQUEST_SIZE
lines = raw_request.split("\r\n", -1)
return nil if lines.empty?
# Quick validation
request_line_parts = lines[0].split(' ', 3)
return nil if request_line_parts.size < 3
new(raw_request)
end
# ... rest of the class remains the same
end
Step 7: Creating a Complete Example Application
Let's put it all together with a practical example:
#!/usr/bin/env ruby
# my_app.rb
require_relative 'web_server'
require_relative 'http_parser'
require_relative 'http_response'
# Create the server
app = WebServer.new(3000, 'localhost')
# Add middleware for logging
app.use do |context|
request = context[:request]
puts "#{Time.now} - #{request.method} #{request.path}"
end
# Add middleware for basic authentication (example)
app.use do |context|
request = context[:request]
# Skip auth for public paths
return if request.path.start_with?('/public')
auth_header = request.headers['authorization']
if auth_header && auth_header.start_with?('Basic ')
# Simple base64 decode (for demo only - use proper auth in production)
encoded = auth_header.split(' ', 2)[1]
decoded = encoded.unpack('m0')[0] rescue ''
username, password = decoded.split(':', 2)
context[:user] = username if username == 'admin' && password == 'secret'
end
end
# Serve static files
app.static('/public', './public')
# Define routes
app.get '/' do |request, params|
<<~HTML
<!DOCTYPE html>
<html>
<head>
<title>Ruby Web Server</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px; }
.container { max-width: 800px; margin: 0 auto; }
.nav { margin-bottom: 20px; }
.nav a { margin-right: 10px; padding: 5px 10px; background: #007acc; color: white; text-decoration: none; }
</style>
</head>
<body>
<div class="container">
<h1>π Welcome to Ruby Web Server!</h1>
<div class="nav">
<a href="/">Home</a>
<a href="/about">About</a>
<a href="/api/status">API Status</a>
<a href="/form">Form Example</a>
</div>
<p>This is a lightweight web server built with pure Ruby!</p>
<p>Features:</p>
<ul>
<li>β
HTTP/1.1 support</li>
<li>β
Static file serving</li>
<li>β
Dynamic routing</li>
<li>β
Middleware support</li>
<li>β
Concurrent request handling</li>
<li>β
Basic authentication</li>
</ul>
</div>
</body>
</html>
HTML
end
app.get '/about' do |request, params|
[200, "About Page - Built with β€οΈ and Ruby", { 'content-type' => 'text/plain' }]
end
app.get '/api/status' do |request, params|
status = {
server: 'RubyServer/1.0',
status: 'running',
timestamp: Time.now.iso8601,
uptime: Process.clock_gettime(Process::CLOCK_MONOTONIC).to_i
}
[200, status.to_json, { 'content-type' => 'application/json' }]
end
app.get '/form' do |request, params|
<<~HTML
<!DOCTYPE html>
<html>
<head>
<title>Form Example</title>
<style>body { font-family: Arial, sans-serif; margin: 40px; }</style>
</head>
<body>
<h1>Form Example</h1>
<form method="POST" action="/form">
<p>
<label>Name: <input type="text" name="name" required></label>
</p>
<p>
<label>Email: <input type="email" name="email" required></label>
</p>
<p>
<label>Message:<br>
<textarea name="message" rows="4" cols="50" required></textarea>
</label>
</p>
<p>
<button type="submit">Submit</button>
</p>
</form>
<a href="/">β Back to Home</a>
</body>
</html>
HTML
end
app.post '/form' do |request, params|
# Parse form data
form_data = CGI::parse(request.body)
name = form_data['name']&.first
email = form_data['email']&.first
message = form_data['message']&.first
response_html = <<~HTML
<!DOCTYPE html>
<html>
<head>
<title>Form Submitted</title>
<style>body { font-family: Arial, sans-serif; margin: 40px; }</style>
</head>
<body>
<h1>β
Form Submitted Successfully!</h1>
<p><strong>Name:</strong> #{CGI.escapeHTML(name || '')}</p>
<p><strong>Email:</strong> #{CGI.escapeHTML(email || '')}</p>
<p><strong>Message:</strong><br>#{CGI.escapeHTML(message || '').gsub("\n", "<br>")}</p>
<a href="/form">β Submit Another</a> |
<a href="/">Home</a>
</body>
</html>
HTML
[200, response_html]
end
# Catch-all route for API endpoints
app.get '/api/*' do |request, params|
error = {
error: 'Not Found',
message: 'API endpoint not found',
path: request.path
}
[404, error.to_json, { 'content-type' => 'application/json' }]
end
# Start the server with threading support
app.start_threaded
Step 8: Running and Testing Your Server
- Save all the code files in the same directory
- Create a
public
directory for static files:
mkdir public
echo "<h1>Static File</h1><p>This is served statically!</p>" > public/test.html
- Run your server:
ruby my_app.rb
- Test it in your browser:
-
http://localhost:3000/
- Home page -
http://localhost:3000/about
- About page -
http://localhost:3000/api/status
- JSON API -
http://localhost:3000/form
- Form example -
http://localhost:3000/public/test.html
- Static file
-
Step 9: Performance Benchmarking
Let's add a simple benchmark route to test our server's performance:
app.get '/benchmark' do |request, params|
start_time = Time.now
# Simulate some work
1000.times { Math.sqrt(rand(1000)) }
end_time = Time.now
processing_time = ((end_time - start_time) * 1000).round(2)
{
processing_time_ms: processing_time,
timestamp: start_time.iso8601,
random_number: rand(1000)
}.to_json
end
Step 10: Production Considerations
While our server is functional, consider these improvements for production use:
Security Enhancements
# Add security headers middleware
app.use do |context|
request = context[:request]
context[:security_headers] = {
'x-frame-options' => 'DENY',
'x-content-type-options' => 'nosniff',
'x-xss-protection' => '1; mode=block',
'referrer-policy' => 'strict-origin-when-cross-origin'
}
end
Error Handling
def handle_request_safely(client)
begin
handle_request(client)
rescue => e
error_response = HTTPResponse.new(500, 'Internal Server Error')
send_response(client, error_response)
puts "Server error: #{e.message}"
puts e.backtrace.first(5)
end
end
Resource Limits
class WebServer
MAX_CONCURRENT_CONNECTIONS = 100
CONNECTION_TIMEOUT = 30
def initialize(port = 3000, host = 'localhost')
# ... existing code ...
@connection_count = 0
@connection_mutex = Mutex.new
end
def start_with_limits
# ... implement connection limiting ...
end
end
Conclusion
Congratulations! You've built a fully functional web server in pure Ruby. This server includes:
- β HTTP/1.1 protocol support
- β GET and POST request handling
- β Static file serving with proper MIME types
- β Dynamic routing system
- β Middleware architecture
- β Concurrent request processing
- β Basic authentication support
- β JSON API endpoints
- β Form processing
- β Error handling
Key Takeaways
- Ruby's Standard Library is Powerful: We built everything using only Ruby's built-in libraries
- HTTP is Simple: The protocol itself is straightforward text-based communication
- Concurrency Matters: Threading dramatically improves performance
- Architecture is Important: Clean separation of concerns makes the code maintainable
- Performance Optimization: Small changes can have big impacts on server performance
Next Steps
To further enhance your server, consider:
- Adding HTTPS/TLS support using OpenSSL
- Implementing HTTP/2 support
- Adding request/response compression
- Creating a plugin system
- Adding database connectivity
- Implementing caching mechanisms
- Adding WebSocket support
This foundation gives you a solid understanding of how web servers work and provides a starting point for more advanced features. Whether you use this in production or just for learning, you now have deep insight into the mechanics of web server operation.
Top comments (0)