Rally

Rally

Package Version Hex Docs

Rally is an opinionated, convention-focused Gleam package for building Lustre apps that server-render the initial HTML, hydrate in the browser, and continue as client-side Lustre apps. You write page modules with page-local models, messages, load handlers, save handlers, views, and broadcast hooks. Rally generates routing composition, server-side rendering, WebSocket transport, request/result protocol code, hydration, browser lifecycle, and typed client-server messaging.

The page file is the contract. Client state, server calls, and the message types that cross the wire all live together until you choose to extract shared code.

Rally chooses conventions so application code can stay small, generated code can stay predictable, and the framework can test the common path hard. A Rally app uses SQLite, Marmot, Proute, and Libero as part of that path. If an app needs a different foundation, fork Rally and submit a tested PR instead of growing local framework glue.

Convention Stack

LibraryWhat Rally uses it for
SQLiteEmbedded application database
MarmotSQL migrations and type-safe query generation from .sql files
ProuteFile-based routes, route params, query params, page enums, and page dispatch
LiberoTyped wire codecs for page-local load/save contracts and broadcasts
LustreTEA views, updates, and effects

Proute is included through Rally. Apps configure routes with proute.toml; they do not need to list proute directly.

What Rally Generates

Rally reads page modules and writes the routing, SSR, WebSocket transport, request and response encoding, and dispatch code around them.

You still write the UI, SQL, auth policy, and server handlers.

