Built · ~11 days
melee
A hosting platform for small, mostly-idle Ruby apps. Each app is compiled to a native binary, sandboxed by the kernel, and woken on demand to answer a request in well under a millisecond.
Visit the app ↗ · Private repo
Built with: Rust · Ruby · Spinel · SQLite
Two thoughts drove this idea:
- I can build my ideas for apps so quickly now that I need to think bigger
- I often build small apps on Cloudflare Workers which is a shame because I actually like Ruby but spinning up a new Rails app, kamal deployment etc is just way too much for the sort of small apps I’ve been building.
If I’m even using Ruby any more, that doesn’t bode well for the future of the language. So what does Ruby need to survive in the future?
Well, the existing Rails apps will still be there to work on, and Rails is still a good fit for starting bigger apps, but knocking out small personal/internal projects like I’ve been doing? Nah. That’s not a Rails-shaped hole. We need something much smaller, lighter-weight, which can scale only when it needs to. Cloudflare Workers is a really good product but it’s javascript-centric.
What if… I could build a new serverless platform and web framework for Ruby?
melee is that. You write a small Sinatra-shaped Ruby app, run melee push, and it comes back with a URL. On the server the app is compiled to a standalone native binary with Spinel, a Ruby-to-C compiler, wrapped in a kernel sandbox, and left asleep until someone asks for it. The first public melee app is the volleyball rotation trainer.
Durable objects
The primitive that makes small apps easy is the durable object: a named Ruby object with its own SQLite storage and a timer, which survives requests, restarts and deploys. This is the solution for background jobs, scheduled jobs, and even as an sort of ORM.
A game in the volleyball app is one of these. The URL contains game id, many of the routes go directly to the durable object (no custom app code), and a game is deleted after a week using the timer.
class Game < Durable
IDLE_SECONDS = 604_800
def setup
storage.put("system", "5-1")
new_match
touch
end
def point(winner)
m = match
return nil if over?(m)
if winner.to_s == "them"
m["them"] = m["them"].to_i + 1
m["serving"] = 0
else
m["us"] = m["us"].to_i + 1
if m["serving"].to_i.zero?
m["rotation"] = (m["rotation"].to_i + 1) % 6
m["serving"] = 1
end
end
save(m)
touch
end
def on_timer
idle = Time.now.to_i - storage.get("last_used").to_i
idle >= IDLE_SECONDS ? destroy : timer(after: IDLE_SECONDS - idle)
end
end
get "/g/:token" do
token = known_token
render :game, token: token, state: Game.get(token).state
end
post "/g/:token/point" do
token = known_token
Game.get(token).point(params.fetch(:winner, "us"))
redirect "/g/#{token}"
end
How it works
A Rust supervisor, melee-server, terminates HTTP and routes by hostname. Each app gets one warm process that starts once and then blocks. Every request is answered by a fork() of that process, an OS-level clone that already has everything loaded, which exits after one response. Server and app talk over UNIX socket pairs with a framed protocol, one pair per concurrency slot, so answers never interleave.
Before an app runs, the server wraps it in layers of sandbox: its own uid and gid, user and mount namespaces, a Landlock allow-list of filesystem paths, a seccomp allow-list of system calls (anything else kills the process), and cgroups for memory, CPU and process count. Durable objects live in a separate worker process per app inside the same sandbox, each with its own SQLite file. A bug in one app can’t reach the host or another app.
Development uses none of this. melee dev runs the unmodified source under CRuby with reloading, and production runs the same source compiled by Spinel into a 2 to 3 MB binary. That gap is the platform’s main risk, so every example app carries a parity test that runs the same requests through both.
The numbers, from the research docs in the repo:
- Warm request: 0.165 ms at p50, 0.40 ms at p99, sandboxed on Linux.
- Fork activation: 0.095 ms at p50. Full cold path from spawn through sandbox and first request: 1.6 to 2.6 ms.
- Resident memory per warm app: 2 to 5 MB, plus about 5.5 MB for a durable-object worker. Spinel idles at 3.8 MB against CRuby’s 25 MB.
- Compile: about 4 s for a warm build, 30 to 37 s for a first one. Binaries are 2.1 to 2.5 MB.
Deploying is melee push from the laptop. The source goes over an authenticated control API through an SSH tunnel, the server compiles and activates the release, and Caddy in front handles TLS. The production box is a single small Debian VM dedicated to melee; the install, including building the compiler and the server, took under six minutes, and the first push 35 seconds.
The same app on Cloudflare Workers
To check I wasn’t kidding myself, I rebuilt the volleyball app on Cloudflare Workers with Durable Objects. The two durable-object models map almost call for call and the port took an afternoon. The TypeScript version was about a quarter more code for the same geometry, with 78 lines of platform config across five files and 90 npm packages, against melee’s ten lines, two files and no dependencies. The difference that actually mattered was the dev loop: wrangler dev runs the production runtime, while melee’s dev runs CRuby and production runs a Spinel binary, which is exactly why the parity tests exist.
What’s not there yet
Today’s deployment is single-tenant on purpose. Multi-tenant hardening, isolated signed builds, and packaging so someone else could run it without a checkout are all still open. An agent-facing surface (an MCP server, scoped tokens, branch previews) and multi-machine work (SQLite replication, placement, custom domains) are planned but not started. The repo is private for now, but the manual is public at melee.ideasasylum.com.