melee
Hosting for small, single-purpose, mostly-idle Ruby apps: each one is compiled to a native binary, kept asleep until someone asks for it, and woken in about two milliseconds.
A melee app is a directory. It has an app.rb with routes in it, some .erb templates, some .sql
migrations, and a public/ folder. You push the directory; the server compiles it to a native binary and
serves it. When nobody is using it, nothing is running.
# app.rb
title "Notes"
get "/" do
render :index, notes: db.query("SELECT id, body FROM notes ORDER BY id DESC")
end
post "/notes" do
db.run "INSERT INTO notes (body) VALUES (?)", params.fetch(:body)
redirect "/"
end
melee new notes && cd notes
melee dev # http://127.0.0.1:4567
melee push # compiled, deployed, live
Where to go
| You want | Read |
|---|---|
| To know what this is and whether it fits | What melee is |
| To understand what happens when a request arrives | How a request reaches your app |
| To have written and deployed something | The tutorial |
| To look up a method | API reference |
| To solve a specific problem | The guides |
| To know what melee will not do for you | What melee cannot do |
| To run a server yourself | Operating melee |
If you are an agent writing app code, reference/llms.txt is the narrow index — the
app-facing surface and nothing else. llms.txt is this whole manual.
The state of things
melee is early. It runs, it is fast, and two real apps are deployed on it, but it is built and operated by the person who wrote it, and a few things a hosting platform normally has are missing: TLS is something you put in front, the CLI expects a checkout of this repository, and there is no recipe yet for standing a server up on a real host. What melee cannot do is the honest list, and it is worth reading before you build something on this.
The app-facing API — everything in the reference — has been through one deliberate design pass and is stable enough to write against. It is not frozen.
What melee is
A place to put the small Ruby apps that are not worth a server: compiled to native binaries, kept asleep, woken on demand, and isolated from each other by the Linux kernel.
Most of the software a person writes is small. A page that shows the family calendar on a tablet. A form that records something. A thing that checks a feed every ten minutes and tells you when it changes. These are an afternoon’s work and then they need somewhere to live, and everywhere they can live is built for software a hundred times their size — a container that is always running, a database that is always running, a bill that arrives whether or not anyone visited.
melee is for that shape of app. The unit is a directory. You push it, and it becomes a native binary that the server starts when a request arrives and stops when nobody has asked for a while. An idle app costs a directory on a disk.
What an app is
notes/
melee.toml name, which server, what URL
app.rb routes
lib/ your own Ruby, loaded with require_relative
views/*.erb templates, compiled at build time
db/migrations/*.sql applied in name order, never edited once applied
public/ files served straight from disk
app.rb is the whole configuration:
# frozen_string_literal: true
title "Notes"
get "/" do
render :index, notes: db.query("SELECT id, body FROM notes ORDER BY id DESC")
end
post "/notes" do
halt 400, "Empty note" if params.fetch(:body).empty?
db.run "INSERT INTO notes (body) VALUES (?)", params.fetch(:body)
redirect "/"
end
If that looks like Sinatra, it is meant to. The shape was chosen because it is the one people and models already know — see If you know Rails or Sinatra for where the resemblance stops.
The four ideas
One request is one process. A request is handled by a process that exists for that request and then exits. Nothing leaks from one request into the next, because there is no next request in that process. This is why every helper can be a plain top-level method with no request object threaded through it: there is only ever one request in scope. It is also why an app cannot have a global cache or a background thread — see What melee cannot do.
The app is a binary. Spinel compiles your Ruby, the melee library and
SQLite into one native executable ahead of time. That is what makes activation cost milliseconds instead of
seconds, and it is why the Ruby you write is real Ruby minus anything that decides what to call at run time:
no eval, no method_missing, no gems. The Ruby that compiles is the rule list.
Isolation comes from the kernel. Each app runs in its own mount and user namespaces, with a Landlock allow-list of the paths its binary actually needs and a seccomp allow-list of 85 syscalls — and, when the server runs as root with a uid range configured, under a Unix user of its own. The compiler is not the security boundary. This means an app can be handed Ruby written by someone you do not know and the blast radius is a process that can read its own release directory and write its own data directory.
State that outlives a request is an object. A request child cannot hold anything — it exits. So anything long-lived is a durable object: a named instance of a class you write, living in a separate long-running process, with its own SQLite database and one timer.
class Household < Durable
def setup = timer(after: 0)
def on_timer
refresh
timer after: 600
end
def refresh = storage.run("UPDATE ...")
end
Household.get("home").refresh
That timer is also melee’s answer to cron and to background jobs, because there are no threads to run them in. Background jobs and scheduled work is the whole story.
What it is not
melee does not run Rails, Sinatra, or RubyGems, and it is not trying to. It is not full CRuby: it is Ruby syntax and core classes on a different runtime, with a closed standard library. It is not a global edge network, and it is not a system of record. If your app needs a background worker fleet, a connection pool to Postgres, or a gem, it wants a real server and Rails is a fine answer.
It is worth being blunt about the stage as well: melee runs, and two real apps are deployed on it, but it has only ever been operated by the person who built it. The list of what is missing is in What melee cannot do, and you should read it before you commit to anything.
Next
- How a request reaches your app — the machinery, in order, with diagrams.
- The tutorial — an app from
melee newto deployed.
How a request reaches your app
From the browser to your route block and back: one supervisor, one warm process per app, and one short-lived child process per request.
You do not need this page to write an app. You need it when you are wondering why an instance variable did not survive, why there is no background thread, or why the app took two milliseconds to answer the first request and a fifth of a millisecond after that.
The pieces
flowchart LR
browser["browser"] -- "HTTP" --> front
subgraph host["one Linux host"]
front["melee-server<br/>terminates HTTP, routes by Host:"]
warm["warm process<br/>your binary, sandboxed<br/>one per app"]
child["request child<br/>one request, then exit"]
worker["worker process<br/>durable objects and timers"]
data[("data/<br/>SQLite")]
front -- "a frame on a free slot" --> warm
warm -- "fork" --> child
child --> data
child -- "Call / Result" --> worker
worker --> data
end
cli["melee CLI<br/>on your machine"] -- "push: a tarball" --> front
Two programs make up the platform: melee-server, which runs on the host and owns every port, and melee,
the command-line tool you run on your own machine. Your app is a third program — a binary of its own — and
the melee library is compiled into it rather than running as a service.
Deploying
melee push tars up your directory and sends it to the server’s control API. The server does not trust
anything in the tarball except the Ruby, the templates, the migrations and the files in public/; a build/
directory is refused outright, so an uploaded executable can never become the app.
flowchart TD
A["melee push<br/>tar.gz of the app directory"] --> B["new release directory"]
B --> C["compile views/*.erb to Ruby<br/>strict locals become keyword arguments"]
C --> D["check the Ruby: syntax, render calls against declared locals,<br/>migrations against a scratch database"]
D --> E["spin build<br/>Spinel compiles your app + the melee library + SQLite<br/>into one native binary"]
E -- ok --> F["swap the 'current' symlink<br/>stop the old warm process"]
E -- error --> X["422 with file:line: message<br/>nothing is deployed, the old release keeps serving"]
Two things about this are unusual and worth holding on to.
The compile is whole-program. Spinel reads your app, the library and everything they call together and infers types across the whole thing, which is why a rebuild is a few seconds rather than instant. It is also why the dialect is restricted: a program that can invent method names at run time cannot be compiled this way. It catches less than you might hope — see what a green build does not prove.
A failed build deploys nothing. The previous release keeps serving, and the diagnostics come back to your
terminal as file:line: message, mapped back to the .erb you wrote rather than the Ruby it was compiled
into.
Serving
When a request arrives for an app whose process is not running, the server starts it. That is the warm
process: your binary, sandboxed, sitting in a loop doing nothing. It is not handling requests. Its only job
is to be already initialised — classes defined, constants built, templates compiled in — so that answering a
request costs a fork rather than a program start.
sequenceDiagram
participant B as browser
participant S as melee-server
participant W as warm process
participant C as request child
B->>S: GET /notes Host: notes.example
S->>S: Host → the app "notes"; read the body (≤ 8 MB)
alt nothing running
S->>W: start the binary, sandboxed, with N sockets on fds 3..3+N
end
S->>S: take a free slot
S->>W: a Request frame on that slot
W->>C: fork
C->>C: your before filters, your route block, your template
C->>S: a Response frame, then exit(0)
S->>B: the HTTP response
fork is the Unix call that makes a copy of a process. The copy — the child — starts life with everything
the parent had: the compiled templates, the open sockets, the sandbox. Copying is cheap because the kernel
does not actually copy the memory, it just marks it to be copied if either side writes to it. The fork
itself measured about 0.09 ms in isolation on Linux; a whole warm request through the real server — HTTP,
frames, fork, router, session — is 0.21–0.27 ms.
The child handles exactly one request and exits. This is the single most important thing on this page. It is why:
params,session,requestandlogcan be top-level methods with nothing passed around — there is one request in the process, so “the current request” is unambiguous;- an instance variable, a class variable or a memoised constant set during a request is gone afterwards;
- a slow request cannot poison anything, and a crashed request cannot corrupt the next one;
- there is nowhere to run a background thread, because the process you would start it in is about to exit.
The warm process itself stays alive between requests, but your code does not run in it. It only selects on its sockets, forks, and reaps the children that finish.
Slots, and how much runs at once
The server hands the warm process a fixed number of socket pairs at start-up — four by default. A socket
pair is just two connected file descriptors, like a pipe that works in both directions; the server holds one
end and the app holds the other. One in-flight request occupies one pair, so the number of pairs is the app’s
concurrency limit. Requests beyond that queue briefly, and then get a 503 with Retry-After: 1 if the
queue is also full.
When things go wrong
| What happens | What the caller sees |
|---|---|
| The child crashes, or the sandbox kills it for a forbidden syscall | 502, within about a second |
| The request takes longer than 30 seconds | 504, and the whole process group is killed |
| Every slot is busy and more than eight requests are already queued on one | 503 with Retry-After: 1 |
fork fails because the app is at its process cap (pids.max, 256) | 503 |
| Nobody has asked for 5 minutes | The process is stopped; the next request restarts it in about 2 ms |
The timeouts and the slot count are the operator’s flags, not the app’s; see Running melee-server.
The sandbox
All of this is set up once, around the warm process, before your code runs — so every request child inherits
it through fork. On macOS none of it exists, which is why melee dev is a development tool and the real
thing is Linux.
flowchart TB
s["melee-server"] -- "clear the environment, then fork" --> w
subgraph w["between fork and exec, in the child"]
direction TB
a["a uid and gid of this app's own"]
b["user and mount namespaces"]
c["Landlock: an allow-list of paths<br/>read its own release, write its own data directory"]
d["seccomp: an allow-list of 85 syscalls<br/>anything else kills the process"]
a --> b --> c --> d
end
w -- exec --> run["your binary"]
s -- "then, from outside:<br/>put the pid in a cgroup<br/>memory.max, cpu.weight, pids.max" --> run
run -- fork --> child["request children inherit all of it"]
Four of those are applied by the child itself between fork and exec, so they are in place before a single
line of your code runs, and every request child inherits them. The environment is cleared by the server
before the fork, and the cgroup is applied by the server afterwards, by writing the new process id into it.
In one line each:
- A uid of its own. Every app gets its own Unix user id from a range the operator sets. That is what file
permissions, signals and
/procare built on, so it keeps one app out of another’s processes. This one is conditional: it needs the server to be running as root with--app-uid-range. Without that every app runs as the server’s own user, and the server says so loudly at startup. - Namespaces give the process its own view of users and of the filesystem mount table, so it is not looking at the host’s.
- Landlock is a Linux kernel feature that fences a process into a list of paths. melee grants read on the system libraries and this app’s own release, read-write on this app’s data directory, and nothing else — so even a path the uid would be allowed to read is refused.
- seccomp filters system calls. melee allows 85 — the ones a Ruby program doing files, sockets and memory
needs — and kills the process on anything else.
listenis not among them, so an app cannot turn a socket into a listener and cannot open a port of its own. (acceptis allowed, but only because the worker answers on a UNIX socket that melee-server binds and hands over; the app never creates one.) - cgroups are the kernel’s resource accounting. Each app gets a memory ceiling (128 MB by default), a CPU share, and a cap on how many processes it may have.
The two credential mechanisms overlap on purpose: the uid covers processes and signals, Landlock covers files, and getting at another tenant’s data means defeating both.
Where things are kept
<home>/apps/<name>/
releases/<id>/source/ the push
releases/<id>/bin/<name> the binary
current -> releases/<id> swapped atomically on deploy
data/ your SQLite databases and anything else you write
env the values melee env set, delivered as environment variables
<home>/logs/<name>.ndjson one JSON object per line
There is no database in the server. The filesystem is the registry, and data/ is the only place an app can
write.
The worker, for things that outlive a request
A request child cannot hold state and cannot run in the background. So melee runs a second long-lived process per app — the worker — in the same sandbox, and durable objects live there.
flowchart LR
child["request child"] -- "Call over data/worker.sock" --> worker["worker process"]
worker -- "Result" --> child
worker --> objdb[("one SQLite file per object")]
worker -- "my next timer is at T" --> server["melee-server"]
server -. "starts the worker at T, even if nothing is being requested" .-> worker
Household.get("home").refresh in a route is a call across that socket: the arguments go over as JSON, the
method runs in the worker, and the return value comes back. Handles are generated at build time from your
class, so the set of callable methods is fixed — though a misspelling still raises at run time rather than at
build time, like any undefined method. The worker runs one call at a time.
Because the worker tells the server when its next timer is due, an app with a timer wakes up on its own schedule without anyone visiting it. That is the whole of melee’s job and cron story — see Background jobs and scheduled work.
How fast, in numbers
Measured on the Linux development VM, sandboxed:
| Warm request, through HTTP, frames, fork, router and session | 0.21–0.27 ms |
| First request after a deploy or an idle stop | 1.6–2.6 ms |
| Throughput, 500 requests at 50 concurrent | 7,700 requests/s |
| Warm process resident memory, after that load | 4.8 MB |
| A whole deploy through the control API (unpack, templates, compile, activate) | 10 s |
spin build alone, after a Ruby change | about 4 s |
Next
- If you know Rails or Sinatra — the same material as a translation table.
- The tutorial — write one.
- What melee cannot do — what the process model costs you.
If you know Rails or Sinatra
What carries over, what is spelled differently, and the four habits that will not work here.
melee’s surface was deliberately shaped like Sinatra, because that is the shape people and models already know. The differences are not stylistic — each one comes from the app being compiled ahead of time and each request being its own process.
The translation table
| Rails / Sinatra | melee |
|---|---|
get "/x" do ... end (Sinatra) | the same |
params[:id] | params[:id] (String or nil) or params.fetch(:id) (String, "" when absent) |
session[:user_id] = 1 | session[:user_id] = "1" — session values are Strings |
redirect_to "/x" | redirect "/x" (always 303) |
head :not_found / halt 404 | halt 404, or halt 400, "why" |
render :index, locals: {a: 1} | render :index, a: 1 |
render partial: "row", locals: {r: r} | partial :row, r: r |
before_action :require_admin | before "/admin" do ... end — a path prefix, not a callback name |
rescue_from | error do |e| ... end |
ActiveRecord::Base | db.query "SELECT ...", bind — SQL, no ORM |
db/migrate/*.rb | db/migrations/NNN_name.sql — SQL files, applied in name order |
app/views/x.html.erb | views/x.erb |
<%= form_authenticity_token %> | <%= csrf_field %> |
Rails.logger.info "x" | log.info "x", key: value — structured, one event per call |
ENV["SECRET"] | ENV["SECRET"], set with melee env SECRET value |
public/ | public/ |
bin/rails server | melee dev |
ActiveJob / sidekiq | a durable object with a timer — see Background jobs |
whenever / cron | the same timer |
Rails.cache | the database, or a durable object |
The four habits that will not work
1. Nothing survives the request in memory
# Rails: fine. melee: the next request gets a fresh process and recomputes it.
def settings
@settings ||= db.query("SELECT * FROM settings")
end
This is not an error — the memoisation works perfectly well within one request, and that is often what you wanted. What does not happen is the second request seeing it. A process-lifetime cache, a connection pool, a class-level registry filled at boot: all of these are per-request in melee.
Where you genuinely need something to persist, that is what a durable object is.
2. There is no background anything
No threads, no Thread.new, no job queue, no after_commit running later, no fork of your own. The
request child answers and exits. Work that must happen outside a request happens in a durable object’s
timer, and work that must happen because of a request either happens during it or gets recorded for a
timer to pick up. Background jobs and scheduled work covers the patterns.
3. No gems, and no metaprogramming
The whole program is compiled ahead of time, so anything that decides what to call at run time is out:
eval, method_missing, define_method with a computed name, send with a computed name, Class.new,
ObjectSpace. Dispatch with case. The available requires are a fixed list — json, base64, digest,
securerandom, uri, net/http, openssl, set, csv, strscan, optparse, pathname, tmpdir,
forwardable — plus require_relative for your own files.
There are a few smaller surprises in the same family: string literals are frozen, there is no Date class
and no Time.parse, and a rescue clause has to name its exception class in full rather than through a
constant alias. The Ruby that compiles is the complete list with a workaround for
each.
4. Compile-time checking is real but narrower than it looks
The build catches the shape of the program, which Rails would not: a template rendered with the wrong
locals, a migration whose SQL does not parse, a require of something that is not there, a forbidden
construct like eval, a call to a top-level helper you never defined.
It does not catch wrong arity, calling a method on nil, or any undefined method reached through an
explicit receiver — db.frist(...), "x".nope and Household.get("home").refrsh all compile and raise
NoMethodError when the line runs, however well the compiler knows the receiver’s type. So a green
melee check is not the same as a working app: click through it under melee dev before you push.
What is better than you expect
- The error page. Under
melee dev, a 500 shows the exception, the request, the params, the session and a backtrace. In production there is no backtrace at all (Spinel does not have them), but there is a structured log line — see When something breaks. - SQLite is enough. It is embedded in the binary, it is on the local disk, and a query is a function call rather than a network round trip. For the size of app melee is for, this removes most of what a database usually costs you.
- Deploys are atomic and instant. A new release is a new directory and a symlink swap. A failed build changes nothing.
- You can read the whole standard library. It is a closed, documented surface — the API reference is generated from the type signatures, so it is exactly what exists.
Next
- The tutorial.
- The Ruby that compiles, if you would rather start with the rules.
Build and deploy an app
A shared notes board, from an empty directory to a deployed URL, in six steps.
By the end you will have written routes, templates, a database schema, a login and a durable object that does work on a timer, and pushed the result to a server. Every snippet here was run before it was written down.
What you need
- The
meleecommand. Today that means a checkout of the melee repository andmise run build; there is no standalone install yet (What melee cannot do explains why). - Ruby 4.0 and the
sqlite3gem, formelee dev. - For the last step only, a running
melee-serverto push to. If you do not have one, Operating melee is how to get one, and the first five steps work without it.
The app
A notes board. Anyone can see the open notes; an admin signs in with a shared secret to add them and tick them off; and a counter tracks how many people looked at the board today, rolled over at midnight by something that runs when nobody is visiting at all.
That last part is the interesting one. There are no threads in a melee app and no job queue, so “do this later” has exactly one answer, and step 5 is it.
The steps
- A new app —
melee new, what it writes, and running it. - Routes and templates — a route, a layout, a partial, and escaping.
- A database — migrations as files, queries with binds.
- Forms, sessions and a login — POST, CSRF,
session,before. - Work that happens on its own — a durable object and its timer.
- Deploying it —
melee check,melee push, logs and secrets.
The finished app is about 140 lines across ten files you write: app.rb, one file under lib/, five
templates and three migrations.
1. A new app
melee new, the nine files it writes, and the development server.
melee new notes
cd notes
created ./notes
The name has to be lowercase letters, digits and dashes, not starting with a dash, at most 40 characters —
the same rule the server applies, so a name that scaffolds is a name that deploys. melee new refuses a
directory that already exists and is not empty.
What it wrote
notes/
melee.toml which server, and what URL the app will answer at
spin.toml the compiler's manifest: this app depends on the melee library
app.rb your routes
views/layout.erb the page wrapper
views/index.erb the one page
public/app.css served straight from disk at /app.css
db/migrations/001_init.sql
.meleeignore paths melee push should not send
README.md
Nine files, and you will edit four of them. Two of the others are worth a look now.
melee.toml is the only configuration:
name = "notes"
server = "http://127.0.0.1:7070"
url = "http://notes.localhost:8080"
spin.toml tells the compiler that this app is a package which depends on the melee library. You will not
normally touch it:
[package]
name = "notes"
version = "0.1.0"
[dependencies]
melee = { path = "/path/to/melee/stdlib" }
And app.rb is a working two-route app:
# frozen_string_literal: true
title "New App"
get "/" do
render :index, greeting: greeting
end
post "/greeting" do
setting "greeting", params.fetch(:greeting)
redirect "/"
end
def greeting = setting("greeting") || "World"
Three things to notice, because they are true of every melee file:
# frozen_string_literal: trueat the top. String literals are frozen in this dialect. Build strings with+""and<<rather than mutating a literal.greetingis a plain top-level method and the route calls it with no receiver. That works because a request is handled by a process that handles nothing else, so there is no instance to hang it off and nothing to thread through.settingis a key/value store in the app’s database — handy for exactly this kind of “one value the app remembers”.
The scaffold also leaves a commented-out durable object at the bottom of app.rb. Delete it; step 5 writes a
real one.
Run it
melee dev
melee dev: prepared 2 templates, 1 migrations
melee dev: http://127.0.0.1:4567 (Ctrl-C to stop)
Open http://127.0.0.1:4567. Type a name into the box, submit, and the page greets you.
Every request prints a line:
GET / -> 200 (4.5 ms)
GET / -> 200 (0.2 ms)
GET /app.css -> 200 (public/)
GET /nope -> 404 (0.0 ms)
melee dev runs your app under ordinary CRuby, not the compiled binary. That is what makes the loop fast,
and it is also the one thing to keep in mind: CRuby will happily run Ruby that Spinel cannot compile. Step 6
runs the real compiler; until then, if something works here it might still not build.
There is no reload. Changing app.rb, a template or a migration means Ctrl-C and melee dev again. The
database lives at .melee/app.sqlite inside the app directory — delete it to start over — and environment
variables come from your shell.
Next
Routes and templates — turn the greeting into a notes board.
2. Routes and templates
A route that returns a page, a layout that wraps it, a partial it reuses, and why
<%=and<%==are different.
Replace app.rb with a notes board. The notes are hardcoded for now; step 3 puts them in a database.
# frozen_string_literal: true
title "Notes"
NOTES = ["Buy milk & bread", "Book the <dentist>", "Water the ferns"].freeze
get "/" do
render :index, notes: NOTES
end
title names the app; app_title reads it back, and the log records it. The route block takes no arguments,
and whatever it returns is the response — a String is HTML with status 200, and render returns a String.
Templates
Templates are .erb files in views/. render :index means views/index.erb.
<%# locals: (notes:) %>
<ul class="notes">
<% notes.each do |note| %>
<%== partial :note, note: note %>
<% end %>
</ul>
<p class="count"><%= notes.size %> open</p>
The first line is not a comment you can skip. Every template declares its locals, and that declaration
becomes the method signature the template is compiled into. It is what lets the build step check every
render call in your app against the template it names — a missing or misspelled local is an error at build
time, with the line in app.rb that got it wrong, rather than a NameError in front of a visitor.
(notes:) is required. (notes:, error: nil) makes error optional with a default.
A template can only see its declared locals plus the top-level helpers (params, session, h,
csrf_field, app_title and the rest). There is no implicit access to instance variables, because there is
no instance.
Partials
views/_note.erb — the underscore marks it as a partial:
<%# locals: (note:) %>
<li><%= note %></li>
Rendered with partial :note, note: note. A partial has its own declared locals and gets nothing it was not
handed.
Layout
views/layout.erb wraps every render unless you pass layout: false:
<%# locals: (content:) %>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><%= app_title %></title>
<link rel="stylesheet" href="/app.css">
</head>
<body>
<h1><%= app_title %></h1>
<%== content %>
</body>
</html>
<%= escapes, <%== does not
This is the one piece of ERB syntax that differs from what you may be used to, and it matters.
<%= value %> | HTML-escapes the value. Use it for everything that came from a person. |
<%== value %> | Inserts it raw. Use it only for HTML you produced — content in the layout, partial output, csrf_field. |
<% ... %> | Runs Ruby, prints nothing. |
Escaping is the default, so the dangerous thing is the one you have to type an extra character for. Load the page and look at the source:
<li>Buy milk & bread</li>
<li>Book the <dentist></li>
Both notes went through <%= note %>, so both are safe text rather than markup.
Routes
get "/x" post "/x" put "/x" patch "/x" delete "/x"
get "/notes/:id" do # params.fetch(:id)
get "/files/*path" do # params.fetch(:path) — the rest of the path
Two more that are worth knowing now:
not_found do "No such page" end
error do |e| "Something broke: #{e.message}" end
Static files need no route at all: anything in public/ is served straight from disk, so public/app.css
answers at /app.css.
Restart and look
melee dev has no reload, so Ctrl-C and start it again:
melee dev: prepared 3 templates, 1 migrations
melee dev: http://127.0.0.1:4567 (Ctrl-C to stop)
GET / -> 200 (0.1 ms)
Next
A database — real notes, in SQLite.
Full detail: The app and Templates.
3. A database
Migrations are
.sqlfiles, queries take?binds, and rows come back as Hashes keyed by column name.
Every app gets one SQLite database, embedded in the binary and sitting on the local disk. There is no ORM and
no connection string. db is a method, available anywhere in your app, that opens the database on first use
and applies the migrations.
Migrations are files
melee new already wrote one:
-- db/migrations/001_init.sql
CREATE TABLE notes (
id INTEGER PRIMARY KEY,
text TEXT NOT NULL,
created_at INTEGER NOT NULL
);
Add a column for ticking notes off — a new file, never an edit to the old one:
-- db/migrations/002_done.sql
ALTER TABLE notes ADD COLUMN done INTEGER NOT NULL DEFAULT 0;
The rules are short:
-
Files in
db/migrations/, applied in name order, which is why they are numbered. -
Each runs once, ever. Never edit one that has been applied — the database has no way to un-apply it. Write the next number instead.
-
They are checked at build time by running each one against a scratch database, so a typo is caught before it is deployed:
db/migrations/003_bad.sql:1: near "TABEL": syntax error
Querying
get "/" do
render :index, notes: db.query("SELECT id, text FROM notes WHERE done = 0 ORDER BY id DESC")
end
| Call | Gives you |
|---|---|
db.query(sql, *binds) | an Array of rows |
db.first(sql, *binds) | the first row, or nil |
db.run(sql, *binds) | how many rows changed |
db.exec(sql) | for DDL and PRAGMA; no binds, no rows |
db.last_id | the rowid of the last insert |
db.transaction { ... } | all of it, or none of it |
A row is a Hash keyed by column name as a String:
note["text"] # yes
note[:text] # nil — always index rows with a String
Values are whatever SQLite stored: Integer, Float, String or nil. A SQLite integer column holding a
boolean comes back as 0 or 1, so note["done"].to_i == 1 rather than if note["done"].
Writing, and binds
post "/notes" do
text = params.fetch(:text).strip
halt 400, "A note needs some text" if text.empty?
db.run "INSERT INTO notes (text, created_at) VALUES (?, ?)", text, Time.now.to_i
redirect "/"
end
post "/notes/:id/done" do
db.run "UPDATE notes SET done = 1 - done WHERE id = ?", params.fetch(:id).to_i
redirect "/"
end
Always ? placeholders, never interpolation. db.run "... WHERE id = #{params[:id]}" is a SQL injection
and nothing in the platform will stop you writing it.
Two other things in those six lines:
params.fetch(:text)returns aString,""if the field was not sent.params[:text]returnsStringornil. Every parameter value is a String — convert with.to_ior.to_fwhen you need a number.halt 400, "..."ends the request immediately with that status and body. So doesredirect, which is always a 303. Neither returns.
The form
<%# locals: (notes:) %>
<form method="post" action="/notes">
<%= csrf_field %>
<input type="text" name="text" placeholder="Something to remember" required>
<button>Add</button>
</form>
<ul class="notes">
<% notes.each do |note| %>
<%== partial :note, note: note %>
<% end %>
</ul>
<p class="count"><%= notes.size %> open</p>
<%# locals: (note:) %>
<li>
<%= note["text"] %>
<form method="post" action="/notes/<%= note["id"] %>/done">
<%= csrf_field %>
<button>Done</button>
</form>
</li>
csrf_field is not optional. Every non-GET request is checked against the session’s CSRF token, and a form
without it gets a 403. Step 4 explains what that is protecting.
Restart and try it
melee dev: prepared 3 templates, 2 migrations
GET / -> 200 (4.3 ms)
POST /notes -> 303 (0.3 ms)
POST /notes -> 400 (0.1 ms)
GET / -> 200 (0.2 ms)
Add a note with an & or a < in it and view the source — it comes back escaped, because the template used
<%= %>.
One writer
SQLite allows many readers but one writer at a time. melee runs four request processes at once by default, so two of them writing at the same moment is a real thing that happens. The database is in WAL mode, so readers never block; a second writer waits up to five seconds and then raises. It is fine for the size of app melee is for, as long as transactions stay short — Working with the database has the detail.
Next
Forms, sessions and a login — stop everyone being able to edit the board.
Full detail: Database.
4. Forms, sessions and a login
A signed cookie, a
beforefilter, and what CSRF is actually stopping.
Right now anyone who finds the board can edit it. Move the editing behind a shared secret: a public page that
lists open notes, and an /admin page that needs a sign-in.
The session
session is a Hash-like object backed by a cookie that melee signs with HMAC. The browser can read what is
in it but cannot change it without the signature failing, so it is safe for “who is this”, and wrong for
anything secret.
session[:admin] = "1"
session[:admin] # => "1"
session.delete(:admin)
session.clear
Values are Strings. Assigning an Integer stores "1"; assigning nil deletes the key. If you want a
number back out, .to_i it.
The login
get "/login" do
render :login, error: nil
end
post "/login" do
if secure_equal?(params.fetch(:secret), ENV["NOTES_SECRET"].to_s)
session[:admin] = "1"
redirect "/admin"
else
status 401
render :login, error: "That secret is wrong."
end
end
secure_equal? compares in constant time — it takes the same amount of time whether the first character is
wrong or only the last. Comparing secrets with == leaks, slowly, how much of a guess was right. Use it for
tokens, secrets and signatures; ordinary == is fine for everything else.
The secret comes from the environment, never from the source. In development that is your shell:
NOTES_SECRET=letmein melee dev
In production it is melee env NOTES_SECRET <value>, which step 6 covers.
views/login.erb:
<%# locals: (error: nil) %>
<% if error %><p class="error"><%= error %></p><% end %>
<form method="post" action="/login">
<%= csrf_field %>
<input type="password" name="secret" required autofocus>
<button>Sign in</button>
</form>
(error: nil) declares the local as optional with a default, which is why render :login, error: nil and a
hypothetical render :login would both be valid.
The filter
before "/admin" do
redirect "/login" unless session[:admin]
end
before takes a path prefix, not a list of action names. This one runs before any route whose path starts
with /admin, and because redirect never returns, a visitor without a session never reaches the route.
before with no prefix runs before everything.
That one filter is what protects all three admin routes:
get "/admin" do
render :admin, notes: db.query("SELECT id, text, done FROM notes ORDER BY done, id DESC")
end
post "/admin/notes" do
text = params.fetch(:text).strip
halt 400, "A note needs some text" if text.empty?
db.run "INSERT INTO notes (text, created_at) VALUES (?, ?)", text, Time.now.to_i
redirect "/admin"
end
post "/admin/notes/:id/done" do
db.run "UPDATE notes SET done = 1 - done WHERE id = ?", params.fetch(:id).to_i
redirect "/admin"
end
And the public page keeps only the list:
get "/" do
render :index, notes: db.query("SELECT id, text FROM notes WHERE done = 0 ORDER BY id DESC")
end
The templates that go with them
views/index.erb loses the form it had in step 3 — the public page only reads now:
<%# locals: (notes:) %>
<ul class="notes">
<% notes.each do |note| %>
<%== partial :note, note: note %>
<% end %>
</ul>
<p class="count"><%= notes.size %> open. <a href="/admin">Admin</a></p>
and views/_note.erb loses its “Done” button along with it:
<%# locals: (note:) %>
<li><%= note["text"] %></li>
views/admin.erb is new, and is where both forms now live:
<%# locals: (notes:) %>
<form method="post" action="/admin/notes">
<%= csrf_field %>
<input type="text" name="text" placeholder="Something to remember" required>
<button>Add</button>
</form>
<ul class="notes">
<% notes.each do |note| %>
<li class="<%= note["done"].to_i == 1 ? "done" : "open" %>">
<%= note["text"] %>
<form method="post" action="/admin/notes/<%= note["id"] %>/done">
<%= csrf_field %>
<button><%= note["done"].to_i == 1 ? "Reopen" : "Done" %></button>
</form>
</li>
<% end %>
</ul>
Note note["done"].to_i == 1 rather than if note["done"]: SQLite has no boolean, so the column comes back
as 0 or 1, and 0 is truthy in Ruby.
CSRF, and why every form needs csrf_field
Your session cookie is sent by the browser on every request to your app — including a request triggered by a
form on somebody else’s website. Without a check, a page anywhere could contain a hidden form posting to
https://notes.example/admin/notes and your browser would obligingly submit it, signed in.
So melee checks every POST, PUT, PATCH and DELETE that arrives with a session cookie for a _csrf
field matching a token in that session. csrf_field renders it:
<%= csrf_field %>
<input type="hidden" name="_csrf" value="WW-pl2fc9HCsnir-AoAiBQ">
A request without it gets a 403 and never reaches your route. The attacker’s page cannot read the token,
because it cannot read your cookies.
For a route that is deliberately called by something that is not a browser form — a webhook, an API endpoint authenticated some other way — exempt it explicitly:
post "/hooks/stripe", csrf: false do
# ...
end
Only do that when something else is authenticating the caller.
Try it
GET /admin -> 303 (0.1 ms) # no session, bounced to /login
GET /login -> 200 (0.3 ms)
POST /login -> 303 (0.2 ms) # signed in
POST /admin/notes -> 303 (0.2 ms)
POST /login -> 401 (0.1 ms) # wrong secret
POST /admin/notes -> 403 (0.1 ms) # no _csrf
Next
Work that happens on its own — the part with no equivalent in a normal Ruby app.
Full detail: Security and Logging people in.
5. Work that happens on its own
A durable object: state that outlives a request, and a timer that fires when nobody is visiting.
Count how many people looked at the board today, and roll the count over at midnight.
Neither half of that is possible in a request child. A counter in a variable disappears when the process exits, which is at the end of the request. And “at midnight” is work with no request behind it at all, in a platform with no threads and no job queue.
Both are what a durable object is for: a named, long-lived object that lives in a second process — the app’s worker — with its own SQLite database and one timer.
The class
# lib/stats.rb
# frozen_string_literal: true
class Stats < Durable
def setup = timer(at: midnight)
def hit
storage.run "INSERT INTO days (on_date, views) VALUES (?, 1) " \
"ON CONFLICT (on_date) DO UPDATE SET views = views + 1", today_date
nil
end
def today = storage.first("SELECT views FROM days WHERE on_date = ?", today_date)&.fetch("views").to_i
def on_timer
storage.run "DELETE FROM days WHERE on_date < ?", (Time.now - 30 * 86_400).strftime("%Y-%m-%d")
timer at: midnight
end
private
def today_date = Time.now.strftime("%Y-%m-%d")
def midnight
now = Time.now
Time.local(now.year, now.month, now.day).to_i + 86_400
end
end
And its schema, in db/objects/stats/001_days.sql — the directory name is the class name underscored:
CREATE TABLE days (
on_date TEXT PRIMARY KEY,
views INTEGER NOT NULL DEFAULT 0
);
Every Stats object gets its own copy of that table in its own file.
Calling it
require_relative "lib/stats"
get "/" do
Stats.get("visits").hit
render :index, notes: open_notes, views: Stats.get("visits").today
end
Stats.get("visits") returns a handle to the object with that id. There is no separate “create”: the object
exists from the first call, and setup runs inside it. Ids are Strings; Stats.list gives you all of them
and Stats.count how many there are.
The handle is generated at build time by reading your class: it gets one forwarding method per public method
you defined, and nothing else. Note that this does not mean a misspelling is caught at build time —
Stats.get("visits").hitt compiles and raises NoMethodError when the line runs, the same as any other
undefined method with an explicit receiver. What the generated handle does buy you is that the set of
callable methods is fixed and readable in .melee/objects.rb.
The four rules
storage persists, instance variables do not. An ivar is a cache that survives while the object happens
to be loaded and vanishes when the worker unloads it — on idle, on deploy, on restart. Anything that must
survive goes through storage, which is the same query/first/run/transaction API as db, plus a
key/value shortcut:
storage.put("last_seen", Time.now.to_i)
storage.get("last_seen")
Note storage, never db: the object has its own database, and the app’s is not for it.
Arguments and return values must be JSON-shaped. They cross a process boundary as JSON, so String,
Integer, Float, true, false, nil, and Arrays and String-keyed Hashes of those. A Time or a
Symbol raises ArgumentError at run time. That is why today returns .to_i of a column rather than the
row, and why hit ends in an explicit nil — storage.run returns a row count, which would be fine, but
being deliberate about what crosses is a habit worth having.
Parameters are positional only. No keyword arguments, no splat, no block. The build step rejects anything else with a file and line.
One call at a time, per app. The worker is single-threaded, and it hosts every durable object in the app.
A slow method on one object delays calls to every other one. Keep them quick — and note that the two calls in
the route above (hit and today) are two round trips.
The timer
def setup = timer(at: midnight)
def on_timer
# ... do the work ...
timer at: midnight
end
One pending timer per object. timer sets it, replacing whatever was there; cancel_timer clears it; and
when it fires the worker calls on_timer. There is no repeating timer — re-arming inside on_timer is how
you get one, and it means a run that fails does not silently schedule itself forever.
What makes this more than a cron line is the last piece: the worker tells the server when its next timer is due, so the server starts the app at that moment even though nobody has visited. An app that is asleep still wakes up for its own timer.
Under melee dev there is no separate worker process — the dev server runs the objects in-process and checks
for due timers about once a second, printing a line when one fires:
timer Stats/visits -> on_timer
Same code path, no socket in between.
The rest of the app
views/index.erb gains the count:
<%# locals: (notes:, views:) %>
<ul class="notes">
<% notes.each do |note| %>
<%== partial :note, note: note %>
<% end %>
</ul>
<p class="count"><%= notes.size %> open, <%= views %> views today. <a href="/admin">Admin</a></p>
Restart, load the page a few times, and the count climbs and stays climbed across restarts.
Next
Full detail: Durable objects and Background jobs and scheduled work.
6. Deploying it
melee checkcompiles it,melee pushships it, andmelee envis where the secret goes.
Everything so far ran under CRuby. The real thing is a native binary, and this is the step that finds out whether your Ruby survives the compiler.
Compile it here first
melee check
melee build: /path/to/notes/build/bin/notes (2002 KB) in 3.4 s
That is the whole app — your code, the melee library and SQLite — in a two-megabyte executable. Nothing was sent anywhere.
If it does not compile you get one line per problem, with the file and line:
views/broken.erb:2: unexpected end-of-input, assuming it is closing the parent top level context
db/migrations/003_bad.sql:1: near "TABEL": syntax error
app.rb:5: views/index.erb does not declare a local `nots`
app.rb:12: unsupported eval of a runtime string is not supported by AOT compilation (define the code statically)
Template errors are mapped back to the .erb you wrote, not the Ruby it was compiled into.
What melee check does not catch
This is the part to take seriously. A green build is not a working app.
db.qeury("SELECT 1") # compiles. 500 at run time.
Stats.get("visits").hitt # compiles. 500 at run time.
render :index, notes: n # arity and nil are not checked either
An undefined method with an explicit receiver compiles and raises NoMethodError when the line runs.
So do wrong arity and calling anything on nil. What is reliably caught is the shape of the program:
templates, migrations, forbidden constructs like eval, unknown requires, and calls to a helper you never
defined.
So: click through the app under melee dev before pushing, and read
When something breaks.
Push it
melee.toml says where:
name = "notes"
server = "http://127.0.0.1:7070"
url = "http://notes.localhost:8080"
server has to be https://, or http:// to a loopback address (127.0.0.1, ::1, localhost) as above
— anywhere else over plain http:// would send the token below in the clear, and melee refuses it.
The control API needs a token. melee looks for it in MELEE_TOKEN, or at a token_file path named in
melee.toml — which has to be outside the app directory, because melee push uploads everything in it.
melee push
using control API at http://127.0.0.1:7070
pushing notes (3 KB) to http://127.0.0.1:7070
deployed notes release 1789257455384-0000 (2002 KB binary) in 3.7s
http://notes.localhost:8080
(That 3.7 s is a local server on a development machine with a warm compiler cache. A push from a laptop to a Linux server measured 10.8 s, and the first build on a fresh machine takes about half a minute while SQLite is compiled.)
Three kilobytes went up — the source, not the binary. The server compiled it, wrote a new release directory,
swapped the current symlink and stopped the old warm process. The next request starts the new one.
A failed build answers with the same diagnostics, deploys nothing, and leaves the previous release serving.
The secret
melee env NOTES_SECRET letmein
set NOTES_SECRET for notes; the app will restart on its next request
It arrives in the app as ENV["NOTES_SECRET"]. This is the only place secrets belong: not in app.rb, not
in melee.toml, not in the tarball.
Watch it work
curl -s -o /dev/null -w "%{http_code} in %{time_total}s\n" https://notes.example/
200 in 0.315581s # cold: the server had to start the process
200 in 0.002725s # warm
200 in 0.001870s
The first request after an idle stop pays for starting the binary. After that it is a fork. On the Linux
test machine, sandboxed, that is 0.2 ms warm and 1.6–2.6 ms cold.
The other commands
melee logs # the last 50 lines
melee logs -f # follow
melee apps # what is deployed on this server
melee objects # the durable objects and their pending timers
melee restart # stop the warm process; the next request starts it again
melee open # print and open the URL
melee objects is how you check the timer you wrote in step 5 is actually scheduled:
CLASS ID CREATED LAST CALL TIMER
Stats visits 0s ago 0s ago in 23h
And the log shows the object being called, one line per event:
23:57:39 call method=hit ms=0.16 object=Stats/visits ok=true
23:57:39 call method=today ms=0.04 object=Stats/visits ok=true
When it breaks in production
There is no error page and no backtrace. A visitor gets:
Something went wrong
and the log gets the line that matters:
undefined method 'qeury' for an instance of Melee::DB class=NoMethodError method=GET path=/ route=GET /
Class, message, method, path and route — no line number, because the compiled binary has no backtrace. That
is why the log line and the route name have to be enough, and why log.info with fields is worth writing as
you go. When something breaks is the whole subject.
The whole thing
For reference, the finished app.rb — 45 lines, and the only Ruby file besides lib/stats.rb:
# frozen_string_literal: true
require_relative "lib/stats"
title "Notes"
before "/admin" do
redirect "/login" unless session[:admin]
end
get "/" do
Stats.get("visits").hit
render :index, notes: open_notes, views: Stats.get("visits").today
end
get "/login" do
render :login, error: nil
end
post "/login" do
if secure_equal?(params.fetch(:secret), ENV["NOTES_SECRET"].to_s)
session[:admin] = "1"
redirect "/admin"
else
status 401
render :login, error: "That secret is wrong."
end
end
get "/admin" do
render :admin, notes: db.query("SELECT id, text, done FROM notes ORDER BY done, id DESC")
end
post "/admin/notes" do
text = params.fetch(:text).strip
halt 400, "A note needs some text" if text.empty?
db.run "INSERT INTO notes (text, created_at) VALUES (?, ?)", text, Time.now.to_i
redirect "/admin"
end
post "/admin/notes/:id/done" do
db.run "UPDATE notes SET done = 1 - done WHERE id = ?", params.fetch(:id).to_i
redirect "/admin"
end
def open_notes = db.query("SELECT id, text FROM notes WHERE done = 0 ORDER BY id DESC")
The rest is lib/stats.rb, five templates, three .sql files and melee.toml.
Done
You have written and deployed an app with routes, templates, a database, a login, and an object that does work on a schedule with nobody watching.
Where to go next:
- Background jobs and scheduled work — the patterns for everything step 5 only sketched.
- What melee cannot do — read before the next app.
- The API reference — everything you can call.
Background jobs and scheduled work
There is no job queue and no thread. There is one durable object with one timer, and it is enough for more than it sounds like.
This is the question every new melee app runs into. Thread.new does not exist. There is no Sidekiq, no
ActiveJob, no after_commit ... later, and no cron. The build step will refuse Thread and Queue by name.
The reason is the process model: a request is handled by a process that exits when the response is written. A thread you started would be killed with it, and there is nothing left running to hand work to.
What there is: a durable object — a named, long-lived object in the app’s worker process, with its own SQLite database and one timer. The server knows when each object’s timer is next due and starts the app at that moment, even if nobody has visited for days.
flowchart LR
req["request child<br/>exits when the response is sent"] -- "record the work" --> store[("the object's storage")]
timer(["the object's timer fires<br/>server starts the app for it"]) --> work["on_timer<br/>does the work"]
work --> store
work -- "re-arm" --> timer
Decide which of four things you have
| What you have | What to do |
|---|---|
| Work that must finish before you answer | Just do it in the route. It is a process of its own; it cannot block anyone else’s request. |
| Work on a schedule (“every ten minutes”, “at 3am”) | A durable object with a timer that re-arms itself. |
| Work caused by a request that need not finish first | Record it in the route, drain it from a timer. |
| Work that must happen, exactly once, even if the machine restarts | Record it in the same transaction as the thing that caused it, then drain it. |
1. Do it in the request
The commonest right answer. A request is its own process with a 30-second budget, so a 200 ms API call or a slow query is not hurting anyone else. There is no shared runtime to protect.
post "/subscribe" do
res = HTTP.post("https://api.example/subscribe", body: params.fetch(:email), timeout: 5)
halt 502, "Upstream is unhappy" unless res.ok?
redirect "/thanks"
end
The limits to respect: the request times out at 30 seconds (the process group is killed and the caller gets a 504), and your app runs four requests at a time by default, so a slow route does eat a slot.
2. A schedule
class Refresher < Durable
def setup = timer(after: 0) # run once, now, when the object is first created
def on_timer
refresh
timer after: 600 # then every ten minutes
end
def refresh
body = Melee::HTTP.get("https://example.com/feed.json", timeout: 10).body
storage.put("feed", JSON.parse(body))
storage.put("refreshed_at", Time.now.to_i)
rescue Melee::HTTP::Error => e
log.warn "refresh failed", error: e.message
end
def feed = storage.get("feed")
end
get "/" do
render :index, feed: Refresher.get("main").feed
end
Four things about this shape are deliberate:
on_timerre-arms. There is no repeating timer. That is a feature: a run that raises does not schedule the next one, so a broken job stops rather than hammering. If you want it to keep trying regardless, re-arm first and do the work after, or re-arm in anensure.- The rescue is inside. An exception out of
on_timeris logged by the worker, but the timer is gone. Catch what you expect. - State goes through
storage. An instance variable survives only while the worker keeps the object loaded, which it stops doing on idle, deploy or restart. - The route reads, the timer writes. Visitors get a cheap read of something already fetched. This is the main reason to reach for a timer at all.
For “at 3am”, compute the next occurrence rather than counting seconds:
def on_timer
do_the_nightly_thing
timer at: next_3am
end
def next_3am
now = Time.now
today = Time.local(now.year, now.month, now.day).to_i + 3 * 3600
today > now.to_i ? today : today + 86_400
end
There is no Date and no Time.parse in this dialect — Time.local, Time.at, Time.now and strftime
are what you have.
3. A queue
When a request should cause work that need not block the response, write the work down and let a timer drain
it. The object’s storage is a SQLite database; a queue is a table.
-- db/objects/outbox/001_queue.sql
CREATE TABLE jobs (
id INTEGER PRIMARY KEY,
kind TEXT NOT NULL,
payload TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
class Outbox < Durable
MAX_ATTEMPTS = 5
def setup = timer(after: 0)
def push(kind, payload)
storage.run "INSERT INTO jobs (kind, payload, created_at) VALUES (?, ?, ?)",
kind, JSON.generate(payload), Time.now.to_i
timer after: 1
nil
end
def on_timer
drain
timer after: pending.zero? ? 60 : 5
end
def pending = storage.first("SELECT COUNT(*) AS n FROM jobs")["n"].to_i
private
def drain
storage.query("SELECT id, kind, payload, attempts FROM jobs ORDER BY id LIMIT 20").each { |job| run_one(job) }
end
def run_one(job)
deliver(job["kind"].to_s, JSON.parse(job["payload"].to_s))
storage.run "DELETE FROM jobs WHERE id = ?", job["id"].to_i
rescue StandardError => e
attempts = job["attempts"].to_i + 1
log.warn "job failed", id: job["id"], kind: job["kind"], attempts: attempts, error: e.message
if attempts >= MAX_ATTEMPTS
storage.run "DELETE FROM jobs WHERE id = ?", job["id"].to_i
log.error "job given up", id: job["id"], kind: job["kind"]
else
storage.run "UPDATE jobs SET attempts = ? WHERE id = ?", attempts, job["id"].to_i
end
end
def deliver(kind, payload)
case kind # `case`, not send: nothing dispatches on a computed name here
when "welcome" then Melee::HTTP.post("https://api.example/mail", body: JSON.generate(payload), timeout: 10)
else log.warn "unknown job kind", kind: kind
end
end
end
From a route:
post "/signup" do
db.run "INSERT INTO users (email) VALUES (?)", params.fetch(:email)
Outbox.get("main").push("welcome", { "email" => params.fetch(:email) })
redirect "/thanks"
end
Note timer after: 1 inside push: the object pulls its own timer forward so a new job is picked up in about
a second rather than at the next scheduled sweep.
The honest caveat. push writes to the object’s database and the user row went into the app’s
database. Those are two files and two transactions, so a crash between them can lose the job. If exactly-once
matters, keep both sides in one database — put the users table in the object’s storage too, and write the
row and the job in a single storage.transaction.
4. Retries and idempotence
Anything a timer does can run twice: the worker can be killed mid-call, the object reloaded, the timer fired
again. Make the work idempotent — a DELETE after success, an upsert keyed on something stable, an external
call with an idempotency key — rather than assuming it happens once.
What this costs you
Be clear-eyed about the ceilings before you build something big on it.
- One call at a time, per app. The worker hosting every durable object in the app is single-threaded. A job that takes ten seconds delays every other object call in that app for ten seconds, including the ones a route is waiting on. Keep durable methods short; do the slow part in chunks across timer fires if you have to.
- One pending timer per object. Not one per job. If you need several schedules, that is several objects — they each get their own timer and their own database.
- No
laterand noevery. Both are named as future work; today you write the re-arm yourself. - No fan-out. Nothing runs two jobs in parallel.
- Timer precision is “soon after”, not “on the tick”. The server starts the app for the timer; a fire is not to-the-millisecond, and an app under a deploy or a restart picks its timer up afterwards.
- A timer keeps the app awake. An object with a 5-second timer means the app is started every 5 seconds, forever, which is the opposite of melee’s whole economics. Prefer minutes.
Things that are not the answer
Thread.new, Queue | Refused by the build step, with a message pointing here. |
fork in app code | The runtime forks; you do not. The sandbox caps process count. |
sleep in a route | Burns a slot and counts against the 30-second timeout. |
| An external cron hitting a URL | Works, and is a reasonable escape hatch — but it needs something outside melee to be reliable, and the timer already wakes an idle app. |
| A long-running loop in the warm process | Your code does not run in the warm process at all. |
See also
- Durable objects — the full API:
setup,destroy, migrations, the JSON-shape rules,melee objects. - Working with the database — transactions and the one-writer rule.
- What melee cannot do.
Structuring a larger app
app.rbfor routes,lib/for everything else, plain top-level methods for helpers, andcasewherever you would have reached for metaprogramming.
A melee app has no autoloader and no app/models. It has require_relative, ordinary Ruby files, and a
compiler that reads the whole program at once. That is less than Rails gives you and more than it sounds.
The layout that works
app.rb routes and filters only
lib/
household.rb a durable object
ical.rb a module of pure functions
formatting.rb helpers used by templates
views/
layout.erb
admin.erb
_events.erb
db/
migrations/001_schema.sql
objects/household/001_feeds.sql
public/
test/
ical_test.rb
app.rb requires what it needs, at the top, with require_relative:
# frozen_string_literal: true
require_relative "lib/household"
require_relative "lib/formatting"
title "Kitchen"
get "/" do
render :display, events: Household.get("home").day_events(Time.now.to_i)
end
There is no autoloading. Every file requires what it uses, including the standard-library modules —
require "json" in the file that calls JSON.parse, not just in app.rb. A missing require is not a
NameError you can read; it turns into an opaque “unsupported call” from the compiler.
Helpers are top-level methods
# app.rb, below the routes
def current_user = session[:user_id] && db.first("SELECT * FROM users WHERE id = ?", session[:user_id].to_i)
def admin? = session[:admin] == "1"
They work because a request is a process of its own, so session and db mean one unambiguous thing. There
is no controller instance to hang them on and nothing to pass around.
Helpers a template needs must also be top-level — a template can see its declared locals and the top-level
surface, nothing else. Putting them in lib/formatting.rb as top-level defs and requiring the file is
fine:
# lib/formatting.rb
# frozen_string_literal: true
def money(pennies) = format("£%.2f", pennies.to_i / 100.0)
def short_date(unix) = Time.at(unix.to_i).strftime("%-d %b")
Modules of pure functions
For anything with real logic, a module with module functions keeps it testable and keeps the compiler happy:
# lib/ical.rb
# frozen_string_literal: true
module ICal
DAY = 86_400
class Error < StandardError; end
def self.parse(text)
# ... returns a calendar
end
def self.expand(calendar, from, to)
# ... returns the occurrences between two Times
end
end
Call it as ICal.expand(...). See Testing an app for why this shape is the one that is easy to
test.
Classes, with the metaprogramming taken out
Plain classes are fine. What is not available is anything that decides what to call at run time.
# no
def handle(kind) = send("handle_#{kind}")
# yes
def handle(kind)
case kind
when "note" then handle_note
when "event" then handle_event
else raise ArgumentError, "unknown kind #{kind}"
end
end
The same goes for a Hash or Array of Procs — a Proc reached out of a container cannot be called in a
compiled app. A case is the dispatch mechanism. It is more typing and it is readable by both the compiler
and the next person.
Three more habits that keep large files compiling:
- Spell a rescued class in full.
rescue Melee::HTTP::Error, neverrescue HTTP::Error— a rescue through a constant alias silently fails to match in a compiled app, while working perfectly undermelee dev. - Frozen string literals at the top of every file; build strings with
+""and<<. - Do not nest a yielding call inside another block, and call yielding methods on a method rather than a
constant (
db.transaction { }, notDB.transaction { }).
The Ruby that compiles is the full list.
Where state lives
| Lifetime | Where |
|---|---|
| One request | local variables, instance variables — everything is thrown away at the end |
| Between requests, small and settable by the app | setting("key") / setting("key", value) |
| Between requests, structured | the app database, db |
| Between requests, owned by one long-lived thing, plus a timer | a durable object’s storage |
| Set from outside, secret | ENV["..."], written with melee env |
There is no process-lifetime cache. A constant computed at the top level of app.rb is computed once, in
the warm process, and inherited by every request child through fork — so a frozen lookup table as a
constant is genuinely free. Anything computed during a request is not.
COUNTRIES = { "gb" => "United Kingdom", "fr" => "France" }.freeze # built once, inherited by every request
When the app gets big
A melee app is meant to be small; if it is growing past a few thousand lines, the constraints start to bite (single worker, one SQLite writer, no gems). That is the point where the honest answer may be that this app belongs on a normal server — see What melee cannot do.
See also
Logging people in
A signed cookie, a constant-time compare, and three patterns that cover most small apps. There is no user system in the box.
melee gives you a signed session, a CSRF check, secure_equal? and random_token. Everything above that —
users, passwords, OAuth, passkeys — you write, or you do not need.
What the session is
A cookie named melee_session: base64url JSON plus an HMAC-SHA256 signature, keyed on a secret the server
generates per app. HttpOnly, SameSite=Lax, and Secure when the request arrived over HTTPS. A tampered
cookie is treated as no session at all.
session[:user_id] = "42" # values are Strings; an Integer is stored as "42"
session[:user_id] # => "42"
session.delete(:user_id)
session.clear
Signed is not encrypted. The visitor can read everything in their own session. Put an id or a flag there; never a password, an API token, or anything you would not show them. Keep it small — the cookie travels on every request, and browsers drop cookies over about 4 KB.
Pattern 1: one shared secret
The right answer for an admin page on a personal app. This is what the tutorial builds.
post "/login" do
if secure_equal?(params.fetch(:secret), ENV["ADMIN_SECRET"].to_s)
session[:admin] = "1"
redirect "/admin"
else
status 401
render :login, error: "That secret is wrong."
end
end
before "/admin" do
redirect "/login" unless session[:admin]
end
post "/logout" do
session.clear
redirect "/"
end
secure_equal? takes the same time whether the guess was wrong in the first character or the last, so it
does not leak how close an attacker is getting. Use it for every secret, token and signature comparison;
== is fine for everything else.
The secret lives in the environment, set with melee env ADMIN_SECRET <value> and read as
ENV["ADMIN_SECRET"]. Never in app.rb, never in melee.toml — melee push uploads the whole directory.
Pattern 2: a long-lived URL token
For a device that cannot type a password — a wall tablet, a TV, a shared display.
get "/d/:token" do
halt 404 unless secure_equal?(params.fetch(:token), display_token)
header "Cache-Control", "no-store"
render :display, layout: false, events: todays_events
end
post "/admin/rotate" do
setting "display_token", random_token
redirect "/admin"
end
def display_token = setting("display_token") || setting("display_token", random_token)
random_token gives a URL-safe token with 24 bytes of entropy by default, which is 32 characters of
base64 — pass a number for more. setting stores it in the app database. halt 404 rather than 403 so the
URL does not confirm it exists.
Give the holder a way to rotate it, and treat the URL as the credential it is — it will end up in a browser history and a screenshot.
Pattern 3: accounts with passwords
Possible, and the place where melee’s constraints bite hardest. Read this whole section before deciding to have user accounts at all.
There are no gems, so no bcrypt, argon2 or scrypt. Worse, OpenSSL::PKCS5 does not exist in a compiled
app, so the obvious fallback does not work either:
# Works under `melee dev`. Raises NoMethodError in the deployed binary.
OpenSSL::PKCS5.pbkdf2_hmac(password, salt, 600_000, 32, OpenSSL::Digest.new("SHA256"))
That is the worst shape a bug can have on this platform — right in development, broken in production — so it
is worth stating plainly. OpenSSL::Digest.new(...) and OpenSSL::Digest::SHA256.new are missing too.
What is available in both runtimes is OpenSSL::HMAC.digest("SHA256", key, data), and PBKDF2 is a short
loop over it:
# lib/password.rb
# frozen_string_literal: true
require "openssl"
require "base64"
ITERATIONS = 200_000
# PBKDF2-HMAC-SHA256, one 32-byte block, written out because OpenSSL::PKCS5 is not available in a compiled
# app. Produces byte-identical output to OpenSSL's own PBKDF2 for the same inputs.
def pbkdf2(password, salt, iterations = ITERATIONS)
block = OpenSSL::HMAC.digest("SHA256", password, salt + "\x00\x00\x00\x01")
acc = block.bytes
i = 1
while i < iterations
block = OpenSSL::HMAC.digest("SHA256", password, block)
b = block.bytes
j = 0
while j < acc.size
acc[j] ^= b[j]
j += 1
end
i += 1
end
Base64.urlsafe_encode64(acc.pack("C*"), padding: false)
end
Measured in a compiled binary: 1,000 iterations in 2.4 ms, 10,000 in 18 ms, 100,000 in 106 ms. So 200,000 iterations costs roughly a fifth of a second per sign-in. Measure it on your own machine rather than trusting that.
post "/signup" do
salt = random_token(16)
db.run "INSERT INTO users (email, salt, hash) VALUES (?, ?, ?)",
params.fetch(:email), salt, pbkdf2(params.fetch(:password), salt)
redirect "/login"
end
post "/login" do
user = db.first("SELECT id, salt, hash FROM users WHERE email = ?", params.fetch(:email))
if user && secure_equal?(pbkdf2(params.fetch(:password), user["salt"].to_s), user["hash"].to_s)
session[:user_id] = user["id"].to_s
redirect "/"
else
status 401
render :login, error: "Wrong email or password."
end
end
Three things to be honest about:
- PBKDF2 is acceptable, not excellent. A memory-hard function would be better and is not available at any iteration count.
- Those iterations burn CPU inside a request that holds one of the app’s four slots, under a 128 MB cap.
- You are hand-rolling a password hash, which is not a sentence anyone enjoys writing.
If an app matters enough to need real accounts, consider putting an identity provider in front of it, or building it somewhere that has bcrypt.
CSRF
On by default for POST, PUT, PATCH and DELETE that arrive with a session cookie. Every form needs
<%= csrf_field %>; a request without it gets a 403 before any filter or route runs. For JavaScript, put
csrf_token in an X-CSRF-Token header.
Exempt a route only when something else authenticates the caller:
post "/hooks/stripe", csrf: false do
signature = request.header("Stripe-Signature").to_s
halt 400 unless secure_equal?(signature, expected_signature(request.body))
# ...
end
Getting the basics right
- Put TLS in front. melee-server speaks plain HTTP. A session cookie over HTTP is readable by anyone on
the path, and the
Secureflag is only set when the request arrived over HTTPS. See Keeping it safe. - Do not put roles in the session and trust them forever. The cookie lasts 30 days; check against the database on the requests that matter.
- Rate-limit by hand if you need it. Nothing throttles login attempts for you. A counter in
settingor a durable object is the tool. haltandredirectdo not return, soredirect "/login" unless session[:admin]in abeforefilter really does stop the request.
See also
- Security — the full surface.
- Reading the request — headers, cookies, the raw body.
Talking to other services
HTTP.getandHTTP.post, with timeouts that matter more than usual, and no way to make two calls at once.
HTTP is what you should use. net/http is on the require allow-list and Melee::HTTP is a thin client
over it, so you can reach for it directly — but the wrapper exists because two Spinel traps sit in the
obvious way of calling it: setting a header with req[k] = v and the block form of Net::HTTP.start both
lose their static types and misbehave in a compiled app. The wrapper also gives you timeouts, redirect
following and rescuable error classes. Use it.
res = HTTP.get("https://api.example/things", query: { "page" => "2" }, timeout: 5)
halt 502 unless res.ok?
things = res.json
HTTP is Melee::HTTP; the two spellings are the same for a call. They are not the same in a rescue
clause — see below.
The shape of it
HTTP.get(url, query: {}, headers: {}, timeout: 10, raise_on_error: false)
HTTP.post(url, body: "...", query: {}, headers: {}, timeout: 10, raise_on_error: false)
The response has status, headers, body, url (after redirects), ok? (2xx), json and
header(name).
There is no put and no delete, and no request signing. That rules out talking to most object stores
today; it is a known gap.
Timeouts
timeout covers the connection and the read, in seconds, and it is the single most important argument here.
Two reasons:
- A request that hangs holds one of your app’s four slots for up to 30 seconds and then gets killed, taking the visitor’s request with it.
- A durable object’s call blocks the app’s whole worker while it runs, including calls from other objects.
timeout is rounded up to a whole number of seconds, so timeout: 0.5 is the same as timeout: 1 — there
is no sub-second timeout. Set it low and deliberately:
HTTP.get(url, timeout: 3) # a request is waiting on this
HTTP.get(url, timeout: 20) # a timer is doing it; nobody is waiting
Always rescue
An unrescued failure becomes a 500 for the visitor.
begin
res = HTTP.get(url, timeout: 5)
parse(res.body)
rescue Melee::HTTP::TimeoutError
log.warn "upstream slow", url: url
halt 504
rescue Melee::HTTP::Error => e
log.warn "upstream failed", url: url, error: e.message
halt 502
end
Write Melee::HTTP::Error in full. A rescue clause that reaches its class through a constant alias —
rescue HTTP::Error — does not match in a compiled app, while matching perfectly under melee dev. So the
app looks correct in development and does not rescue at all in production. melee check rejects the short
spelling and names the line, which is the one place this trap is caught for you.
The classes: Melee::HTTP::Error is the parent; Melee::HTTP::TimeoutError and
Melee::HTTP::TooManyRedirects (after five) inherit from it. A 4xx or 5xx is not an error unless you ask
for raise_on_error: true — otherwise check res.ok?.
TLS
HTTPS verifies certificates and there is no way to turn that off. That is deliberate.
Doing it once instead of every request
The pattern that makes a slow upstream stop mattering: fetch on a timer, read from storage.
class Feed < Durable
def setup = timer(after: 0)
def on_timer
fetch
timer after: 600
end
def fetch
res = Melee::HTTP.get("https://example.com/feed.json", timeout: 20)
storage.put("items", res.json)
storage.put("fetched_at", Time.now.to_i)
rescue Melee::HTTP::Error => e
log.warn "feed failed", error: e.message # keep the last good copy
end
def items = storage.get("items") || []
def stale? = Time.now.to_i - storage.get("fetched_at").to_i > 3600
end
get "/" do
render :index, items: Feed.get("main").items, stale: Feed.get("main").stale?
end
The visitor’s request never touches the network. A failed fetch leaves the previous copy in place, and
stale? lets the page say so. Background jobs and scheduled work is the fuller
treatment.
No concurrency
There is no way to make two outbound requests at the same time. No threads, no fibers you can schedule, no
Async. Ten feeds means ten sequential fetches — which is fine on a timer and not fine in a request.
What to watch out for
- Nothing stops an app calling a private address today. If you fetch a URL a visitor supplied, you are
one step from a request against
169.254.169.254or a service on the host’s network. Validate the host yourself, and prefer an allow-list. (Refusing private addresses by default is a known open task.) - A response body is a String in memory, inside a 128 MB cap. Do not fetch something enormous.
- Redirects are followed up to five times, and
res.urltells you where the body actually came from.
See also
- Outbound HTTP — the full reference.
- The Ruby that compiles — the alias-in-rescue rule and the rest.
Static files and assets
Put files in
public/. There is no asset pipeline, no fingerprinting, and no file uploads.
public/
app.css -> /app.css
favicon.svg -> /favicon.svg
robots.txt -> /robots.txt
img/logo.png -> /img/logo.png
Anything under public/ is served straight from disk by melee-server, before any route is considered — for
GET and HEAD. A POST to a path under public/ still goes to your app, so a route can shadow a file for
one method and not the other. No route declaration, no helper, no manifest. The same files are served by
melee dev, which prints them differently so you can tell:
GET /app.css -> 200 (public/)
Reference them with plain paths:
<link rel="stylesheet" href="/app.css">
<img src="/img/logo.png" alt="">
What the server sends
HTTP/1.1 200 OK
content-type: text/css; charset=utf-8
cache-control: public, max-age=300
server: melee
content-length: 182
Content type from the extension, and a flat five-minute cache. That is deliberately conservative: there is no fingerprinting, so a longer cache would mean visitors stuck on an old stylesheet after a deploy. Better cache headers for content-addressed assets is a known open task.
If you want a long cache today, do the fingerprinting yourself — name the file app-7f3a.css, reference it
by that name, and change both when it changes. You still only get max-age=300, so this buys correctness
rather than speed.
There is no build step for assets
No Sprockets, no esbuild, no Tailwind CLI, no import. If you want compiled CSS or bundled JavaScript, run
the tool on your own machine and commit the output into public/ — melee push sends whatever is there.
Keep node_modules/ and source files out of the tarball with .meleeignore:
node_modules/
src/
*.scss
For most apps of this size, one hand-written stylesheet is genuinely the right answer.
There are no file uploads
This is the significant gap on this page. A melee app cannot usefully accept a file today:
request.formhandles urlencoded bodies only. Multipart is not parsed. A file input posts multipart, and the fields come back empty.- The whole request body is capped at 8 MB by the server.
- An app can only write to its own data directory, which is on the machine’s local disk and is not backed up for you.
- There is no object-storage connector and no request signing, so you cannot hand the bytes to S3 either.
If you need uploads, the honest options are to put them somewhere else (a form that posts directly to a service that accepts browser uploads) or to use a different platform for that app. An object storage connector with presigned URLs is designed but not built.
Serving a file from a route
You can always return bytes yourself, for something generated rather than stored:
# at the top of app.rb
require "csv"
get "/notes.csv" do
out = +""
out << CSV.generate_line(%w[id text done])
db.query("SELECT id, text, done FROM notes ORDER BY id").each do |row|
out << CSV.generate_line([row["id"], row["text"], row["done"]])
end
header "Content-Disposition", "attachment; filename=\"notes.csv\""
text(out, type: "text/csv; charset=utf-8")
end
Note +"" and <<: string literals are frozen in this dialect, so building a String starts with an unfrozen
one.
The response is collected in memory before it is sent — streaming is passed through as a whole today — so this is for kilobytes and megabytes, not gigabytes, and it runs under the 30-second request timeout.
See also
- Writing the response —
text,json,html,header,status. - What melee cannot do.
Working with the database
One SQLite file per app, on the local disk, with one writer at a time. Migrations are files; rows are Hashes keyed by String.
db.query("SELECT id, text FROM notes WHERE done = 0 ORDER BY id DESC")
db.first("SELECT COUNT(*) AS n FROM notes")["n"]
db.run("UPDATE notes SET done = 1 WHERE id = ?", id)
db.transaction { ... }
db.last_id
db.exec("PRAGMA optimize")
db is a method, never a constant. Melee.db.transaction do ... end and db.transaction do ... end both
work; DB.transaction do ... end on a constant does not compile.
Rows
A row is a Hash keyed by column name as a String, with SQLite’s own types as values — Integer,
Float, String or nil.
row["text"] # yes
row[:text] # nil, always
row["done"].to_i == 1
There is no type mapping and no boolean: a column holding 0/1 comes back as 0/1. Narrow explicitly at
the edges — .to_i, .to_s, .to_f — which is the same habit params needs, and which the compiler likes
because it makes the type of every value obvious.
Name your columns in the SELECT rather than using *. It is what makes the row shape readable to you, to
the compiler and to whoever reads the template.
Binds, always
db.run "INSERT INTO notes (text, created_at) VALUES (?, ?)", text, Time.now.to_i # yes
db.run "DELETE FROM notes WHERE id = #{params[:id]}" # SQL injection
? placeholders are never parsed as SQL. Nothing in the platform stops you interpolating, so this is a
habit, not a guard rail.
Migrations
Files in db/migrations/, applied in name order, once each, on the database’s first use.
db/migrations/001_schema.sql
db/migrations/002_done.sql
db/migrations/003_index_on_date.sql
- Never edit an applied migration. Write the next number.
- They are checked at build time against a scratch database, so SQL that does not parse fails the build with the file and line.
- A durable object’s schema lives separately, in
db/objects/<class_name>/NNN.sql, and is applied per object.
SQLite’s ALTER TABLE is limited — you can add a column, rename, and drop, but not change a type or add a
constraint. The usual workaround is a new table, a INSERT INTO ... SELECT, and a rename, in one migration.
The one-writer rule
This is the thing to understand about melee’s storage.
The database runs in WAL mode (synchronous=NORMAL, foreign_keys=ON, a 5-second busy timeout). WAL means
readers never block — a SELECT runs happily during a write. But there is one writer at a time, and your
app runs four requests at once by default, so contention is real.
A second writer waits up to five seconds and then raises. So:
# no: the HTTP call holds the write lock for as long as the network takes
db.transaction do
res = HTTP.get(url, timeout: 10)
db.run "UPDATE feeds SET body = ? WHERE id = ?", res.body, id
end
# yes: do the slow part first, hold the lock for microseconds
res = HTTP.get(url, timeout: 10)
db.transaction do
db.run "UPDATE feeds SET body = ? WHERE id = ?", res.body, id
end
db.transaction uses BEGIN IMMEDIATE, so it takes the write lock at the start rather than failing halfway
through. Do not nest transactions.
Rules of thumb: keep transactions to the writes; never do I/O, parsing or template rendering inside one; and if you are writing on every request, think about whether that write belongs in a durable object on a timer instead.
setting, for one-off values
setting("display_token") # -> String or nil
setting("display_token", random_token) # writes and returns it
A small key/value table the platform creates for you. Good for “the app remembers one thing”; not a cache and not a substitute for a column.
Two databases, not one
db | the app’s database, app.sqlite in the app’s data directory. Shared by every request child. |
storage | one SQLite file per durable object, reachable only from inside that object. |
They are separate files with separate transactions. A write to each is not atomic across both — if two things must commit together, put them in the same database. Background jobs has the worked version of this trap.
Backups
There is no backup. The database is a file in the app’s data directory on one machine’s local disk, and nothing copies it anywhere. Until replication exists, backing it up is the operator’s job — see Logs, backups and upgrades.
For an app you care about, a durable object on a daily timer that writes a copy of the important rows
somewhere else is a reasonable stopgap, bearing in mind that HTTP has no put and no request signing yet.
Performance
- A query is a function call into SQLite in the same process. There is no network hop and no connection pool, so “too many queries” costs much less here than in a Rails app.
- Add the indexes you need in a migration.
EXPLAIN QUERY PLANworks throughdb.query. - The whole database is on local disk with a 128 MB memory cap on the process. Tens of megabytes of data are comfortable; tens of gigabytes are not what this is for.
See also
- Database — the full reference.
- Durable objects —
storageand object migrations.
Testing an app
There is no test framework. There is a script that runs your code under CRuby and under the compiler and fails if the two disagree.
No minitest, no RSpec, no gems. What melee’s own example apps do — and what works well — is a plain Ruby file that prints what it observed, a committed file of expected output, and a runner that diffs them and then compiles the same file with Spinel and diffs that against CRuby.
That second diff is the part worth having. It is what catches the dialect traps that make an app behave one
way under melee dev and another way deployed.
The shape
test/
run.sh
ical_test.rb
expected/ical.txt
fixtures/family.ics
# test/ical_test.rb
# frozen_string_literal: true
require_relative "../lib/ical"
cal = ICal.parse(File.read(File.expand_path("fixtures/family.ics", __dir__)))
events = ICal.expand(cal, Time.local(2026, 9, 1), Time.local(2026, 9, 8))
puts "events: #{events.size}"
events.each { |e| puts "#{e.starts_at.strftime("%Y-%m-%d %H:%M")} #{e.summary}" }
No assertions, no framework: it prints. The expected file is the assertion.
#!/usr/bin/env bash
# lib/ical.rb: the expansion must match test/expected/ical.txt, and CRuby and Spinel must agree.
set -euo pipefail
cd "$(dirname "$0")/.."
SPINEL="${SPINEL:-../../vendor/spinel/bin/spinel}"
OUT=$(mktemp -d)
export TZ=Europe/London
ruby test/ical_test.rb > "$OUT/cruby.out"
diff test/expected/ical.txt "$OUT/cruby.out"
"$SPINEL" test/ical_test.rb -o "$OUT/bin" && "$OUT/bin" > "$OUT/spinel.out"
diff "$OUT/cruby.out" "$OUT/spinel.out" && echo IDENTICAL
export TZ matters: anything touching Time.local is machine-dependent otherwise.
Add a --bless flag that copies the new output over the expected file, and make reading
git diff test/expected/ part of accepting a change. The diff is the review.
What to test this way
Pure functions. Parsing, formatting, expansion, calculation — anything in lib/ that takes values and
returns values. This is the strongest argument for
keeping logic in modules rather than inside route blocks: a route
block cannot be tested this way, and a module function can.
Anything with a dialect risk. Time arithmetic, string building, regular expressions, JSON round trips,
anything using Struct or pattern matching. These are exactly where CRuby and the compiler can differ, and
the parity diff is the only thing that will tell you.
What you cannot test this way
- Routes. There is no test client, no
get "/"helper, no way to drive the router in-process. Checking routes meansmelee devand a browser orcurl. - Templates. They are compiled by the build step; a test file cannot render one.
- Durable objects. They need a worker, or the dev server’s in-process stand-in.
- The database, unless you open one yourself, which means the CRuby half needs the
sqlite3gem and the Spinel half does not use the same backend.
That is a real gap, and it means the practical test strategy for a melee app is: pure logic under the parity
runner, everything else by clicking through melee dev and reading the log.
The other three checks
ruby -wc app.rb lib/*.rb # syntax and warnings, instantly
melee check # the full compile: templates, migrations, dialect, the build
melee dev # click through it
melee check is not a test. It proves the program compiles, which as
the tutorial explains does not prove a misspelled
method is absent. Run all three.
In CI
There is nothing melee-specific. A CI job needs Ruby, the sqlite3 gem and a built Spinel; then it runs
test/run.sh and melee check. Since the compiler is pinned to a commit and built from source, most of a CI
setup is caching that build.
See also
When something breaks
There are no backtraces in a deployed app. The log line and the route name have to be enough, so write them as you go.
Three places things go wrong, and they need different tools: the build, the request under melee dev, and
the request in production.
The build failed
Every diagnostic is one line: file:line: message, against the file you wrote.
views/broken.erb:2: unexpected end-of-input, assuming it is closing the parent top level context
db/migrations/003_bad.sql:1: near "TABEL": syntax error
app.rb:5: views/index.erb does not declare a local `nots`
app.rb:12: unsupported eval of a runtime string is not supported by AOT compilation (define the code statically)
app.rb:12: unsupported call: node 14591 (CallNode `totally_not_a_method`) recv=-/ty-1 argc=1 arg0ty3
The last one is the compiler’s own voice and the one that reads badly. unsupported call on a name you
recognise almost always means one of three things:
| You called a method that does not exist | a typo in a top-level helper, or one you meant to define |
The file did not require what it uses | add require "json" to the file that calls JSON.parse |
| The construct is not compilable | eval, send with a computed name, define_method, a Proc out of a Hash |
A failed melee push deploys nothing and the previous release keeps serving, so a broken build is never an
outage.
If you cannot tell what the compiler is objecting to, cut the file down until it compiles — the smallest program that still fails is usually the answer, and it is also what an upstream bug report needs.
It is wrong under melee dev
This is where the tools are good. A 500 renders a page with the exception class and message, the request, the params, the session and a CRuby backtrace.
Enjoy it, and do not build a habit on it: production has none of that.
GET /admin -> 500 (1.2 ms)
log.info with fields goes to the terminal:
info msg="refresh requested" feeds=3
And remember the dev server runs your code under CRuby, so it will happily run things the compiler rejects
and behave differently from the deployed binary in the handful of places the dialect differs. melee check
is the cross-check.
It is wrong in production
The visitor gets:
Something went wrong
and you get a line in the log:
undefined method 'qeury' for an instance of Melee::DB class=NoMethodError method=GET path=/ route=GET /
Class, message, HTTP method, path, and the route pattern that matched. No line number and no backtrace —
Exception#backtrace and caller return [] in a compiled app, because the binary does not carry them.
So the route name is often all you have to locate the failure, which has three consequences worth building into how you write:
- Raise with messages that say where you were.
raise ArgumentError, "feed #{id} has no url"rather thanraise ArgumentError. - Log before the risky line, not only after it. A line that says what you were about to do survives the thing that stops you doing it.
- Keep routes short. A route with one call in it and a log line is a route whose failure you can place.
post "/admin/refresh" do
log.info "refresh requested", feeds: db.first("SELECT COUNT(*) AS c FROM feeds")["c"].to_s
Household.get("home").refresh
redirect "/admin"
rescue Durable::RemoteError => e
log.error "refresh failed", remote_class: e.remote_class, message: e.message
halt 502
end
Reading the logs
melee logs # the last 50 lines
melee logs --tail 200
melee logs -f # follow
Lines are HH:MM:SS msg key=value …. Three kinds are mixed together:
- your
log.*events, each carrying the request id; - anything the app wrote to stderr (
$stderr.puts, runtime noise), shown as-is — note thatputsgoes to stdout and is not collected, so uselog; - the server’s own events, tagged
[melee].
The server’s events are the ones that explain a failure with no application line at all:
[melee] warm process started (pid 91129, 4 slots, sandbox off, cgroup off), release 1789257411211-0000
[melee] worker failed to start: bind .../worker.sock: path must be shorter than SUN_LEN; trying again in 60s
[melee] killing warm process group 91129 (environment changed)
[melee] warm process exited: signal: 9 (SIGKILL)
Reading a status code
| Code | What happened |
|---|---|
403 | The CSRF check. A form without csrf_field, or a stale session. |
404 | No route matched, or your own halt 404. |
500 | Your code raised. The log line is the only detail there is. |
502 | The request process died without answering — a crash, or the sandbox killing it for a forbidden syscall. |
503 | Every slot busy and the queue full (Retry-After: 1), or fork failed against the process cap. |
504 | The request took longer than 30 seconds; the process group was killed. |
A 502 with nothing in the log is the sandbox signature: the process was killed before it could say
anything. That means a syscall outside the allow-list, which for app code almost always means trying to do
something the platform does not intend — open a listening socket, run a subprocess.
Durable objects
melee objects # every object: class, id, created, last call, next timer
melee objects Stats # one class
CLASS ID CREATED LAST CALL TIMER
Stats visits 0s ago 0s ago in 23h
The TIMER column is the one to check when scheduled work is not happening: - means nothing is pending,
and re-arming only happens if on_timer completes.
Calls are logged individually:
call method=hit ms=0.16 object=Stats/visits ok=true
An exception inside an object surfaces in the caller as Durable::RemoteError, with remote_class naming
the original exception — or "Melee::Objects::WorkerError" when the worker could not be reached at all.
There is no way to tell those two apart except by that string.
When you suspect the compiler
If CRuby and the deployed binary genuinely disagree, that is a dialect trap, and the project keeps a list of
every one found so far in docs/research/spinel.md in the repository. Reduce it to the smallest program that
shows the difference, run it under both, and check the list.
See also
- Logging — levels, fields, the 500 line.
- The Ruby that compiles — the traps themselves.
- Testing an app.
API reference
Every method an app may call, with its signature. Generated from
stdlib/sig/*.rbs.
This page is written by stdlib/bin/melee-docs-api from the RBS signatures, so it lists exactly what the
compiler is told the surface is. It is a list, not an explanation: for what each area is for, with
worked examples, read the pages it links to — the app, the request,
the response, templates, the database,
durable objects, outbound HTTP, logging and
security.
Types are RBS. ?x is an optional parameter, x: a required keyword, bot a method that never returns,
and untyped a value Spinel is not told the type of. A heading is spelled the way an app calls it:
Melee::HTTP.get on the module, Melee::DB#query on an instance, and a bare name for the top-level
surface.
At a glance
- The top-level surface
- Routes and filters —
get,post,put,patch,delete,before,not_found,error,title,app_title - The current request —
request,params,form,query,session,log,db - Responses —
redirect,back,halt,status,header,json,text,html - Templates (generated per app by the build step from views/) —
render,partial,h - Strings, security and settings —
url_encode,url_decode,csrf_token,csrf_field,secure_equal?,random_token,setting - Melee —
Melee.db,Melee.env - Melee::Params —
Melee::Params#[],Melee::Params#fetch,Melee::Params#key?,Melee::Params#to_h,Melee::Params#each,Melee::Params#empty? - Melee::Request —
Melee::Request#method,Melee::Request#path,Melee::Request#query,Melee::Request#host,Melee::Request#scheme,Melee::Request#remote_addr,Melee::Request#headers,Melee::Request#body,Melee::Request#log,Melee::Request#id,Melee::Request#params,Melee::Request#path_params,Melee::Request#header,Melee::Request#content_type,Melee::Request#base_url,Melee::Request#get?,Melee::Request#post?,Melee::Request#query_params,Melee::Request#form,Melee::Request#json,Melee::Request#cookies,Melee::Request#session,Melee::Request#env,Melee::Request.url_encode,Melee::Request.url_decode - Melee::Response —
Melee::Response#status,Melee::Response#headers,Melee::Response#body,Melee::Response.html,Melee::Response.text,Melee::Response.json,Melee::Response.redirect,Melee::Response.not_found,Melee::Response.forbidden,Melee::Response.error,Melee::Response.escape,Melee::Response#header,Melee::Response#with_header - Melee::Halt —
Melee::Halt#response - Melee::Session —
Melee::Session#[],Melee::Session#[]=,Melee::Session#delete,Melee::Session#clear,Melee::Session#dirty?,Melee::Session#to_h,Melee::Session#csrf_token,Melee::Session#csrf_valid? - Melee::DBError
- Melee::DB —
Melee::DB#query,Melee::DB#first,Melee::DB#run,Melee::DB#exec,Melee::DB#last_id,Melee::DB#transaction,Melee::DB#get,Melee::DB#put,Melee::DB#delete - Melee::HTTP —
Melee::HTTP.get,Melee::HTTP.post - Melee::HTTP::Error
- Melee::HTTP::TimeoutError
- Melee::HTTP::TooManyRedirects
- Melee::HTTP::Response —
Melee::HTTP::Response#status,Melee::HTTP::Response#headers,Melee::HTTP::Response#body,Melee::HTTP::Response#url,Melee::HTTP::Response#ok?,Melee::HTTP::Response#json,Melee::HTTP::Response#header - Melee::Log —
Melee::Log#request_id,Melee::Log#debug,Melee::Log#info,Melee::Log#warn,Melee::Log#error,Melee::Log#exception,Melee::Log#with - Durable —
Durable#id,Durable#setup,Durable#on_timer,Durable#storage,Durable#timer,Durable#cancel_timer,Durable#destroy,Durable#log,Durable#release,Durable#live! - Durable::RemoteError —
Durable::RemoteError#remote_class - Durable::Destroyed
- Constants —
HTTP
The top-level surface
Everything an app.rb, a file under lib/ or a template may call without a receiver.
Routes and filters
get
def get: (String pattern, ?csrf: bool) { () -> untyped } -> void
Declares a GET route; the block’s return value is the response. csrf: false exempts it from the check.
post
def post: (String pattern, ?csrf: bool) { () -> untyped } -> void
Declares a POST route.
put
def put: (String pattern, ?csrf: bool) { () -> untyped } -> void
Declares a PUT route.
patch
def patch: (String pattern, ?csrf: bool) { () -> untyped } -> void
Declares a PATCH route.
delete
def delete: (String pattern, ?csrf: bool) { () -> untyped } -> void
Declares a DELETE route.
before
def before: (?String prefix) { () -> untyped } -> void
Runs the block before any route whose path starts with prefix; halt or redirect in it ends the request.
not_found
def not_found: () { () -> untyped } -> void
Sets the body for responses to a path no route matched (404).
error
def error: () { (StandardError) -> untyped } -> void
Sets the body for responses to an uncaught exception (500); the block receives the exception.
title
def title: (String t) -> void
Names the app, for the default layout and the log.
app_title
def app_title: () -> String
The name given to title, or “” when none was set.
The current request
request
def request: () -> Melee::Request
The request being handled; only meaningful inside a route block or a filter.
params
def params: () -> Melee::Params
Path params, then query string, then form fields, later sources winning.
form
def form: () -> Melee::Params
Fields of a urlencoded request body, when the distinction from the query string matters.
query
def query: () -> Melee::Params
Query-string fields only, when the distinction from the body matters.
session
def session: () -> Melee::Session
The signed cookie session; values are Strings.
log
def log: () -> Melee::Log
The request’s logger, with the request id already attached.
db
def db: () -> Melee::DB
The app’s SQLite database, opened and migrated on first use.
Responses
redirect
def redirect: (String to) -> bot
Stops the request with a 303 to to; never returns.
back
def back: () -> String
The Referer header, or “/” when there is none; for redirect back.
halt
def halt: (?Integer status, ?String body, ?type: String?) -> bot
Stops the request with this status and body; never returns.
status
def status: (Integer code) -> void
Sets the status of the response the route is about to return.
header
def header: (String name, String value) -> void
Adds a header to the response the route is about to return.
json
def json: (untyped value, ?type: String?) -> Melee::Response
A JSON response (application/json unless type says otherwise) of any JSON-serialisable value.
text
def text: (String body, ?type: String?) -> Melee::Response
A text/plain response.
html
def html: (String body, ?type: String?) -> Melee::Response
A text/html response, the same as returning the String from the route.
Templates (generated per app by the build step from views/)
render
def render: (Symbol name, ?layout: bool, **untyped locals) -> String
Renders views/<name>.erb with the given locals, wrapped in views/layout.erb unless layout: false.
A local the layout declares is forwarded to the layout as well; the generated signature names those
keywords explicitly, so they arrive here in locals.
partial
def partial: (Symbol name, **untyped locals) -> String
Renders the partial views/_<name>.erb with the given locals.
h
def h: (untyped v) -> String
HTML-escapes a value; templates escape <%= %> already, so this is for HTML built by hand.
Strings, security and settings
url_encode
def url_encode: (String s) -> String
Percent-encodes a String for one path segment or one query value (spaces as %20).
url_decode
def url_decode: (String s) -> String
Decodes a percent-encoded String, turning “+” into a space.
csrf_token
def csrf_token: () -> String
The session’s CSRF token, created on first use.
csrf_field
def csrf_field: () -> String
A hidden _csrf input carrying the session’s token, for a form in a template.
secure_equal?
def secure_equal?: (String? a, String? b) -> bool
Constant-time String compare that tolerates nil on either side; for secrets and tokens.
random_token
def random_token: (?Integer bytes) -> String
A new URL-safe random token with bytes bytes of entropy (32 characters by default).
setting
def setting: (String | Symbol key) -> String?
def setting: (String | Symbol key, String value) -> String
Reads a value from the app’s key/value table (nil when unset), or writes one and returns it.
Melee
The objects the top-level surface hands back: the request, the response, params, the session, the
database, the outbound HTTP client and the logger. Internals (Proto, Runtime, Native, Router, Current)
are deliberately unsigned. Design: docs/design/ergonomics.md. Check with rbs -I stdlib/sig validate.
These signatures are also Spinel’s --rbs seeds, and a seed is trusted rather than checked: pinning a
container to a storage kind Spinel did not infer makes the program read its own data back as garbage, with
no diagnostic (docs/research/spinel.md; probe in spike/e-10-rbs-seeds). So scalars and nominal types are
written exactly, but a Hash return carries the value type from spinel --emit-rbs on the unseeded build –
Hash[String, String] only where inference already says so, Hash[String, untyped] otherwise, with the
real values named in the comment – and a container-typed parameter is left untyped, since a caller can
hand in any kind. A scalar parameter more than one Ruby type may reach is written as the union for the same
reason: a seed narrower than the program converts at the call site with no diagnostic, so ?timeout: Integer over a Float had the callee see 0. A union of scalars is not pinned, it widens to the slow path,
which is what an app build (no seeds) already does. Run stdlib/test/run.sh after changing one.
Melee.db
def self.db: () -> Melee::DB
The app’s database, opened and migrated on first use; the same object as the top-level db.
Melee.env
def self.env: (String name, ?String? default) -> String?
A configuration value set with melee env set NAME value, or default when it is unset or empty.
Melee::Params
A String-or-Symbol keyed view over one set of request parameters. Every value is a String.
Melee::Params#[]
def []: (String | Symbol key) -> String?
The value for this key, or nil when it was not sent.
Melee::Params#fetch
def fetch: (String | Symbol key, ?String default) -> String
The value for this key, or default (“” unless given) when it was not sent; never nil.
Melee::Params#key?
def key?: (String | Symbol key) -> bool
Whether this key was sent at all, including as an empty String.
Melee::Params#to_h
def to_h: () -> Hash[String, untyped]
The underlying name/value Hash; the values are Strings.
Melee::Params#each
def each: () { (String, String) -> void } -> void
Yields each name and value.
Melee::Params#empty?
def empty?: () -> bool
Whether no parameters were sent.
Melee::Request
One HTTP request. A request child handles exactly one, so the top-level helpers can read it directly.
Melee::Request#method
attr_reader method: String
The HTTP method, upper case (“GET”, “POST”, …).
Melee::Request#path
attr_reader path: String
The path, without the query string (“/admin/notes”).
Melee::Request#query
attr_reader query: String
The raw query string, without the “?” (“a=1&b=2”), “” when there is none.
Melee::Request#host
attr_reader host: String
The Host header.
Melee::Request#scheme
attr_reader scheme: String
“http” or “https”, as seen by melee-server.
Melee::Request#remote_addr
attr_reader remote_addr: String
The client’s IP address.
Melee::Request#headers
attr_reader headers: Array[[ String, String ]]
Every header as [name, value] pairs, in the order they arrived.
Melee::Request#body
attr_reader body: String
The raw request body; “” when there is none.
Melee::Request#log
attr_reader log: Melee::Log
This request’s logger, with the request id attached.
Melee::Request#id
attr_reader id: Integer
The request id melee-server gave this request; it appears in every log line.
Melee::Request#params
def params: () -> Hash[String, untyped]
Path params, then query string, then form fields, later sources winning; the values are Strings.
Melee::Request#path_params
def path_params: () -> Hash[String, untyped]
The params captured from the route pattern (“:id”, “*path”) only; the values are Strings.
Melee::Request#header
def header: (String name) -> String?
A header by name, case-insensitively; nil when it was not sent.
Melee::Request#content_type
def content_type: () -> String
The Content-Type header, or “” when there is none.
Melee::Request#base_url
def base_url: () -> String
Scheme and host, with no trailing slash (“https://kitchen.example”).
Melee::Request#get?
def get?: () -> bool
Whether this is a GET.
Melee::Request#post?
def post?: () -> bool
Whether this is a POST.
Melee::Request#query_params
def query_params: () -> Hash[String, String]
Query-string fields only.
Melee::Request#form
def form: () -> Hash[String, String]
Fields of a urlencoded body; {} for any other content type (multipart is not supported).
Melee::Request#json
def json: () -> untyped
The body parsed as JSON; raises JSON::ParserError when it is not JSON.
Melee::Request#cookies
def cookies: () -> Hash[String, String]
The cookies sent with the request, by name.
Melee::Request#session
def session: () -> Melee::Session
The signed cookie session, loaded on first use.
Melee::Request#env
def env: () -> Hash[String, untyped]
A Rack-compatible view of the request; “rack.input” is the body String, not an IO.
Melee::Request.url_encode
def self.url_encode: (String s) -> String
Percent-encodes a String for one path segment or one query value (spaces as %20).
Melee::Request.url_decode
def self.url_decode: (String s) -> String
Decodes a percent-encoded String, turning “+” into a space.
Melee::Response
A response: a status, headers and a body. A route may return one instead of a String.
Melee::Response#status
attr_reader status: Integer
The HTTP status.
Melee::Response#headers
attr_reader headers: Array[[ String, String ]]
The response headers as [name, value] pairs.
Melee::Response#body
attr_reader body: String
The response body.
Melee::Response.html
def self.html: (String body, ?status: Integer, ?headers: untyped, ?type: String?) -> Melee::Response
A text/html response; headers is a Hash of header name => value, type replaces the Content-Type.
Melee::Response.text
def self.text: (String body, ?status: Integer, ?headers: untyped, ?type: String?) -> Melee::Response
A text/plain response.
Melee::Response.json
def self.json: (untyped value, ?status: Integer, ?headers: untyped, ?type: String?) -> Melee::Response
An application/json response, generated from any JSON-serialisable value.
Melee::Response.redirect
def self.redirect: (String location, ?status: Integer) -> Melee::Response
A redirect, 303 unless another status is given.
Melee::Response.not_found
def self.not_found: (?String message) -> Melee::Response
A 404 with a plain-text body.
Melee::Response.forbidden
def self.forbidden: (?String message) -> Melee::Response
A 403 with a plain-text body.
Melee::Response.error
def self.error: (?String message) -> Melee::Response
A 500 with a plain-text body.
Melee::Response.escape
def self.escape: (String s) -> String
HTML-escapes &, <, >, “ and ’.
Melee::Response#header
def header: (String name) -> String?
A header by name, case-insensitively; nil when it is not set.
Melee::Response#with_header
def with_header: (String name, String value) -> Melee::Response
A copy of this response with one more header.
Melee::Halt
Raised by halt and redirect to stop the request; the dispatcher turns it back into a response.
Inherits StandardError.
Melee::Halt#response
attr_reader response: Melee::Response
The response to send instead of the route’s return value.
Melee::Session
The signed cookie session. Values are Strings; anything assigned is converted with to_s.
Melee::Session#[]
def []: (String | Symbol key) -> String?
The value stored under this key, or nil.
Melee::Session#[]=
def []=: (String | Symbol key, String? value) -> void
Stores a value under this key; assigning nil deletes it.
Melee::Session#delete
def delete: (String | Symbol key) -> void
Removes this key.
Melee::Session#clear
def clear: () -> void
Empties the session.
Melee::Session#dirty?
def dirty?: () -> bool
Whether the session changed during this request, and so a cookie will be set.
Melee::Session#to_h
def to_h: () -> Hash[String, untyped]
The session contents; every value written through the session is a String.
Melee::Session#csrf_token
def csrf_token: () -> String
This session’s CSRF token, created on first use.
Melee::Session#csrf_valid?
def csrf_valid?: (String? token) -> bool
Whether token matches this session’s CSRF token, compared in constant time.
Melee::DBError
Raised when SQLite reports an error.
Inherits StandardError.
Melee::DB
The app’s SQLite database. Migrations in db/migrations/*.sql are applied in name order on first use.
Melee::DB#query
def query: (String sql, *untyped binds) -> Array[Hash[String, untyped]]
Rows for a SELECT, each a Hash keyed by column name. A value is an Integer, a Float, a String or nil, following SQLite’s own types.
Melee::DB#first
def first: (String sql, *untyped binds) -> Hash[String, untyped]?
The first row of a SELECT, or nil when it returned none.
Melee::DB#run
def run: (String sql, *untyped binds) -> Integer
Runs an INSERT, UPDATE or DELETE and returns the number of rows it changed.
Melee::DB#exec
def exec: (String sql) -> void
Runs SQL that takes no bind values and returns no rows (DDL, PRAGMA).
Melee::DB#last_id
def last_id: () -> Integer
The rowid of the last INSERT on this connection.
Melee::DB#transaction
def transaction: () { () -> void } -> void
Runs the block in a transaction, rolling back and re-raising if it raises.
Melee::DB#get
def get: (String key) -> untyped
The value stored under this key by put, or nil when it was never set (storage.get, on a durable
object’s database).
Melee::DB#put
def put: (String key, untyped value) -> untyped
Stores a JSON-shaped value under this key, creating the table on first use, and returns it.
Melee::DB#delete
def delete: (String key) -> bool
Removes this key; returns whether a row was deleted.
Melee::HTTP
The outbound HTTP client: GET and POST, with timeouts and up to five redirects. HTTP in an app.
Melee::HTTP.get
def self.get: (String url, ?query: untyped, ?headers: untyped, ?timeout: Integer | Float, ?raise_on_error: bool) -> Melee::HTTP::Response
Fetches a URL. query and headers are Hashes of name => value; raises Error or TimeoutError on
failure, and on a 4xx/5xx with raise_on_error: true. timeout is seconds and is rounded up to a
whole number, so a Float is taken but says nothing a whole number does not.
Melee::HTTP.post
def self.post: (String url, body: String, ?query: untyped, ?headers: untyped, ?timeout: Integer | Float, ?raise_on_error: bool) -> Melee::HTTP::Response
Posts a body to a URL; raises the same errors as get.
Melee::HTTP::Error
Raised for a transport failure, an unsupported URL, a body that is not JSON, or a 4xx/5xx with
raise_on_error: true.
Inherits StandardError.
Melee::HTTP::TimeoutError
Raised when the connection or the read exceeded timeout seconds.
Inherits Melee::HTTP::Error.
Melee::HTTP::TooManyRedirects
Raised after five redirects.
Inherits Melee::HTTP::Error.
Melee::HTTP::Response
One HTTP response from HTTP.get or HTTP.post.
Melee::HTTP::Response#status
attr_reader status: Integer
The HTTP status.
Melee::HTTP::Response#headers
attr_reader headers: Array[[ String, String ]]
The response headers as [name, value] pairs.
Melee::HTTP::Response#body
attr_reader body: String
The response body.
Melee::HTTP::Response#url
attr_reader url: String
The URL the body finally came from, after any redirects.
Melee::HTTP::Response#ok?
def ok?: () -> bool
Whether the status is 2xx.
Melee::HTTP::Response#json
def json: () -> untyped
The body parsed as JSON; raises Melee::HTTP::Error when it is not JSON.
Melee::HTTP::Response#header
def header: (String name) -> String?
A header by name, case-insensitively; nil when it was not sent.
Melee::Log
Structured logging: one event per call, as key/value pairs, with the request id attached.
Melee::Log#request_id
attr_reader request_id: Integer
The id of the request this logger belongs to; 0 outside a request.
Melee::Log#debug
def debug: (String msg, **untyped fields) -> void
A debug event.
Melee::Log#info
def info: (String msg, **untyped fields) -> void
An informational event.
Melee::Log#warn
def warn: (String msg, **untyped fields) -> void
A warning.
Melee::Log#error
def error: (String msg, **untyped fields) -> void
An error.
Melee::Log#exception
def exception: (Exception e, **untyped fields) -> void
An error carrying an exception’s class and message (Spinel has no backtraces).
Melee::Log#with
def with: (**untyped extra) -> Melee::Log
A copy of this logger with extra fields attached to every event it writes.
Durable
The base class of a durable object (docs/design/persistent.md, objects.md): a named, long-lived object
with its own SQLite database and one timer, hosted by the app’s worker process. Apps never construct or
call these directly; the build step generates Klass.get(id) and a Handle per subclass, which is why no
signature is written for a generated Handle here.
Durable#id
attr_reader id: String
This object’s own id, given by Klass.get(id).
Durable#setup
def setup: () -> void
Called once, inside the call that first created the object, before that call’s own method runs.
Durable#on_timer
def on_timer: () -> void
Called when the pending timer fires.
Durable#storage
def storage: () -> Melee::DB
This object’s own SQLite database, opened (and migrated) on first use.
Durable#timer
def timer: (?after: Integer, ?at: Integer | Time) -> Integer
Sets the one pending timer, replacing any previous one, and returns it as Unix seconds.
Durable#cancel_timer
def cancel_timer: () -> void
Clears the pending timer, if any.
Durable#destroy
def destroy: () -> void
Deletes the object’s database and its registry row; storage, timer and cancel_timer raise
Durable::Destroyed if called again in the same call.
Durable#log
def log: () -> Melee::Log
A logger tagged with this object, since no request is current in the worker.
Durable#release
def release: () -> void
Closes the storage handle when the worker drops this instance; the next storage call reopens it.
Durable#live!
def live!: () -> void
Raises Durable::Destroyed if this object was already destroyed earlier in this call.
Durable::RemoteError
Raised in the caller when a durable method raised inside the worker, or the worker could not be reached.
Inherits StandardError.
Durable::RemoteError#remote_class
attr_reader remote_class: String
The raising exception’s class name, or “Melee::Objects::WorkerError” when the worker could not be reached at all.
Durable::Destroyed
Raised by storage, timer and cancel_timer after destroy in the same call.
Inherits StandardError.
Constants
HTTP
HTTP: singleton(Melee::HTTP)
HTTP in an app is Melee::HTTP: HTTP.get(url, timeout: 10).
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).
Reading the request
Every helper that reads the request being served:
params,session, headers, cookies, bodies.
Every helper here is a top-level method that reads the request being served. One request per process, so there is nothing to pass around and no argument to thread through.
params, form, query
get "/notes/:id" do
id = params.fetch(:id).to_i
"note #{id} sorted by #{params.fetch(:sort, "date")}"
end
params merges three sources, later ones winning: path params, then the query string, then urlencoded body
fields. form is the body fields only; query is the query string only. All three return a
Melee::Params, a Symbol-or-String view over a Hash{String => String}.
| Call | Returns |
|---|---|
params[:name] | String or nil — nil when the key is absent |
params.fetch(:name) | String, "" when absent — never nil, never raises |
params.fetch(:name, "date") | String, the default when absent |
params.key?(:name) | true / false |
params.to_h | Hash{String => String} |
| `params.each { | k, v |
params.empty? | true / false |
The idiom. Use params.fetch(:x) whenever you want a String — it always gives one, so .to_i,
.strip, .match? and interpolation are safe. Use params[:x] only when absent and empty must be told
apart. Do not write params[:x].to_s; fetch already did that.
Values are always Strings. Convert explicitly: params.fetch(:n).to_i, params.fetch(:price).to_f.
Validate before you trust:
post "/notes" do
halt 400, "Date must be YYYY-MM-DD" unless params.fetch(:on_date).match?(/\A\d{4}-\d\d-\d\d\z/)
db.run "INSERT INTO notes (on_date, text) VALUES (?, ?)", params.fetch(:on_date), params.fetch(:text)
redirect "/notes"
end
Only application/x-www-form-urlencoded bodies are parsed into form. Multipart file uploads are not
supported; a multipart body leaves form empty and stays available as request.body.
Values arrive already decoded. To go the other way — putting a value into a URL you build — use the two
top-level helpers: url_encode(String) -> String and url_decode(String) -> String.
redirect "/search?q=#{url_encode(params.fetch(:q))}"
request
request returns the Melee::Request.
| Call | Returns |
|---|---|
request.method | String, upper case: "GET", "POST", … |
request.path | String, no query string |
request.query | String, the raw query string, "" when there is none |
request.host | String |
request.scheme | String, "http" or "https" |
request.base_url | String, e.g. "https://kitchen.example.com" |
request.remote_addr | String, the client address |
request.header("Accept") | String or nil, case-insensitive |
request.headers | Array[[String, String]] in wire order |
request.content_type | String, "" when the header is absent |
request.body | String, "" when there is no body |
request.json | the parsed body; raises JSON::ParserError on bad input |
request.cookies | Hash{String => String} |
request.get?, request.post? | true / false |
request.id | Integer, the request id that appears in the logs |
There is no request.ip; the name is request.remote_addr.
post "/webhook", csrf: false do
payload = request.json
halt 400 unless payload.is_a?(Hash)
log.info "webhook", kind: payload["type"].to_s
text "ok"
end
request.json returns whatever the body decoded to — Hash, Array, String, Integer, nil. Index
hashes by String keys (payload["type"], not payload[:type]) and narrow with is_a? before doing
arithmetic on a value.
session
session is a signed cookie holding Strings only.
post "/login" do
if secure_equal?(params.fetch(:secret), ENV["ADMIN_SECRET"].to_s)
session[:admin] = "1"
redirect "/admin"
else
status 401
render :login, error: "That secret is wrong."
end
end
| Call | Returns |
|---|---|
session[:user] | String or nil |
session[:user] = "ada" | stores value.to_s; assigning nil deletes the key |
session.delete(:user) | same as assigning nil |
session.clear | empties it |
session.to_h | Hash{String => String} |
session.csrf_token | String, minted on first read |
The cookie is only rewritten when you touched the session. Details, size limits and the CSRF rules are in
security.md.
Configuration
ENV["NAME"] reads a variable set with melee env NAME value. It is String or nil, so write
ENV["NAME"].to_s. Melee.env("NAME", "default") returns the default when the variable is missing or
empty. For values the app itself writes and reads back — tokens, last-run times — use setting (see
database.md).
Writing the response
What a route may return, and the helpers that build it:
render,redirect,halt,json,status.
A route block returns its response. There is no render-and-return-nil, no implicit template lookup, and no
response object to mutate.
What a block may return
| Return | Becomes |
|---|---|
String | 200 text/html; charset=utf-8 with that body |
render :name, ... | a String (the rendered page) — same as above |
json(value) / text(s) / html(s) | a Melee::Response with that content type |
Melee::Response.stream { |out| ... } | a chunked response |
| anything else | to_s, sent as HTML |
redirect and halt do not return: they unwind the block immediately.
get "/notes" do
render :notes, notes: db.query("SELECT id, text FROM notes ORDER BY id")
end
status and header
get "/feed.xml" do
status 200
header "Cache-Control", "no-store"
text db.first("SELECT body FROM feed")["body"].to_s
end
status(Integer) and header(name, value) set the status and headers of the response the block is about to
return. Both stringify the value. They apply to a returned String, render, json, text and html.
They do not apply to redirect or halt, which build their own response and ignore anything set
earlier. A redirect therefore carries only Location and Content-Length.
json, text, html
get "/api/notes" do
json({ "notes" => db.query("SELECT id, text FROM notes") })
end
| Call | Returns | Content-Type |
|---|---|---|
json(value) | Melee::Response | application/json (JSON.generate(value)) |
text(string) | Melee::Response | text/plain; charset=utf-8 |
html(string) | Melee::Response | text/html; charset=utf-8 |
json accepts Hashes, Arrays, Strings, Integers, Floats, true, false, nil. Not your own classes:
build a Hash first.
All three take an optional type: to override the content type, for the formats that are text under another
name:
get "/card.vcf" do
text build_vcard, type: "text/vcard; charset=utf-8"
end
redirect
post "/notes" do
db.run "INSERT INTO notes (text) VALUES (?)", params.fetch(:text)
redirect "/notes"
end
redirect(to) sends 303 See Other with a Location header and an empty body. Always 303, for every
method. redirect back uses the Referer header, falling back to "/" — back is a plain method
returning a String.
halt
get "/d/:token" do
halt 404 unless secure_equal?(params.fetch(:token), setting("display_token"))
render :display, layout: false
end
post "/notes" do
halt 400, "Date must be YYYY-MM-DD" unless params.fetch(:on_date).match?(/\A\d{4}-\d\d-\d\d\z/)
db.run "INSERT INTO notes (on_date, text) VALUES (?, ?)", params.fetch(:on_date), params.fetch(:text)
redirect "/notes"
end
halt(status = 200, body = "", type: nil) stops the request with a text/plain response carrying body,
or type: if you give one. It works inside a route, inside a before filter, and inside any method a route
calls. It discards a status or header set earlier in the request.
Streaming
get "/export.csv" do
Melee::Response.stream(content_type: "text/csv; charset=utf-8",
headers: { "Content-Disposition" => "attachment; filename=notes.csv" }) do |out|
out << "id,text\n"
db.query("SELECT id, text FROM notes").each { |r| out << "#{r["id"]},#{r["text"]}\n" }
end
end
Melee::Response.stream(content_type: "text/plain; charset=utf-8", status: 200, headers: {}) { |out| ... }
returns a Melee::Response. out accepts << with a String and nothing else. The headers set with
header are not merged into a streamed response — pass them in the headers: keyword instead. An exception
raised inside the block ends the response early and is logged; the client has already had a 200. Under
melee dev the whole stream is buffered before anything is sent.
Errors
An exception that escapes the block is logged and turned into a 500 — either your error handler’s output
or Something went wrong. See app.md.
What is not here
There is no send_file: static files belong in public/, which the server sends without waiting for the
app. There is no content_type setter (use type: on text/html/json, or header "Content-Type", ...),
no attachment, no last_modified/etag, and no cookie API beyond session — set one with
header "Set-Cookie", ... if you must.
Templates
.erbfiles compiled to Ruby at build time, with their locals declared on line one.
Templates are .erb files in views/. They are compiled to plain Ruby methods at build time — there is no
ERB at runtime and no eval anywhere — so a syntax mistake in a template is a build error naming the .erb
file and line rather than a 500 in production.
views/layout.erb the layout, applied automatically when this file exists
views/display.erb render :display
views/_events.erb partial :events
Declaring locals
The first line of every template declares its locals. A template with no declaration, or one declaring
locals: (), takes none, and passing one is a build error.
<%# locals: (events:, notes:, empty:, compact: false) %>
The declaration becomes the method’s keyword parameters, so events: is required and compact: false has a
default. A missing or misspelled local at a render or partial call site is a build error naming the
template and the line of the call — see “What the build catches” below. A name used in the template body
that was never declared is still a NameError when the template runs.
render
get "/notes" do
render :notes, notes: db.query("SELECT id, text FROM notes"), heading: "All notes"
end
render(name, layout: true, **locals) -> String. name is a Symbol matching views/<name>.erb. It returns
the rendered HTML as a String, which the route returns as the response body. layout: false skips the
layout. A name with no file is a build error, no template views/<name>.erb. Always write a literal Symbol;
a computed one defeats the whole design, and skips the check — it raises the same message at runtime.
Layout
If views/layout.erb exists it wraps every render that does not pass layout: false. The layout is an
ordinary template; it must declare content: and emit it unescaped:
<%# locals: (content:) %>
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title><%= app_title %></title>
<link rel="stylesheet" href="/app.css"></head>
<body><%== content %></body>
</html>
With no views/layout.erb, there is no layout and layout: false is a no-op.
Locals the layout declares
A layout usually wants a per-page title or meta description. Declare it as a local of the layout and
render forwards it there, alongside content::
<%# locals: (content:, title: "at.here", description: nil) %>
<!doctype html>
<title><%= title %></title>
<% if description %><meta name="description" content="<%= description %>"><% end %>
<body><%== content %></body>
render :card, handle: h, title: "#{h} on at.here", description: bio
handle: goes to the page, description: to the layout, and title: to both if the page declares it too.
- A keyword goes to whichever template declares it, and to both when both do. One neither declares is a build error naming both files.
- A layout local with a default may be left out; one the layout requires has to be passed by every
renderthat keeps the layout, or the build fails. content:is the layout’s own local — it is the rendered page — so a call site never passes it.layout: falseskips the layout, and with it the forwarding.- When both declare the name and both give it a default, the layout’s default is the one that reaches
the page through
render; the page’s own applies only to a directViews.<name>(...)call. name,layout,localsandbodyare what the generatedrendercalls its own parameters, so a layout cannot declare them. A layout local’s default must be a literal (a String, Symbol, number,true/false/nil, or an Array or Hash of those), becauserenderevaluates it where the layout’s other locals do not exist. Either is a build error naming the layout.
Partials
<%= partial :events, events: today, notes: today_notes, empty: "Nothing on." %>
partial(name, **locals) -> String renders views/_<name>.erb. The leading underscore is in the filename
only, never in the call. Partials declare locals the same way and never take a layout.
Inside a template you may also write <%= render "events", events: today %> with a String name; the
compiler rewrites it to a direct call. Both spellings work; prefer partial :events in templates and
render :page in routes so the two roles stay distinguishable.
Tags
| Tag | Meaning |
|---|---|
<%= expr %> | evaluate and output, HTML-escaped |
<%== expr %> | evaluate and output raw, no escaping |
<% code %> | Ruby statement: if, each, end — no output |
<%# comment %> | dropped (this is also where locals: lives) |
-%> | trims the newline that follows the tag |
Escaping is on for <%= %> and escapes & < > " '. The three helpers that return HTML on purpose —
render, partial and csrf_field — are recognised and not escaped. Everything else is. Use <%== %>
only for HTML you built yourself; never for anything derived from params, the database or an HTTP
response.
What you can call in a template
Anything top level: app_title, csrf_field, h, params, session, request, setting, and any method
you defined in app.rb or lib/. Ruby core is available too — Time.at(...), strftime, String,
Array, Hash.
<%# locals: (feeds:) %>
<% feeds.each do |f| -%>
<tr>
<td><span class="dot" style="background:<%= f["colour"] %>"></span> <%= f["name"] %></td>
<td><%= f["fetched_at"] ? Time.at(f["fetched_at"].to_i).strftime("%-d %b %H:%M") : "never" %></td>
</tr>
<% end -%>
Database rows are Hash{String => value}: index them with f["colour"], never f[:colour].
Every form that changes something needs the CSRF field:
<form method="post" action="/notes">
<%= csrf_field %>
<input type="text" name="text" required>
<button>Add</button>
</form>
What the build catches
The build step syntax-checks each template on its own, so an unbalanced if or each is a build error
naming the file and the .erb line:
views/broken.erb:2: unexpected end-of-input, assuming it is closing the parent top level context
It then reads every render and partial call in app.rb, lib/**/*.rb and the templates themselves, and
checks it against the template it names:
app.rb:30: no template views/logon.erb
app.rb:30: views/login.erb does not declare a local `errro`
app.rb:30: views/login.erb requires `error`
views/display.erb:22: views/_events.erb requires `empty`
app.rb:30: neither views/login.erb nor views/layout.erb declares a local `titel`
app.rb:30: views/layout.erb requires `title`
views/layout.erb:1: the layout requires `title`, which no render passes
The layout is checked too: it must declare content, and a local it requires that no render passes is a
build error against the layout rather than a 500 on every page.
A call is skipped when there is nothing to check it against: a template name that is not a literal Symbol,
or locals passed as **hash. Those raise at runtime instead, as does a name used in a template body that
was never declared. Load every page under melee dev before pushing. One caveat for a skipped call: a
local the layout declares has a value in render whatever the call site did, so where an unchecked call
used to raise missing keyword it now renders the layout’s default instead — another reason to keep
template names literal.
What is not here
No content_for/yield, no nested layouts, no template inheritance, no helper modules to include, no
.erb.html or other extensions, no HAML/Slim, no runtime template lookup by a computed name, no capture.
Templates cannot be added or changed without a rebuild — they are compiled into the binary.
Database
The app’s SQLite database: queries, binds, transactions, migrations as files, and the one-writer rule.
Every app gets one SQLite database. db is a method returning it; call it fresh each time, do not assign it
to a constant (a yielding call on a constant receiver does not compile under Spinel).
get "/notes" do
render :notes, notes: db.query("SELECT id, text, done FROM notes ORDER BY id")
end
The five calls
| Call | Returns |
|---|---|
db.query(sql, *binds) | Array[Hash{String => Integer|Float|String|nil}], [] when nothing matched |
db.first(sql, *binds) | the first row Hash, or nil |
db.run(sql, *binds) | Integer, the number of rows changed |
db.last_id | Integer, the rowid of the last insert on this connection |
db.exec(sql) | runs SQL with no binds and no result; for PRAGMA and multi-statement scripts |
db.transaction { ... } | runs the block inside BEGIN IMMEDIATE; COMMIT on success, ROLLBACK and re-raise on any exception. Returns nil |
post "/notes" do
db.run "INSERT INTO notes (on_date, text) VALUES (?, ?)", params.fetch(:on_date), params.fetch(:text)
redirect "/notes/#{db.last_id}"
end
post "/feeds/:id/delete" do
id = params.fetch(:id).to_i
db.transaction do
db.run "DELETE FROM events WHERE feed_id = ?", id
db.run "DELETE FROM feeds WHERE id = ?", id
end
redirect "/admin"
end
Always use ? placeholders. Never interpolate a value into SQL — "... WHERE id = #{id}" is an injection
and there is no escaping helper to make it safe.
The row shape
A row is a Hash keyed by String column names, in SELECT order. Values come back as the SQLite type:
Integer, Float, String, or nil for NULL. There is no type coercion and no model layer.
row = db.first("SELECT MIN(fetched_at) AS t FROM feeds")
t = row.nil? ? nil : row["t"]
stale = t.nil? || Time.now.to_i - t.to_i > 3600
Two rules that follow from it: index with row["name"] (never row[:name], which is always nil), and
narrow a value with .to_i / .to_s / is_a? before arithmetic, because the static type is
“Integer or Float or String or nil”. Booleans are 0 and 1; timestamps are best stored as
INTEGER Unix seconds (Time.now.to_i) or TEXT in YYYY-MM-DD form.
Migrations are files
db/migrations/001_schema.sql
db/migrations/002_add_colour.sql
Each file is plain SQL and may hold several statements. The build step runs every file against a throwaway
in-memory database — a typo fails the build with db/migrations/002_add_colour.sql:1: <sqlite message> —
then embeds them all in the binary. At the app’s first database use they are applied in filename order and
recorded by name in a melee_migrations table, so a file already applied is never re-run and adding a file
later does not re-run the earlier ones.
Consequences: never edit an applied migration, add a new file instead; name files so the sort order is the
apply order (001_, 002_, …); there is no rollback, no schema.rb, and no melee migrate command.
-- db/migrations/001_schema.sql
CREATE TABLE notes (
id INTEGER PRIMARY KEY,
on_date TEXT NOT NULL,
text TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX notes_on_date ON notes (on_date);
setting
A built-in key/value table for small state the app writes and reads back: tokens, last-run times, flags.
def display_token = setting("display_token") || setting("display_token", random_token)
post "/admin/rotate" do
setting "display_token", random_token
redirect "/admin"
end
setting(key) -> String | nil reads. setting(key, value) -> String writes and returns value.to_s. Keys
and values are stringified. It uses a melee_settings table it creates on first use; you do not migrate it.
Use ENV for configuration you set from outside (melee env), setting for what the app itself decides.
SQLite facts that matter
- The file is
app.sqlitein the app’s data directory, in WAL mode withsynchronous=NORMAL,foreign_keys=ONand a 5 s busy timeout. - WAL means readers never block, but there is one writer at a time. Two requests writing at once means
one waits up to 5 s and then raises. Keep transactions short: do the HTTP call, the parsing and the
formatting outside the
db.transactionblock, and only the writes inside it. db.transactionusesBEGIN IMMEDIATE, so it takes the write lock at the start rather than failing halfway. Do not nest transactions.- The database is per app. There is no connection pool, no second database, and no way to reach another
app’s data. A
Durableobject’sstorageis a separate SQLite file per object, reached throughstorage, never throughdb(seeobjects.md). - A failing statement raises; the class is the backend’s, so rescue
StandardErrorif you must rescue at all. Prefer letting it become a 500 with a log line.
Durable objects
Named, long-lived objects with their own SQLite database and one timer, hosted by the app’s worker.
A class that inherits from Durable is a named, long-lived object with its own SQLite database and one
timer. It lives in the app’s worker process, not in a request child, so it survives between requests and can
wake itself up when nothing is happening.
# app.rb, or lib/household.rb
class Household < Durable
def setup = timer(after: 0) # once, when the object is first created
def on_timer # when the timer fires
refresh
timer after: 600
end
def refresh
storage.query("SELECT * FROM feeds").each { |f| refresh_feed(f) }
storage.put("last_refresh", Time.now.to_i)
end
def events_for(day) = storage.query("SELECT * FROM events WHERE day = ? ORDER BY starts_at", day.to_s)
def stale? = Time.now.to_i - storage.get("last_refresh").to_i > 3600
end
get "/d/:token" do
home = Household.get("home")
render :display, today: home.events_for(today), stale: home.stale?
end
class Household < Durable must be at the top level of app.rb or a file under lib/, not nested in a
module or another class. The build step reads it, so a durable class inside a def or an if is never seen.
Getting a handle
| Call | Returns |
|---|---|
Household.get(id) | a handle to the Household with this id. The id must be a non-empty String; anything else raises. The object exists from the first call — there is no separate “create”. |
Household.list | every id in this class, oldest first |
Household.count | how many there are |
There is no default instance and no way to list across classes. Household.get("home") always returns a
handle, whether or not "home" exists yet; the object is created, and setup runs, on that handle’s first
call.
Every other method comes from the class itself: home.refresh, home.events_for(today). The build step
reads your class and generates one forwarding method per public method it finds, so the set of callable
methods is fixed and readable in .melee/objects.rb.
It does not follow that a misspelling is caught at build time. home.refrsh compiles and raises
NoMethodError when the line runs, the same as any other undefined method with an explicit receiver
(dialect.md, “Errors that only appear at runtime”). Wrong arity raises ArgumentError at run time too.
Measured 2026-09-13 against the pinned Spinel a9f1d9ae.
Inside the object
id is the object’s own id, a String. log is a request-shaped logger (log.info "msg", key: value),
useful here because no request is current in the worker to attach one to.
storage is the object’s own SQLite database — a separate file per object, not the app’s db. It has the
same query/first/run/exec/transaction/last_id as db (see database.md), plus a key/value
shortcut:
storage.put("last_refresh", Time.now.to_i) # -> the value
storage.get("last_refresh") # -> the value, or nil
storage.delete("last_refresh") # -> true if a row was removed
put/get/delete share one table, created on first use. A put value must be JSON-shaped — see below —
so it survives being written and read back exactly.
Plain instance variables are a cache: they persist between calls while the object is in memory, and vanish
whenever the worker decides to unload it (an idle object, a deploy, a restart). Anything that must survive
goes through storage, never through an ivar.
Timers
timer after: 600 # ten minutes from now
timer at: Time.now + 3600 # a specific time, or Unix seconds
cancel_timer # clear the pending timer, if any
One pending timer per object; setting it replaces whatever was there. When it fires, the worker calls the
object’s on_timer method — define it to do anything on a schedule, and call timer again inside it to keep
firing (there is no repeating “every N seconds” yet; re-arming on_timer covers it). timer and
cancel_timer return once the pending timer is recorded, which survives a crash between calls: nothing is
lost if the worker dies right after.
(in: would read better than after:, but Ruby cannot name a keyword argument in.)
setup and destroy
def setup = timer(after: 0)
setup runs exactly once, inside the call that first created the object, before that call’s own method runs.
Use it to schedule the first timer or seed storage.
def close_out
destroy
nil
end
destroy deletes the object’s database file and its row in the registry. Anything still held for this id is
dead afterwards — a further storage, timer or cancel_timer call in the same method raises
Durable::Destroyed. There is no undo and nothing is collected automatically otherwise: an object you never
destroy lives forever.
Migrations
An object class’s own schema lives in db/objects/<class_name>/NNN.sql, where <class_name> is the class
name underscored (Household -> household). Each file is plain SQL, checked at build time the same way the
app’s own migrations are, and applied once per object’s database, in filename order, the first time that
object is opened:
-- db/objects/household/001_feeds.sql
CREATE TABLE feeds (id INTEGER PRIMARY KEY, name TEXT NOT NULL, colour TEXT NOT NULL, url TEXT NOT NULL,
fetched_at INTEGER, error TEXT);
Every Household gets its own copy of this table, in its own file. Adding 002_....sql later migrates every
object the next time each one is opened; nothing re-runs 001.
The rules
- Positional parameters only. A durable method may take required and optional positional parameters
(literal defaults, like
def add(a, b = 1)); no keyword arguments, no splat, no block, no destructuring. The build step rejects anything else with the file and line. - JSON-shaped in, JSON-shaped out. Arguments and return values may only be
String,Integer,Float,true,false,nil, and Arrays and String-keyed Hashes of those — the same shape adb.queryrow already has. ATimeor aSymbolis a runtimeArgumentErroron bothmelee devand in production, not a build error: narrow with.to_i/.to_sthe way route code narrowsparams. - Reserved and forbidden names.
setup,on_timer,timer,cancel_timer,storage,log,id,destroy,get,list,count(and a few internals:live!,release,initialize) are the object’s own vocabulary and cannot be redefined as callable methods. A further list —to_s,inspect,class,send,call,each,close, and the rest of whatObjectandIOalready mean — is a build error if you define it, because a Handle would collide with what Ruby itself expects there. - One call at a time, per app. The worker that hosts every Durable object in an app is single-threaded: a slow method on one object delays calls to every other object in that app, in this milestone. Keep durable methods quick.
- Ivars are a cache, as above — they do not survive an unload. Only
storagedoes.
Under melee dev
There is no separate worker process in development: melee dev handles one HTTP request at a time already,
so it runs the same dispatch in-process and keeps the objects alive between requests in the dev server
itself. Its accept loop checks for due timers roughly once a second — including while no request is
happening — and fires them, printing a line (timer Household/home -> on_timer) or the exception if one
raised. Same code path as production, no socket in between.
Routing straight to an object
A route can name an object method instead of taking a block, and declare what to do with what it returns. The method still runs in the worker, exactly as it does through a handle; the difference is that the route says what the answer is for, so you do not write the lines that unpack it.
get "/c/:course/l/:lesson", to: "Lesson#show", view: :lesson, id: [:course, :lesson]
post "/c/:course/l/:lesson/done", to: "Lesson#complete", id: [:course, :lesson], redirect: "/c/:course/l/:id"
get "/board", to: "Board#items", view: :board, id: "board", create: true
to: "Lesson#show" | the class and the method, as one string |
view: :lesson | render views/lesson.erb with the returned Hash as its locals |
layout: false | as on render; the default is true |
redirect: "/c/:course" | redirect to this path, with each :name filled in |
id: | which path param is the object’s id, when it is not the only one |
create: true | make the object if this id has none yet; without it an unknown id is a 404 |
A route has a block or a to:, never both, and a to: route has exactly one of view: and redirect:.
Everything else about the route is unchanged: before filters, CSRF and not_found all still run first, in
the request child, so a filter can turn a request away before the object is called at all.
The route reaches an object that already exists. A handle creates its object on the first call — that is
what Household.get("home") does — so a route whose id comes from the URL would make one for every path
anybody typed, filling the registry with objects nobody asked for. An object route therefore looks the id up
first and answers with your not_found block when there is none. Say create: true where first visit really
should create it: a per-URL cache object, or a fixed id: you wrote yourself.
The method returns a Hash, which is what a durable method already had to return — String keys, JSON-shaped
values, the same shape as a db.query row. For a view: route those keys are the template’s locals, and the
method must return every local the template declares; forgetting one is an error naming the method, the key
and the template rather than a blank space in the page. Returning nil means the method had nothing to
render — the object exists, its storage is empty or the thing asked for is not in it — and your not_found
block answers.
class Lesson < Durable
def show(params)
row = storage.first("SELECT title, body FROM lesson")
return nil if row.nil?
{ "title" => row["title"].to_s, "body" => row["body"].to_s }
end
def complete = { "id" => "done" }
end
The method takes no arguments, or one, which receives the request’s merged params (path, then query, then
form) as a Hash{String => String} — the same values params gives a block route. A JSON request body is not
in it.
The object’s id comes from the path. With one :param in the pattern that is the id; otherwise name it
with id: :handle. id: [:course, :lesson] makes a composite id from several params, URL-encoded and joined,
so two courses can each have a lesson intro without colliding. id: "home" is a fixed id for an app with
one of something. Inside the method the individual parts are still readable from params.
For a redirect: route each :name in the path is filled from the returned Hash first and the request’s
params second, so a create can redirect to the thing it just made ({ "id" => storage.last_id } fills
:id). Here nil is simply no values to fill in, since there is no page to render: a method that only does
work needs to return nothing in particular.
What you give up is branching: one route, one method, one view or one redirect, with no way to choose a different template, set a status or read the session on the way through. When a page needs any of that, write an ordinary block route and call the same method through a handle — the two forms reach the same objects, and nothing stops an app using both.
Errors a route can see
A call that reaches a live worker and raises inside the object surfaces as Durable::RemoteError in the
route, with remote_class set to the raising exception’s class name ("ArgumentError", and so on):
begin
Household.get("home").refresh
rescue Durable::RemoteError => e
log.error "refresh failed", remote_class: e.remote_class, message: e.message
halt 502
end
The same exception class also covers a worker that could not be reached at all, with remote_class set to
"Melee::Objects::WorkerError" — there is no way to tell the two apart except by that string. The request’s
own timeout still bounds the whole thing, so a durable method stuck forever turns into a 504 rather than
hanging the route indefinitely.
From the command line
melee objects # every durable object, across every class
melee objects Household # just this class
melee objects Household home --destroy
Lists class, id, created, last call and the next pending timer (- when none), oldest first. --destroy
needs both a class and an id, and deletes the object exactly as calling destroy from inside it would.
Outbound HTTP
HTTP.getandHTTP.post: the only way an app talks to another server.
HTTP is the only way an app talks to another server. It is a thin client over net/http with
timeouts, redirect following and errors you can rescue. HTTP and Melee::HTTP are the same module;
either name works.
res = HTTP.get("https://example.com/feed.ics", timeout: 10)
halt 502, "feed is down" unless res.ok?
body = res.body
The two calls
HTTP.get(url, query: {}, headers: {}, timeout: 10, raise_on_error: false) -> HTTP::Response
HTTP.post(url, body:, query: {}, headers: {}, timeout: 10, raise_on_error: false) -> HTTP::Response
url is a String with an http or https scheme; anything else raises HTTP::Error. headers is a
Hash{String => String} (both are stringified). body is a required String on post — set your own
Content-Type header if the server needs one; there is no form or JSON encoding step, so build the body
yourself with JSON.generate(...) or by hand.
query: is a Hash appended to the URL, percent-encoded for you. Use it rather than building a query string
by hand:
res = HTTP.get("https://example.com/search", query: { "q" => params.fetch(:q), "limit" => 20 })
Only GET and POST exist. There is no PUT, PATCH, DELETE, HEAD, no request streaming, no connection reuse and no cookie jar.
The response
| Call | Returns |
|---|---|
res.status | Integer, e.g. 200 |
res.ok? | true when status is 200–299 |
res.body | String, "" when there was no body |
res.header("content-type") | String or nil, case-insensitive |
res.headers | Array[[String, String]] |
res.url | String, the URL this response came from — the final one after any redirects |
res.json | the body parsed as JSON; raises HTTP::Error when the body is not JSON |
res = HTTP.get("https://example.com/api/notes")
notes = res.ok? ? res.json : []
Index a parsed body by String keys and narrow with is_a? before arithmetic, as with request.json.
A 4xx or 5xx is a normal return, not an exception — check res.ok? or res.status. Pass
raise_on_error: true to get a HTTP::Error for any status ≥ 400 instead.
Timeouts
timeout: is whole seconds and applies to both connect and read; the default is 10. A timeout raises
HTTP::TimeoutError. Choose it deliberately: the request that is calling out has its own deadline at
the server, and a slow upstream holds a request slot for the whole time.
A Float is rounded up — timeout: 0.5 waits a second — because a sub-second timeout is not something the
compiled app can express. Write the number you mean. timeout: 0 is the one value that reads backwards:
net/http takes it as no timeout, so the call waits until melee-server kills the request.
Redirects
Any 3xx with a Location is followed automatically, up to 5 hops. 307 and 308 repeat the method and
body; every other 3xx becomes a GET with no body. A sixth hop raises HTTP::TooManyRedirects.
Errors
def refresh_feed(feed_id, name, url)
body = HTTP.get(url, timeout: 10).body
store_events(feed_id, ICal.parse(body))
log.info "feed refreshed", feed: name
rescue Melee::HTTP::Error => e
db.run "UPDATE feeds SET error = ? WHERE id = ?", "#{e.class}: #{e.message}", feed_id
log.warn "feed failed", feed: name, error: e.message
end
| Class | Raised when |
|---|---|
HTTP::TimeoutError | connect or read exceeded timeout: |
HTTP::TooManyRedirects | more than 5 hops |
HTTP::Error | DNS failure, connection refused, TLS failure, a non-http(s) URL, or raise_on_error: with a ≥ 400 status |
The first two are subclasses of HTTP::Error, so one rescue Melee::HTTP::Error catches everything this
module raises. Always rescue it somewhere: a network call inside a request that raises becomes a 500 for the
visitor.
Write the full path in a rescue. HTTP is an alias for Melee::HTTP, and a rescue clause that reaches
the class through an alias does not match under Spinel — it matches under CRuby, so melee dev looks right
and the deployed app does not rescue at all. Use rescue Melee::HTTP::Error. melee check refuses the other
spelling, naming the line, so this is a build error rather than something to remember. Calls are fine either
way: HTTP.get is the same as Melee::HTTP.get. (dialect.md, docs/research/spinel.md.)
TLS
https URLs verify the certificate chain against the system store. There is no flag to turn that off, and
none is planned. Certificate problems arrive as HTTP::Error.
The User-Agent is melee/0.1 and Accept is */* unless you set them in headers:.
Where to call it from
Anywhere a route can reach — a route block, a helper, an error handler. There is no background job runner
yet, so an outbound call happens while a visitor waits. For anything slow, fetch it on a request that can
afford it (an admin “refresh now” button, as the kitchen app does) and store the result in the database.
Logging
Structured events with
log.debug/info/warn/error, where the lines go, and what a 500 records.
log is a top-level method returning the request’s logger. There is one logger per request and it carries
the request id, so lines from one visit can be picked out of the stream.
post "/admin/refresh" do
log.info "refresh requested", feeds: db.first("SELECT COUNT(*) AS c FROM feeds")["c"].to_s
refresh_feeds
redirect "/admin"
end
The four levels
log.debug(message, **fields)
log.info(message, **fields)
log.warn(message, **fields)
log.error(message, **fields)
message is a String. fields are keyword arguments; both keys and values are stringified, so pass what
you like and it arrives as text. All four return nil — never use the return value.
log.warn "feed failed", feed: name, error: e.message, status: res.status
Keep fields flat and short. There is no nesting, no JSON value, no object serialisation: a Hash or Array
passed as a field arrives as its to_s, which is rarely what you want. Format it yourself first.
There is no log.level=, no per-app filtering and no way to suppress a level — every line is written.
Where the lines go
Under melee dev a line is written to the terminal (stderr) as level key=value …:
info msg="refresh requested" feeds=3
error msg=bad route="GET /boom" method=GET path=/boom class=ArgumentError
Values containing a space, a quote or = are quoted. The dev server also prints one line per request
(GET /admin -> 200 (4.2 ms)).
In production the same event is sent to melee-server as a log frame, stored as one NDJSON object per line in
the app’s log file, and read back with melee logs:
melee logs # the last 50 lines
melee logs --tail 200
melee logs -f # follow
melee logs prints HH:MM:SS msg key=value …. Anything the app writes to stderr directly ($stderr.puts,
and any uncaught output from the runtime) is captured too and shown as-is. Use log, not puts: puts
goes to stdout, which is buffered and not collected.
The 500 line
When an exception escapes a route, melee logs exactly one error line before your error handler runs:
error msg=<exception message> route="GET /boom" method=GET path=/boom class=ArgumentError
route is the pattern that matched, not the concrete path. That is all there is — a Spinel-compiled app has
no backtrace (Exception#backtrace returns []), so the class, the message and the route are the whole
story. Two things follow:
- Raise with a message that identifies the place:
raise ArgumentError, "feed #{id} has no url"rather thanraise ArgumentError. - Log before the risky call, not only after it. A
log.info "fetching", url: urlline is often the only way to know how far a request got.
Under melee dev you also get a full trace page in the browser, because CRuby does keep backtraces. Do not
rely on it for anything you need in production.
Fields that are already there
The request id is attached to every frame, so melee logs can group a request’s lines without you passing
anything. The route is attached to the 500 line only — log.info lines inside a route do not carry it, so
include what you need in the message:
log.info "note added", id: db.last_id.to_s, on_date: params.fetch(:on_date)
What is not here
No Logger object to configure, no formatters, no log rotation from the app’s side, no correlation ids you
set yourself, no metrics or counters, and no way to read the log back from inside the app.
Security
What is on by default — escaping, CSRF, signed sessions, TLS — and the four helpers you call yourself.
On by default
<%= %>escapes HTML in every template. Only<%== %>does not.- CSRF is checked on
POST,PUT,PATCHandDELETEfor any request carrying a session cookie. - The session cookie is signed,
HttpOnly,SameSite=Lax, andSecurewhen the request came over HTTPS. - Outbound HTTPS verifies certificates, with no way to turn it off.
db.query/first/runtake?placeholders, so bound values are never parsed as SQL.- The app runs as its own user in a kernel sandbox with no access to other apps’ files or databases.
Sessions
post "/login" do
if secure_equal?(params.fetch(:secret), ENV["ADMIN_SECRET"].to_s)
session[:admin] = "1"
redirect "/admin"
else
status 401
render :login, error: "That secret is wrong."
end
end
before "/admin" do
redirect "/login" unless session[:admin]
end
The session is a cookie named melee_session: base64url(JSON) plus an HMAC-SHA256 signature, keyed on the
app’s MELEE_SESSION_SECRET, which the server generates per app. It holds Strings only — session[:n] = 5
stores "5", and reading it back gives "5".
Signed is not encrypted. The visitor can read every value in their own session. Put an id or a flag there, never a password, a token or anything you would not show them. A tampered or unsigned cookie is treated as no session at all, silently.
The cookie is sent only when the request changed the session, lasts 30 days, and lives at Path=/. Keep it
small: browsers drop cookies over about 4 KB, and everything in the session travels on every request.
CSRF
Every form that changes something needs the field:
<form method="post" action="/admin/notes">
<%= csrf_field %>
<input type="text" name="text" required>
<button>Add</button>
</form>
csrf_field -> String is the hidden input, already HTML and not escaped by <%= %>. csrf_token -> String
is the raw value if you need to put it in a header from JavaScript (X-CSRF-Token is accepted in place of
the _csrf field).
The check runs before filters and before the route. A missing or wrong token is 403 Missing or invalid CSRF token, and the route never runs.
One thing to know: the check only applies when the request carries a session cookie. A POST from a
visitor who has no session is not checked, because there is no session to ride on. This is the right rule for
cross-site request forgery, but it means an unauthenticated write endpoint is protected by nothing — put
those behind a token or a login.
For a webhook, opt out at the route:
post "/webhook", csrf: false do
halt 403 unless secure_equal?(request.header("X-Hook-Secret"), ENV["HOOK_SECRET"].to_s)
text "ok"
end
The helpers
| Call | Returns |
|---|---|
secure_equal?(a, b) | true / false. Constant-time for equal-length inputs; false when either side is nil or the lengths differ |
random_token(bytes = 24) | String, URL-safe base64 without padding — 32 characters for the default 24 bytes |
h(value) | String, HTML-escaped (& < > " '). Templates do this for you; use it when you build HTML in Ruby |
csrf_token / csrf_field | see above |
Use secure_equal? for every comparison against a secret — a password, a token in a URL, a webhook
signature. == on Strings leaks the answer through timing.
get "/d/:token" do
halt 404 unless secure_equal?(params.fetch(:token), setting("display_token"))
render :display, layout: false
end
random_token is the right source for anything unguessable: display tokens, invite codes, API keys. Store
it with setting (see database.md) so it can be rotated.
Things to get right yourself
- Validate every value out of
paramsbefore it reaches SQL, a URL you fetch, or a filename. <%== %>on anything derived fromparams, the database or an HTTP response is an XSS. Use<%= %>.- Do not build SQL by interpolation, ever. There is no escaping helper because there is no safe one.
HTTP.get(params.fetch(:url))lets a visitor make the server fetch a URL of their choosing. Check it against a list you control first.- Secrets belong in
melee env, read asENV["NAME"]. Never inapp.rb, never in the repository.
What does not exist
No passkeys or WebAuthn, no password hashing helper, no rate limiting, no per-user authorisation layer, no
encrypted cookie, no signed URL helper, no CORS handling, and no Content-Security-Policy unless you set the
header yourself.
The Ruby that compiles
The Ruby that compiles: what the whole-program compiler forbids, and the workaround for each.
melee apps are compiled to a native binary by Spinel, a whole-program Ruby compiler. The language is real Ruby, but anything that decides what to call at runtime cannot be compiled ahead of time, and a few of the standard library’s parts are missing. This page is the list. Every rule has the reason in one clause and the thing to write instead.
The same code also runs under CRuby for melee dev, so a habit that works here works in both.
No runtime metaprogramming
eval, instance_eval/class_eval with a String, method_missing, define_method with a computed name,
send with a name that is not a literal somewhere in the program, Class.new, runtime include, extend,
attr_accessor outside a class body, ObjectSpace, .methods, .instance_variables, refinements — none of
these compile, because the compiler must see every call.
Write it out. Where you would dispatch on a name, use a case:
def label_for(kind)
case kind
when "note" then "Note"
when "event" then "Event"
else "Item"
end
end
No threads
Thread, Mutex, Queue, thread pools, background workers. One request is one process and it exits when
the response is written; nothing outlives it. Do the work in the request, or write a row and do it on a
later request (an admin “refresh now” button, as the kitchen app does), or give it to a durable object with
a timer.
melee check refuses them by name, so you find out at build time. It has to: the compiler accepts threads
and they behave under Spinel much as they do under CRuby, but a request is served by a fork of the warm
process and a fork keeps only the calling thread — a thread you started when the app loaded is not there in
the child that serves the request, and a lock held at fork time is held for ever. That failure only appears
in production, which is why the check exists rather than a note here.
A rescue names the class in full
Spell the exception class the whole way down from the top:
rescue Melee::HTTP::Error => e # matches
rescue HTTP::Error => e # never matches
HTTP is an alias for Melee::HTTP, and a rescue that reaches its class through a constant alias does not
match under Spinel: the exception walks straight past the handler and out of the request. An alias bound
straight to the class — your own E = Melee::HTTP::Error, and rescue E — never matches. An alias to the
namespace — rescue HTTP::Error — matches only while no other class anywhere in the program has the same
last name segment, and Error stops being unique the moment anything pulls in uri, which net/http does
for you. So in an app, neither spelling works. CRuby matches all of them, which is why the app looks right
under melee dev and only drops the error once it is deployed; melee shipped that bug in its own example app
before the check existed.
melee check refuses both, naming the file, the line and the path to write instead. A class of your own is
unaffected — rescue Feeds::NotFound matches on both runtimes — and so are calls through the alias:
HTTP.get and Melee::HTTP.get are the same call.
No Date, no Time.parse
There is no Date, no DateTime, and require "time" does not add Time.parse — the parsing half of Time
is not there. What you have is Time.now, Time.at(seconds), Time.local(y, m, d, h, min, s),
Time.utc(...), #strftime, #to_i, arithmetic in seconds, and the accessors (year, month, hour, …).
DAY = 86_400
def parse_date(s) # "2026-09-05" -> Time, or nil
return nil unless s.match?(/\A\d{4}-\d\d-\d\d\z/)
Time.local(s[0, 4].to_i, s[5, 2].to_i, s[8, 2].to_i)
end
Store timestamps as INTEGER Unix seconds (Time.now.to_i) or as TEXT in YYYY-MM-DD, and format with
strftime. Slice and to_i to parse.
No gems, and a short require list
There is no bundler and no Gemfile. require resolves only these: json, base64, digest,
securerandom, uri, net/http, openssl, set, csv, strscan, optparse, pathname, tmpdir,
forwardable. Anything else is a build error. require_relative "lib/ical" for your own files is fine and
is how lib/ is used.
Two exceptions inside that list: stringio exists but must never be required — a StringIO anywhere in the
program breaks method dispatch on real IO objects — and erb is a stub, which is why templates are compiled
at build time instead.
The melee stdlib already requires json, net/http, openssl, uri, securerandom and base64, so they
are available in app.rb without a require.
Frozen string literals
Every String literal is frozen, always. Put # frozen_string_literal: true at the top of your files so
CRuby agrees. To build a String, start from +"" or interpolation and append:
out = +""
rows.each { |r| out << r["text"].to_s << "\n" }
out = "" then out << x raises FrozenError.
Types are inferred, so keep containers uniform
The compiler gives every expression one type. An array holds one kind of thing; an empty [] that never
receives anything is an array of Integers. Two consequences:
- Do not put mixed kinds in one array. For a pair, use a two-key Hash or a small class with
attr_reader. - Prefer
map/selectoveracc = []followed by pushes of different shapes.
Hash keys must be Strings, Symbols, Integers or Floats — an Array cannot be a key, and your own #hash and
#eql? are ignored. Range bounds must be Integer, Float or String.
Values that may be nil need narrowing before you use them: row["n"].to_i, params.fetch(:x),
ENV["K"].to_s. Reading a missing key out of a numeric container gives a sentinel rather than raising, so
comparisons can quietly succeed on nonsense — check key? or use fetch with a default.
Data with behaviour: no blocks in containers
A Proc reached through a Hash or an Array cannot be called — undefined method 'call' for an instance of Proc at runtime — so a registry of little functions does not work. This is the shape most often reached for
first, and it is the one to unlearn:
# Does not work: the Proc comes back out of the Hash and cannot be called.
APPS = { "bluesky" => ->(who) { "https://bsky.app/profile/#{who}" } }
APPS["bluesky"].call("ada")
Store data and put the behaviour in a method. A placeholder String plus sub covers most of it:
APPS = {
"bluesky" => "https://bsky.app/profile/{who}",
"github" => "https://github.com/{who}"
}.freeze
def profile_url(app, who)
template = APPS[app]
template.nil? ? nil : template.sub("{who}", url_encode(who))
end
Where the behaviour really differs per entry, use a case keyed on the same String. Blocks are fine
everywhere else — each, map, db.transaction do, a route block — as long as they are written at the
call site rather than stored and fetched.
Errors that only appear at runtime
Wrong arity, calling a method on nil, and Integer + String all compile and raise when they run. So does
any undefined method with an explicit receiver, whatever the compiler knows about that receiver’s type:
"x".nope, 1.nope, db.frist(sql) and Household.get("home").refrsh all build cleanly and raise
NoMethodError on the line. What the compiler does reject is a receiverless call it cannot resolve — a
top-level helper you never defined, or self.nope. (Measured 2026-09-13 against the pinned Spinel
a9f1d9ae; melee check catching a misspelt method is not something to rely on.)
So the build succeeding is not proof the app works — run it under melee dev and click through it.
And there are no backtraces: Exception#backtrace and caller return [] in a compiled app. Raise with
messages that say where you were (raise ArgumentError, "feed #{id} has no url"), and log before the risky
line, not only after it.
Smaller edges
- A method that takes a block and calls itself recursively does not compile. Split it: a public method that takes the block, a private one that recurses.
defined?is resolved at compile time; do not use it to detect a runtime environment.- Integer overflow raises rather than promoting to bignum.
- Strings are UTF-8 or binary; there are no other encodings, and
String#bdoes not exist. - The regexp engine is its own: at most 31 capture groups, and backtracking is bounded, so a pathological pattern fails rather than hanging.
- Treat the filesystem as read-only. The app’s directory cannot be written to; the database is the place for
state. Do not shell out with
systemor backticks — nothing is guaranteed to be installed.
Working on an app
The commands you use while writing an app:
melee new,dev,check,push,logs,env.
All of these read melee.toml in the app directory (name, server, url) and take --dir <path> if you
are not standing in it. melee new is the exception: it writes that file.
melee new
melee new notes && cd notes
melee dev
Creates notes/ with a working two-route app: melee.toml, spin.toml, app.rb, views/layout.erb and
views/index.erb, public/app.css, a first migration, a .meleeignore and a README. The name must be
lowercase letters, digits and dashes, not starting with a dash, at most 40 characters — the same rule the
server applies. It refuses a directory that exists and is not empty.
melee dev
ADMIN_SECRET=letmein melee dev # http://127.0.0.1:4567
Compiles the templates and migrations, then runs the app under CRuby with a small development server. It prints one line per request:
melee dev: prepared 5 templates, 1 migrations
melee dev: http://127.0.0.1:4567 (Ctrl-C to stop)
GET /admin -> 200 (4.2 ms)
GET /admin.css -> 200 (public/)
- Port from
PORT, default 4567. One request at a time, no keep-alive, no TLS. - Files under
public/are served straight from disk, as the real server does. - The database is
.melee/app.sqliteinside the app directory. Delete it to start fresh. - Environment variables come from your shell, standing in for
melee env. - The session secret is
MELEE_SESSION_SECRET, defaulting to a fixed development value. - No reload. Changing
app.rb, a template or a migration means stopping and starting it again.
The error page
Under melee dev only, a 500 renders a page with the exception class and message, the request, the params,
the session and a CRuby backtrace. It exists to tell you what happened while you are writing the app.
Production has no backtrace at all (see logging.md), so do not build a habit on it.
melee check
melee check
The build without the deploy: the same template and migration steps, then a full Spinel compile of the app and the stdlib. Nothing is sent anywhere. It is the fastest way to find out whether the code compiles.
Every diagnostic is one line, file:line: message, and template errors are mapped back from the generated
Ruby to the .erb you wrote:
views/broken.erb:2: unexpected end-of-input, assuming it is closing the parent top level context
db/migrations/002_bad.sql:1: near "TABEL": syntax error
app.rb:30: views/login.erb requires `error`
What is caught here: Ruby syntax in app.rb and lib/**/*.rb (reported against the file you edited, before
Spinel runs), template syntax, every render/partial call against the template’s declared locals,
migration SQL (each file is run against a scratch database), unknown requires, eval, thread primitives, a
rescue clause that reaches its class through a constant alias, the other things dialect.md forbids, and a
call to a top-level helper that does not exist. What is not caught: wrong arity, calling a method on
nil, and any undefined method reached through an explicit receiver — db.frist(...), "x".nope and
Household.get("home").refrsh all compile and raise NoMethodError when the line runs. So click through the
app under melee dev before pushing.
melee push
melee push
Tars the app directory and sends it to the server named in melee.toml; the server builds it, and on success
activates the new release and prints the URL. build/, .git/, .melee* and spin.lock are never sent, and
a .meleeignore file (one path or name per line) excludes more.
A failed build answers with the same file:line: message diagnostics and deploys nothing; the previous
release keeps serving.
melee logs, env, restart
melee logs # last 50 lines
melee logs -f # follow
melee logs --tail 200
melee env ADMIN_SECRET letmein # set a variable; the app restarts on its next request
melee restart # stop the warm process; the next request starts it again
melee apps # what is deployed on this server
melee open # print and open the app's URL
melee env is the only place secrets belong. They arrive in the app as ENV["ADMIN_SECRET"].
Generated files
The build step writes three files. They are regenerated on every dev, check and push; never edit them
and do not commit them.
| File | From |
|---|---|
.melee/views.rb | views/*.erb — one method per template plus the render/partial dispatch |
.melee/migrations.rb | db/migrations/*.sql embedded as Melee.migrate([[name, sql], ...]) |
bin/<name>.rb | the entry point: requires the two above, then app.rb, then starts the runtime |
The same thing by hand
melee new writes these; this is what they are.
melee.toml:
name = "hello"
server = "http://127.0.0.1:7070"
url = "http://hello.localhost:8080"
The control API wants a token on every request. melee looks for it in MELEE_TOKEN, else a token_file
path named here — which has to be outside the app directory, because melee push uploads everything in it.
The development server writes one for you, so the local loop needs nothing here.
spin.toml:
[package]
name = "hello"
version = "0.1.0"
[dependencies]
melee = { path = "../melee/stdlib" }
app.rb:
# frozen_string_literal: true
title "Hello"
get "/" do
"<h1>Hello from #{app_title}</h1>"
end
Then melee dev and open http://127.0.0.1:4567.
What melee cannot do
The honest list: what the process model rules out permanently, what is simply not built yet, and when you should use something else.
Read this before you commit an app to melee. Some of these are consequences of the design and will not change; others are gaps with work planned behind them. They are marked differently on purpose.
By design means it follows from compiling ahead of time and running one process per request. Not built means it is intended and missing.
The process model
By design — no background work in your app. No threads, no Thread.new, no Queue, no job runner. The
build step refuses Thread and Queue by name. A request child exits when the response is written, and
there is nothing left to hand work to. The replacement is a durable object with a timer — see
Background jobs and scheduled work.
Not built — nothing schedules fibers. The platform rule is “fibers, not threads”, and the intent is that the library’s I/O suspends the current fiber. Nothing implements that today, so in practice there is no concurrency inside a request at all.
By design — nothing in memory survives a request. Instance variables, class variables, memoised values,
an in-process cache, a connection pool: all per-request. Only constants computed at the top level survive
(they are built once in the warm process and inherited through fork).
No concurrency inside a request. Two outbound HTTP calls run one after the other. Ten feeds is ten sequential fetches.
By design — no shared state between requests except the database. There is no Rails.cache, no Redis,
no memcached, no shared memory. A cache is a table, or a durable object.
The dialect
By design. melee apps are compiled whole-program, so anything that decides what to call at run time is
out: eval, method_missing, define_method with a computed name, send with a computed name, Class.new,
ObjectSpace, binding. Dispatch with case.
Also: no gems, ever. The available requires are a fixed list (json, base64, digest,
securerandom, uri, net/http, openssl, set, csv, strscan, optparse, pathname, tmpdir,
forwardable), plus require_relative for your own files. Never stringio.
Smaller ones that bite: string literals are frozen; there is no Date class and no Time.parse; a rescue
clause must name its class in full rather than through a constant alias; a Proc fetched out of a Hash or
Array cannot be called; Exception#backtrace and caller return [].
The Ruby that compiles is the complete list, with a workaround for each.
And the build checks less than it looks like it does. An undefined method reached through an explicit
receiver — db.frist(...), "x".nope, Household.get("home").refrsh — compiles cleanly and raises
NoMethodError when the line runs. So do wrong arity and anything called on nil. A green melee check is
not a working app.
Missing from the app surface
| File uploads | Not built. Multipart bodies are not parsed at all — a file input’s fields arrive empty — and the request body is capped at 8 MB. |
| Object storage | Not built. HTTP has no put or delete and no request signing, so you cannot hand bytes to S3 either. Designed, with presigned URLs, not started. |
| Not built. Nothing sends mail. An HTTP API for a mail provider is the only route today. | |
| WebSockets, SSE, long polling | Not built. An app cannot open a listening socket, and a request is a short-lived process under a 30-second cap. |
| Streaming responses | Partly. A route can produce a streamed body, but it is collected and then sent rather than passed through as it arrives. |
later / every on objects | Not built. You re-arm the timer inside on_timer yourself. |
| Routing straight to a durable object | Not built; under design. Every entry point is a route today. |
A cache helper | Not built. Use setting, a table, or an object. |
| Middleware | Not built. before filters with a path prefix are what there is. |
| Sub-second timeouts | Not built, and blocked: timeout: is rounded up to whole seconds because the compiler truncates a Float at the FFI boundary, so 0.5 would reach net/http as 0, which it reads as no timeout. |
The numbers
Defaults an operator can change, but that your app lives inside:
| Requests at once, per app | 4 (slots) |
| Request timeout | 30 s, then the process group is killed and the caller gets a 504 |
| Request body | 8 MB |
| Memory per app | 128 MB (memory.max, root only) |
| Processes per app | 256 (pids.max) |
| Idle stop | 5 minutes with nothing in flight |
| Cold start after that | about 2 ms |
| Database writers | one at a time; a second waits 5 s and then raises |
| Durable object calls | one at a time, per app, across every object |
| Redirects followed | 5 |
| Static file cache | max-age=300, no fingerprinting |
Operating it
This is where melee is least finished.
- Not built — no TLS.
melee-serverspeaks plain HTTP on both its ports. TLS is something you put in front (Caddy is the intended answer). Until you do, the control-API token and everymelee envvalue cross the network in the clear. - Not built — no installable CLI.
meleefinds the standard library through a checkout of this repository. There is no package, nobrew install, and the compiler is a git submodule you build yourself. - Not built — no deployment recipe. No systemd unit, no guidance on where the home directory lives, no log rotation, no upgrade path for the server or the compiler pin.
- Not built — no backups. The database is a file on one machine’s local disk and nothing copies it anywhere.
- Not built — single machine. No replication, no failover, no placement, no custom domains. An app is on one host, and that host is a single point of failure.
- Not built — no rollback command and no way to delete an app. Deploying the previous source again is the rollback; removing an app means deleting directories on the server by hand.
- Not built — no metrics. Logs are what there is: no request counters, no latency histogram, no health
beyond
/v1/health. - By design for now — the build runs on the server, as the server’s user, unsandboxed, with the toolchain on its PATH. It compiles source you pushed. That is fine when you are the only person who can push and not fine otherwise.
Security, stated plainly
The sandbox around a running app is real: a uid per app, user and mount namespaces, a Landlock path allow-list, a seccomp allow-list of 85 syscalls, and cgroup limits. One app cannot read another’s database, environment or session secret, and cannot signal its processes.
What is not closed:
- An app can make outbound connections anywhere, including loopback and private addresses. If your app fetches a URL a visitor supplied, that is an SSRF and nothing stops it.
- The build step is not sandboxed.
- An app can enumerate the host’s processes (no PID namespace, no private
/proc), though it cannot read or signal them. - The control API is a TCP port on loopback with a bearer token; moving it to a UNIX socket is open work.
- A uid is never retired, so a deleted app’s id is not safely reusable.
The threat model and the full findings table live in docs/design/security.md in the repository. The short
version: melee is currently safe to run your own code on a machine you control, and is not yet ready to
accept apps from people you do not trust.
When to use something else
Use a normal server and Rails (or anything else) when:
- the app needs gems, or a library that only exists as a gem;
- it needs Postgres, a connection pool, or more than one writer;
- it needs background workers, real queues, or fan-out;
- it needs file uploads, or to serve large media;
- it needs to stay up when one machine does not;
- it holds data you cannot afford to lose and cannot back up yourself;
- it is going to keep growing.
melee is for the app that is small, mostly idle, and would otherwise not be worth deploying at all. Inside that shape it is genuinely good. Outside it, it will fight you.
See also
- How a request reaches your app — why most of the first section is the way it is.
- The Ruby that compiles.
- Operating melee.
What a server needs
A Linux machine, root, Ruby, a built Spinel compiler and a checkout of melee. There is no package to install, and that is the biggest thing standing between melee and other people running it.
Be clear about the stage first. melee has only ever been operated by the person who built it. There is a working server, a working deploy, and a real sandbox; there is no installer, no systemd unit, no TLS and no backups. Everything on this page is true and none of it is a product.
The machine
| OS | Linux. The sandbox is seccomp, Landlock, namespaces and cgroups v2 — all Linux. melee-server runs on macOS but applies no sandbox whatever the flags, so macOS is for development only. |
| Kernel | Linux 6.7 or newer, and cgroups v2. melee asks for Landlock ABI v4 as a hard requirement, so an older kernel makes the server refuse to sandbox rather than sandbox weakly. A current Ubuntu or Debian is fine. |
| Privileges | root, if you want the sandbox to be worth anything: only root can give each app a uid of its own, and only root can set cgroup limits. The server refuses to start as root without --app-uid-range. |
| Architecture | x86-64 or arm64. |
| Disk | Small. Each release is a binary of a couple of megabytes plus the source; the databases are yours. |
| Memory | Each app is capped at 128 MB by default and idle apps are stopped, so the server’s own footprint plus a few tens of megabytes per active app. |
What has to be on it
Ruby 4.0, with the sqlite3 gem. The build step (stdlib/bin/melee-build) is a CRuby script that the
server shells out to on every deploy. Not needed to serve — only to build.
The gem is easy to miss and the failure is silent: the build step checks each migration by running it against
a scratch database, and without sqlite3 that check is skipped, not failed. A server missing it builds
happily and a migration with a typo first fails when the app next opens its database. gem install sqlite3.
The Spinel compiler, built from the commit melee is pinned to. It is a git submodule; you build it with
make deps && make inside it. This is the part with no shortcut: there is no release binary to download.
A C compiler and libssl. Spinel emits C and links it; the app binary depends on libc, libm and libssl.
A checkout of the melee repository, because the server needs the standard library sources (--stdlib) and
the build script. This is the honest core of the packaging problem: the server is not self-contained.
The two melee binaries, melee-server and melee, built with cargo build --release.
Getting them
From a checkout, with mise for the toolchains:
git clone --recurse-submodules <the melee repository>
cd melee
mise run setup # tools, the Spinel submodule, the sqlite3 gem, a Spinel build
mise run build # melee-server and the melee CLI into supervisor/target/release
mise run setup is written for a development machine. On a server you want the same three things: the
toolchains, a built vendor/spinel/bin/spin, and cargo build --release.
What the CLI needs, on your own machine
melee runs where you write the app, not on the server. It needs to find the standard library, which it does
through MELEE_STDLIB, a walk up from the app directory inside a checkout, or its own binary’s location.
Outside a checkout, none of those work.
melee dev additionally needs Ruby and the sqlite3 gem: it runs the app under CRuby and never invokes the
compiler. melee check needs a built Spinel on top of that, because it compiles the app locally.
melee push, melee logs, melee env and the rest only need the binary and network access to the control
API.
What is missing, specifically
These are P-03 and P-06 in the project’s own TASKS.md — packaging, and a deployment recipe. Until they
exist:
- there is no versioning story between the CLI, the standard library and the Spinel pin, and a mismatch is a miscompile rather than an error;
- there is no service unit, so keeping the server running across reboots is yours to arrange;
- there is no TLS anywhere, so the control API’s token and every
melee envvalue cross the network in the clear unless you put something in front.
Next
Running melee-server — the flags and what they mean.
Running melee-server
One process, two ports, one directory. Every flag, what it defaults to, and the two it refuses to start without.
melee-server \
--home /var/lib/melee \
--listen 0.0.0.0:8080 \
--api 127.0.0.1:7070 \
--api-token-file /etc/melee/token \
--domain apps.example.com \
--app-uid-range 60000-60999 \
--stdlib /opt/melee/stdlib \
--spin /opt/melee/vendor/spinel/bin/spin \
--ruby /usr/bin/ruby \
--tz Europe/London
The two ports
--listen (default 127.0.0.1:8080) is where visitors arrive. The app is chosen by the Host header
against --domain: with --domain apps.example.com, kitchen.apps.example.com is the app named kitchen.
Plain HTTP only — put a TLS terminator in front.
--api (default 127.0.0.1:7070) is the control plane: deploys, logs, env, restarts, objects. It is
what melee push talks to. Keep it on loopback and reach it over SSH, or put TLS in front of it; the token
and every environment value you set cross it in the clear.
| Route | What |
|---|---|
GET /v1/health | ok — and it needs the token too; there is no unauthenticated corner |
GET /v1/apps | what is deployed |
POST /v1/apps/{name}/deploys | a tarball; builds and activates |
GET /v1/apps/{name}/logs | the log, with follow |
PUT /v1/apps/{name}/env/{key} | set an environment value |
POST /v1/apps/{name}/restart | stop the warm process |
GET /v1/apps/{name}/objects, POST .../objects/destroy | durable objects |
The two it refuses to start without
A token. --api-token-file is required — there is no unauthenticated mode, even on loopback. Every
control request must carry Authorization: Bearer <the file's contents>.
install -m 600 -o root -g root /dev/null /etc/melee/token
head -c 32 /dev/urandom | base64 | tr -d '\n=' > /etc/melee/token
A uid range, when running as root. --app-uid-range LOW-HIGH gives every app a Unix user and group of
its own out of that range, which is what keeps one app out of another’s files, processes and signals. Running
as root without it is refused outright, because every app would then run as root. Pick a range no other
user on the machine uses:
--app-uid-range 60000-60999
Running as a non-root user is allowed and is what development does. You still get Landlock and seccomp; you do not get per-app uids or cgroup limits, and the server says so at startup:
WARN not root, so every app runs as uid 501: one app can read another's session secret out of /proc and
signal its processes. Single-tenant and development only; multi-tenant needs root and --app-uid-range
Every flag
| Flag | Default | What |
|---|---|---|
--home | ./melee-home | Apps, releases, data, logs. Everything melee knows lives here. |
--listen | 127.0.0.1:8080 | Where app traffic arrives. |
--api | 127.0.0.1:7070 | The control API. |
--api-token-file | required | File holding the control-API bearer token. |
--domain | localhost | Suffix that maps Host to an app name. |
--trusted-proxy | none | IP or CIDR (repeatable, comma-separated) of an edge allowed to set X-Forwarded-Proto, X-Forwarded-For and X-Melee-App. Unset: http, the socket peer, and Host-only routing. |
--edge-secret-file | none | File holding a shared secret (mode 600, 32+ bytes) a --trusted-proxy peer must also send as X-Melee-Edge to be believed. Without it, any local process at a trusted address can pass as the edge. |
--app-uid-range | none | LOW-HIGH; required as root, refused as anyone else. |
--slots | 4 | Requests at once per app. Also the number of socket pairs handed to each warm process. |
--idle-secs | 300 | Idle before a warm process is stopped. |
--request-timeout-secs | 30 | Then the process group is killed and the caller gets a 504. |
--build-timeout-secs | 300 | Then the build is killed and the deploy fails. |
--max-body | 8388608 | Largest accepted request body, in bytes. |
--memory-max | 128M | cgroup memory.max per app (root only). |
--cpu-weight | 100 | cgroup cpu.weight per app, 1–10000 (root only). |
--no-sandbox | off | Run apps with no kernel sandbox. Development, and macOS where there is none. |
--stdlib | ../stdlib | The melee standard library checkout, used by deploys. |
--spin | ../vendor/spinel/bin/spin | The Spinel compiler, used by deploys. |
--ruby | ruby | Ruby used to run the build step. |
--tz | UTC | TZ handed to app processes. |
Never use --no-sandbox on a machine serving anything real. It is there so the server can run on macOS,
where the sandbox does not exist.
Choosing the timeouts and slots
They are per-server, not per-app, which is a real limitation if you host apps with different shapes.
--slotsis both the concurrency limit and the number of file descriptors per warm app. Raising it lets one app use more of the machine and costs descriptors; the server logs the descriptor limit at startup.--idle-secsis the whole economics of melee. Low means apps are stopped sooner and cold starts are more frequent (about 2 ms). High means memory is held for apps nobody is using.--request-timeout-secsbounds the damage from a stuck request. Do not raise it to accommodate a slow route; move the slow work to a timer.
The home directory
<home>/
apps/<name>/
releases/<id>/source/ the pushed source, plus the spin.toml the server wrote
releases/<id>/bin/<name> the compiled binary
current -> releases/<id> swapped atomically on deploy
data/ the app's SQLite databases; the only place it can write
env KEY=VALUE, 0600, delivered as environment
logs/<name>.ndjson one JSON object per line
uids/<id> which app owns which uid
There is no database in the server; the filesystem is the registry. Two things follow:
- The home directory is the whole state of the system. Back it up, and understand that doing so means copying live SQLite files (see Logs, backups and upgrades).
- Its permissions matter. App processes must be able to traverse it to reach their own release. A
--homeinside a directory the app uids cannot traverse, or a server started with a restrictive umask, produces an app that looks broken rather than a clear error.
Keeping it running
There is no service unit shipped with melee, and writing one is open work (P-06 in the project’s TASKS.md).
What a unit has to get right, if you write one:
- Run as root and pass
--app-uid-range, or it is single-tenant. TimeoutStopSeclong enough for an orderly stop.SIGTERMmakes the server stop the app processes it owns; killing it instead leaves them behind.- Restart on failure, and expect app processes to be restarted on demand afterwards — nothing is lost by a server restart except warm processes.
- A umask that leaves release trees readable by the app uids (see above).
For development, scripts/server.sh in the repository runs it in the foreground on macOS and backgrounded
as root on Linux. It is a development helper and assumes the checkout; do not deploy with it.
Next
Deploys and releases
A push is source. The server compiles it, writes a new release directory, swaps a symlink, and stops the old process. Nothing is atomic-er than a
rename.
What happens
flowchart TD
A["POST /v1/apps/{name}/deploys<br/>a tar.gz of the app directory"] --> B["unpack into releases/<id>/source"]
B --> C["write spin.toml pointing at this server's stdlib<br/>(the pushed one is ignored)"]
C --> D["melee-build: compile templates, check the Ruby,<br/>run each migration against a scratch database"]
D --> E["spin build: Spinel compiles app + stdlib + SQLite"]
E -- ok --> F["releases/<id>/bin/<name>"]
F --> G["rename() the 'current' symlink"]
G --> H["stop the old warm process and worker"]
E -- error --> X["422 with diagnostics; nothing changes"]
The release id is <millis>-<counter>, created exclusively so two deploys cannot collide on one. The
current symlink is swapped with rename, which is atomic: a request either sees the old release or the new
one, never half of either.
The old warm process is stopped rather than drained. An in-flight request finishes; the next one starts the new release, which costs the usual couple of milliseconds.
What the server does not trust
The tarball is tenant input, and the server treats it that way:
- a
build/directory in the tarball is refused, so an uploaded executable can never become the app; - a symlink entry is refused;
- file modes are masked, so a planted setuid file comes out non-setuid;
spin.tomlis rewritten to point at this server’s standard library, whatever the pushed one said.
What it does not do is sandbox the build itself. melee-build runs as the server’s user with the toolchain
on its PATH, compiling Ruby that somebody pushed. That is fine when the only person who can push is you, and
it is the reason melee is not ready for untrusted app authors. It is open work (SEC-04, and P-01 for signing
what comes out).
One deploy at a time per app. A second concurrent deploy of the same app is refused rather than queued — two builds in one source tree race to activate and the loser’s release ends up live with the winner’s binary. Different apps deploy concurrently.
A failed build changes nothing
app.rb:12: unsupported eval of a runtime string is not supported by AOT compilation
The response is a 422 carrying {diagnostics: [{file, line, message}], output}, and the CLI prints one
line each. No release is activated; the previous one keeps serving. A broken push is never an outage.
Diagnostics are any path.rb:LINE: or path.erb:LINE: found in the build output, made relative to the
pushed source, with template lines mapped back from the generated Ruby to the .erb.
The build is killed at --build-timeout-secs (300 by default) and the deploy fails.
When a release won’t start
A build can succeed and the binary still fail to run: code that raises while the app loads, or
MELEE_SESSION_SECRET missing at boot. When the warm process dies before it has answered a single request,
the server retries once immediately, on the chance it was a one-off. A second failure in a row is taken as
proof the release itself is broken rather than bad luck: the server stops respawning it for 10 seconds and
answers every request in that window with a 503 and Retry-After set to whatever is left of it, instead of
trying to start the process again for each one. The log gets one line per attempt:
[melee] warm process exited before answering its first request; trying again
[melee] warm process exited before answering its first request, 2 times in a row; cooling down for 10s before trying again
A slow first request that simply times out is not held against the release — a cold start under load looks
identical to a dead process from the server’s side, and backing off after one slow request would turn a busy
moment into a ten-second outage. The most common real cause is either your own code raising before any route
can run, or MELEE_SESSION_SECRET being unset: the server generates one and stores it for you on an app’s
first deploy, but an env file that ends up without a usable value some other way (hand-edited outside melee env, say) leaves the runtime nothing to sign sessions with, and it refuses to start rather than run with no
key. Its refusal line shows up verbatim in melee logs:
melee: MELEE_SESSION_SECRET is not set; refusing to start in warm mode (melee-server sets this on deploy; melee-drive needs --env MELEE_SESSION_SECRET=...)
The cooldown is not something to wait out: a new melee push, melee restart, or melee env all clear it
immediately, so once the release is fixed the very next request tries again rather than waiting out the 10
seconds.
Rolling back
There is no rollback command. The releases are all still on disk, but nothing exposes them.
What you can do:
melee push # from the previous source
Re-pushing the previous source is the rollback, and it costs a rebuild. If you need better than that, keep the app in git and tag what you deployed — there is no provenance recorded on the server today (that is P-01: a release manifest and a signature).
Old release directories are not pruned. They accumulate, one per successful deploy, each holding the
source plus a two-megabyte binary. Removing old ones is a manual job; leave current and the one before it.
Environment values
melee env NOTES_SECRET letmein
PUT /v1/apps/{name}/env/{key} writes <home>/apps/<name>/env, mode 0600, replaced by rename. The app is
restarted on its next request, so the change takes effect without a deploy. MELEE_SESSION_SECRET is the one
key with a rule of its own: it has to be at least 32 bytes, and the server refuses anything shorter with a
400.
Two things to know:
- The value crosses the control API in the clear unless you have TLS in front of it.
- Setting an environment value on an app name that has never been deployed creates the app directory and permanently spends a uid from the range. A typo costs an id, and ids are never retired (SEC-30).
Restarting
melee restart
Stops the warm process and the worker. The next request starts them again. Useful after changing something on disk, and harmless — no state lives in those processes that is not also in the database.
Watching a deploy
melee logs -f
[melee] warm process started (pid 91129, 4 slots, sandbox off, cgroup off), release 1789257411211-0000
[melee] killing warm process group 91129 (environment changed)
[melee] warm process exited: signal: 9 (SIGKILL)
Server events are tagged [melee] and are mixed in with the app’s own log lines.
Next
Keeping it safe
What the sandbox actually stops, what it does not, and the four things you have to do yourself.
melee’s position is that the compiler is not a security boundary; the kernel is. An app is Ruby compiled to a native binary, and the platform assumes that binary may do anything. What contains it is applied to the process before your code runs.
What is in place
Four of these are applied by the child itself between fork and exec, in this order, so they are in place
before the app’s own code runs and every request child inherits them. The other two are the server’s work,
before and after:
| A uid and gid per app | From --app-uid-range. This is what file modes, signals and /proc are built on. One app cannot signal another’s processes or read its files by permission. |
| User and mount namespaces | Its own view of users and of the mount table. No network namespace — apps may make outbound connections. |
| Landlock | An allow-list of paths: read on the system libraries and this app’s own release, read-write on this app’s data directory, nothing else. Paths the uid would be allowed to read are still refused. |
| seccomp | An allow-list of 85 syscalls, KillProcess on anything else. No mount, unshare, setns, ptrace, process_vm_readv, listen, bpf or keyctl. |
Around them:
| A clean environment | Built on the command by the server before the fork: cleared and rebuilt from an explicit list. |
| cgroup v2 | Applied by the server after the child exists, by writing its process id into the group: memory.max 128M, cpu.weight, pids.max 256, per app, root only. |
At the front, Host maps to an app name through a validator; response headers are rebuilt so CRLF injection
is impossible; Content-Length and Transfer-Encoding from the app are dropped and recomputed; static paths
refuse .., empty and dotfile segments; bodies are capped at --max-body; remote_addr is the socket peer,
and app routing is by Host alone, unless the peer is a configured --trusted-proxy (in which case
X-Melee-App can route instead, SEC-34). X-Melee-App and X-Melee-Edge are the server’s own headers and
never reach the app either way.
The two credential mechanisms overlap deliberately: uids cover processes and signals, Landlock covers files. Reaching another tenant’s data means defeating both.
What is not in place
Say these out loud before you put melee on the internet.
Outbound connections are unrestricted. There is no network namespace and no egress proxy. An app can
connect() anywhere, including loopback and the host’s private network — so an app that fetches a
visitor-supplied URL is an SSRF against anything else on that machine, including melee’s own control API.
Restricting this in the HTTP client is open work (SEC-17); a network namespace with an egress proxy is the
real fix and is a later milestone.
The build is not sandboxed. It runs as the server’s user, unsandboxed, on source somebody pushed, with a compiler and a C toolchain on its PATH. If you accept pushes from anyone you do not fully trust, this is the hole (SEC-04).
Apps can see the host’s processes. No PID namespace and no private /proc, so an app can enumerate what
is running — it just cannot read or signal it (SEC-10, SEC-12).
The control API is a TCP port. Loopback by default and token-protected, but a port. Moving it to a UNIX socket owned by the operator would be a real boundary now that apps have their own uids (SEC-28).
Uids are never retired. There is no delete-app route; removing an app is deleting directories by hand, and the next app to ask can be given that id along with whatever the old tenant left behind (SEC-30).
There is no TLS. Both ports are plain HTTP.
The full threat model, attacker positions and findings table live in docs/design/security.md in the
repository, and it is re-run at every milestone.
The four things you have to do
1. Put TLS in front
melee-server speaks plain HTTP on both ports. Without a terminator in front:
- session cookies are readable by anyone on the path, and the
Secureflag is only set when the request arrived over HTTPS; - the control-API token and every
melee envvalue cross the network in the clear.
The intended answer is a reverse proxy owning TLS, custom domains and compression, with melee-server behind
it keeping routing and activation. Point it at --listen, and keep --api on loopback reachable over SSH
rather than exposing it at all.
Set --trusted-proxy to the proxy’s address (127.0.0.1 when Caddy is on the same box) once it is in
place. Without it melee-server does not believe X-Forwarded-Proto, X-Forwarded-For or X-Melee-App from
anyone, so every request looks like plain http from the proxy’s own address, routed by Host alone; this
is what closes SEC-07 and SEC-34, letting any client forge Secure cookies, its own client address, or the
app it is routed to. A trusted peer’s last header line wins for all four of X-Forwarded-Proto,
X-Forwarded-For, X-Melee-App and X-Melee-Edge (for X-Forwarded-For that means its rightmost entry,
specifically) — anything earlier in any of them could be whatever an untrusted upstream hop claimed.
--trusted-proxy alone only checks the TCP address, not the process behind it. On one machine, a
sandboxed app shares 127.0.0.1 with Caddy, so without more, that app could send these same headers to
itself. Add --edge-secret-file <path> — a file holding a shared secret, same rules as --api-token-file
(mode 600, at least 32 bytes, openssl rand -hex 32 is enough) — and a --trusted-proxy peer additionally
has to send it back as X-Melee-Edge, checked in constant time; the header itself never reaches the app.
This closes SEC-33.
The two flags are meant to be set together, and the server warns at startup if only one is:
WARN --trusted-proxy is set without --edge-secret-file: TCP carries no process identity, so on one machine
every sandboxed app shares the trusted address with Caddy and can forge X-Forwarded-* and X-Melee-App for
itself the same way Caddy does (SEC-07)
WARN --edge-secret-file is set without --trusted-proxy: nothing is a trusted proxy yet, so no peer's
X-Forwarded-*, X-Melee-App or X-Melee-Edge is ever believed — pass --trusted-proxy too
On the Caddy side, set the header from its own environment:
header_up X-Melee-Edge {env.MELEE_EDGE_SECRET}
kept short here on purpose; the full Caddy recipe lives in deploy/README.md.
2. Protect the token
It is a bearer token in a file. Anything holding it can deploy any app, read any log and set any environment value on that server.
install -m 600 -o root -g root /dev/null /etc/melee/token
head -c 32 /dev/urandom | base64 | tr -d '\n=' > /etc/melee/token
On the client side, melee reads it from MELEE_TOKEN or a token_file named in melee.toml. That path
must be outside the app directory, because melee push uploads everything in it — the CLI refuses a
token file inside. There is deliberately no ~/.melee/token fallback: a single token read for any directory
would be sent to whatever host that directory’s melee.toml named, so checking out somebody else’s
repository would hand them a working deploy credential.
There is one token per server, not per app. Scoped per-app tokens are a later milestone.
Plain http:// is refused for any server other than loopback (SEC-36). 127.0.0.1, 127.x.x.x,
::1 and localhost are allowed over http://, because that is what an SSH tunnel or a same-machine dev
server look like; anything else has to be https://, since plain http:// to a real host would send the
control-API token in the clear to whatever can see the network in between.
server and url in melee.toml can each be overridden without editing the file: --server/MELEE_SERVER
and --url/MELEE_URL, a flag winning over its environment variable winning over melee.toml. An empty
override — --server "" or MELEE_SERVER= set but empty, the kind of thing a script leaves behind — counts
as not set, not as a value that wins.
Every command that talks to the control API (push, apps, env, restart, objects, logs) prints
which server it is about to use, before it reads the token or makes any request:
using control API at http://127.0.0.1:7070
or, when a flag or the environment picked it instead of melee.toml:
--server overrides melee.toml: using control API at http://127.0.0.1:7071
This is how to push to production without editing the committed melee.toml, which stays the development
configuration (pointing at 127.0.0.1:7070, reached without a tunnel):
ssh -L 7071:127.0.0.1:7070 <host>
melee --server http://127.0.0.1:7071 push
The tunnel’s local port (7071) is deliberately not 7070, the port the dev melee.toml points at:
mistyping the command and dropping --server then reaches nothing, rather than quietly deploying to
production. (The tunnel’s local end is loopback, so this still satisfies the http:// restriction above;
the encryption happens inside the SSH tunnel, not the control API’s own HTTP.)
melee open opens url in a browser and is the other place an override can hand it a value: it now checks
that url starts with http:// or https:// before invoking open/xdg-open, and otherwise prints why it
did not, rather than pass an untrusted string straight to that command.
3. Run as root, with a uid range
--app-uid-range 60000-60999
Without it, every app runs as the server’s user, which means one app can read another’s session secret and signal its processes. The server refuses to start as root without it, and warns loudly when it is not root at all. Pick a range no other user on the machine uses.
4. Decide whose code you will run
This is the real question. Today melee is safe to run your own apps on a machine you control. It is not ready to accept apps from people you do not trust, because the build step is unsandboxed and there is no provenance on what gets built. If you want to host other people, that is P-01 and SEC-04 first.
Checking it works
The repository carries a table of negative probes — things an app must not be able to do — run against real
sandboxed processes on Linux (cargo test -p melee-server -- --ignored sandbox::). They are the evidence for
the claims on this page, and they only run on Linux. A claim about sandbox behaviour that was checked on
macOS is worth nothing: there is no sandbox there at all.
Next
Logs, backups and upgrades
One NDJSON file per app that nothing rotates, a home directory nothing backs up, and an upgrade path you have to think about because the compiler is pinned.
Logs
<home>/logs/<name>.ndjson
One JSON object per line, holding three kinds of entry mixed together: the app’s own log.* events (each
carrying a request id), anything the app wrote to stderr, and the server’s own events.
melee logs # the last 50 lines, formatted
melee logs --tail 500
melee logs -f # follow
or read the file directly on the server — it is NDJSON, so jq works.
Nothing rotates them and nothing caps their size. An app logging in a loop will fill the disk. Until
melee does this itself (it is open work), a logrotate entry with copytruncate is the pragmatic answer:
melee holds the file open and appends, so truncating in place is safer than renaming out from under it.
There is no log level filter and no way to suppress a level — every line an app writes is written.
Backups
There is no backup and no replication. The whole state of the system is --home, and it is on one machine’s
local disk.
What is in there:
apps/<name>/data/ | the only thing that is irreplaceable — the app’s SQLite databases and durable object storage |
apps/<name>/env | environment values; irreplaceable if you did not record them elsewhere |
apps/<name>/releases/ | rebuildable from source |
logs/ | worth keeping, not critical |
uids/ | the uid assignments; keep it, or apps change ownership |
Do not copy a live SQLite file with cp. The database is in WAL mode and a plain copy can catch it
mid-write. Use SQLite’s own backup, which is consistent against a live database:
sqlite3 /var/lib/melee/apps/notes/data/app.sqlite ".backup '/backup/notes-$(date +%F).sqlite'"
Durable objects have a file each, under the app’s data directory — back the whole directory up the same way, per file.
The files are owned by the app’s uid and the directories are 0700, so this runs as root. That is the point:
if you can read them without sudo, the isolation is not working.
Restoring is putting the files back and restarting the server. There is no import command.
Upgrading
Three things have versions and they have to move together: melee-server, the melee standard library, and
the Spinel commit the library is pinned to. A mismatch is a miscompile, not an error — there is nothing
today that checks them against each other, which is the sharpest edge on this page.
A safe-ish sequence:
- Update the checkout on the server, submodule included.
- Rebuild Spinel (
make deps && makeinvendor/spinel) and the binaries (cargo build --release). - Stop the server with
SIGTERMso it stops the app processes it owns. - Start the new one.
- Re-push every app. Deployed binaries were compiled against the old standard library and the old compiler. They keep running — nothing invalidates them — but they are not what the new server would build, and the next deploy of that app will be. Re-pushing makes the fleet consistent while you are watching rather than later when you are not.
Step 5 is the part it is tempting to skip. Whether skipping it is safe depends on what changed in the standard library, and nothing tells you.
Upgrading the Spinel pin deserves more care than upgrading melee: it is a whole-program compiler, the
dialect’s traps are recorded against a specific commit, and the project’s own rule is to rebase the
submodule deliberately rather than track master. Read docs/research/spinel.md and run the test suite before
and after.
Restarts and reboots
A server restart loses nothing but warm processes. Apps restart on their next request, and scheduled work survives: pending timers are recorded in each app’s own database, and after a start or a deploy the server runs each app’s worker once to ask what it has pending, so a timer still fires even though nobody visited.
Stop with SIGTERM and give it time. The server’s orderly shutdown stops the app processes it owns;
SIGKILL leaves them behind as strays that nothing will reap.
Disk
Three things grow and nothing prunes them:
- Release directories, one per successful deploy: source plus a binary of a couple of megabytes. Delete
old ones by hand, keeping
currentand the one before it. - Logs, as above.
- App data, which is the app’s business.
A failed build also leaves an un-activated release directory behind.
What you do not have
- No metrics: no request counters, no latency histogram, no per-app resource reporting. Logs and
melee appsare the observability story. - No alerting.
- No health check beyond
GET /v1/health(which needs the token). - No multi-machine anything: no replication, no failover, no placement.
For an app whose availability matters, that list is the argument for running it somewhere else.
Next
Standing up a real server
The recipe actually run against a Hetzner CPX12 (1 vCPU, 2 GB, x86_64), Debian 13, on 2026-09-15: get the code there, install, start, push from a laptop, check it, back it up, and know what to do when it breaks. Where a step has not been fully verified yet, that is said plainly rather than papered over.
Everything here is deploy/ made concrete for one box, one operator, one app (docs/design/production.md,
tasks PROD-01..11 in TASKS.md). Four mechanisms outside Rails show up repeatedly below: fork (the
kernel clones a running process; melee forks a warm process into a short-lived request child per HTTP
request), Landlock (a Linux feature that lets a process narrow its own filesystem access to an
allow-list), seccomp (a kernel filter on which syscalls a process may make at all), and the user
namespace (unshare(CLONE_NEWUSER), letting a process remap its own uid as one more wall, on top of — not
instead of — the real per-app uid). The full account of what they stop and don’t is
Keeping it safe; this page is only the operating recipe.
What you need
- A Linux machine with root over SSH. Not the box your other projects share — this one runs sandboxed code.
- Kernel 6.7 or newer, with Landlock (ABI v4+) and cgroup v2.
melee-servertreats both as a hard requirement and refuses to start without them (What a server needs). - Debian 13, not Ubuntu 24.04. Ubuntu’s default
kernel.apparmor_restrict_unprivileged_userns=1sysctl very likely blocks the sandbox’s drop-uid-then-unshare(CLONE_NEWUSER)sequence. Confirmed absent on this box’s Debian 13 (kernel6.12.107+deb13-cloud-amd64) — the worry doesn’t even arise here — but still untested against any kernel that actually carries it, Ubuntu’s own included. - Provider firewall: 22, 80, 443 in — and 443 out. Outbound is easy to forget, and it isn’t for GitHub
(see below); it’s for
apt, mise’s and Caddy’s own package repositories, all fetched over HTTPS. Found the hard way on this run: without it,install.shdoesn’t fail loudly — it just hangs on its first HTTPS fetch, and the fix was adding the outbound rule at the provider, not anything in the script. - A DNS record for the app’s hostname, pointing straight at the box’s IP. Not through a CDN proxy: Caddy issues its own certificate with the HTTP-01 challenge on the first request it sees for that hostname, which has to reach the box itself. A proxy in front of the box would answer that challenge instead, or not forward it at all, and Caddy would never get a certificate.
Getting the code there
The melee repository is private and this box carries no GitHub credentials, so install.sh’s own
git clone step was skipped in favour of rsync-ing an existing checkout onto the box:
rsync -a --exclude='*.o' --exclude='*.a' --exclude='target' --exclude='build' \
./ root@<host>:/opt/melee/
Two things this run needed that a plain rsync doesn’t get right on its own:
- A worktree’s
.gitis a pointer file, not a repository — it names another checkout’s.git/worktrees/...path, which doesn’t exist on this box. It has to be replaced with a real, standalone clone’s.gitdirectory before anygitcommand on the box will work at all. chown -R root:rootafter, sincersyncfrom a laptop leaves everything owned by that laptop’s uid, andinstall.sh— andmelee-serveritself — run as root.
With that done, install.sh is told the commit is already there:
MELEE_COMMIT="$(git rev-parse HEAD)" ./deploy/install.sh
install.sh checks whether $MELEE_CHECKOUT/.git is already at $MELEE_COMMIT before it does anything
network-shaped, and skips the clone (and the vendor/spinel submodule update) entirely when it is — which is
exactly the case here.
Installing
install.sh runs as root, once, and is safe to rerun. In order: kernel checks (version, Landlock — probed
through the landlock_create_ruleset syscall itself, not a /sys file that turns out not to exist on any
kernel — cgroup v2, the AppArmor userns sysctl); system packages and Caddy; mise and the exact Ruby/Rust
mise.toml pins; the checkout (above); Spinel (make deps && make); melee-server and melee
(cargo build --release); the home directory; the control-API token and the Caddy edge secret; and the
systemd units, Caddyfile, logrotate config and backup timer, all read from the checkout’s own deploy/.
The two unit files are not installed verbatim. systemd rejects an ExecStart= line whose first word is
a ${VAR} — it checks the executable against the literal unit-file text before any variable substitution
happens — so melee-server.service and melee-backup.service write @MELEE_CHECKOUT@/@MELEE_RUBY@ as
plain-text placeholders in the executable position, and install.sh substitutes them with sed before
installing the unit, the same way it already does for logrotate.conf’s @MELEE_HOME@. Everything after the
executable — ${MELEE_HOME}, ${MELEE_DOMAIN}, ${MELEE_APP_UID_RANGE}, ${MELEE_TRUSTED_PROXY},
${MELEE_TZ} — stays a genuine systemd variable, read from /etc/melee/server.env at every start: those are
what an operator is meant to edit later, and a systemctl restart melee-server alone is enough to pick them
up.
On this box (1 vCPU, 1.9 GB) the whole run took 5 minutes 34 seconds: Spinel about 2 minutes 30 seconds,
cargo build --release 2 minutes 29 seconds, no OOM despite the box’s small memory. Both the kernel checks
and the home-directory layout checks passed cleanly.
What’s left is not automated — install.sh says so at the end — because it is genuinely operator judgment:
Variable (/etc/melee/server.env) | What |
|---|---|
MELEE_CHECKOUT, MELEE_HOME | Where the repo and the app data live — /opt/melee, /var/lib/melee by default |
MELEE_DOMAIN | --domain; only matters under the <app>.<domain> routing shape in the Caddyfile |
MELEE_APP_UID_RANGE | --app-uid-range; must not collide with any other user on the box |
MELEE_TRUSTED_PROXY | 127.0.0.1 — Caddy, assumed to run on this same box |
MELEE_TZ, MELEE_RUBY, PATH | TZ for app processes; Ruby resolved once at install time; just enough PATH for cc, which spin shells out to by bare name |
Review that file, edit /etc/caddy/Caddyfile’s hostname and routing option, and confirm the firewall really
is 22/80/443-only before starting anything.
Starting
systemctl enable --now melee-server melee-backup.timer
systemctl restart caddy # a full restart, not reload — the edge secret's EnvironmentFile is read at start
Caddy requested and got a certificate for volleyball.apps.ideasasylum.com on its first request, with DNS
and outbound 443 both in place. Before trusting any of it, the gate is three checks, not a feeling:
journalctl -u melee-server -n 50 # no "--trusted-proxy is set without --edge-secret-file" warning
ss -ltnp # 22, 80, 443, loopback 8080/7070 — nothing on 2019
journalctl -u caddy | grep MELEE_EDGE_SECRET # empty — the secret never reaches the journal
A warning at startup, or anything on 2019 (Caddy’s admin API, which should be a UNIX socket, not TCP,
here), or a hit in the Caddy journal means the edge-secret protection (PROD-03, SEC-33) is not actually
in place on this box, whatever the Caddyfile says.
Pushing from your laptop
The control API (--api) is loopback-only by design — reach it by tunnelling in, on a port that is
deliberately not the development default, so a dropped --server flag fails loudly instead of quietly
hitting production:
ssh -L 7071:127.0.0.1:7070 <host>
scp <host>:/etc/melee/token ~/.melee-production-token && chmod 600 ~/.melee-production-token
Keep that file outside any app directory — melee push uploads everything in the app directory, and the CLI
refuses a token file inside it.
MELEE_TOKEN="$(cat ~/.melee-production-token)" melee --server http://127.0.0.1:7071 push
The first line printed is the destination, before the token is even read:
--server overrides melee.toml: using control API at http://127.0.0.1:7071
The first push — a full Spinel compile of volleyball and the stdlib, on this box’s own CPU — took 34.9 seconds.
Checking it works
robots.txt (served straight from public/, no warm process involved) came back fine on the very first
try, before anything else did — which is the trap this run actually hit: it makes the sandbox look healthy
from the outside even when it isn’t. It wasn’t, yet. The warm process’s first start on x86_64 died
immediately with SIGSYS, because the seccomp allow-list in sandbox.rs was built from an arm64 strace
(M0-11), and arm64 only ever has the *at/ppoll/dup3 forms of several calls and no arch_prctl at
all — so six legacy x86_64 entry points glibc and the Spinel runtime still reach for were simply never in
the list: access, arch_prctl, dup2, mkdir, poll, unlink, found one at a time by stracing
volleyball outside the sandbox on the box itself, each the next syscall the warm process was killed on.
Every real route 503’d until the fix (T-13: all six pushed onto the allow-list under
#[cfg(target_arch = "x86_64")]) landed and was rebuilt on the box. What follows is what ran after that
rebuild, not a plan for later:
curl -i https://volleyball.apps.ideasasylum.com/ # 200
curl -i https://volleyball.apps.ideasasylum.com/robots.txt # 200
curl -i -H 'Accept-Encoding: gzip' .../public/game.css # gzip
GET /→200, with a session cookie carrying bothSecureandHttpOnly.POST /games, with the page’s own CSRF token (not a stale or guessed one) →303.- The created game’s own page →
200. POSTa point on it →303, and the point shows up in the game’s history.robots.txt→200; the CSS is served gzip-encoded.- The negative control: a request straight to loopback (
127.0.0.1:8080, bypassing Caddy entirely) with a spoofedX-Forwarded-Proto: httpsheader and noX-Melee-Edgegets a cookie back withoutSecure. This is the proof that matters — not that--trusted-proxyis set, but that the edge secret, not just the source address, is what it actually checks (PROD-03,SEC-33); loopback is shared with every sandboxed app, so the address alone proves nothing.
Separately, sandbox::tests ran on the box itself as an unprivileged (non-root) user — 4 tests, all
passing. That’s the half of PROD-06 the arm64 dev VM could assert the mechanism for but never the box’s
own kernel: Landlock, seccomp and the uid drop, exercised for real, not by extension from another
architecture.
Backups and restore
melee-backup.timer runs backup.sh daily (OnCalendar=daily, a 15-minute random delay so it doesn’t
always land on the same second as anything else), as root, into dated directories under
/var/backups/melee/<date>/ — a sqlite3 .backup of every app’s data plus its env and uid files, pruned
after 14 days.
The timer running on schedule proves nothing by itself, so it was run by hand once rather than trusted on
faith: melee-backup.service backed up all 3 of volleyball’s SQLite databases (the app’s own plus one per
durable object — a game is a Durable instance with its own storage file). Then the drill in
deploy/restore.md — stop the server, find the dated backup, restore the app’s data with its uid, restart,
check the app — was followed for real, restoring into a scratch directory rather than over the live one:
the restored database opened cleanly, and its melee_objects registry (docs/design/persistent.md) came
back with 2 rows, matching what was actually live on the box. PROD-09’s own rule — nothing counts as “done”
until a restore has actually been tried — is what this satisfies.
Upgrading
melee-server, the stdlib and the Spinel pin move together — a mismatch between them is a miscompile, not
an error, because nothing checks them against each other yet:
git -C /opt/melee fetch origin && git -C /opt/melee checkout --detach <new-sha>
git -C /opt/melee submodule update --init vendor/spinel
(cd /opt/melee/vendor/spinel && make deps && make) # only if the Spinel pin moved
mise exec -- cargo build --release --manifest-path /opt/melee/supervisor/Cargo.toml
systemctl restart melee-server # SIGTERM: stops the app processes it owns first
Then re-push every app. A deployed binary keeps running against whatever stdlib and Spinel commit it was built with — nothing invalidates it on its own — but it is not what the new server would build from the same source, and the next deploy of that app will be.
Don’t restart the server on purpose while a push is in flight. A push’s activation is atomic — it only swaps
the current symlink after a full, successful build — so one that loses its server partway through just
leaves the previous release running rather than half-installing anything. That’s a safety net for an
accident, not a reason to interleave an upgrade with a deploy; finish the push first.
When it breaks
A release that dies before answering its first request gets one immediate retry, then a 10-second cooldown
where every request gets a 503 rather than a fresh spawn attempt each time — see
Deploys and releases, “When a release won’t start” for the exact mechanics. The most common
cause is MELEE_SESSION_SECRET missing, which shows up verbatim in the logs:
melee: MELEE_SESSION_SECRET is not set; refusing to start in warm mode
melee --server http://127.0.0.1:7071 logs -f # the app's own log lines, mixed with the server's [melee] events
journalctl -u melee-server -f # the unit's own stdout/stderr — startup warnings, panics
What is not covered yet
- Multi-tenant hardening — a pid namespace and private
/proc, the build running as its own uid rather than root, per-app credential hygiene across many apps. None of it is missing by accident; there’s one tenant here, and it’s the operator. See Keeping it safe, “What is not in place.” - Packaging (P-03). This box still needs the whole repository checkout and toolchain, same as the dev
machine —
deploy/install.shis a recipe, not an installer you hand to someone else. - Signed builds (P-01). Nothing records what commit produced a given release binary or proves it wasn’t tampered with in transit; a rollback today is “re-push the old source,” not “restore a known-good artifact.”
Next
Where the rest of the documentation lives
This manual is for people using melee. The repository holds a second set of documents, written for the people building it.
If you are reading the published site, these are files in the melee repository rather than pages here. They are listed because a question this manual answers with “it works like this” is often answered there with “and here is why, and what we measured”.
In the repository
| Path | What |
|---|---|
README.md | The repository itself: layout, toolchain, everyday commands. |
PLAN.md | Roadmap: milestones, what is built, what is next. |
TASKS.md | The task list. Every SEC-*, P-* and milestone task referenced in this manual is defined here. |
docs/proposal.md | The founding proposal. Historical — parts of it were superseded by what got built. |
docs/design/architecture.md | How the pieces fit, written for contributors. The source of most of How a request reaches your app. |
docs/design/supervisor.md, activation.md, protocol.md | The Rust server, the warm-process strategy, and the frame protocol apps speak. |
docs/design/stdlib.md | The layer map from the app-facing DSL down to the frames. |
docs/design/persistent.md | The full design of durable objects. |
docs/design/security.md | The threat model, trust boundaries, attacker positions and the findings table. This is the document behind Keeping it safe. |
docs/design/ergonomics.md | Why the app-facing surface is shaped the way it is. |
docs/design/first-app.md, at-here-port.md | The two real apps, and what porting one taught. |
docs/decisions/ | Architecture decision records, numbered. Short, and the best answer to “why is it like that”. |
docs/research/spinel.md | Every Spinel trap found so far, with a workaround for each. If the compiled app and melee dev disagree, read this. |
docs/research/cruby-vs-spinel.md | Where the two runtimes differ. |
docs/research/m*-results.md | Measurements, per milestone, with dates. The numbers quoted in this manual come from here. |
examples/kitchen/, examples/at-here/ | Two complete apps. Worth reading before writing your own. |
stdlib/README.md | The standard library’s own internals, including the rules that keep it compiling. |
The decision records
Worth knowing these exist, because they explain most of the constraints this manual describes:
| 0002 | HTTP is terminated in the supervisor; apps speak frames over a socket pair |
| 0005 | Fibers only, no threads, as a platform rule |
| 0006 | The library runs under both CRuby and Spinel: develop on CRuby, deploy on Spinel |
| 0008 | Activation is warm-process fork, with the sandbox applied before exec |
| 0010 | The app boundary is a typed request and response, not Rack |
| 0011 | Durable objects have explicit per-object storage; no checkpointed instance variables |
| 0014 | A TLS edge in front, owning TLS and custom domains |
| 0015 | A uid per app, and a Landlock allow-list rather than a read-only root |
Building this manual
The markdown under docs/guide/ is the canonical form. mise run docs assembles it and builds the HTML into
docs/site/; mise run docs:serve does the same with live reload.
Two files in it are generated and committed, and mise run docs:check fails if either is stale:
stdlib/docs/reference.md | from stdlib/sig/*.rbs, by stdlib/bin/melee-docs-api |
docs/guide/llms.txt | from docs/guide/SUMMARY.md, by scripts/docs-index.rb |
docs/guide/reference/ is a symbolic link to stdlib/docs/, so the API pages live with the library they
document and still read correctly from either place.