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.
# HTTP servers are essential for:
# - Web application development
# - API creation
# - Microservices
# - Development and testing
# - Understanding web frameworks internals
WEBrick is Ruby's standard library HTTP server. It's perfect for development, testing, and simple applications.
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
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
You can also build HTTP servers using Ruby's lower-level networking capabilities combined with HTTP protocol handling.
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
Let's enhance our HTTP server with additional features like middleware, routing patterns, and better error handling.
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
A common use case for HTTP servers is serving static files. Let's implement a robust 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
Modern web applications often need real-time communication. Here's a basic WebSocket implementation.
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
Watch and learn simple http server