Jimmu (神武)

A lightweight Ruby web framework. Full-featured but simple: ERB, SQLite, file-based routing, and WebSockets in one file, so you can go from nothing to a working small web app in minutes.

gem install jimmu
jimmu new myapp
cd myapp
jimmu server

Then visit http://127.0.0.1:3000/.

Why

  • One file. The entire framework is lib/jimmu.rb. Nothing to configure, nothing to generate boilerplate for.
  • No native gem dependencies. SQLite support talks directly to your system's libsqlite3 via Ruby's built-in Fiddle library -- there's no sqlite3 gem, so there's no native extension to compile when you install. Everything else is Ruby standard library.
  • A file is a page. Drop a .erb file under views/ and it's routable. No router config, no controller classes.

Installing

gem install jimmu

Requires Ruby >= 2.7 and a system SQLite3 shared library, which ships with macOS and is available via your package manager on Linux (apt install libsqlite3-0, dnf install sqlite-libs, etc.) and typically already present with Ruby installations on Windows.

The CLI

Command Effect
jimmu new <name> Create a new project in ./<name>
jimmu server Start the dev server, reading ./app.rb
jimmu server -p 8080 ...on a specific port (overrides port in app.rb)
jimmu server -h 0.0.0.0 ...on a specific host (overrides host in app.rb)
jimmu version Print the installed version
jimmu help Show usage

Project layout

myapp/
├── app.rb              # entry point -- config, hooks, then `run`
├── views/
│   ├── layout.erb       # auto-applied around every page unless disabled
│   ├── index.erb        # ->  /
│   ├── 404.erb           # shown on 404
│   └── 500.erb           # shown on unhandled errors
├── public/
│   ├── css/style.css    # ->  /css/style.css
│   ├── js/app.js         # ->  /js/app.js
│   └── img/
└── db/                   # SQLite files live here by convention

File-based routing

A file's path under views/ becomes its URL. index.erb files match their parent directory:

views/index.erb              ->  /
views/about.erb               ->  /about
views/blog/index.erb          ->  /blog
views/blog/post.erb           ->  /blog/post

Square brackets make a segment dynamic and land in params:

views/user/[id].erb                  ->  /user/:id            params[:id]
views/post/[category]/[id].erb       ->  /post/:category/:id  params[:category], params[:id]

A literal file always wins over a dynamic one at the same level (so views/user/admin.erb would take priority over views/user/[id].erb for /user/admin specifically, while every other value still hits the dynamic route).

Every HTTP method routes through the same file -- check request.method inside the template to branch between, say, showing a form on GET and handling its submission on POST to the same path.

Inside a view

Each .erb file has access to:

params[:id]                    # route + query + form + JSON body params (merged)
request.method                 # "GET", "POST", ...
request.path                   # "/user/42"
request.ip                     # client IP
request.header("user-agent")   # any request header, by lowercase name
request.query_params           # just the ?query=string part
cookie[:theme]                 # read a cookie
cookie[:theme] = "dark"        # queue a Set-Cookie
session[:user_id]              # read/write server-side session data
                                # (survives across requests via a cookie;
                                # cleared with session.clear or session.delete(:k))

status 404                     # set the response status (doesn't stop rendering)
header "X-Custom", "value"     # set a response header

redirect "/login"              # 302 and stop
json(ok: true)                 # render JSON and stop
text "plain text"              # render text/plain and stop
html "<b>hi</b>"                # render text/html and stop
render "other_view"            # render another view (through the layout) and stop

layout false                   # skip views/layout.erb for this render

log "message"                  # timestamped line on the server console
db                              # the most recently opened `sqlite` connection

redirect / json / text / html / render all end the current template immediately -- nothing written after them in the same .erb file runs.

Layouts

views/layout.erb wraps every page automatically; put <%= yield %> where the page's own content should go. Turn it off per-page with <% layout false %> anywhere in that page's template.

Errors

views/404.erb renders on an unmatched route; views/500.erb renders on an unhandled exception (it can read error.message / error.backtrace if you want to show details, or just show a friendly message). You can also trigger either one yourself:

