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:
https://www.example.com.attacker.tld/x(prefix-match fool)//evil.com/x(protocol-relative)/\evil.com/x(some browsers normalize\to/, turning this into//evil.com/x)/@evil.com/x(parses as a userinfo authority)
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
Rack::Timeout25s: the outermost cap.term_on_timeout: 1asks the worker to SIGTERM itself instead of usingThread#raise, which can leave locks and connections in odd states. Puma's cluster mode reboots the worker.shutdown_debug on_force: truedumps thread backtraces if the worker has to be force-killed past the shutdown deadline.- Postgres
statement_timeout = 24_000ms: aborts a slow query cleanly beforeRack::Timeoutfires, returning a normalPG::QueryCanceledthe app can rescue. - HTTP client timeouts under 25s on every outbound call so a slow third party can't outlast the request budget.
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.