rally build follows the Rally Scoreboard Example path. It runs configured Marmot codegen, runs Proute when proute.toml exists, composes Proute routes with Libero codecs, writes src/generated/rally/** and src/generated/libero/**, then builds the current package for Erlang and JavaScript.

Create an app

gleam new my_app
cd my_app
gleam add rally libero
gleam run -m rally init
gleam run -m rally migrate
gleam run -m rally build
gleam run

rally init writes the starter app into the current Gleam project, including src/my_app.gleam. It replaces the default files from gleam new that Rally needs to take over: gleam.toml, .gitignore, README.md, and src/my_app.gleam. If you already wrote your own README.md, Rally leaves it alone. If any other target file already exists, Rally stops before writing anything and tells you which file needs attention.

rally migrate delegates to marmot migrate; Rally has no migration runner of its own. Marmot owns the configured database path and migration directory. The starter uses db/migrations and stores local SQLite databases under db/. rally build then regenerates framework glue and builds the app for Erlang and JavaScript. Start the server with gleam run -m rally server and open http://localhost:8080. To use a different port, set PORT in .env or run PORT=8081 gleam run -m rally server. Run rally migrate before rally build and before deploying against a new database.

Common workflow commands:

CommandWhat it does
gleam run -m rally genRuns Marmot, Proute, Rally, and Libero codegen without building
gleam run -m rally regenDeletes src/generated and then runs gen
gleam run -m rally buildRuns gen, then builds Erlang and JavaScript targets
gleam run -m rally migrateDelegates to marmot migrate
gleam run -m rally resetDelegates to marmot reset, including seeds
gleam run -m rally serverStops any process on PORT or 8080, then runs gleam run in the foreground

Writing a page

A page file in src/<namespace>/pages/ is a Lustre component with Rally contracts beside the client UI. A page that loads server data has this shape:

import generated/proute/public/page_input
import gleam/int
import gleam/list
import lustre/effect.{type Effect}
import lustre/element.{type Element}
import lustre/element/html
import public/page_shared_state.{type PublicPageSharedState}
import rally/runtime/load as runtime_load

@target(erlang)
import generated/sql/public/pages/games_sql
@target(erlang)
import sqlight

pub type Game {
  Game(id: Int, name: String)
}

pub type LoadResult {
  PublicGamesLoaded(games: List(Game))
}

pub type ServerMsg {
  PublicGamesLoad
}

pub type Model {
  Model(games: List(Game))
}

pub type Message {
  Loaded(Result(List(Game), runtime_load.LoadError))
  NavigateGame(id: Int)
}

pub fn initial_model(
  _page_shared_state: PublicPageSharedState,
  _query_params: page_input.QueryParams,
) -> Model {
  Model(games: [])
}

pub fn update(
  _page_shared_state: PublicPageSharedState,
  model: Model,
  msg: Message,
) -> #(Model, Effect(Message)) {
  case msg {
    Loaded(Ok(games)) -> #(Model(games:), effect.none())
    Loaded(Error(_)) | NavigateGame(_) -> #(model, effect.none())
  }
}

pub fn view(model: Model) -> Element(Message) {
  html.main([], [
    html.ul([], list.map(model.games, view_game)),
  ])
}

fn view_game(game: Game) -> Element(Message) {
  html.li([], [
    html.text(game.name <> " #" <> int.to_string(game.id)),
  ])
}

@target(erlang)
pub fn load(
  db: sqlight.Connection,
) -> Result(List(Game), runtime_load.LoadError) {
  case games_sql.list_games(db:) {
    Ok(rows) ->
      Ok(list.map(rows, fn(row) { Game(id: row.id, name: row.name) }))
    Error(sqlight.SqlightError(..)) ->
      Error(runtime_load.LoadError(message: "Could not load games."))
  }
}

Model, Message, initial_model, update, and view are normal Lustre TEA, with page shared state passed into page lifecycle functions for app-wide browser state. ServerMsg, LoadResult, and load define the load boundary. The page does not call load directly: generated Rally browser code sends PublicGamesLoad over the WebSocket, generated server code calls load(db), and the browser dispatches Loaded(...) back into update.

Pages that save data add save constructors to ServerMsg, define a page-local save error type, and export an Erlang handle_save function. Browser updates call the generated generated/rally/server.save_* effect to send those save messages. Pages that receive live updates add broadcast_subscriptions and apply_broadcast in a // BROADCAST section.

There is no separate API schema. Rally discovers page-local ServerMsg, LoadResult, load, handle_save, and broadcast contracts, then passes those page-owned wire types to Libero as codec seeds. Libero generates typed codec artifacts. Rally generates the browser and server glue that calls those codecs.

File-based routing

The filename determines the URL:

FileURLRoute variant
home_.gleam/Home
about.gleam/aboutAbout
games.gleam/gamesGames
products/id_.gleam/products/:idProductsId(id: Int)
settings/profile.gleam/settings/profileSettingsProfile

home_.gleam is the default route for the directory it lives in. A trailing _ makes the segment dynamic. Params named id or ending in _id parse as Int; others parse as String.

What to import

Most Rally apps use only a few modules directly:

ModuleUse it for
rally/runtime/loadStandard page load error type
rally/runtime/dbSQLite open, timed queries, nested transactions, SQL value helpers
rally/runtime/systemApp startup and background jobs
rally/runtime/sessionSession cookie generation, parsing, response headers
rally/runtime/authAuth policy types, load result types, secret hashing, login codes
rally/runtime/auth_httpStandard sign-in, sign-out, email-code, and Google provider HTTP routes
rally/runtime/envAPP_ENV parsing and production cookie policy
rally/runtime/test_dbFast in-memory SQLite for tests

The rally/internal/... modules are codegen implementation. App code should treat them as private. The generated files under src/generated/ are the boundary Rally presents to your app.

Auth helpers

rally/runtime/auth contains the shared types Rally expects for page auth, plus helpers for common auth flows. auth.hash stores passwords or other submitted secrets with PBKDF2-SHA256 using Erlang/OTP crypto. auth.verify checks a submitted secret against a stored hash.

For short login-code flows, use auth.generate_login_code, then store auth.hash_login_code(scope:, code:, secret_key:). Later, check the submitted code with auth.verify_login_code(stored:, scope:, code:, secret_key:). The scope is usually an email address or another app-owned lookup value. Rally trims and lowercases both the scope and the code before hashing. The secret_key should be a stable app secret that is not stored in the database.

rally/runtime/auth_http owns the standard provider route mechanics. The email-code flow uses POST /sign_in/code to ask the app to deliver a code for an email, then POST /sign_in verifies a submitted code and issues the Rally auth session. The Google flow uses /sign_in/google and /sign_in/google/callback for provider redirect, state cookies, and session issuing after the app exchanges the provider code and returns a local user id. Apps provide callbacks for user lookup/upsert, code storage/delivery, OAuth credentials, provider identity verification, return-path narrowing, and authorization policy.

Generated files

Running gleam run -m rally build reads the app’s standard project config and produces Rally Scoreboard Example generated files:

For broadcast-aware pages in the Rally Scoreboard Example surface, app code owns typed topics and broadcast event payloads. Page broadcast_subscriptions and apply_broadcast hooks live together in a // BROADCAST section. Generated Rally glue maps typed topics to text topic sync frames, filters broadcasts on the server per connection, and calls page apply_broadcast hooks with decoded events.

Examples

More docs

Contributing

Rally is a Gleam project targeting Erlang. You need Gleam (v1.x), Erlang/OTP 26+, SQLite3, and Node.js.

git clone <repo-url>
cd rally
gleam build
gleam test

Influences

License

MIT. See LICENSE.

Search Document