Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 wantRead
To know what this is and whether it fitsWhat melee is
To understand what happens when a request arrivesHow a request reaches your app
To have written and deployed somethingThe tutorial
To look up a methodAPI reference
To solve a specific problemThe guides
To know what melee will not do for youWhat melee cannot do
To run a server yourselfOperating 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

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, request and log can 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 happensWhat the caller sees
The child crashes, or the sandbox kills it for a forbidden syscall502, within about a second
The request takes longer than 30 seconds504, and the whole process group is killed
Every slot is busy and more than eight requests are already queued on one503 with Retry-After: 1
fork fails because the app is at its process cap (pids.max, 256)503
Nobody has asked for 5 minutesThe 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 /proc are 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. listen is not among them, so an app cannot turn a socket into a listener and cannot open a port of its own. (accept is 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 session0.21–0.27 ms
First request after a deploy or an idle stop1.6–2.6 ms
Throughput, 500 requests at 50 concurrent7,700 requests/s
Warm process resident memory, after that load4.8 MB
A whole deploy through the control API (unpack, templates, compile, activate)10 s
spin build alone, after a Ruby changeabout 4 s

Next

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 / Sinatramelee
get "/x" do ... end (Sinatra)the same
params[:id]params[:id] (String or nil) or params.fetch(:id) (String, "" when absent)
session[:user_id] = 1session[:user_id] = "1" — session values are Strings
redirect_to "/x"redirect "/x" (always 303)
head :not_found / halt 404halt 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_adminbefore "/admin" do ... end — a path prefix, not a callback name
rescue_fromerror do |e| ... end
ActiveRecord::Basedb.query "SELECT ...", bind — SQL, no ORM
db/migrate/*.rbdb/migrations/NNN_name.sql — SQL files, applied in name order
app/views/x.html.erbviews/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 servermelee dev
ActiveJob / sidekiqa durable object with a timer — see Background jobs
whenever / cronthe same timer
Rails.cachethe 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 receiverdb.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

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 melee command. Today that means a checkout of the melee repository and mise run build; there is no standalone install yet (What melee cannot do explains why).
  • Ruby 4.0 and the sqlite3 gem, for melee dev.
  • For the last step only, a running melee-server to 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

  1. A new appmelee new, what it writes, and running it.
  2. Routes and templates — a route, a layout, a partial, and escaping.
  3. A database — migrations as files, queries with binds.
  4. Forms, sessions and a login — POST, CSRF, session, before.
  5. Work that happens on its own — a durable object and its timer.
  6. Deploying itmelee 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: true at the top. String literals are frozen in this dialect. Build strings with +"" and << rather than mutating a literal.
  • greeting is 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.
  • setting is 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 &amp; bread</li>
<li>Book the &lt;dentist&gt;</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 .sql files, 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
CallGives 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_idthe 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 a String, "" if the field was not sent. params[:text] returns String or nil. Every parameter value is a String — convert with .to_i or .to_f when you need a number.
  • halt 400, "..." ends the request immediately with that status and body. So does redirect, 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 before filter, 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 nilstorage.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

Deploying it.

Full detail: Durable objects and Background jobs and scheduled work.

6. Deploying it

melee check compiles it, melee push ships it, and melee env is 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

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 haveWhat to do
Work that must finish before you answerJust 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 firstRecord it in the route, drain it from a timer.
Work that must happen, exactly once, even if the machine restartsRecord 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_timer re-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 an ensure.
  • The rescue is inside. An exception out of on_timer is 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 later and no every. 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, QueueRefused by the build step, with a message pointing here.
fork in app codeThe runtime forks; you do not. The sandbox caps process count.
sleep in a routeBurns a slot and counts against the 30-second timeout.
An external cron hitting a URLWorks, 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 processYour code does not run in the warm process at all.

See also

Structuring a larger app

app.rb for routes, lib/ for everything else, plain top-level methods for helpers, and case wherever 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, never rescue HTTP::Error — a rescue through a constant alias silently fails to match in a compiled app, while working perfectly under melee 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 { }, not DB.transaction { }).

The Ruby that compiles is the full list.

Where state lives

LifetimeWhere
One requestlocal variables, instance variables — everything is thrown away at the end
Between requests, small and settable by the appsetting("key") / setting("key", value)
Between requests, structuredthe app database, db
Between requests, owned by one long-lived thing, plus a timera durable object’s storage
Set from outside, secretENV["..."], 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.tomlmelee 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 Secure flag 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 setting or a durable object is the tool.
  • halt and redirect do not return, so redirect "/login" unless session[:admin] in a before filter really does stop the request.

See also

Talking to other services

HTTP.get and HTTP.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.254 or 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.url tells you where the body actually came from.

See also

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.form handles 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

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

dbthe app’s database, app.sqlite in the app’s data directory. Shared by every request child.
storageone 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 PLAN works through db.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

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 means melee dev and a browser or curl.
  • 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 sqlite3 gem 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 exista typo in a top-level helper, or one you meant to define
The file did not require what it usesadd require "json" to the file that calls JSON.parse
The construct is not compilableeval, 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 backtraceException#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 than raise 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 that puts goes to stdout and is not collected, so use log;
  • 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

CodeWhat happened
403The CSRF check. A form without csrf_field, or a stale session.
404No route matched, or your own halt 404.
500Your code raised. The log line is the only detail there is.
502The request process died without answering — a crash, or the sandbox killing it for a forbidden syscall.
503Every slot busy and the queue full (Retry-After: 1), or fork failed against the process cap.
504The 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

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 filtersget, post, put, patch, delete, before, not_found, error, title, app_title
  • The current requestrequest, params, form, query, session, log, db
  • Responsesredirect, back, halt, status, header, json, text, html
  • Templates (generated per app by the build step from views/)render, partial, h
  • Strings, security and settingsurl_encode, url_decode, csrf_token, csrf_field, secure_equal?, random_token, setting
  • MeleeMelee.db, Melee.env
  • Melee::ParamsMelee::Params#[], Melee::Params#fetch, Melee::Params#key?, Melee::Params#to_h, Melee::Params#each, Melee::Params#empty?
  • Melee::RequestMelee::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::ResponseMelee::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::HaltMelee::Halt#response
  • Melee::SessionMelee::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::DBMelee::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::HTTPMelee::HTTP.get, Melee::HTTP.post
  • Melee::HTTP::Error
  • Melee::HTTP::TimeoutError
  • Melee::HTTP::TooManyRedirects
  • Melee::HTTP::ResponseMelee::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::LogMelee::Log#request_id, Melee::Log#debug, Melee::Log#info, Melee::Log#warn, Melee::Log#error, Melee::Log#exception, Melee::Log#with
  • DurableDurable#id, Durable#setup, Durable#on_timer, Durable#storage, Durable#timer, Durable#cancel_timer, Durable#destroy, Durable#log, Durable#release, Durable#live!
  • Durable::RemoteErrorDurable::RemoteError#remote_class
  • Durable::Destroyed
  • ConstantsHTTP

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.

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

A .:format pattern needs an extension to match, so /cards/42 falls through to a separate "/cards/:id" route (declare either order; they cannot both match the same path). A "/feed.:format" pattern works the same way with a literal name in front. *rest never takes a format.

The router does not know which extensions you serve: it splits at the last dot whatever follows it. If your identifiers can contain dots (hostnames, did:plc:... values), a plain identifier like alice.bsky.social matches the format route with params[:format] == "social". Check the value against the formats you serve and re-join the identifier otherwise; examples/at-here/app.rb does exactly this.

get "/cards/:id.:format" do
  card = card_for(params[:id])
  params.fetch(:format) == "vcf" ? text(vcard(card), type: "text/vcard; charset=utf-8") : json(card)
end

Routes are tried in declaration order; the first match wins. HEAD is answered by the matching GET route with the body dropped. If no route matches the method but one matches the path, the reply is 405 with an Allow header. Otherwise the not_found handler runs.

csrf: false exempts one route from the CSRF check (webhooks). See security.md.

A route can also name a durable object method instead of taking a block — get "/u/:id", to: "User#show", view: :user — which renders the view with what the method returns. See “Routing straight to an object” in objects.md.

Filters

before "/admin" do
  redirect "/login" unless session[:admin]
end

before do
  header "X-Frame-Options", "DENY"
end

before(prefix = "") { ... } runs before any matched route whose path starts with prefix. An empty prefix matches every path. Filters run in declaration order, all of them, unless one calls halt or redirect — those end the request there. Filters do not run when no route matched.

not_found and error

not_found do
  "No page at #{request.path}"
end

error do |e|
  "Something broke: #{e.message}"
end

not_found takes no arguments and its result is always sent as 404; a status call inside it is ignored. error takes the exception and its result is always sent as 500. Both accept the same return values as a route. If error itself raises, a plain 500 Something went wrong is sent.

Without an error block, production sends 500 Something went wrong and logs one line (see logging.md). melee dev shows a trace page instead. Spinel-compiled apps have no backtraces, so the log line carries the exception class, message, route, method and path — nothing more.

The request lifecycle

melee-server accepts the HTTP request. If it names a file under public/, the server sends that file and the app never runs. Otherwise the server sends a request frame to a warm app process, which forks a child for this one request; the child builds a Melee::Request, matches a route, enforces CSRF for POST/PUT/ PATCH/DELETE, runs the matching before filters, then the route block. The block’s return value becomes the response (a String is HTML with status 200); halt and redirect unwind to the same place. If the session was touched, a Set-Cookie header is added. The child writes one response and exits. One request per process means params, session, db and log can be plain top-level methods with no thread safety to think about — and it means there is no state that survives a request except the database.

What is not here

No App class or class-based DSL, no configure block, no middleware, no helpers do ... end (define plain top-level methods), no route-level provides/condition, no mounting, and no every/later scheduling — a Durable object’s timer covers repeating work instead (see objects.md).

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}.

CallReturns
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_hHash{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.

CallReturns
request.methodString, upper case: "GET", "POST", …
request.pathString, no query string
request.queryString, the raw query string, "" when there is none
request.hostString
request.schemeString, "http" or "https"
request.base_urlString, e.g. "https://kitchen.example.com"
request.remote_addrString, the client address
request.header("Accept")String or nil, case-insensitive
request.headersArray[[String, String]] in wire order
request.content_typeString, "" when the header is absent
request.bodyString, "" when there is no body
request.jsonthe parsed body; raises JSON::ParserError on bad input
request.cookiesHash{String => String}
request.get?, request.post?true / false
request.idInteger, 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
CallReturns
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.clearempties it
session.to_hHash{String => String}
session.csrf_tokenString, 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

ReturnBecomes
String200 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 elseto_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
CallReturnsContent-Type
json(value)Melee::Responseapplication/json (JSON.generate(value))
text(string)Melee::Responsetext/plain; charset=utf-8
html(string)Melee::Responsetext/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

.erb files 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 render that 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: false skips 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 direct Views.<name>(...) call.
  • name, layout, locals and body are what the generated render calls 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), because render evaluates 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

TagMeaning
<%= 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

CallReturns
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_idInteger, 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.sqlite in the app’s data directory, in WAL mode with synchronous=NORMAL, foreign_keys=ON and 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.transaction block, and only the writes inside it.
  • db.transaction uses BEGIN 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 Durable object’s storage is a separate SQLite file per object, reached through storage, never through db (see objects.md).
  • A failing statement raises; the class is the backend’s, so rescue StandardError if 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

CallReturns
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.listevery id in this class, oldest first
Household.counthow 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 a db.query row already has. A Time or a Symbol is a runtime ArgumentError on both melee dev and in production, not a build error: narrow with .to_i/.to_s the way route code narrows params.
  • 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 what Object and IO already 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 storage does.

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: :lessonrender views/lesson.erb with the returned Hash as its locals
layout: falseas 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: truemake 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.get and HTTP.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

CallReturns
res.statusInteger, e.g. 200
res.ok?true when status is 200–299
res.bodyString, "" when there was no body
res.header("content-type")String or nil, case-insensitive
res.headersArray[[String, String]]
res.urlString, the URL this response came from — the final one after any redirects
res.jsonthe 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
ClassRaised when
HTTP::TimeoutErrorconnect or read exceeded timeout:
HTTP::TooManyRedirectsmore than 5 hops
HTTP::ErrorDNS 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 than raise ArgumentError.
  • Log before the risky call, not only after it. A log.info "fetching", url: url line 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, PATCH and DELETE for any request carrying a session cookie.
  • The session cookie is signed, HttpOnly, SameSite=Lax, and Secure when the request came over HTTPS.
  • Outbound HTTPS verifies certificates, with no way to turn it off.
  • db.query/first/run take ? 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

CallReturns
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_fieldsee 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 params before it reaches SQL, a URL you fetch, or a filename.
  • <%== %> on anything derived from params, 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 as ENV["NAME"]. Never in app.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/select over acc = [] 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#b does 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 system or 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.sqlite inside 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 receiverdb.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.

FileFrom
.melee/views.rbviews/*.erb — one method per template plus the render/partial dispatch
.melee/migrations.rbdb/migrations/*.sql embedded as Melee.migrate([[name, sql], ...])
bin/<name>.rbthe 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 uploadsNot 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 storageNot 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.
EmailNot built. Nothing sends mail. An HTTP API for a mail provider is the only route today.
WebSockets, SSE, long pollingNot built. An app cannot open a listening socket, and a request is a short-lived process under a 30-second cap.
Streaming responsesPartly. A route can produce a streamed body, but it is collected and then sent rather than passed through as it arrives.
later / every on objectsNot built. You re-arm the timer inside on_timer yourself.
Routing straight to a durable objectNot built; under design. Every entry point is a route today.
A cache helperNot built. Use setting, a table, or an object.
MiddlewareNot built. before filters with a path prefix are what there is.
Sub-second timeoutsNot 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 app4 (slots)
Request timeout30 s, then the process group is killed and the caller gets a 504
Request body8 MB
Memory per app128 MB (memory.max, root only)
Processes per app256 (pids.max)
Idle stop5 minutes with nothing in flight
Cold start after thatabout 2 ms
Database writersone at a time; a second waits 5 s and then raises
Durable object callsone at a time, per app, across every object
Redirects followed5
Static file cachemax-age=300, no fingerprinting

Operating it

This is where melee is least finished.

  • Not built — no TLS. melee-server speaks 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 every melee env value cross the network in the clear.
  • Not built — no installable CLI. melee finds the standard library through a checkout of this repository. There is no package, no brew 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

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

OSLinux. 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.
KernelLinux 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.
Privilegesroot, 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.
Architecturex86-64 or arm64.
DiskSmall. Each release is a binary of a couple of megabytes plus the source; the databases are yours.
MemoryEach 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 env value 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.

RouteWhat
GET /v1/healthok — and it needs the token too; there is no unauthenticated corner
GET /v1/appswhat is deployed
POST /v1/apps/{name}/deploysa tarball; builds and activates
GET /v1/apps/{name}/logsthe log, with follow
PUT /v1/apps/{name}/env/{key}set an environment value
POST /v1/apps/{name}/restartstop the warm process
GET /v1/apps/{name}/objects, POST .../objects/destroydurable 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

FlagDefaultWhat
--home./melee-homeApps, releases, data, logs. Everything melee knows lives here.
--listen127.0.0.1:8080Where app traffic arrives.
--api127.0.0.1:7070The control API.
--api-token-filerequiredFile holding the control-API bearer token.
--domainlocalhostSuffix that maps Host to an app name.
--trusted-proxynoneIP 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-filenoneFile 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-rangenoneLOW-HIGH; required as root, refused as anyone else.
--slots4Requests at once per app. Also the number of socket pairs handed to each warm process.
--idle-secs300Idle before a warm process is stopped.
--request-timeout-secs30Then the process group is killed and the caller gets a 504.
--build-timeout-secs300Then the build is killed and the deploy fails.
--max-body8388608Largest accepted request body, in bytes.
--memory-max128Mcgroup memory.max per app (root only).
--cpu-weight100cgroup cpu.weight per app, 1–10000 (root only).
--no-sandboxoffRun apps with no kernel sandbox. Development, and macOS where there is none.
--stdlib../stdlibThe melee standard library checkout, used by deploys.
--spin../vendor/spinel/bin/spinThe Spinel compiler, used by deploys.
--rubyrubyRuby used to run the build step.
--tzUTCTZ 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.

  • --slots is 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-secs is 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-secs bounds 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 --home inside 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.
  • TimeoutStopSec long enough for an orderly stop. SIGTERM makes 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/&lt;id&gt;/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/&lt;id&gt;/bin/&lt;name&gt;"]
  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.toml is 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 appFrom --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 namespacesIts own view of users and of the mount table. No network namespace — apps may make outbound connections.
LandlockAn 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.
seccompAn 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 environmentBuilt on the command by the server before the fork: cleared and rebuilt from an explicit list.
cgroup v2Applied 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 Secure flag is only set when the request arrived over HTTPS;
  • the control-API token and every melee env value 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

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>/envenvironment 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:

  1. Update the checkout on the server, submodule included.
  2. Rebuild Spinel (make deps && make in vendor/spinel) and the binaries (cargo build --release).
  3. Stop the server with SIGTERM so it stops the app processes it owns.
  4. Start the new one.
  5. 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 current and 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 apps are 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-server treats 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=1 sysctl very likely blocks the sandbox’s drop-uid-then-unshare(CLONE_NEWUSER) sequence. Confirmed absent on this box’s Debian 13 (kernel 6.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.sh doesn’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 .git is 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 .git directory before any git command on the box will work at all.
  • chown -R root:root after, since rsync from a laptop leaves everything owned by that laptop’s uid, and install.sh — and melee-server itself — 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_HOMEWhere 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_PROXY127.0.0.1 — Caddy, assumed to run on this same box
MELEE_TZ, MELEE_RUBY, PATHTZ 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 both Secure and HttpOnly.
  • POST /games, with the page’s own CSRF token (not a stale or guessed one) → 303.
  • The created game’s own page → 200.
  • POST a point on it → 303, and the point shows up in the game’s history.
  • robots.txt200; the CSS is served gzip-encoded.
  • The negative control: a request straight to loopback (127.0.0.1:8080, bypassing Caddy entirely) with a spoofed X-Forwarded-Proto: https header and no X-Melee-Edge gets a cookie back without Secure. This is the proof that matters — not that --trusted-proxy is 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.sh is 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

PathWhat
README.mdThe repository itself: layout, toolchain, everyday commands.
PLAN.mdRoadmap: milestones, what is built, what is next.
TASKS.mdThe task list. Every SEC-*, P-* and milestone task referenced in this manual is defined here.
docs/proposal.mdThe founding proposal. Historical — parts of it were superseded by what got built.
docs/design/architecture.mdHow the pieces fit, written for contributors. The source of most of How a request reaches your app.
docs/design/supervisor.md, activation.md, protocol.mdThe Rust server, the warm-process strategy, and the frame protocol apps speak.
docs/design/stdlib.mdThe layer map from the app-facing DSL down to the frames.
docs/design/persistent.mdThe full design of durable objects.
docs/design/security.mdThe threat model, trust boundaries, attacker positions and the findings table. This is the document behind Keeping it safe.
docs/design/ergonomics.mdWhy the app-facing surface is shaped the way it is.
docs/design/first-app.md, at-here-port.mdThe 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.mdEvery 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.mdWhere the two runtimes differ.
docs/research/m*-results.mdMeasurements, 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.mdThe 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:

0002HTTP is terminated in the supervisor; apps speak frames over a socket pair
0005Fibers only, no threads, as a platform rule
0006The library runs under both CRuby and Spinel: develop on CRuby, deploy on Spinel
0008Activation is warm-process fork, with the sandbox applied before exec
0010The app boundary is a typed request and response, not Rack
0011Durable objects have explicit per-object storage; no checkpointed instance variables
0014A TLS edge in front, owning TLS and custom domains
0015A 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.mdfrom stdlib/sig/*.rbs, by stdlib/bin/melee-docs-api
docs/guide/llms.txtfrom 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.