Rally
Rally is a full-stack web framework for Gleam on the BEAM. You write page modules where client code and server code live in the same file, and Rally generates the glue: routing, server-side rendering, WebSocket transport, and typed client-server messaging.
Rally apps use SQLite by default. You get an embedded database, migrations, and type-safe SQL codegen without running a separate database server.
Each page is a standard Lustre TEA component (Model, Msg, init, update, view). Add a server_* handler function for anything that needs the database, and Rally generates the encoders, decoders, and dispatch so the client can call it with a typed message. The wire protocol comes from libero, which owns the RPC contract between client and server.
Create an app
gleam new my_app
cd my_app
gleam add rally libero
gleam run -m rally init
bin/devrally init writes the starter app into the current Gleam project. bin/dev runs codegen, builds the JS client, and starts the server on port 8080. SQLite is the database; there is no separate database service to install.
Fresh apps default to APP_ENV=dev. Set APP_ENV=prod in production so session cookies include Secure and browser console logging stays off.
Writing a page
A page file in src/<namespace>/pages/ is a Lustre component:
pub type Model { Model(count: Int, name: String) }
pub type Msg { Increment; GotData(Result(Data, List(String))) }
pub fn init(client_context: ClientContext) -> #(Model, Effect(Msg))
pub fn update(client_context: ClientContext, model: Model, msg: Msg) -> #(Model, Effect(Msg))
pub fn view(client_context: ClientContext, model: Model) -> Element(Msg)That's the client side. To add a server call, define a handler in the same file:
pub type ServerLoadData { ServerLoadData(id: Int) }
pub fn server_load_data(
msg msg: ServerLoadData,
server_context server_context: ServerContext,
) -> Result(Data, List(String))The client calls it by constructing the message type:
rally_effect.rpc(ServerLoadData(id: 42), on_response: GotData)
There is no separate API definition. The type is the contract. Libero discovers server_* functions, walks their type signatures, and generates the wire protocol automatically.
File-based routing
The filename determines the URL:
| File | URL | Route variant |
|---|---|---|
home_.gleam or index.gleam | / | Home |
about.gleam | /about | About |
products/id_.gleam | /products/:id | ProductsId(id: Int) |
settings/profile.gleam | /settings/profile | SettingsProfile |
Rally follows Elm Land's homepage convention: home_.gleam is reserved for /. index.gleam also maps to its parent directory. Outside those root-page cases, a file or directory segment ending in _ becomes dynamic. After Rally removes the trailing _, params named id or ending in _id parse as Int; other params parse as String. Adding a route means creating a file and re-running codegen.
Stateful server model
Most server work is just a server_* handler called with rally_effect.rpc. Use the stateful model only when the server needs to remember page state between client messages. In that case, define ToServer/ToClient message types and a ServerModel:
pub type ToServer { ToggleFavorite; AddComment(body: String) }
pub type ToClient { ArticleUpdated(Article); CommentAdded(Comment) }
pub type ServerModel { ServerModel(article_id: Int) }
pub fn server_init(slug: String, server_context: ServerContext)
-> #(ServerModel, Effect(ToClient))
pub fn server_update(model: ServerModel, msg: ToServer, server_context: ServerContext)
-> #(ServerModel, Effect(ToClient))
The client sends ToServer messages with rally_effect.send_to_server, and the server responds or pushes ToClient messages from server_init and server_update. Start with RPC; reach for the stateful model when you need server-side state between calls, like entity ownership for authorization. See examples/realworld/ for both patterns side by side.
Broadcast
Server-to-client messaging at four scopes, built on OTP pg process groups:
| Effect | Who receives it |
|---|---|
send_to_client(msg) | One specific connection |
broadcast_to_session(msg) | Every tab in the same browser session |
broadcast_to_page(msg) | Every connection viewing the same page |
broadcast_to_app(msg) | Every connection to the app |
Connections auto-subscribe to their relevant topics on WebSocket connect.
SSR with hydration
The first request renders full HTML server-side with the model embedded as flags. The client reads the flags, boots Lustre, and takes over as a SPA. Subsequent navigations are client-side only (modem handles pushState).
A page can optionally export load (server-side data fetch) and init_loaded (client init from pre-fetched data).
Under the hood
This section covers what Rally generates and how the codegen pipeline works. You don't need this to start building, but it helps when debugging or contributing.
What Rally generates
Running gleam run -m rally reads [[tools.rally.clients]] from gleam.toml and produces these files for each client namespace:
Server-side (in src/generated/<namespace>/):
| File | What it does |
|---|---|
router.gleam | Route type, parse_route, route_to_path, href |
page_dispatch.gleam | PageModel/PageMsg unions, per-route init/update/view dispatch |
rpc_dispatch.gleam |
Routes wire messages to server_* handler functions |
ssr_handler.gleam |
Calls load, renders view wrapped in layout, embeds model as flags |
ws_handler.gleam | WebSocket frame loop: page topics, RPC dispatch, push frame delivery |
http_handler.gleam | HTTP POST /rpc handler for non-WebSocket clients |
protocol_wire.gleam | Protocol facade: delegates to libero's ETF or JSON wire module |
Client-side (in .generated_clients/<namespace>/):
| File | What it does |
|---|---|
src/generated/app.gleam | Lustre SPA entry: per-page TEA loop, WebSocket transport, modem routing |
src/generated/transport.gleam | FFI bridge to the WebSocket runtime |
src/generated/types.gleam | ClientMsg type mirroring server dispatch variants |
src/generated/codec.gleam | SSR flag decoding for hydration |
src/<namespace>/pages/*.gleam | Tree-shaken page modules (server code stripped, client code kept) |
src/rally_runtime/effect.gleam | Client-side effect shim: rpc, navigate, send_to_client_context |
The client package is a standalone Gleam project with its own gleam.toml. The server project is the single source of truth.
The codegen pipeline
The pipeline runs once per [[tools.rally.clients]] entry:
gleam.toml config
|
v
scanner Walks src/<namespace>/pages/, builds a List(ScannedRoute)
| from the filesystem structure.
v
parser Parses each page's source with Glance AST, extracts
| Model/Msg types, function signatures, auth constants.
| Produces a PageContract per page.
v
libero Scans for server_* handlers, discovers RPC endpoints,
| walks the type graph for codec generation.
v
generators Emit server-side Gleam (router, dispatch, SSR, WS, HTTP),
| client-side Gleam (app, transport, tree-shaken pages),
| Erlang (atoms, wire), and JS (codec, transport).
v
tree shaker Strips server-only code from page source before copying
| it to the client package. Uses Glance AST to identify
| server functions and trace reachability.
v
dependency Follows import chains from client pages to copy any
resolver shared modules the client needs (and catches
| @external(erlang) imports that can't run in JS).
v
output Writes all generated files, formats .gleam files
with `gleam format`, skips unchanged files.
The types that flow through this pipeline are defined in src/rally/types.gleam.
Libero and the wire protocol
Libero handles the wire protocol and RPC contracts. It scans page modules for server_* handler functions, walks the type graph to discover what needs encoding, and generates the ETF and JSON codec functions for both sides of the wire.
Rally's codegen calls into libero after the scanner and parser have extracted the page structure. From that point, libero owns everything related to wire protocol and RPC dispatch: the protocol_wire.gleam facade, the JS codec modules, and the Erlang atom/wire helpers all come from libero's type walk. If you're working on how messages get encoded, decoded, or dispatched, you're working in libero's domain.
Messages are serialized as ETF (Erlang External Term Format) by default. ETF is the BEAM's native binary format: atoms, tuples, and tagged variants survive the round trip without a separate schema definition layer. JSON is available as an alternative for non-Gleam clients.
Design decisions
Single source, generated client
You write one Gleam project. The codegen reads your source, extracts client-side types and functions, and generates a complete client package (its own gleam.toml, dependencies, transport layer, codec). The tradeoff: you depend on the codegen to correctly split client from server, and debugging generated code requires understanding the tree shaker.
Colocation-first
Types, state, and logic live in the page file until they need to be shared. There's no upfront shared domain layer. Extract when duplication becomes a maintenance problem, not before.
SQLite ships with every app
Every Rally app gets SQLite with WAL mode, busy timeout, and foreign keys enabled. One embedded database, configured once in db.open. The tradeoff is that there is no tradeoff: you don't need anything more than sqlite3. Marmot generates type-safe query functions from .sql files via live SQLite introspection.
Lamdera-inspired, not Lamdera-bound
Lamdera's architecture is the starting point: explicit server handler types as the client-server contract, server-side state per connection, TEA on both sides. But Gleam on the BEAM gives us OTP processes, pg groups, and native concurrency that Elm can't access. Where the BEAM offers a better primitive, we use it (four-level broadcast via pg, process dictionary for handler state, native ETF codec, libero for RPC dispatch).
Rally vs Lustre server components
These are two different architectures for building full-stack Lustre apps.
Lustre server components run the TEA loop on the server: model, update, and view all execute server-side. On first connect, the server sends the full VDOM. On each subsequent update, it diffs the old and new VDOM and sends only the patch. The client is a thin JS shell (~10KB) that applies DOM patches and forwards events back to the server.
Rally runs TEA in the browser for UI state. Server work is explicit: most pages call stateless server_* RPC handlers, while pages that need per-connection server state use server_init/server_update and ToServer/ToClient messages. In both cases the wire carries domain messages, not VDOM patches.
| Lustre server components | Rally | |
|---|---|---|
| Where UI runs | Server (model + update + view) | Client (model + update + view) |
| What goes over the wire | VDOM patches down, DOM events up | Domain messages in both directions |
| Interaction latency | Every event round-trips to server | Local state changes are instant |
| Server memory | Model + VDOM + event handler cache (shared across subscribers of same component) | Optional ServerModel per connection for stateful pages; stateless RPC pages keep no page model on the server |
| Client JS bundle | Minimal (DOM patcher, ~10KB) | Full app logic (Lustre + page modules) |
| Client/server decision | None: everything is server-side | You decide per interaction |
| Real-time multi-user | Built in (all subscribers see same state) | Requires explicit broadcast |
| Code to write | One update function | Client update plus server handlers; stateful pages also define server_update |
When server components make more sense
To be honest? Most of the time. For apps where interactions are button clicks, form submissions, and navigation, the server round-trip on same-region infra is 10-50ms and users won't notice. You get a simpler mental model (one update function, no client/server split decisions), a tiny client bundle, zero codec concerns, and real-time multi-user for free since all clients subscribe to the same server-side state.
Server components can also embed client-side Lustre components as web components for spots that need local interactivity, with the server pushing data via attributes and context providers. For apps that are 90% server-driven with a few interactive widgets, this hybrid approach works well.
When Rally makes more sense
Multiple client surfaces. The explicit server handler layer is a typed API contract. A web client calls handlers over WebSocket. A CLI calls the same handlers over HTTP. An AI agent uses the CLI. A JS SDK calls the same endpoints from a static site. One set of server_* functions serves all of them.
With server components, the wire protocol is VDOM patches: only a browser can consume them. If you later need a CLI or SDK, you build a separate API layer, maintain two ways to invoke the same business logic, two auth paths, two testing surfaces.
Responsive local interactions. For continuous client interactions (typing with live feedback, drag-and-drop, editors, optimistic updates), the server round-trip becomes perceptible. Rally keeps those interactions local and only crosses the network for things that actually need the server.
The cost is real: you write two update functions per page, you decide what belongs on the client vs. server for every interaction, the client bundle is larger, and you need a broadcast system for real-time multi-user features. But if your app has multiple client surfaces, the "overhead" of explicit domain messages is actually the architecture that enables them.
Examples
examples/realworld/: RealWorld (Conduit) clone with auth, articles, comments, tags, favorites, follows. See its README for a walkthrough.
Contributing
Prerequisites
Rally is a Gleam project targeting Erlang. You need:
- Gleam (v1.x)
- Erlang/OTP 26+
- SQLite3 (usually already present on macOS and Linux)
- Node.js (for building and testing the generated JS client)
git clone <repo-url>
cd rally
gleam build
Rally depends on libero. App projects should add both packages with gleam add rally libero.
Running tests
gleam test # All Gleam tests
gleam run -m birdie # Review snapshot test changes interactively
gleam run -m birdie accept # Accept all new snapshots
test/js/run_auth_error_test.sh # JS-side auth error detection (not part of gleam test)
Tests create temporary directories in /tmp/rally_test_* and clean up after themselves. Test-only fixture apps live under fixtures/; codec and wire tests use in-memory SQLite via test_db.gleam.
Project layout
src/
rally.gleam # CLI entry point, orchestrates the pipeline
rally/
scanner.gleam # Filesystem walk -> List(ScannedRoute)
parser.gleam # Glance AST -> PageContract
types.gleam # Shared types for the pipeline
tree_shaker.gleam # Strips server code from page source
dependency_resolver.gleam # Follows imports to copy shared modules
format.gleam # Runs gleam format on generated code
generator.gleam # Route type, parse_route, page dispatch codegen
generator/
client.gleam # Client package: gleam.toml, app.gleam, transport
codec.gleam # Client codegen: types, decoders, effect shim, per-page modules
ssr_handler.gleam # SSR handler codegen
ws_handler.gleam # WebSocket handler codegen
http_handler.gleam # HTTP RPC handler codegen
json_rpc_dispatch.gleam # JSON-specific RPC dispatch codegen
rally_runtime/
effect.gleam # rpc, broadcast, navigate, send_to_client_context
db.gleam # SQLite: open (WAL/busy/FK), query, transaction
system.gleam # System DB: message logging, job queue
jobs.gleam # Background job runner with retry
session.gleam # Session cookie generation and extraction
env.gleam # APP_ENV parsing, secure cookie policy
topics.gleam # OTP pg pub/sub for broadcast
wire.gleam # Thin wrapper over libero wire protocol
codec.gleam # Base64 ETF encode/decode for SSR flags
ssr.gleam # Lustre element to HTML string
auth.gleam # AuthPolicy, LoadResult, Cookie types
migrate.gleam # SQL migration runner
test_db.gleam # In-memory test DB with migration caching
transport_ffi.mjs # Browser WebSocket client (reconnect, RPC, push, debug)
rally_runtime_ffi.mjs # JS FFI stubs for server-only functions
rally_effect_ffi.mjs # Browser-side navigate via pushState
examples/
realworld/ # RealWorld (Conduit) clone: full CRUD with auth
test/
rally/ # Scanner, parser, generator, codec, auth tests
rally_runtime/ # Wire, session, broadcast, jobs, topics tests
js/ # Browser-side JS tests (auth errors, frame decode)Two module trees, two audiences.rally/ is the codegen tool that app developers run at build time. rally_runtime/ is the library that ships with every Rally app and runs at request time. Contributors working on routing or code generation stay in rally/. Contributors working on WebSocket behavior, broadcasts, or database helpers stay in rally_runtime/.
Where to start reading
If you're new to the codebase, read in this order:
src/rally/types.gleam: the type vocabulary. Every pipeline type is documented here. Read this first so the rest of the code makes sense.src/rally/scanner.gleam: the simplest module in the pipeline. Walks the filesystem, returns routes. Good warmup.src/rally/parser.gleam: uses Glance AST to extract the page contract. Shows how Rally discovers what a page exports.src/rally.gleam: the orchestrator. Long file, but it shows how scanner, parser, libero, generators, tree shaker, and dependency resolver connect.src/rally_runtime/effect.gleam: the API that app developers call. Shows how server push, broadcast, and RPC work from the app's perspective.examples/realworld/: a full app built with Rally. Seeexamples/realworld/README.mdfor a walkthrough of the pages and patterns.
For the codegen generators (generator/*.gleam): these files build Gleam/Erlang/JS source as strings. They're inherently harder to read than normal code. Start with generator.gleam (route type and parse function), which is the simplest, before moving to generator/ws_handler.gleam or generator/ssr_handler.gleam.
Influences and credits
- Lamdera: explicit server handler types as the client-server contract, TEA on both sides
- Lustre: TEA, effects, and the client-side UI runtime
- elm-land: file-based routing conventions, including
home_for the root page - Libero: wire protocol and RPC contract layer (type graph walking, ETF/JSON codec generation,
server_*handler discovery) - Marmot: SQL-first codegen with live SQLite introspection
Technical reference
llms.txt is the machine-readable framework contract: codegen modules, runtime library, wire protocol details, auth framework, configuration, and the full page module spec. It's maintained alongside the code and reflects the current state of the framework.