ruby / web framework

I used a ~500-line Ruby and Rack-based web framework. The app was a monolith serving HTML with HTML templates, Postgres via DB, Rack middleware, and background work via job queues.

Why Rack, not Rails

Rails carries opinions on everything from asset pipelines to mailers. Most are irrelevant to any given application, yet they arrive anyway: as dependencies to manage, abstractions to navigate, and conventions for an AI to misremember.

A Rack app contains only what it uses. My entire framework fits in a few files, which makes it simple to read, quick to change, and easy to test. When something breaks, the stack trace is short and points to your code rather than framework internals.

The small surface area also helps AI-assisted development. A language model can hold my whole framework in context rather than drawing on a decade of shifting Rails conventions. Code generation becomes faster and more accurate.

If the application outgrows Ruby, a Rack app this size can be ported (to Go, say) one handler at a time.

Rack app with routing

The core is a Rack app that maps URL patterns to handler classes. Handler classes and their constructor args are stored at boot; a fresh instance is created per-request for thread safety:

module Framework
  class App
    def initialize
      @routes = {"GET" => [], "POST" => []}
    end

    def get(path, klass, *args, **kwargs)
      @routes["GET"] << [compile(path), path, klass, args, kwargs]
    end

    def post(path, klass, *args, **kwargs)
      @routes["POST"] << [compile(path), path, klass, args, kwargs]
    end

    def redirect(from, to)
      @routes["GET"] << [compile(from), from, :redirect, [to], {}]
    end

    # Rack interface
    def call(env)
      req = Rack::Request.new(env)
      result = match(req.request_method, req.path_info)

      if result.nil?
        return [404, {"Content-Type" => "text/html"}, [File.read("public/404.html")]]
      end

      klass, args, kwargs, params = result

      if klass == :redirect
        return [301, {"Location" => args[0]}, []]
      end

      env["router.params"] = params

      response = catch(:halt) do
        klass.new(*args, **kwargs).call(env)
      end

      response
    end

    private def match(method, path)
      @routes[method]&.each do |pattern, _, klass, args, kwargs|
        if (m = pattern.match(path))
          return [klass, args, kwargs, m.named_captures]
        end
      end
      nil
    end

    private def compile(path)
      pattern = path.gsub(/:([a-z_]+)/, '(?<\1>[^/]+)')
      Regexp.new("\\A#{pattern}\\z")
    end
  end
end

Routes are registered at boot:

app = Framework::App.new

app.get "/health", HealthHandler::Check, db
app.get "/companies/:id", CompaniesHandler::Show, db
app.post "/companies/create", CompaniesHandler::Create, db
app.redirect "/old-path", "/new-path"

run app

Path parameters like :id become named captures available via env["router.params"].

The catch(:halt) in call allows handlers to short-circuit with throw :halt, response. This is useful for auth checks that need to redirect without awkward return if patterns.

Handlers

Handler classes are registered at boot with their dependencies. My router creates a fresh instance per-request, so there is no shared mutable state across threads. Subclasses implement handle (not call) so setup and auth are automatic:

module Framework
  class Handler
    attr_reader :db, :req, :params, :session

    def initialize(db)
      @db = db
    end

    def call(env)
      setup(env)
      require_login
      resp = handle
      resp = wrap_html(resp)
      resp
    end

    def setup(env)
      @env = env
      @req = Rack::Request.new(env)
      @params = @req.params.merge(env["router.params"] || {})
      @session = env["rack.session"] || {}
    end

    def handle
      raise NotImplementedError, "#{self.class} must implement #handle"
    end

    # Returns an HTML string. For partials and composition.
    def render(template, **locals)
      all_locals = locals.merge(default_locals)
      Template.render(template, all_locals, layout: nil)
    end

    # Returns a Rack response with layout. For full page renders.
    def page(template, title: "EDS", **locals)
      all_locals = locals.merge(default_locals).merge(title: title)
      html = Template.render(template, all_locals, layout: "layouts/application")
      [200, {"Content-Type" => "text/html"}, [html]]
    end

    def redirect(location)
      if !same_origin_or_internal_path?(location)
        return head(400)
      end
      [303, {"Location" => location}, []]
    end

    private def same_origin_or_internal_path?(location)
      if internal_only_path?(location)
        return true
      end
      target = URI.parse(location)
      base = URI.parse(base_url)
      target.scheme == base.scheme && target.host == base.host && target.port == base.port
    rescue URI::InvalidURIError
      false
    end

    private def internal_only_path?(path)
      if path.nil?
        return false
      end
      path.start_with?("/") && !["/", "\\", "@"].include?(path[1])
    end

    def head(status, headers = {})
      [status, headers, []]
    end

    private def require_login
      if current_user
        return true
      end

      @session[:return_to] = @req.fullpath
      throw :halt, redirect("/login")
    end

    # Auto-wrap string returns from handle into Rack responses.
    private def wrap_html(resp)
      if resp.is_a?(String)
        [200, {"Content-Type" => "text/html"}, [resp]]
      else
        resp
      end
    end

    private def default_locals
      {
        current_user: current_user,
        params: params,
        session: @session,
        flash: @env["app.flash"] || {}
      }
    end
  end
end

render returns an HTML string, useful for composing partials or returning ajax fragments. page wraps the template in a layout and returns a Rack response. call auto-wraps string returns from handle via wrap_html, so ajax handlers can return render(...) directly. redirect parses the target and rejects anything that isn't an internal path or a same-origin URL. Open redirects come in a few shapes:

internal_only_path? rejects all four by requiring a leading / and excluding /, \, and @ as the second character.

A handler hierarchy keeps auth secure by default. Handler requires login. PublicHandler skips auth (for login pages, webhooks, health checks). AdminHandler adds require_admin.

A health check handler:

module HealthHandler
  class Check < Framework::PublicHandler
    def handle
      db.exec("SELECT 1")
      head 200
    end
  end
end

A typical page handler:

module CompaniesHandler
  class Show < Framework::Handler
    PageData = Data.define(:name, :status)

    def handle
      co = Companies::Find.new(db).call(id: params["id"])
      if co.nil?
        return [404, {"Content-Type" => "text/html"}, [File.read("public/404.html")]]
      end

      data = PageData.new(
        name: co["name"],
        status: co["status"]
      )

      page "companies/show", title: data.name, data: data
    end
  end
end

Encrypted cookies

For sensitive values like auth tokens, use AES-256-GCM encryption rather than just signing:

class EncryptedCookieJar
  def initialize(request_cookies, secret)
    @request_cookies = request_cookies
    @key = OpenSSL::Digest::SHA256.digest(secret)[0, 32]
  end

  def [](name)
    data = @request_cookies[name]
    if data.nil?
      return nil
    end

    decrypt(data)
  end

  private def encrypt(plaintext)
    cipher = OpenSSL::Cipher.new("aes-256-gcm").encrypt
    cipher.key = @key
    iv = cipher.random_iv
    ciphertext = cipher.update(plaintext) + cipher.final
    tag = cipher.auth_tag
    Base64.urlsafe_encode64(iv + tag + ciphertext)
  end

  private def decrypt(data)
    raw = Base64.urlsafe_decode64(data)
    cipher = OpenSSL::Cipher.new("aes-256-gcm").decrypt
    cipher.key = @key
    cipher.iv = raw[0, 12]
    cipher.auth_tag = raw[12, 16]
    cipher.update(raw[28..]) + cipher.final
  rescue
    nil
  end
end

Use Rack::Session::Cookie (signed) for the session. Use the encrypted cookie jar only for the auth token.

Middleware

Cross-cutting concerns are handled with Rack middleware. Each is a simple class with initialize(app) and call(env).

CSRF

class CSRF
  SAFE_METHODS = %w(GET HEAD OPTIONS TRACE).freeze

  def initialize(app)
    @app = app
  end

  def call(env)
    req = Rack::Request.new(env)

    if !SAFE_METHODS.include?(req.request_method)
      session_token = req.session[:csrf_token]
      param_token = req.params["authenticity_token"]
      header_token = env["HTTP_X_CSRF_TOKEN"]

      if session_token.nil? || (param_token != session_token && header_token != session_token)
        req.session.clear
        return [303, {"Location" => "/login"}, []]
      end
    end

    req.session[:csrf_token] ||= SecureRandom.base64(32)
    @app.call(env)
  end
end

Flash messages

class Flash
  def initialize(app)
    @app = app
  end

  def call(env)
    req = Rack::Request.new(env)
    env["app.flash"] = req.session[:flash] || {}
    req.session.delete(:flash)
    @app.call(env)
  end
end

