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)