Ruby Logo

Simple HTTP Server

Build HTTP servers using WEBrick and Ruby's networking capabilities. Learn server fundamentals and create web applications from scratch.

Home Ruby Simple HTTP Server

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

Quick Navigation

Related Topics

Video Tutorial

Watch and learn simple http server

Pro Tip: After reading through the content above, watch this video to reinforce your understanding and see the concepts in action!