The app
The shape of an app directory, the routes in
app.rb, filters, and the life of one request.
A melee app is a directory. app.rb holds the routes and helpers; everything else is files in known places.
app.rb routes and helpers
views/ *.erb templates, layout.erb, _partials.erb
db/migrations/ NNN_name.sql, applied in name order
public/ static files, served by the server before the app sees the request
lib/ plain Ruby, loaded with require_relative
melee.toml name, server (control API URL), url
There is no App.new and no Melee.run in your code. The build step writes an entry point that loads the
generated templates and migrations, then app.rb, then starts the runtime.
app.rb
# frozen_string_literal: true
require_relative "lib/ical"
title "Kitchen"
get "/" do
redirect "/admin"
end
title(String) sets the name used by app_title (available in routes and templates). app_title returns
"" when title was never called.
Routes
get "/notes" do render :notes end
post "/notes" do redirect "/notes" end
put "/notes/:id" do redirect "/notes" end
patch "/notes/:id" do redirect "/notes" end
delete "/notes/:id" do redirect "/notes" end
Signature: get(pattern, csrf: true) { ... }, same for the other four. The block takes no arguments.
Patterns are matched segment by segment:
"/notes/:id"bindsparams[:id]to one segment."/files/*rest"bindsparams[:rest]to everything left, joined with/(""when nothing is left)."/cards/:id.:format"bindsparams[:format]to the extension without its dot andparams[:id]to the rest of the segment. The split is at the segment’s last dot, so/cards/did:plc:abc.def.vcfgivesparams[:id] == "did:plc:abc.def"andparams[:format] == "vcf".- Anything else must match literally, dots included:
"/feed.xml"matches only that path.
A .:format pattern needs an extension to match, so /cards/42 falls through to a separate "/cards/:id"
route (declare either order; they cannot both match the same path). A "/feed.:format" pattern works the
same way with a literal name in front. *rest never takes a format.
The router does not know which extensions you serve: it splits at the last dot whatever follows it. If your
identifiers can contain dots (hostnames, did:plc:... values), a plain identifier like alice.bsky.social
matches the format route with params[:format] == "social". Check the value against the formats you serve
and re-join the identifier otherwise; examples/at-here/app.rb does exactly this.
get "/cards/:id.:format" do
card = card_for(params[:id])
params.fetch(:format) == "vcf" ? text(vcard(card), type: "text/vcard; charset=utf-8") : json(card)
end
Routes are tried in declaration order; the first match wins. HEAD is answered by the matching GET route
with the body dropped. If no route matches the method but one matches the path, the reply is 405 with an
Allow header. Otherwise the not_found handler runs.
csrf: false exempts one route from the CSRF check (webhooks). See security.md.
A route can also name a durable object method instead of taking a block — get "/u/:id", to: "User#show", view: :user — which renders the view with what the method returns. See “Routing straight to an object” in objects.md.
Filters
before "/admin" do
redirect "/login" unless session[:admin]
end
before do
header "X-Frame-Options", "DENY"
end
before(prefix = "") { ... } runs before any matched route whose path starts with prefix. An empty
prefix matches every path. Filters run in declaration order, all of them, unless one calls halt or
redirect — those end the request there. Filters do not run when no route matched.
not_found and error
not_found do
"No page at #{request.path}"
end
error do |e|
"Something broke: #{e.message}"
end
not_found takes no arguments and its result is always sent as 404; a status call inside it is ignored.
error takes the exception and its result is always sent as 500. Both accept the same return values as a
route. If error itself raises, a plain 500 Something went wrong is sent.
Without an error block, production sends 500 Something went wrong and logs one line (see logging.md).
melee dev shows a trace page instead. Spinel-compiled apps have no backtraces, so the log line carries the
exception class, message, route, method and path — nothing more.
The request lifecycle
melee-server accepts the HTTP request. If it names a file under public/, the server sends that file and the
app never runs. Otherwise the server sends a request frame to a warm app process, which forks a child for
this one request; the child builds a Melee::Request, matches a route, enforces CSRF for POST/PUT/
PATCH/DELETE, runs the matching before filters, then the route block. The block’s return value becomes
the response (a String is HTML with status 200); halt and redirect unwind to the same place. If the
session was touched, a Set-Cookie header is added. The child writes one response and exits. One request per
process means params, session, db and log can be plain top-level methods with no thread safety to
think about — and it means there is no state that survives a request except the database.
What is not here
No App class or class-based DSL, no configure block, no middleware, no helpers do ... end (define plain
top-level methods), no route-level provides/condition, no mounting, and no every/later scheduling —
a Durable object’s timer covers repeating work instead (see objects.md).