DocsWorking with Rahti
Commands
Every command a Rahti project answers to, in one place: the Cargo commands you type all day, the two scaffolder verbs and their flags, and the environment variables that move a build from your machine to a deployment.
Install
cargo-rahti is the only piece installed globally — everything else arrives as a normal Cargo dependency of the project it creates. cargo-watch is optional, and is what the cargo dev alias below hands the restart to.
cargo install cargo-rahti # the scaffolder — once per machine
cargo install cargo-watch # what `cargo dev` hands the restart toEveryday commands
These are the ones you type inside a project. None of them is a Rahti invention except the dev alias: the build step runs from build.rs, so an ordinary Cargo command regenerates the router on its way to compiling.
cargo run # regenerate routes and serve
cargo dev # cargo run, restarted on every edit (needs cargo-watch)
cargo check # verify without running
cargo test # this application's tests
cargo rahti upgrade # refresh unedited scaffold files| Command | What it does |
|---|---|
| cargo run | Regenerates src/routes.rs, the component and model wiring, and .rahti/manifest.json; compiles CSS according to the configured engine; then serves. |
| cargo dev | The scaffold's alias in .cargo/config.toml. Re-runs cargo run on every change under src/ or to rahti.config.json, and the open tab reloads itself when the new server comes up. Needs cargo-watch. |
| cargo check | The same generation and validation, without producing a binary. A route, RPC or socket-name conflict is a build error, so this is what catches one fastest. |
| cargo test | The application's own tests. See Testing for driving the generated router in-process. |
| cargo build --release | A production binary. Development reload is off, and a busy port is an error rather than something to walk past. |
The scaffolder
cargo rahti has two verbs that do work, and two that only report. Every feature flag adds something, and leaving it out is how you say no — one spelling per answer, so there are no opposing flags to reconcile. An interactive run asks about whatever you did not name, defaulting to no; a run with nowhere to ask — a pipe, a CI job — takes the flags at their word.
cargo rahti new <name> [--tailwind] [--db [backend]] [--ws] [--local <path>]
cargo rahti upgrade [--dry-run] [--force [path...]] [--db [backend]] [--ws]
cargo rahti help
cargo rahti --versioncargo rahti new
# A plain project. An interactive run asks about the rest, defaulting to no.
cargo rahti new my-app
# Tailwind, a SQLite database, and WebSockets — nothing left to answer.
cargo rahti new my-app --tailwind --db sqlite --ws
# Depend on a Rahti checkout by path. For working on the framework itself.
cargo rahti new my-app --local ../rahti| Flag | What it adds |
|---|---|
| --tailwind | Compiles src/app/globals.css with the pinned standalone Tailwind CLI. Without it, plain CSS. |
| --db [backend] | SeaORM wiring for sqlite, postgres or mysql — bare --db is SQLite, the one that needs no server. Writes src/db.rs, src/models/ and src/migrations/, adds SeaORM to Cargo.toml, and records the backend in rahti.config.json. |
| --ws | Puts features = ["ws"] on the rahti dependency, which is what compiles rahti::ws and the #[socket] attribute, and records "ws": true in the config. |
| --local <path> | Path dependencies on a Rahti checkout instead of published crates. For working on the framework itself. |
Every project is written with its own documentation — AGENTS.md, the CLAUDE.md that points at it, and the convention documents under docs/conventions/, with the database and WebSocket documents only where the feature was chosen. They are ledgered scaffold files, so upgrade refreshes unedited copies as the framework moves.
cargo rahti upgrade
An upgrade rewrites or removes only files whose current hash still matches the ledger in rahti.config.json. A file you edited is yours: it is preserved and reported, never guessed at.
# Refresh unedited scaffold files, and move the version pins forward.
cargo rahti upgrade
# Print what would change, and change nothing.
cargo rahti upgrade --dry-run
# Add a feature to a project that started without it.
cargo rahti upgrade --db postgres
cargo rahti upgrade --ws| Flag | What it does |
|---|---|
| -n, --dry-run | Print what would change, and change nothing. A dry run never asks a question — name the feature to preview adding it. |
| -f, --force [path...] | Take back scaffolded files you have edited. See below. |
| --db [backend] | Add a database to a project that has none — the same flag new takes. Writes the files, records the backend, and adds SeaORM to Cargo.toml and DATABASE_URL to .env. |
| --ws | Add WebSockets to a project without them, cargo feature and all. |
| -h, --help | Print the usage message. cargo rahti help and a bare cargo rahti do the same. |
| -V, --version | Print the installed cargo-rahti version. |
It never regenerates Cargo.toml or .env from a template — that would undo your dependencies, a path dependency, or a real credential — but it does amend them, by the smallest edit that makes the files it just wrote compile and run. The amendments are driven by rahti.config.json rather than by what this run added, so a project that gained a feature by editing its config is repaired by running upgrade again; every edit is skipped when it is already there, so running it any number of times is the same as running it once.
In the config file, upgrade rewrites only what it owns: createdWith and the scaffold ledger. The port, the app directory, and every CSS setting stay exactly as you wrote them. The CSS engine is deliberately not offered: switching it rewrites an authored stylesheet, which is a migration rather than an addition.
Version pins move with the tool
upgrade moves the rahti and rahti-build version pins in Cargo.toml to the version of the tool that is running. The framework ships in lockstep, and the files an upgrade writes are written against its own version — a pin left behind is the failure that hides best, because the project still compiles and still runs, and quietly does none of what its new main.rs says.
- Only ever forward, never backward onto an older pin.
- Never onto a
--localpath dependency, which is a checkout you are deliberately building against. - Never over a requirement you wrote to float — a caret, a wildcard, a pre-release — which is you asking for something other than one exact version.
- A shape it cannot read — a
[dependencies.rahti]table, a dependency spread over several lines — is reported with the line to add rather than guessed at.
--force: rejoining the scaffold
An upgrade leaves edited files alone, which is right until the day you want the framework's current copy back. --force is how a project that diverged rejoins the scaffold.
# Preview first — a replaced file is not kept anywhere.
cargo rahti upgrade --force --dry-run
# Take back the framework's own files: src/main.rs, build.rs,
# .cargo/config.toml, the documents, the PulsePoint assets.
cargo rahti upgrade --force
# Take back exactly these, an application file included.
cargo rahti upgrade --force src/app/page.rs src/app/layout.rs| Form | What it takes |
|---|---|
| --force | The framework's own files — src/main.rs, build.rs, .cargo/config.toml, the documents, the PulsePoint assets — and nothing else under src/, because that is your application. |
| --force <path...> | Exactly the paths named, an application file included. A path that is not a scaffold file of this project is refused rather than silently ignored. |
Environment variables
These are read at run time, not at build time. rahti::load_env() is the one .env reader for the process: it is idempotent and never overwrites a variable the environment already carries, so a real environment variable always wins over the file.
| Variable | Read by | For |
|---|---|---|
| PORT | rahti::listen | Overrides the configured port. The name every managed platform sets. |
| HOST | rahti::listen | Overrides the configured host. Set it to 0.0.0.0 where a platform routes traffic in from outside the process. |
| RAHTI_DEV | the runtime | Forces development reload on or off, whatever the build profile says. |
| RAHTI_PUBLIC_DIR | the runtime | Overrides the configured static directory. |
| DATABASE_URL | src/db.rs | The connection string. See Database. |
| AUTH_SECRET | rahti::auth | Signing the session cookie. Generated per project. |
| AUTH_COOKIE_NAME | rahti::auth | The cookie's name. Generated per project. |
| SESSION_LIFETIME_HOURS | rahti::auth | Optional session lifetime. |
| RAHTI_TAILWIND | the build step | A Tailwind binary to use instead of the one in .rahti/bin or on PATH. |
Four more belong to uploads — RAHTI_MAX_UPLOAD, RAHTI_MAX_FILE, RAHTI_SPILL_THRESHOLD and RAHTI_SPILL_DIR, all byte counts but the last. They are documented beside the limits they move, in RPC & uploads.
Where the server binds
rahti.config.json records the address a project develops on, and the generated routes.rs hands that pair to rahti::listen. A managed platform does not read that file: it starts the built binary and names the address through the environment, usually assigning a different port on every deploy. So the environment wins.
# A project scaffolds with host 0.0.0.0, so a deployment usually names
# only the port — no edit to main.rs, no second copy of the address.
PORT=8080 ./my-app
# HOST is there for the other direction: a project that narrowed its
# config to the loopback, or a platform that wants one specific address.
PORT=8080 HOST=0.0.0.0 ./my-appThat is the whole deployment story for the address — a project develops on the address it was scaffolded with and deploys with no edit to main.rs, no second copy of the port in the config, and no platform-specific branch. A variable exported empty counts as unset: a platform that writes PORT= has not chosen a port, and the configured value stands. A PORT that is not a number from 0 to 65535 stops the server rather than being guessed at.
// src/main.rs — the same two lines in development and in production.
let listener = rahti::listen(routes::HOST, routes::PORT).await;
// Not the bind address verbatim: `0.0.0.0` is every interface, which is
// not somewhere a browser can go.
println!("Server running on {}", rahti::local_url(&listener));The startup line names where to open a browser, which is not always the address that bound. 0.0.0.0 and [::] mean "every interface on this machine" — a sentence about listening, not a destination — and a browser handed one refuses it outright. So rahti::local_url prints a wildcard bind as localhost, the same server named the way this machine reaches it; any other address prints as it is.
When the port is taken
Binding's one common failure has two correct answers. In development the culprit is a server from an earlier run still holding the port, and the goal is a running server rather than that exact port — so the server walks forward to the next free one and says so, however the port was chosen. In release the port is a contract, because whatever routes traffic to the process points at it: a busy port is stated, the source of the value is named, and the process exits.
