Skip to content

Frankie Web Server

Frankie ships a built-in HTTP web server in the style of Sinatra / Camping. Zero external dependencies — it runs on Python's standard library http.server.


Quick Start

app = web_app()

app.get("/") do |req|
  html_response("<h1>Hello from Frankie! 🧟</h1>")
end

app.run(3000)
frankiec run myapp.fk
# 🧟 Frankie web server running on http://0.0.0.0:3000

Routes

Register routes with .get, .post, .put, .delete, .patch. Each takes a path pattern and a do |req| ... end block. The block receives a FrankieRequest and must return a response value.

app.get("/hello") do |req|
  response("Hello!")
end

app.post("/echo") do |req|
  response(req.body)
end

app.delete("/items/:id") do |req|
  response("Deleted #{req.params["id"]}")
end

Path Parameters

Use :name segments to capture parts of the URL:

app.get("/users/:id/posts/:slug") do |req|
  id   = req.params["id"]
  slug = req.params["slug"]
  response("User #{id}, post #{slug}")
end

Query Parameters

# GET /search?q=frankie&page=2
app.get("/search") do |req|
  q    = req.query["q"]
  page = req.query["page"]
  response("q=#{q} page=#{page}")
end

Request Object

Property Type Description
method String "GET", "POST", "PUT", "DELETE", "PATCH"
path String URL path, e.g. "/users/42"
params Hash Path parameters, e.g. {id: "42"}
query Hash Query-string parameters, e.g. {page: "2"}
headers Hash HTTP request headers
body String Raw request body
json Hash Body parsed as JSON, or nil if not valid JSON
form Hash Body parsed as application/x-www-form-urlencoded
cookies Hash Incoming cookies

Query Parameter Helpers (introduced in v1.15)

req.query values are always strings. These helpers parse and coerce them with a fallback default so route handlers stay clean.

Method Returns Notes
req.query_int(key, default) Integer Returns default if key is missing or not a valid integer
req.query_float(key, default) Float Returns default if key is missing or not a valid float
req.query_bool(key, default) Boolean "true", "1", "yes"true; everything else → false
app.get("/items") do |req|
  page      = req.query_int("page", 1)
  per_page  = req.query_int("per_page", 20)
  min_score = req.query_float("min_score", 0.0)
  show_all  = req.query_bool("show_all", false)

  items = db.find_all("items")
  json_response({page: page, per_page: per_page, items: items})
end

Response Helpers

Function Status Content-Type
response(body) 200 text/plain
response(body, status) custom text/plain
response(body, status, headers) custom text/plain
html_response(html) 200 text/html
html_response(html, status) custom text/html
json_response(hash) 200 application/json
json_response(hash, status) custom application/json
redirect(location) 302
redirect(location, status) custom
halt(status, body) custom text/plain

Returning a plain string from the handler is also fine — it becomes a 200 text/plain response automatically. Returning a hash or vector auto-converts to JSON.


JSON API

# Receive JSON
app.post("/api/users") do |req|
  data = req.json           # parsed hash or nil
  if data == nil
    halt(400, "Expected JSON")
  else
    name = data["name"]
    json_response({id: 1, name: name}, 201)
  end
end

# Send JSON
app.get("/api/info") do |req|
  json_response({version: "1.4", alive: true})
end

Before / After Filters

Filters run before or after every matched route.

app.before do |req|
  puts "#{req.method} #{req.path}"
end

app.after do |req, res|
  puts "  -> #{res.status}"
end

before receives the request. after receives the request and the response.


Custom 404

app.not_found do |req|
  html_response("<h1>404</h1><p>Nothing at #{req.path}</p>", 404)
end

Starting the Server

app.run(3000)           # listen on 0.0.0.0:3000 (default)
app.run(8080)           # custom port
app.run(3000, "127.0.0.1")  # localhost only

The server is multi-threaded (one thread per request) and blocks until Ctrl+C.


Cookies and Sessions (v1.13.1)

Reading and Writing Cookies

Every request exposes its cookies as a hash via req.cookies. Every response can set a cookie via resp.set_cookie(name, value, opts).

app.get("/prefs") do |req|
  theme = req.cookies["theme"] or "light"
  resp = html_response("<p>Theme: #{theme}</p>")
  resp.set_cookie("theme", theme, {max_age: 86400})
  resp
end

set_cookie options (all optional):

Option Default Description
path "/" Cookie scope path
http_only true Hide from JavaScript
max_age nil Expiry in seconds (omit for session cookie)
same_site "Lax" CSRF protection ("Strict", "Lax", "None")

session(req, resp) returns a FrankieSession — a hash-like object backed by a single JSON cookie (_fk_session). Read it, mutate it, and call .save() before returning the response. No server-side state, no database, no configuration.

app = web_app()

app.get("/counter") do |req|
  resp = response("")
  s = session(req, resp)

  count = (s["count"] or 0) + 1
  s["count"] = count
  s.save()

  html_response("You have visited #{count} time(s).")
end

app.post("/login") do |req|
  user = req.json["username"]
  resp = response("")
  s = session(req, resp)
  s["user"] = user
  s["logged_in_at"] = now()
  s.save()
  redirect("/dashboard")
end

app.get("/logout") do |req|
  resp = response("")
  s = session(req, resp)
  s.clear()
  s.save()
  redirect("/")
end

app.run()

Session API:

Method Description
s["key"] Read value (nil if missing)
s["key"] = value Write value
s.has_key?(key) Check existence
s.keys All session keys
s.delete(key) Remove one key
s.clear() Remove all keys
s.save() Write cookie to response — must be called before returning

Important: the session cookie is HttpOnly and SameSite=Lax. It is not encrypted — store user IDs, not passwords or secrets.


Concurrency (v1.14)

spawn { }

Background Blocks

spawn runs a block in a background thread and returns immediately — the rest of the handler keeps going without waiting for the block to finish. Use it for work that shouldn't delay the response: sending emails, writing logs, hitting a slow external API, processing an uploaded file.

app.post("/register") do |req|
  user = req.json

  # Respond right away — email sends in the background
  spawn do
    send_welcome_email(user["email"])
  end

  json_response({status: "registered"}, 201)
end

spawn works anywhere in a Frankie program, not just inside route handlers:

spawn do
  report = build_monthly_report(data)
  file_write("report.json", json_dump(report))
end

puts "Report building in background..."

Spawned blocks capture variables by value at spawn time. Mutations inside the block do not affect the outer scope.


timeout(n) { }

Time-Bounded Execution

timeout(seconds) runs a block and raises TimeoutError if it takes longer than n seconds. Wrap it in begin/rescue to handle the failure gracefully. Essential any time you call an external service that might hang.

app.get("/weather") do |req|
  begin
    data = timeout(5) do
      http_get("https://api.weather.example.com/current")
    end
    json_response(data)
  rescue TimeoutError
    json_response({error: "weather service unavailable"}, 503)
  end
end

Use a short timeout and a sensible fallback — a hung external call should never take your whole server down with it:

app.get("/profile/:id") do |req|
  user = db.find("users", {id: req.params["id"]})

  # Try to enrich with remote data, fall back to basics if slow
  begin
    extra = timeout(2) do
      http_get("https://profile-api.example.com/#{user["id"]}")
    end
    user["bio"] = extra["bio"]
  rescue TimeoutError
    user["bio"] = ""
  end

  json_response(user)
end

Async Routes

app.get_async

Regular routes are synchronous — one slow handler blocks others waiting in queue. Async routes are non-blocking: while one handler is waiting on I/O, another request can run. Use await inside the block to pause without blocking.

app = web_app()

# Both fetches run while other requests are served freely
app.get_async("/dashboard") do |req|
  posts  = await http_get("https://api.example.com/posts")
  alerts = await http_get("https://api.example.com/alerts")
  json_response({posts: posts, alerts: alerts})
end

app.run(3000)

All five HTTP methods have async variants:

app.get_async("/items")      do |req| ... end
app.post_async("/items")     do |req| ... end
app.put_async("/items/:id")  do |req| ... end
app.delete_async("/items/:id") do |req| ... end
app.patch_async("/items/:id") do |req| ... end

Use async routes when your handler spends most of its time waiting — on a database, an HTTP call, a file read. For CPU-heavy work, spawn is the better fit.


Middleware (v1.14)

app.use registers a middleware layer that wraps every request. Middleware receives the request and a next_fn — call next_fn.(req) to pass control to the next layer (and eventually the route handler). Return a response directly to short-circuit the chain without reaching the route.

Middleware layers run in registration order, so register them before your routes.

Logging

app.use do |req, next_fn|
  puts "→ #{req.method} #{req.path}"
  resp = next_fn.(req)
  puts "← #{resp.status}"
  resp
end

Auth guard

app.use do |req, next_fn|
  if req.path.start_with?("/admin")
    key = req.headers["X-Api-Key"]
    if key != env("API_KEY")
      halt(401, "Unauthorized")
    end
  end
  next_fn.(req)
end

app.get("/admin/users") do |req|
  json_response(db.find_all("users"))
end

Combining middleware

app = web_app()

# 1. Log every request
app.use do |req, next_fn|
  puts "#{req.method} #{req.path}"
  next_fn.(req)
end

# 2. Require a session for anything under /app/*
app.use do |req, next_fn|
  if req.path.start_with?("/app")
    resp = response("")
    s = session(req, resp)
    if s["user_id"] == nil
      redirect("/login")
    else
      next_fn.(req)
    end
  else
    next_fn.(req)
  end
end

app.get("/app/dashboard") do |req|
  html_response("<h1>Dashboard</h1>")
end

app.get("/login") do |req|
  html_response("<form method='post'><input name='user'><button>Log in</button></form>")
end

app.run(3000)

before and after filters still work alongside use — they run inside the middleware chain.


Static File Serving *(v1.14)*

app.static serves all files in a directory. The path is relative to where frankiec is run from (normally the project root).

app = web_app()

# Serve ./public/ at /  — http://localhost:3000/style.css → ./public/style.css
app.static("./public")

app.run(3000)

Serve at a URL prefix to keep static assets at a predictable path:

# http://localhost:3000/static/app.js → ./assets/app.js
app.static("./assets", "/static")

Static files are matched before routes — if both a static file and a route exist at the same path, the file wins. File types served automatically: .html, .css, .js, .json, .png, .jpg, .gif, .svg, .webp, .ico, .woff, .woff2. Directory listing is disabled — unknown paths return 404.


WebSockets (v1.18)

Hand-rolled RFC 6455 on the built-in web server — handshake, framing, ping/pong, clean close. Zero dependencies, as always.

Server — app.websocket

app = web_app()

app.websocket("/ws/:room") do |ws|
  ws.send("welcome to #{ws.params["room"]}")
  loop do
    msg = ws.recv()
    break if msg == nil      # client closed
    ws.send("echo: #{msg}")
  end
end

app.run(3000)

Each connection runs the handler in its own thread and closes automatically when the handler returns. recv() answers pings for you and returns nil when the peer disconnects.

Client — ws_connect

sock = ws_connect("ws://localhost:3000/ws/lobby", timeout: 5)
sock.send("hello")
puts sock.recv()
sock.close()

Only ws:// URLs are supported (no TLS).

WebSocket object

Method / property Description
ws.send(msg) Send a text message
ws.recv() Receive a text message — nil when the peer closes
ws.close() Close the connection
ws.params Route parameters (e.g. :room) — server side
ws.path Request path — server side
ws.peer Remote "host:port"

Web API Summary

Function / Method Description
web_app() Create a new application
app.get(path) do \|req\| end Register a GET route
app.post(path) do \|req\| end Register a POST route
app.put(path) do \|req\| end Register a PUT route
app.delete(path) do \|req\| end Register a DELETE route
app.patch(path) do \|req\| end Register a PATCH route
app.get_async(path) do \|req\| end Register a non-blocking GET route
app.post_async(path) do \|req\| end Register a non-blocking POST route
app.use do \|req, next_fn\| end Register a middleware layer
app.static(dir) Serve a directory at /
app.static(dir, prefix) Serve a directory at a URL prefix
app.websocket(path) do \|ws\| end Register a WebSocket route (v1.18)
ws_connect(url, timeout: nil) WebSocket client connection (v1.18)
app.before do \|req\| end Register a before-filter
app.after do \|req, res\| end Register an after-filter
app.not_found do \|req\| end Register a custom 404 handler
app.run(port) Start the server (blocking)
response(body, status, headers) Plain-text response
html_response(body, status) HTML response
json_response(data, status) JSON response
redirect(location, status) Redirect response
halt(status, body) Error response
req.cookies Parsed Cookie header as a hash
resp.set_cookie(name, val, opts) Append a Set-Cookie header
session(req, resp) Cookie-backed session hash
spawn { } Run a block in a background thread
timeout(n) { } Run a block with a time limit in seconds
await expr Non-blocking wait inside async routes