Simple HTTP Server in Ruby
Introduction to Ruby HTTP Servers
Ruby provides several ways to create HTTP servers, from the built-in WEBrick server to more advanced solutions. Understanding these fundamentals helps you build web applications and understand how web frameworks work under the hood.
Why Build HTTP Servers?
# HTTP servers are essential for:
# - Web application development
# - API creation
# - Microservices
# - Development and testing
# - Understanding web frameworks internals
WEBrick - Ruby's Built-in HTTP Server
WEBrick is Ruby's standard library HTTP server. It's perfect for development, testing, and simple applications.
Basic WEBrick Server
require 'webrick'
# Create a basic HTTP server
server = WEBrick::HTTPServer.new(
Port: 3000,
DocumentRoot: Dir.pwd
)
# Define a simple route
server.mount_proc '/hello' do |req, res|
res.content_type = 'text/html'
res.body = '<h1>Hello from WEBrick!</h1>'
end
# Handle shutdown gracefully
trap('INT') { server.shutdown }
# Start the server
puts "Server starting on http://localhost:3000"
server.start
WEBrick with Request Handling
require 'webrick'
require 'json'
class HelloServlet < WEBrick::HTTPServlet::AbstractServlet
def do_GET(request, response)
response.status = 200
response.content_type = 'application/json'
data = {
message: 'Hello from WEBrick Servlet!',
method: 'GET',
path: request.path,
query: request.query,
time: Time.now.iso8601
}
response.body = JSON.pretty_generate(data)
end
def do_POST(request, response)
response.status = 201
response.content_type = 'application/json'
# Parse request body
begin
body_data = JSON.parse(request.body)
rescue JSON::ParserError
body_data = { raw_body: request.body }
end
data = {
message: 'Data received!',
method: 'POST',
received_data: body_data,
time: Time.now.iso8601
}
response.body = JSON.pretty_generate(data)
end
end
# Create server
server = WEBrick::HTTPServer.new(
Port: 3000,
Logger: WEBrick::Log.new($stdout, WEBrick::Log::INFO)
)
# Mount servlet
server.mount '/api', HelloServlet
# Static file serving
server.mount '/static', WEBrick::HTTPServlet::FileHandler, './public'
# Simple proc-based handler
server.mount_proc '/info' do |req, res|
res.content_type = 'text/html'
res.body = <<~HTML
<h1>Server Information</h1>
<p><strong>Method:</strong> #{req.request_method}</p>
<p><strong>Path:</strong> #{req.path}</p>
<p><strong>Query:</strong> #{req.query_string}</p>
<p><strong>User Agent:</strong> #{req['User-Agent']}</p>
<p><strong>Content Type:</strong> #{req.content_type}</p>
HTML
end
# Graceful shutdown
trap('INT') { server.shutdown }
puts "WEBrick server starting on http://localhost:3000"
puts "Try these endpoints:"
puts " GET http://localhost:3000/api"
puts " POST http://localhost:3000/api"
puts " GET http://localhost:3000/info"
puts " GET http://localhost:3000/static/ (serve files from ./public/)"
server.start
Building HTTP Servers with Standard Library
You can also build HTTP servers using Ruby's lower-level networking capabilities combined with HTTP protocol handling.
Simple HTTP Server from Scratch
require 'socket'
require 'uri'
require 'json'
class SimpleHTTPServer
def initialize(port = 3000)
@port = port
@routes = {}
end
def route(method, path, &block)
@routes[[method.upcase, path]] = block
end
def start
server = TCPServer.new(@port)
puts "HTTP Server listening on port #{@port}"
puts "Visit http://localhost:#{@port}"
loop do
client = server.accept
Thread.new(client) { handle_request(client) }
end
rescue Interrupt
puts "\nShutting down server..."
server&.close
end
private
def handle_request(client)
request_line = client.gets
return unless request_line
# Parse request line
method, path, version = request_line.split(' ')
path, query_string = path.split('?', 2)
# Read headers
headers = {}
while (line = client.gets.chomp) != ''
key, value = line.split(': ', 2)
headers[key] = value
end
# Read body if present
body = ''
if headers['Content-Length']
body = client.read(headers['Content-Length'].to_i)
end
# Create request object
request = {
method: method,
path: path,
query_string: query_string,
headers: headers,
body: body
}
# Find and execute route
handler = @routes[[method, path]]
if handler
response = handler.call(request)
send_response(client, response)
else
send_404(client)
end
rescue => e
send_500(client, e)
ensure
client.close
end
def send_response(client, response)
status = response[:status] || 200
headers = response[:headers] || {}
body = response[:body] || ''
# Default headers
headers['Content-Type'] ||= 'text/html'
headers['Content-Length'] = body.bytesize.to_s
headers['Connection'] = 'close'
# Send status line
client.print "HTTP/1.1 #{status} #{status_message(status)}\r\n"
# Send headers
headers.each do |key, value|
client.print "#{key}: #{value}\r\n"
end
# Send empty line
client.print "\r\n"
# Send body
client.print body
end
def send_404(client)
send_response(client, {
status: 404,
headers: { 'Content-Type' => 'text/html' },
body: '<h1>404 Not Found</h1><p>The requested resource was not found.</p>'
})
end
def send_500(client, error)
send_response(client, {
status: 500,
headers: { 'Content-Type' => 'text/html' },
body: "<h1>500 Internal Server Error</h1><p>#{error.message}</p>"
})
end
def status_message(code)
case code
when 200 then 'OK'
when 201 then 'Created'
when 404 then 'Not Found'
when 500 then 'Internal Server Error'
else 'Unknown'
end
end
end
# Usage example
server = SimpleHTTPServer.new(3000)
# Define routes
server.route 'GET', '/' do |request|
{
status: 200,
headers: { 'Content-Type' => 'text/html' },
body: <<~HTML
<!DOCTYPE html>
<html>
<head><title>Simple HTTP Server</title></head>
<body>
<h1>Welcome to Simple HTTP Server</h1>
<p>This server is built from scratch using Ruby!</p>
<ul>
<li><a href="/api/time">Current Time API</a></li>
<li><a href="/api/info">Server Info API</a></li>
</ul>
</body>
</html>
HTML
}
end
server.route 'GET', '/api/time' do |request|
{
status: 200,
headers: { 'Content-Type' => 'application/json' },
body: JSON.pretty_generate({
current_time: Time.now.iso8601,
timezone: Time.now.zone,
timestamp: Time.now.to_i
})
}
end
server.route 'GET', '/api/info' do |request|
{
status: 200,
headers: { 'Content-Type' => 'application/json' },
body: JSON.pretty_generate({
method: request[:method],
path: request[:path],
query: request[:query_string],
headers: request[:headers],
server: 'SimpleHTTPServer',
ruby_version: RUBY_VERSION
})
}
end
server.route 'POST', '/api/echo' do |request|
begin
body_data = JSON.parse(request[:body])
rescue JSON::ParserError
body_data = { raw_body: request[:body] }
end
{
status: 200,
headers: { 'Content-Type' => 'application/json' },
body: JSON.pretty_generate({
message: 'Echo response',
received: body_data,
timestamp: Time.now.iso8601
})
}
end
# Start the server
server.start
Advanced HTTP Server Features
Let's enhance our HTTP server with additional features like middleware, routing patterns, and better error handling.
HTTP Server with Middleware Support
class AdvancedHTTPServer
def initialize(port = 3000)
@port = port
@routes = []
@middleware = []
end
def use(middleware)
@middleware << middleware
end
def get(pattern, &block)
add_route('GET', pattern, block)
end
def post(pattern, &block)
add_route('POST', pattern, block)
end
def put(pattern, &block)
add_route('PUT', pattern, block)
end
def delete(pattern, &block)
add_route('DELETE', pattern, block)
end
def start
server = TCPServer.new(@port)
puts "Advanced HTTP Server listening on port #{@port}"
loop do
client = server.accept
Thread.new(client) { handle_request(client) }
end
rescue Interrupt
puts "\nShutting down server..."
server&.close
end
private
def add_route(method, pattern, handler)
@routes << {
method: method,
pattern: compile_pattern(pattern),
handler: handler,
original_pattern: pattern
}
end
def compile_pattern(pattern)
# Convert pattern like "/users/:id" to regex
regex_pattern = pattern.gsub(/:(\w+)/, '(?<\\1>[^/]+)')
/\A#{regex_pattern}\z/
end
def handle_request(client)
request = parse_request(client)
context = { request: request, params: {} }
# Apply middleware
@middleware.each do |middleware|
result = middleware.call(context)
if result[:halt]
send_response(client, result[:response])
return
end
end
# Find matching route
route = find_route(request[:method], request[:path])
if route
# Extract parameters
match = route[:pattern].match(request[:path])
context[:params] = match ? match.named_captures : {}
# Execute handler
response = route[:handler].call(context)
send_response(client, response)
else
send_404(client)
end
rescue => e
send_500(client, e)
ensure
client.close
end
def parse_request(client)
# [Previous request parsing code here - abbreviated for space]
# Returns parsed request hash
end
def find_route(method, path)
@routes.find do |route|
route[:method] == method && route[:pattern].match(path)
end
end
# [Response sending methods here - same as before]
end
# Middleware examples
class LoggerMiddleware
def call(context)
request = context[:request]
start_time = Time.now
puts "#{request[:method]} #{request[:path]} - #{Time.now}"
# Continue to next middleware/route
{ halt: false }
end
end
class AuthMiddleware
def initialize(protected_paths = [])
@protected_paths = protected_paths
end
def call(context)
request = context[:request]
path = request[:path]
if @protected_paths.any? { |p| path.start_with?(p) }
auth_header = request[:headers]['Authorization']
unless auth_header && valid_token?(auth_header)
return {
halt: true,
response: {
status: 401,
headers: { 'Content-Type' => 'application/json' },
body: JSON.generate({ error: 'Unauthorized' })
}
}
end
end
{ halt: false }
end
private
def valid_token?(auth_header)
# Simple token validation (in real apps, use proper JWT/OAuth)
token = auth_header.split(' ').last
token == 'valid-token'
end
end
# Usage example
server = AdvancedHTTPServer.new(3000)
# Add middleware
server.use(LoggerMiddleware.new)
server.use(AuthMiddleware.new(['/admin']))
# Define routes with parameters
server.get '/users/:id' do |context|
user_id = context[:params]['id']
{
status: 200,
headers: { 'Content-Type' => 'application/json' },
body: JSON.generate({
user_id: user_id,
name: "User #{user_id}",
email: "user#{user_id}@example.com"
})
}
end
server.get '/posts/:post_id/comments/:comment_id' do |context|
post_id = context[:params]['post_id']
comment_id = context[:params]['comment_id']
{
status: 200,
headers: { 'Content-Type' => 'application/json' },
body: JSON.generate({
post_id: post_id,
comment_id: comment_id,
content: "This is comment #{comment_id} on post #{post_id}"
})
}
end
server.get '/admin/dashboard' do |context|
{
status: 200,
headers: { 'Content-Type' => 'text/html' },
body: '<h1>Admin Dashboard</h1><p>Protected content!</p>'
}
end
server.start
File Server Implementation
A common use case for HTTP servers is serving static files. Let's implement a robust file server.
Static File Server
require 'mime/types'
require 'time'
class FileServer
def initialize(root_directory = 'public', port = 3000)
@root = File.expand_path(root_directory)
@port = port
end
def start
server = TCPServer.new(@port)
puts "File Server running on http://localhost:#{@port}"
puts "Serving files from: #{@root}"
loop do
client = server.accept
Thread.new(client) { handle_request(client) }
end
rescue Interrupt
puts "\nShutting down file server..."
server&.close
end
private
def handle_request(client)
request_line = client.gets
return unless request_line
method, path, _ = request_line.split(' ')
# Only handle GET requests for file serving
unless method == 'GET'
send_error(client, 405, 'Method Not Allowed')
return
end
# Clean and secure the path
file_path = clean_path(path)
full_path = File.join(@root, file_path)
# Security check - ensure path is within root directory
unless safe_path?(full_path)
send_error(client, 403, 'Forbidden')
return
end
if File.directory?(full_path)
serve_directory(client, full_path, file_path)
elsif File.file?(full_path)
serve_file(client, full_path)
else
send_error(client, 404, 'Not Found')
end
rescue => e
send_error(client, 500, 'Internal Server Error')
puts "Error: #{e.message}"
ensure
client.close
end
def clean_path(path)
# Remove query parameters and decode URL
path = path.split('?').first
path = URI.decode_www_form_component(path)
# Remove leading slash and resolve relative paths
path = path.sub(/\A\//, '')
path = File.expand_path(path, '/')
path.sub(/\A\//, '')
end
def safe_path?(full_path)
# Ensure the path is within the root directory
File.expand_path(full_path).start_with?(@root)
end
def serve_file(client, file_path)
file_size = File.size(file_path)
file_mtime = File.mtime(file_path)
content_type = determine_content_type(file_path)
# Send headers
client.print "HTTP/1.1 200 OK\r\n"
client.print "Content-Type: #{content_type}\r\n"
client.print "Content-Length: #{file_size}\r\n"
client.print "Last-Modified: #{file_mtime.httpdate}\r\n"
client.print "Connection: close\r\n"
client.print "\r\n"
# Send file content
File.open(file_path, 'rb') do |file|
while chunk = file.read(8192)
client.write(chunk)
end
end
end
def serve_directory(client, dir_path, url_path)
entries = Dir.entries(dir_path).reject { |e| e.start_with?('.') }.sort
html = <<~HTML
<!DOCTYPE html>
<html>
<head>
<title>Directory: /#{url_path}</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px; }
.file { display: block; padding: 8px; text-decoration: none; color: #333; }
.file:hover { background: #f0f0f0; }
.dir { font-weight: bold; color: #0066cc; }
.back { color: #666; font-style: italic; }
</style>
</head>
<body>
<h1>Directory: /#{url_path}</h1>
HTML
# Add parent directory link if not at root
unless url_path.empty?
parent_path = File.dirname(url_path)
parent_path = '' if parent_path == '.'
html << " <a href=\"/#{parent_path}\" class=\"file back\">📁 .. (parent directory)</a>\n"
end
entries.each do |entry|
entry_path = File.join(dir_path, entry)
url_entry_path = File.join(url_path, entry)
if File.directory?(entry_path)
html << " <a href=\"/#{url_entry_path}\" class=\"file dir\">📁 #{entry}/</a>\n"
else
file_size = File.size(entry_path)
size_str = format_file_size(file_size)
html << " <a href=\"/#{url_entry_path}\" class=\"file\">📄 #{entry} (#{size_str})</a>\n"
end
end
html << " </body>\n</html>"
# Send response
client.print "HTTP/1.1 200 OK\r\n"
client.print "Content-Type: text/html\r\n"
client.print "Content-Length: #{html.bytesize}\r\n"
client.print "Connection: close\r\n"
client.print "\r\n"
client.print html
end
def determine_content_type(file_path)
mime_type = MIME::Types.type_for(file_path).first
mime_type ? mime_type.content_type : 'application/octet-stream'
end
def format_file_size(size)
return '0 B' if size == 0
units = ['B', 'KB', 'MB', 'GB']
unit_index = 0
while size >= 1024 && unit_index < units.length - 1
size /= 1024.0
unit_index += 1
end
"#{size.round(1)} #{units[unit_index]}"
end
def send_error(client, status, message)
body = "<h1>#{status} #{message}</h1>"
client.print "HTTP/1.1 #{status} #{message}\r\n"
client.print "Content-Type: text/html\r\n"
client.print "Content-Length: #{body.bytesize}\r\n"
client.print "Connection: close\r\n"
client.print "\r\n"
client.print body
end
end
# Usage
server = FileServer.new('public', 3000)
server.start
HTTP Server with WebSocket Support
Modern web applications often need real-time communication. Here's a basic WebSocket implementation.
Simple WebSocket Server
require 'digest/sha1'
require 'base64'
class WebSocketServer
WEBSOCKET_MAGIC_STRING = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
def initialize(port = 3000)
@port = port
@clients = []
end
def start
server = TCPServer.new(@port)
puts "WebSocket Server listening on port #{@port}"
loop do
client = server.accept
Thread.new(client) { handle_client(client) }
end
rescue Interrupt
puts "\nShutting down WebSocket server..."
server&.close
end
private
def handle_client(client)
request = parse_request(client)
if websocket_request?(request)
perform_handshake(client, request)
handle_websocket(client)
else
serve_html_page(client)
end
rescue => e
puts "Client error: #{e.message}"
ensure
@clients.delete(client)
client.close
end
def parse_request(client)
request_line = client.gets
method, path, _ = request_line.split(' ')
headers = {}
while (line = client.gets.chomp) != ''
key, value = line.split(': ', 2)
headers[key.downcase] = value
end
{ method: method, path: path, headers: headers }
end
def websocket_request?(request)
headers = request[:headers]
headers['upgrade'] == 'websocket' &&
headers['connection']&.downcase&.include?('upgrade')
end
def perform_handshake(client, request)
websocket_key = request[:headers]['sec-websocket-key']
accept_key = generate_accept_key(websocket_key)
response = [
"HTTP/1.1 101 Switching Protocols",
"Upgrade: websocket",
"Connection: Upgrade",
"Sec-WebSocket-Accept: #{accept_key}",
"", ""
].join("\r\n")
client.write(response)
@clients << client
puts "WebSocket client connected. Total clients: #{@clients.length}"
end
def generate_accept_key(websocket_key)
Base64.strict_encode64(
Digest::SHA1.digest(websocket_key + WEBSOCKET_MAGIC_STRING)
)
end
def handle_websocket(client)
loop do
frame = read_frame(client)
break unless frame
case frame[:opcode]
when 0x1 # Text frame
message = frame[:payload]
puts "Received: #{message}"
# Echo message to all clients
broadcast_message("Echo: #{message}")
when 0x8 # Close frame
break
when 0x9 # Ping frame
send_frame(client, 0xA, frame[:payload]) # Send pong
end
end
end
def read_frame(client)
first_byte = client.read(1)
return nil unless first_byte
fin = (first_byte.ord & 0x80) != 0
opcode = first_byte.ord & 0x0F
second_byte = client.read(1)
return nil unless second_byte
masked = (second_byte.ord & 0x80) != 0
payload_length = second_byte.ord & 0x7F
# Handle extended payload length
if payload_length == 126
extended_length = client.read(2)
payload_length = extended_length.unpack('n')[0]
elsif payload_length == 127
extended_length = client.read(8)
payload_length = extended_length.unpack('Q>')[0]
end
# Read mask if present
mask = masked ? client.read(4) : nil
# Read payload
payload = client.read(payload_length)
return nil unless payload
# Unmask payload if needed
if masked && mask
payload = payload.bytes.map.with_index do |byte, i|
byte ^ mask.bytes[i % 4]
end.pack('C*')
end
{
fin: fin,
opcode: opcode,
payload: payload
}
end
def send_frame(client, opcode, payload)
frame = [0x80 | opcode].pack('C') # FIN + opcode
if payload.bytesize < 126
frame << [payload.bytesize].pack('C')
elsif payload.bytesize < 65536
frame << [126, payload.bytesize].pack('Cn')
else
frame << [127, payload.bytesize].pack('CQ>')
end
frame << payload
client.write(frame)
end
def broadcast_message(message)
@clients.each do |client|
begin
send_frame(client, 0x1, message)
rescue
@clients.delete(client)
end
end
end
def serve_html_page(client)
html = <<~HTML
<!DOCTYPE html>
<html>
<head>
<title>WebSocket Test</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px; }
#messages { border: 1px solid #ccc; height: 300px; overflow-y: scroll; padding: 10px; margin: 10px 0; }
input, button { padding: 8px; margin: 5px; }
</style>
</head>
<body>
<h1>WebSocket Test Page</h1>
<div id="messages"></div>
<input type="text" id="messageInput" placeholder="Enter message" onkeypress="handleKeyPress(event)">
<button onclick="sendMessage()">Send</button>
<button onclick="disconnect()">Disconnect</button>
<script>
const ws = new WebSocket('ws://localhost:#{@port}');
const messages = document.getElementById('messages');
const messageInput = document.getElementById('messageInput');
ws.onopen = function() {
addMessage('Connected to WebSocket server');
};
ws.onmessage = function(event) {
addMessage('Server: ' + event.data);
};
ws.onclose = function() {
addMessage('WebSocket connection closed');
};
function addMessage(message) {
const div = document.createElement('div');
div.textContent = new Date().toLocaleTimeString() + ' - ' + message;
messages.appendChild(div);
messages.scrollTop = messages.scrollHeight;
}
function sendMessage() {
const message = messageInput.value;
if (message) {
ws.send(message);
addMessage('You: ' + message);
messageInput.value = '';
}
}
function handleKeyPress(event) {
if (event.key === 'Enter') {
sendMessage();
}
}
function disconnect() {
ws.close();
}
</script>
</body>
</html>
HTML
response = [
"HTTP/1.1 200 OK",
"Content-Type: text/html",
"Content-Length: #{html.bytesize}",
"Connection: close",
"", html
].join("\r\n")
client.write(response)
client.close
end
end
# Start the server
server = WebSocketServer.new(3000)
server.start
HTTP Server Best Practices
- Always handle errors gracefully and return appropriate HTTP status codes
- Implement proper security measures (path traversal protection, input validation)
- Use threading or async I/O for handling multiple concurrent connections
- Set appropriate Content-Type headers for different file types
- Implement proper logging for debugging and monitoring
- Handle HTTP methods correctly (GET, POST, PUT, DELETE, etc.)
- Support HTTP/1.1 features like keepalive connections when possible
- Use middleware patterns for cross-cutting concerns
- Consider using established servers (Puma, Unicorn) for production
- Test your servers thoroughly with various clients and edge cases