<% unless post
     status 404
     render "404"   # note: a string, not :404 -- see "Gotchas" below
   end %>

If neither file exists, Jimmu shows its own built-in page -- a plain message for 404, and a full backtrace for 500 (since that's only ever seen when you haven't set up your own error page, it defaults to maximally useful rather than maximally polished).

SQLite

# in app.rb
db = sqlite "db/app.db"
db.exec("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)")
<%# in any view %>
<% users = db.query("SELECT * FROM users WHERE name = ?", params[:name]) %>
<% db.exec("INSERT INTO users(name) VALUES (?)", params[:name]) %>
<% db.transaction do
     db.exec("...")
     db.exec("...")
   end %>

query returns an Array of Hashes with Symbol keys. exec returns the number of rows changed. db.last_insert_rowid gives you the last auto-increment id. ? placeholders are the only supported binding style; always use them for anything that includes user input.

File uploads

<form method="post" enctype="multipart/form-data">
  <input type="file" name="avatar">
</form>
<% file = params[:avatar]
   file.filename        # "photo.png"
   file.content_type    # "image/png"
   file.size             # bytes
   file.save("public/uploads/photo.png") %>

WebSockets

Registered in app.rb, separately from the file-based view routes:

websocket "/ws/[room]" do
  log "opened: #{params[:room]}"
  on_message { |msg| send("echo: #{msg}") }
  on_close   { log "closed" }
end

Inside the block (and inside on_message / on_close), self is the connection, so send, close, params, session, cookie, request, log, and db are all available directly. There's no special "broadcast" method -- see examples/chatroom and examples/poll for the few lines of plain Ruby (a shared Array/Hash + Mutex) it takes to build one yourself.

Hooks

before do
  log "#{request.method} #{request.path}"
end

after do
  header "X-Powered-By", "Jimmu"
end

before runs ahead of every request (including static files); calling redirect/json/etc. inside one stops the request right there, which is enough to build things like auth gates. after runs once a response body has been produced.

Gotchas

  • render "404", not render :404. :404 isn't valid Ruby (a bare symbol can't start with a digit) -- use a String for view names that start with a number.
  • Helpers belong at the true top level of app.rb. def my_helper / MY_CONST = ... / module Helpers ... end written directly in app.rb are ordinary top-level Ruby and are callable from anywhere -- views, before/after, websocket blocks. (Concretely: app.rb is loaded as a normal file, not stringwise eval'd, specifically so this works.)
  • Sessions are in-memory. They reset when the server restarts. Fine for development and small apps; swap in a database-backed store yourself if you need durability.
  • One request per connection. Keep-alive isn't implemented -- each HTTP request gets its own TCP connection, which keeps the server simple. This doesn't apply to WebSocket connections, which stay open as long as the client does.
  • No chunked request bodies. Incoming requests are read via Content-Length; chunked Transfer-Encoding on requests isn't parsed (this practically never comes up for browser form posts or fetch()).

Examples

Twelve complete apps in examples/, each jimmu server-able as-is:

Example Shows off
hello-world The absolute minimum, plus one dynamic route
bbs A message board -- SQLite, forms, session-remembered name
chatroom Named chat rooms over WebSocket, with a hand-rolled broadcast
todo SQLite CRUD via small per-action routes
blog Posts list/detail, /post/[id], a create form, custom 404
counter A JSON API endpoint called from client-side fetch()
url-shortener Dynamic redirect route, plus a JSON API to create links
visitor-counter before hook + cookie to tell new vs. returning visitors
image-upload multipart/form-data, saving into public/, a gallery
poll WebSocket used to push live results to every open tab
json-api A headless JSON REST API (no HTML views at all)
login-demo Session-based auth with a before-hook route guard
cd examples/chatroom
jimmu server

(If you're running straight from this source checkout rather than an installed gem, use ruby -I../../lib ../../exe/jimmu server instead, or gem install --local the built .gem first.)

License

MIT. See LICENSE.