Flash is read from the session, made available to the handler via env["app.flash"], then deleted so it only appears once.

Content Security Policy

A CSP middleware sets the Content-Security-Policy header on HTML responses. See Content Security Policy below for the policy itself and the nonce-aware middleware.

HTTPS redirect with HSTS

class HTTPS
  def initialize(app)
    @app = app
  end

  def call(env)
    scheme = env["HTTP_X_FORWARDED_PROTO"] || Rack::Request.new(env).scheme
    if scheme == "http"
      url = "https://#{env["HTTP_HOST"]}#{env["REQUEST_URI"]}"
      return [301, {"Location" => url}, []]
    end

    status, headers, body = @app.call(env)
    headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
    [status, headers, body]
  end
end

Error handling

class ErrorHandler
  def initialize(app)
    @app = app
  end

  def call(env)
    @app.call(env)
  rescue => e
    Sentry.capture_exception(e)

    if ENV["APP_ENV"] == "development"
      [500, {"Content-Type" => "text/html"}, [<<~HTML]]
        <h1>500</h1>
        <pre>#{CGI.escapeHTML(e.message)}\n#{CGI.escapeHTML(e.backtrace.join("\n"))}</pre>
      HTML
    else
      [500, {"Content-Type" => "text/html"}, [File.read("public/500.html")]]
    end
  end
end

Request logging

class RequestLogger
  def initialize(app)
    @app = app
  end

  def call(env)
    t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
    status, headers, body = @app.call(env)

    path = env["PATH_INFO"]
    if path != "/health"
      ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t) * 1000).round(1)
      puts "#{status} #{env["REQUEST_METHOD"]} #{path} #{ms}ms"
    end

    [status, headers, body]
  end
end

Composing the stack

Rack::Builder composes the middleware stack with the app:

module Framework
  module Server
    def self.app(db)
      routes = build_routes(db) # returns Framework::App

      Rack::Builder.new do
        use RequestLogger
        use ErrorHandler
        use HTTPS if ENV["APP_ENV"] == "production"
        use Rack::Session::Cookie,
          key: "_session",
          secret: ENV.fetch("SECRET_KEY_BASE"),
          same_site: :lax,
          secure: ENV["APP_ENV"] == "production"
        use SafeStaticCache,
          urls: ["/css", "/js", "/fonts", "/images"],
          root: "public",
          header_rules: [[:all, {"Cache-Control" => "public, max-age=31536000, immutable"}]]
        use CSRF
        use Flash
        run routes
      end.to_app
    end
  end
end

Routes without middleware can be exposed separately for testing:

def self.routes(db)
  app = Framework::App.new
  app.get "/health", HealthHandler::Check.new(db)
  # ... more routes
  app
end

This lets handler tests bypass auth and CSRF.

Content Security Policy

A Content Security Policy header tells the browser which sources are allowed to load scripts, styles, frames, and other resources. A tight policy is the last line of defense against XSS and injection in third-party content.

Start from default-deny and list only what the app actually loads:

[
  "default-src 'self'",
  "base-uri 'none'",
  "object-src 'none'",
  "frame-ancestors 'none'",
  "script-src 'self'",
  "style-src 'self' 'unsafe-inline'",
  "img-src 'self' data: https://your-image-cdn.example",
  "connect-src 'self'"
].join("; ")

Every directive names specific origins. Each new external host (an analytics script, a third-party widget) is a deliberate addition rather than an automatic one.

Pitfalls to avoid

'unsafe-eval' enables eval, new Function, and setTimeout called with a string body. A modern app does not need any of those. Allowing them widens the impact of any XSS that lands.

A wildcard like https: in script-src accepts any HTTPS origin. That undoes most of CSP's value and turns the directive into documentation rather than enforcement. List specific hosts; if there are none, 'self' is enough.

'unsafe-inline' in script-src allows inline <script> tags and event handlers. Inline scripts are the most common XSS vector. Move them to files served from your origin, or use a nonce.

Nonces for inline

If a small inline script is unavoidable (for example, to bridge server-rendered data into JS), generate a per-response nonce:

class CSP
  def initialize(app)
    @app = app
  end

  def call(env)
    nonce = SecureRandom.base64(16)
    env["app.csp_nonce"] = nonce

    status, headers, body = @app.call(env)

    if headers["Content-Type"].to_s.include?("text/html")
      headers["Content-Security-Policy"] = [
        "base-uri 'none'",
        "object-src 'none'",
        "script-src 'self' 'nonce-#{nonce}'",
        "style-src 'self' 'unsafe-inline'"
      ].join("; ")
    end

    [status, headers, body]
  end
