Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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" binds params[:id] to one segment.
  • "/files/*rest" binds params[:rest] to everything left, joined with / ("" when nothing is left).
  • "/cards/:id.:format" binds params[:format] to the extension without its dot and params[:id] to the rest of the segment. The split is at the segment’s last dot, so /cards/did:plc:abc.def.vcf gives params[:id] == "did:plc:abc.def" and params[: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).