A Ruby web framework that unifies the primitives an enterprise backend actually needs - so you (and your LLM) learn one DSL and use it everywhere.
Sinatra speed and simplicity, with the features of Roda, Rails, and Hanami - unified under a single shared DSL. Rack-based, Sequel ORM, PostgreSQL.
gem install lux-fw
lux new my-app && cd my-app && bundle install
createdb my_app_development && lux db:am && lux s# config.ru
require 'lux-fw'
Lux do
routes do
map foo: 'foo#call' # /foo -> FooController#call
body 'Hello world, this is 404'
end
endrackup it and you're up. Lux scales down to one file and up to a full
enterprise backend through the same DSL.
require 'lux-fw' loads the framework only - no .env, no
config.yaml, no plugin loaders, no DB connect. App boot is one
explicit call: Lux.boot!. It resolves LUX_ENV, loads
.env*, runs Bundler.require, reads config/config.yaml, and fires
every configured plugin's loader (DB connect, exception logger, etc).
Idempotent.
# config/env.rb - the canonical bootstrap
require 'bundler/setup'
require 'lux-fw'
Lux.boot!
# host-specific tweaks (config is loaded, plugins active)
Lux.config.localize = false
Dir.require_all './config/initializers'# config.ru
require_relative './config/env'
run LuxCLI tasks declare needs :app and the :app task in bin/lux runs
Lux.boot! for you. Light commands like lux mount or lux --help
never call it, so they stay fast. Lux::Application#call also calls
Lux.boot! defensively on the first request, so hosts that skip
config/env.rb in config.ru still work.
Enterprise backends need the same set of things: schemas, type coercion, access policies, params validation, JSON APIs, multi-DB, background jobs, mailers, sessions, error handling. In most frameworks these are bolted on from different libraries with different DSLs, different option names, different type vocabularies. Every new subsystem is a new dialect.
Lux ships these as first-class modules that share the same primitives:
- One schema DSL drives params validation in controllers
(
opt :name, String, max: 30), API endpoints (params do ... end), model field definitions, and DB migrations. - One type system (
:email,:uuid,:slug,:locale, ...) is the vocabulary everywhere a type is named. - One access policy used identically by controllers, APIs, and
models (
@blog.can.read?). - One request context (
Lux.current) used by everything that needs to know about the in-flight request.
The win is twofold:
- For humans: learn
opt :email, type: :email, req: falseonce - it works the same in a controller, an API, a schema block, a form helper. - For LLMs: one DSL means generated code is consistent across the
codebase - fewer hallucinations and better completions. The framework
self-documents via
/sys/AGENTS.mdso any deployed app exposes its full API surface to agents.
- Routing DSL with tree-style scoping (
map,root,subdomain,plugin_route, HTTP-method predicates), usable at the top level ofLux do ... endor inside an optionalroutes dowrapper - Controllers with the shared
opt/params doschema DSL - JSON-RPC-style APIs with auto-generated explorer, OpenAPI, Postman, and
/sys/AGENTS.mdfor agents - Schema + type system used identically in controllers / APIs / models / DB migrations
- Access policies usable from controllers, APIs, and models
- Multi-DB Sequel pool, eager-on-boot, lazy-on-access
- JWT-encrypted sessions
- Memory / Memcached / SQLite / null cache with one API
- Custom reloader that skips
Gem.path- reload stays fast even with a fat Gemfile Lux.deferbackground threads with a cleanLux.currentand parent context passed explicitly to the block- HTML mailer + template rendering via Tilt (HAML, ERB, ...)
- Pluggable plugin system with canonical folder layout
luxCLI built onlux-hammer- declarative tasks, typed options, namespace tree, zero runtime deps
The same line parser handles the schema in a controller, in an API, in
a model, or in a standalone Lux.schema block:
# in a controller
class UsersController < Lux::Controller
opt :name, String, max: 30
opt :email, type: :email
def create
# current.params is already validated, coerced, undeclared keys dropped
end
end
# in an API
class UsersApi < ApplicationApi
desc 'Create a user'
params do
name String, max: 30
email type: :email
end
def create
# @api.params is already validated, coerced
end
end
# in a model
class User < ApplicationModel
schema do
name String, max: 30
email type: :email, index: true
end
endSame DSL. Same type vocabulary. Same option keys.
Every sub-module under lib/lux/<name>/ ships a README.md. LLM-focused
guidance is consolidated in the top-level AGENTS.md.
| Module | Adapter / usage |
|---|---|
Lux::Api |
Lux::Api (subclass ApplicationApi) |
Lux::Application |
Lux do ... end / Lux.app |
Lux::Browser::Channel |
Lux.channel(user).push(...) |
Lux::Cache |
Lux.cache |
Lux::Boot::Config |
Lux.config |
Lux::Controller |
class X < Lux::Controller |
Lux::Current |
Lux.current / current / lux |
Lux::Db |
Lux.db / Lux.db(:name) / DB |
Lux::Environment |
Lux.env / Lux.debug? / Lux.runtime |
Lux::Error |
Lux.error / Lux.error.not_found |
Lux::Hash |
{}.to_lux_hash / Lux::Hash.new |
Lux::JsonExporter |
class X < Lux::JsonExporter |
Lux::Logger |
Lux.log / Lux.logger / Lux.logger(:n) |
Lux::Mailer |
class Mailer < Lux::Mailer |
Lux::Plugin |
Lux.plugin :name |
Lux::Policy |
class XPolicy < Lux::Policy |
Lux::Reloader |
Lux::Reloader.run / reload! |
Lux::Render |
Lux.render / Lux.render.get(...) |
Lux::Response |
response / Lux.current.response |
Lux::Schema |
Lux.schema(:name) { ... } |
Lux::Shell |
Lux.shell.exec / .info / .error |
Lux::Template |
Lux::Template.render |
Lux::Type |
Lux::Type.load(:email) / type symbols |
Lux::ViewCell |
class X < Lux::ViewCell |
JSON-RPC-ish API classes. Shares the params do DSL with controllers
and the schema layer. Auto-mounts, relative to the API's mount point
(e.g. /api): sys/web (interactive explorer), sys/openapi.json,
sys/postman.json, sys/AGENTS.md.
class UsersApi < ApplicationApi
desc 'Create a user'
params do
name String, max: 30
email type: :email
end
def create
User.create!(@api.params.to_h)
end
endRouter and request lifecycle. Lifecycle callbacks at the top level of
Lux do ... end; routing DSL inside routes do ... end.
Lux do
before do
nav.map_path # classify id segments; format from Lux.config.ref_format
end
# post-render: expand T[key.path] placeholders to real translations
after do
response.body { |b| b.gsub(/T\[([\w.]+)\]/) { Translation.fetch($1) } }
end
rescue_from do |err|
call 'main#error' # MainController#error
end
routes do
root 'main'
map about: 'static#about' if get?
map 'admin' do
raise Lux.error.not_found unless user&.can&.admin? # path-scoped guard
map users: 'admin/users'
end
map '/api' => ApiApp
end
endTwo rules worth knowing up front:
- The first match ends routing. A dispatch that writes the response body
throws
:done, caught once in the router, so every later statement in the block is skipped. Nounless response.body?guards needed after amap/call/root. -and_are the same character tomap,match, controllerfilterand resourceful dispatch - normalised on both sides at compare time.nav.pathkeeps the URL's own spelling, so slug lookups still seemy-post-title.
Unified cache API across memory / memcached / sqlite / null backends.
Lux.cache.fetch('users/count', ttl: 60) { User.count }
Lux.cache.delete('users/count')
Lux.cache.lock('task', 3) { do_it }YAML config + .env loader + lifecycle hooks. Indifferent access.
Lux.config.host # read from config/config.yaml
Lux.config.app_timeout = 30 # write at runtime
Lux.config.on_mail_send { |m| ... } # lifecycle hookHTTP controllers. Rails-shaped lifecycle; params declared with the
shared opt / params do DSL.
class BoardsController < Lux::Controller
before { @user = User.current or Lux.error.unauthorized }
opt :name, String, max: 30
opt :tags?, [String]
def create
@user.boards.create!(current.params.to_h)
end
endThread-local request context. One per request, accessible as
Lux.current, current, or lux.
current.params # validated/coerced params
current.session[:user_id] = @user.id # JWT-encrypted session
current[:account] = @user.account # request-scoped bag
current.cache(:billing) { ... } # request-scoped memo
Lux.defer { Mailer.deliver(...) } # bg thread, clean Lux.current insideMulti-DB Sequel pool. DB is a lazy proxy to Lux.db(:main).
Lux.db # :main Sequel::Database
Lux.db(:log) # any named connection
DB[:users].where(active: true).all # via proxyServer -> browser push. Name a channel, push to it, subscribe by name - one SSE connection per tab carries every channel it is entitled to.
Lux.channel(user).push(html: 'Import finished') # server, from anywhereLux.subscribe('user:abc123', msg => log.append(msg.html)) // browserA session resolver decides what a connection may hear, so a client cannot ask
for someone else's channel. Cross-process delivery (a job pushing to a browser
held by the web process) is on by default via PG LISTEN/NOTIFY, and the backend
is swappable through one channel_url config key.
See doc/browser-push.md for the full walkthrough.
Three orthogonal facets: name, behavior, runtime.
Lux.env.production? # name (dev/prod/test)
Lux.debug? # behavior toggle (debug/reload/silent)
Lux.runtime.web? # process kind (web/cli/rake)Stable per-deploy identifier: same value across restarts and across every app server of one deploy, changing only when code/assets are redeployed. Use it for cache-busting (asset URLs, cache keys, ETags) or to tag logs/metrics by release.
Lux::DEPLOY_ID # => "b1114a67" (8-char hash) or your env value
ENV['DEPLOY_ID'] # mirrors Lux::DEPLOY_ID exactlyResolution order (first match wins): explicit ENV['DEPLOY_ID'] (used verbatim)
-> git short SHA -> newest ./app file mtime -> boot time. When derived, the
result is hashed to 8 chars and written back to ENV['DEPLOY_ID']. Set
DEPLOY_ID in CI/containers (where .git is usually absent) for a reliable value.
Thin exception class plus raise helpers that also set the response status.
Lux.error.not_found # 404
Lux.error.forbidden 'no access' # 403
Lux.error(418, "I'm a teapot") # arbitrary status
Lux::Error.render(exception) # last-resort renderingHash with indifferent access. All keys are coerced to String, so
:foo, 'foo' and .foo hit the same slot. Integer / Class keys
round-trip via to_s (h[1] and h['1'] are the same). nil /
empty keys are rejected on write. Used everywhere the framework
returns or accepts flexible-key data (config, JSON, params).
h = { 'name' => 'Dux' }.to_lux_hash
h[:name] == h['name'] == h.name # all 'Dux'The Lux::Hash(...) helper builds a frozen enum hash. Storage stays
clean (code -> value); the constant name becomes a method on the
returned hash. Lookup works by either:
class Order
# storage: { "1" => "Active", "2" => "Done", "3" => "Archived" }
# also creates Order::STATUS_ACTIVE = 1, etc.
STATUS = Lux::Hash(self, constants: :status) do |opt|
opt.ACTIVE 1 => 'Active'
opt.DONE 2 => 'Done'
opt.ARCHIVED 3 => 'Archived'
end
end
Order::STATUS[1] # => 'Active' (lookup by DB code)
Order::STATUS.DONE # => 'Done' (lookup by constant name)
Order::STATUS_ACTIVE # => 1 (the code as a Ruby constant)
Order::STATUS.to_h # => { "1" => "Active", "2" => "Done", "3" => "Archived" }Structured JSON export from any object. One exporter class per model, multiple shapes.
class UserExporter < Lux::JsonExporter
define do
json[:ref] = model.ref
json[:name] = model.name
end
end
UserExporter.export(@user)Default logger + named loggers with rotation.
Lux.log 'request handled' # info shortcut
Lux.logger.error 'boom'
Lux.logger(:audit).info 'user logged in' # -> ./log/audit.logMail composition + template rendering, wrapper over the mail gem.
class Mailer < Lux::Mailer
def welcome user
mail.subject = 'Welcome'
mail.to = user.email
@user = user
end
end
Mailer.deliver(:welcome, user)Plugin loader with canonical folder layout.
Lux.plugin :db, :authcog, :html
Lux.plugin.get(:db).folder # filesystem path of a loaded pluginAccess policies usable from models, controllers, and APIs.
class BlogPolicy < Lux::Policy
def read?
model.created_by == user.id
end
end
@blog.can.read? # bool
@blog.can.read! # raises Lux::Policy::Error on fail
authorize @blog.can.read? # in a controller: 403 on failCustom code reloader that skips installed gems. Fires per-request in dev/web.
Lux::Reloader.run # explicit
reload! # console helperRender pages, controllers, templates, view cells - with or without an HTTP server.
Lux.render.get('/about').body # full-page render via router
Lux.render.controller('users#show') { @user = User.first }.body
Lux.render.template(self, './app/views/welcome.haml')
Lux.render.cell(:user, self).avatar(@user)HTTP response builder. Default cache is private; public is opt-in.
response.status 201
response.header 'x-app', 'lux'
response.cache_public 10.minutes
response.etag :report, Report.max(:updated_at)
response.send_file './tmp/report.pdf', inline: trueThe schema DSL at the heart of the framework - shared by controllers, APIs, models, and migrations.
Lux.schema :user do
name String, max: 30
email type: :email, index: true
age Integer, min: 13, max: 130
end
Lux.schema(:user).validate(params, strict: true)Tilt-based template rendering with helper module mixing.
Lux::Template.render(self, './app/views/users/show.haml')
helper = Lux::Template.helper({ '@user' => @user }, :html, :main)
helper.link_to 'Home', '/'Named types - the type vocabulary the rest of the framework uses.
Plug-in new types under lib/lux/type/types/.
opt :email, type: :email # in a controller or API
opt :country, type: :country
opt :id, type: :uuid
Lux::Type.load(:email).new('foo@bar.baz').getReusable view components. One class per cell; one template per method.
class UserCell < ApplicationCell
def card
render :card
end
end
UserCell.new.card # standalone
Lux.render.cell(:user, self).card # via Lux.render
# in HAML: = cell(:user).cardOptional features, loaded with Lux.plugin :name. Canonical layout: see
Lux::Plugin.
| Plugin | What | Docs |
|---|---|---|
db |
Sequel model extensions, auto-migrate, link associations |
README |
web_common |
Shared web layer: html builders, assets, authcog controller, user session + sudo, PG exception logger + /admin |
README |
job_runner |
Background job queue (LuxJob) | README |
lux_logger |
Structured database logger | README |
oauth |
OAuth integration | README |
lux server # Start web server (alias: s, ss)
lux console # Start Pry console (alias: c)
lux render /path # Render any path locally (session, bearer, headers)
lux routes # Print mounted route tree
lux mount # Print plugin/app mount map
lux generate # Generate models, cells, controllers
lux evaluate CODE # Evaluate Ruby in app context (alias: eval, e)
lux test # Run the test suite (alias: t)
lux secrets # Display ENV and secrets
lux stats # Project stats
lux memory # Profile memory usageSee bin/README.md for full CLI docs.
The lux executable is built on lux-hammer -
a small declarative CLI builder. Every lux <cmd> is a hammer task,
discovered at startup from:
bin/cli/*_hammer.rb(framework tasks)plugins/<name>/Hammerfileandplugins/<name>/hammer/*_hammer.rb(per-plugin tasks - only loaded if the plugin is configured inconfig/config.yaml)./lib/tasks/*_hammer.rb(project tasks)./Hammerfile(ad-hoc project tasks)
A hammer task is a task :name do ... end block. Inside it:
| Function | Purpose |
|---|---|
desc 'text' |
one-line description (shown in lux help) |
example 'cmd args' |
one or more usage examples for lux help <cmd> |
opt :name, ... |
typed option: type:, default:, alias:, placeholder:, desc: |
alt :other |
command alias (lux foo -> lux other) |
needs :env |
prerequisite tasks (e.g. load ./config/env) |
proc do |opts| ... end |
the body; opts[:args] for positional, opts[:name] for declared opts |
# bin/cli/foo_hammer.rb (or plugins/<name>/hammer/foo_hammer.rb)
task :foo do
desc 'Run foo with options'
example 'foo -v --env=prod some-arg'
needs :env
opt :verbose, alias: :v, type: :boolean, default: false, desc: 'verbose output'
opt :env, alias: :e, default: 'dev', desc: 'environment'
proc do |opts|
say.green "running foo in #{opts[:env]} verbose=#{opts[:verbose]}"
say "args: #{opts[:args].inspect}"
end
endnamespace :db do
task :migrate do
desc 'Run pending migrations'
proc { |_| Lux::Db.migrate! }
end
namespace :seed do
task :load do
desc 'Load seed data'
proc { |_| load './db/seeds/all.rb' }
end
end
endInvoke as lux db:migrate / lux db:seed:load.
say 'plain'
say.green 'success'
say.red 'error'
say.yellow 'warning'
say.blue 'info'Hammer's full source: https://github.com/dux/lux-hammer
- Models use
ref(string ULID) as primary key, not integerid - Config from
config/config.yamlviaLux.config(indifferent access) .envfiles loaded automatically on boot- Inside
module Lux, preferobj.is_hash?overobj.is_a?(Hash)(Hashlexically resolves toLux::Hash) - Use
FOO ||=for constants, notFOO = - End files with newline, no trailing spaces on empty lines
bundle exec hammer test # all tests (folder-isolated)
hammer test --folder lux_tests # one suite
hammer test --isolated # per-spec processesSpecs live under spec/<area>_tests/ and are Minitest::Spec - named
*_spec.rb despite being Minitest, not RSpec.
- Version: see
.version - License: MIT, (c) 2017 Dino Reic
- GitHub: https://github.com/dux/lux-fw
- Author: Dino Reic (@dux)
Contributions welcome.