end

The handler exposes the nonce as a default local so templates can reference it:

%script{nonce: csp_nonce, type: "application/json", id: "boot"}
  != bootstrap_data_json

The browser executes the inline tag only if its nonce matches the response header.

Report-only first

When tightening a policy, ship a Content-Security-Policy-Report-Only header for a release before enforcing. The header is identical; the browser sends violation reports instead of blocking. Read the reports, fix the genuine sources, then flip the header name.

CSP tests

A request test checks the header on every HTML response:

def test_csp_excludes_unsafe_eval
  resp = get("/")
  csp = resp.headers["Content-Security-Policy"].to_s
  ok { csp.include?("script-src 'self'") }
  ok { !csp.include?("unsafe-eval") }
  ok { !csp.match?(/script-src[^;]*\bhttps:\B/) }
end

A regression that adds unsafe-eval to silence a console error fails the test before it ships.

Boot sequence

Require gems explicitly rather than using Bundler.require. Explicit requires make the dependency graph visible. Bundler.require hides what you depend on and adds autoload magic that slows boot.

Running with Puma

require "puma"
require_relative "lib/db"
require_relative "lib/framework/boot"
require_relative "lib/framework/server"

DB.configure do |c|
  c.pool_size = ENV.fetch("WEB_CONCURRENCY").to_i * ENV.fetch("WEB_THREADS").to_i
  c.reap = true
  c.statement_timeout_ms = 24_000
end

conf = Puma::Configuration.new do |c|
  c.app Framework::Server.app(DB.pool)
  c.environment ENV.fetch("APP_ENV")
  c.bind "tcp://0.0.0.0:#{ENV.fetch("PORT")}"
  c.threads ENV.fetch("WEB_THREADS").to_i, ENV.fetch("WEB_THREADS").to_i
  c.workers ENV.fetch("WEB_CONCURRENCY").to_i

  c.cluster do
    c.preload_app!

    c.before_worker_boot do
      DB.reset_pool! # fresh connections after fork
    end

    c.shutdown_debug on_force: true
  end
end

Puma::Launcher.new(conf).run

Timeout chain

A request that hangs ties up a Puma thread, a database connection, and any HTTP sockets it opened. Each layer caps the one above it so a stuck request fails cleanly:

use Rack::Timeout, service_timeout: 25, term_on_timeout: 1

term_on_timeout requires running Puma in cluster mode (WEB_CONCURRENCY >= 2); a single-process Puma cannot reboot itself.

Testing handlers

Handler tests use Rack::Test against the routes without the full middleware stack. See ruby / test framework for the test framework.

class HandlerTest < Test
  include Rack::Test::Methods

  def app
    Framework::Server.routes(db)
  end

  def sign_in
    set_cookie("remember_token=test")
  end
end

class HealthTest < HandlerTest
  def test_health
    resp = get("/health")
    ok { resp.status == 200 }
  end
end

class CompaniesShowTest < HandlerTest
  def test_show
    sign_in
    co = insert_company(name: "Acme Inc")

    resp = get("/companies/#{co.id}")

    ok { resp.status == 200 }
    ok { resp.body.include?("Acme Inc") }
  end
end

Response modes

Every handler declares which response modes it supports:

# Ajax-only (forms, partials)
class CompaniesHandler::Edit < Framework::Handler
  modes :ajax

  def handle
    render "companies/edit", data: data
  end
end

# Dual-mode (full page + ajax partial)
class CompaniesHandler::Show < Framework::Handler
  modes :html, :ajax

  def handle
    if ajax?
      render "companies/ajax_show", data: data
    else
      page "companies/show", title: data.name, data: data
    end
  end
end

# HTML-only (default; no modes declaration needed)
class DashHandler::Home < Framework::Handler
  def handle
    page "dash/home", title: "Home", data: data
  end
end

The base class enforces this. Requests for unsupported modes return 400.

See ruby / test framework for ping test coverage that guarantees every handler mode has a test.

HTTP methods

GET and POST only. HTML forms can only submit GET and POST. Two verbs simplify routing, middleware, and CSRF handling.

← All articles