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 |
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") |
Cookie-Backed Sessions
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.
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.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 |