Skip to content

Repository files navigation

Lux logo

Lux

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

Sinatra-simple if that's all you need

# config.ru
require 'lux-fw'

Lux do
  routes do
    map foo: 'foo#call'           # /foo -> FooController#call
    body 'Hello world, this is 404'
  end
end

rackup it and you're up. Lux scales down to one file and up to a full enterprise backend through the same DSL.

Standard app shape

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 Lux

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

Why Lux

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:

  1. For humans: learn opt :email, type: :email, req: false once - it works the same in a controller, an API, a schema block, a form helper.
  2. For LLMs: one DSL means generated code is consistent across the codebase - fewer hallucinations and better completions. The framework self-documents via /sys/AGENTS.md so any deployed app exposes its full API surface to agents.

Framework features

  • Routing DSL with tree-style scoping (map, root, subdomain, plugin_route, HTTP-method predicates), usable at the top level of Lux do ... end or inside an optional routes do wrapper
  • Controllers with the shared opt / params do schema DSL
  • JSON-RPC-style APIs with auto-generated explorer, OpenAPI, Postman, and /sys/AGENTS.md for 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.defer background threads with a clean Lux.current and parent context passed explicitly to the block
  • HTML mailer + template rendering via Tilt (HAML, ERB, ...)
  • Pluggable plugin system with canonical folder layout
  • lux CLI built on lux-hammer - declarative tasks, typed options, namespace tree, zero runtime deps

A taste of the unification

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
end

Same DSL. Same type vocabulary. Same option keys.

Modules

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

Lux::Api

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
end

Lux::Application

Router 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
end

Two 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. No unless response.body? guards needed after a map / call / root.
  • - and _ are the same character to map, match, controller filter and resourceful dispatch - normalised on both sides at compare time. nav.path keeps the URL's own spelling, so slug lookups still see my-post-title.

Lux::Cache

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 }

Lux::Boot::Config

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 hook

Lux::Controller

HTTP 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
end

Lux::Current

Thread-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 inside

Lux::Db

Multi-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 proxy

Lux::Browser::Channel

Server -> 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 anywhere
Lux.subscribe('user:abc123', msg => log.append(msg.html))  // browser

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

Lux::Environment

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)

Lux::DEPLOY_ID

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 exactly

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

Lux::Error

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 rendering

Lux::Hash

Hash 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" }

Lux::JsonExporter

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)

Lux::Logger

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

Lux::Mailer

Mail 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)

Lux::Plugin

Plugin loader with canonical folder layout.

Lux.plugin :db, :authcog, :html
Lux.plugin.get(:db).folder             # filesystem path of a loaded plugin

Lux::Policy

Access 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 fail

Lux::Reloader

Custom code reloader that skips installed gems. Fires per-request in dev/web.

Lux::Reloader.run                       # explicit
reload!                                 # console helper

Lux::Render

Render 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)

Lux::Response

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: true

Lux::Schema

The 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)

Lux::Template

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', '/'

Lux::Type

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').get

Lux::ViewCell

Reusable 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).card

Plugins (plugins/)

Optional 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

CLI

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 usage

See bin/README.md for full CLI docs.

Hammer (the CLI engine)

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>/Hammerfile and plugins/<name>/hammer/*_hammer.rb (per-plugin tasks - only loaded if the plugin is configured in config/config.yaml)
  • ./lib/tasks/*_hammer.rb (project tasks)
  • ./Hammerfile (ad-hoc project tasks)

Root functions inside a task

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
end

Namespaces

namespace :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
end

Invoke as lux db:migrate / lux db:seed:load.

say helper (inside a proc do |opts|)

say 'plain'
say.green 'success'
say.red 'error'
say.yellow 'warning'
say.blue 'info'

Hammer's full source: https://github.com/dux/lux-hammer

Convention quick-reference

  • Models use ref (string ULID) as primary key, not integer id
  • Config from config/config.yaml via Lux.config (indifferent access)
  • .env files loaded automatically on boot
  • Inside module Lux, prefer obj.is_hash? over obj.is_a?(Hash) (Hash lexically resolves to Lux::Hash)
  • Use FOO ||= for constants, not FOO =
  • End files with newline, no trailing spaces on empty lines

Testing

bundle exec hammer test                 # all tests (folder-isolated)
hammer test --folder lux_tests          # one suite
hammer test --isolated                  # per-spec processes

Specs live under spec/<area>_tests/ and are Minitest::Spec - named *_spec.rb despite being Minitest, not RSpec.

Status

Contributions welcome.

About

ruby web framework

Resources

Stars

6 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages