Project

jimmu

0.0
The project is in a healthy, maintained state
Jimmu (神武) is a lightweight Ruby web framework focused on simplicity. It provides file-based routing, ERB templates, SQLite support via Fiddle and the system libsqlite3, WebSocket support, cookies, sessions, JSON/HTML/text responses, and a built-in development server with no required gems beyond Ruby's standard library.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies
 Project Readme

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.