# rattery
**Ship a [ratatui](https://ratatui.rs) app as a thin binary that runs it sandboxed and talks
to your server.**
Write the app with ratatui. Declare its backend calls as `#[rattery_app::server]`
functions, Leptos / Dioxus fullstack style. The app compiles to a WASI component; a
few lines of `build.rs` build it and a few lines of `main.rs` embed it, so `cargo
build` of your CLI produces one binary with the app inside, pointed at your API:
```rust
// build.rs
rattery_build::App::new("../app").build();
// main.rs
let report = rattery::App::from_bytes(rattery::embed!().to_vec())
.origin("https://api.example.com")
.run_blocking()?;
std::process::exit(report.exit_code());
```
The host inside that binary is to the app what a browser is to a web page: it runs
the component in a [wasmtime](https://wasmtime.dev) sandbox, hands it the terminal
through a small WIT interface, keeps its cookies, and lets it make HTTP requests to
**its own origin only** unless you or the other server say otherwise. The same host can
just as well fetch the component from a URL at startup, so an app can be deployed by
replacing one file on the server. A *rattery* is an enclosure for rats. This one keeps a
ratatui app where it can't touch your filesystem, your network, or your other
terminals.
## Why
- **One fullstack dev model.** `#[rattery_app::server]` is `server_fn`'s `#[server]`
with the client filled in. The same shared crate compiles into the app (calls become
HTTP) and into the server (bodies run). It is the crate Leptos and Dioxus use, so
request/response, streaming responses, websockets, multipart uploads, and cookie
sessions all work as they do there.
- **A real sandbox.** The component gets the terminal, a clock, randomness, and HTTP
to its origin. Nothing else is linked in. Embedding someone else's TUI, or loading
one from a URL, is as safe as opening a web page.
- **Embeddable first.** `rattery::App` is the product: a builder your CLI calls. There
is no daemon and no required command; `examples/rattery-cli` shows a general-purpose
runner in 150 lines if you want one.
- **Deploy by URL, optionally.** `App::from_url` fetches the component like a browser
would; with `.watch(true)` a running app reloads when the server publishes a new one.
- **Thick client.** UI state stays local, the server only answers RPC. Compare with
SSH-app frameworks, which run the whole UI server-side and stream frames.
## How it works
```
┌────────── your terminal ──────────┐
│ your CLI (rattery::App inside) │ HTTP ┌────────────────────┐
│ crossterm ⇄ terminal (WIT) ⇄ app │ ───────────────▶ │ axum server │
│ wasi:http ── origin policy ──────┼────────────────▶ │ /api/* server fns │
│ cookies │ POST /api/... │ (/app.wasm, opt.) │
└───────────────────────────────────┘ └────────────────────┘
```
- **`crates/rattery-app/wit/rattery.wit`** is the entire contract. It mirrors ratatui's `Backend` trait
(draw a list of changed cells, cursor, size, flush) plus a crossterm-shaped event
stream and a `wasi:io` pollable so an app can `await` key presses and server
responses at the same time. A second small interface provides websockets, which
WASI 0.2 lacks; the host applies the same origin policy and cookie jar to them.
- **`crates/rattery`** is the host library: wasmtime + `wasmtime-wasi` +
`wasmtime-wasi-http`, crossterm behind the terminal interface, a loader, the origin
policy, the cookie jar, and a headless mode. `rattery::App` is the entry point.
- **`crates/rattery-build`** builds an app to a component from `build.rs` so a shim can
embed it with `rattery::embed!()`.
- **`crates/rattery-app`** is what apps depend on: a ratatui `Backend` over the WIT
interface, the event API, background tasks, timers, websockets, and a `server_fn`
client that speaks `wasi:http@0.3`. On native targets it provides only what the
server build of a shared crate needs.
- **`crates/rattery-macros`** provides `#[rattery_app::server]`.
- **`examples/rattery-cli`** is a general-purpose runner built on the library, used by
the dev loop and the benchmark, and the reference for a shim that takes everything
as flags. It is not published.
Diffing happens inside ratatui's `Terminal` in the guest, so a frame is one `draw` call
carrying only the cells that changed, and one `flush`.
**The app is a real async program.** It exports one `async func run` and the host
drives it with the component model's async ABI, so waiting for a key press, a server
response, a websocket message, or a timer is a plain `.await` and other tasks in the
app keep running meanwhile. Input is an `async func` on the terminal interface; HTTP
and timers use WASI 0.3, while the standard library keeps using WASI 0.2 for stdio.
All of this builds on stable Rust for `wasm32-wasip2`: the async ABI does not need
the `wasm32-wasip3` target, which has no prebuilt standard library yet.
## Quick start
Everything is in the nix dev shell (`direnv allow` or `nix develop`): stable Rust
with the `wasm32-wasip2` target, `wasm-tools`, and the `wasmtime` CLI.
```sh
# the dev loop: build the app and the server, serve, rebuild on change
cargo xtask dev
# in another terminal, run the app like a browser would; --watch reloads it
# in place every time the component is rebuilt
cargo run -p rattery -- --watch http://127.0.0.1:3000/app.wasm
```
Or by hand:
```sh
cargo build -p counter-app --target wasm32-wasip2
cargo run -p counter-server
cargo run -p rattery -- http://127.0.0.1:3000/app.wasm
```
## Writing an app
A shared crate holds the server functions and any types they exchange:
```rust
// counter-shared/src/lib.rs
use rattery_app::server_fn::codec::{StreamingText, TextStream};
use rattery_app::{server, ServerFnError};
#[server]
pub async fn adjust_count(delta: i64) -> Result<i64, ServerFnError> {
Ok(state::adjust(delta)) // only compiled with the `ssr` feature
}
/// A streaming response: the server pushes lines for as long as the app reads.
#[server(output = StreamingText)]
pub async fn live_feed() -> Result<TextStream, ServerFnError> {
Ok(TextStream::from(state::ticks()))
}
/// A websocket: a stream in, a stream out, for as long as the connection lives.
#[server(protocol = Websocket<JsonEncoding, JsonEncoding>)]
pub async fn chat(
input: BoxedStream<String, ServerFnError>,
) -> Result<BoxedStream<String, ServerFnError>, ServerFnError> {
Ok(input.map(|m| m.map(|text| format!("echo: {text}"))).into())
}
/// A file upload. The app builds a `rattery_app::multipart::FormData`; the server
/// gets the parsed parts as a `multer` stream.
#[server(input = MultipartFormData)]
pub async fn upload(data: MultipartData) -> Result<String, ServerFnError> {
let mut parts = data.into_inner().expect("server side");
while let Some(field) = parts.next_field().await? { /* ... */ }
Ok("thanks".into())
}
```
```toml
[features]
ssr = ["rattery/ssr"]
axum = ["ssr", "rattery/axum"]
```
The app is an ordinary binary crate built for `wasm32-wasip2`; `rattery_app::app!`
exports the component's entry point (and supplies the placeholder `main` a binary
needs; the host never calls it, because a synchronous `main` could not await). Server
calls run as background tasks so the UI never blocks; a finished task surfaces as
`Event::Wake`:
```rust
use rattery_app::prelude::*;
use rattery_app::{event, task};
rattery_app::app!(app);
async fn app(mut terminal: Terminal) -> Result<(), Box<dyn std::error::Error>> {
let mut count = 0;
let mut pending: Option<Task<Result<i64, ServerFnError>>> = Some(task::spawn(adjust_count(0)));
loop {
terminal.draw(|frame| frame.render_widget(count.to_string(), frame.area()))?;
match event::next().await {
Event::Wake => {
if let Some(result) = pending.as_mut().and_then(Task::try_take) {
pending = None;
count = result?;
}
}
Event::Key(key) if key.code == KeyCode::Char('q') => break Ok(()),
Event::Key(key) if key.code == KeyCode::Up => pending = Some(task::spawn(adjust_count(1))),
_ => {}
}
}
}
```
The server depends on the shared crate with the `axum` feature and mounts two routes:
```rust
Router::new()
.route("/app.wasm", get(serve_component))
.route("/api/{*rest}", any(rattery_app::server_fn::axum::handle_server_fn))
```
`examples/counter` is the complete version: background calls with a spinner, a
streaming live feed, a websocket echo, a multipart upload, cookie sessions with
per-session state, and ETags for `--watch`. `rattery_app::websocket::WebSocket` is also usable
directly, outside server functions.
`rattery_app::location()` returns the URL the app was loaded from, query string included,
so `rattery https://host/app.wasm?team=infra` passes parameters the way a web page
gets them (the example reads `?title=`). `rattery_app::origin()` is where server calls go.
Event types mirror crossterm's (`KeyCode::Char('q')`, `KeyModifiers::CONTROL`, ...)
so existing ratatui code ports by changing an import. `event::next_timeout` and
`rattery_app::time::sleep` drive animations; `task::wake` lets a long-running task ask for
a redraw, which is how the example renders a streaming response line by line.
## The host
Everything below is a method on `rattery::App`; `examples/rattery-cli` exposes each as
a flag, shown here because it reads well:
```
rattery <URL or path>
[--origin URL] [--allow-origin URL]... [--allow-all-origins]
[--incognito | --no-cookies | --cookie-jar FILE]
[--watch] [--location URL] [--env KEY=VALUE]... [--no-mouse] [--no-cache]
[--headless COLSxROWS [--script FILE] [--timeout SECS]]
```
**Origin policy.** An app may reach its own origin: where it was loaded from (after
same-origin redirects; cross-origin redirects are refused), or `--origin` for an app
loaded from a file. `--allow-origin` adds more and `--allow-all-origins` disables the
check. Every request carries an `Origin` header. Everything else is refused before a
connection is opened. There is no CORS mode: cross-origin access is allow-list only
until proper preflight and credential semantics exist. A `RequestPolicy` on the
builder sees every allowed request and can refuse or edit it.
**Cookies.** The host keeps a jar the way a browser does: the app never sees `Cookie`
or `Set-Cookie`, so ordinary cookie sessions on the server work unchanged and
`HttpOnly` means what it says. The jar persists under the user's local data directory;
`--incognito` keeps it in memory, `--no-cookies` drops everything, `--cookie-jar` picks
a file.
**Reload.** `--watch` polls the URL with `If-None-Match` and restarts the app in place
when the server publishes a new component.
**Safety.** Everything the app sends toward the terminal is validated: control
characters and malformed symbols never reach the screen or the title, cells outside
the screen are dropped, and guest output is rendered with escapes shown rather than
interpreted. Resource use is bounded by `Limits` (memory, CPU time on a continuous
10 ms epoch tick, queues, message and body sizes, concurrency). Ctrl-C three times
within 1.5 seconds interrupts an unresponsive app. Raw mode and the alternate screen
are always restored, including on panic, and every background task is stopped before
the terminal is handed back. See `docs/security.md`.
**Stats.** `Report::timings` and `Report::stats` (the `--stats` flag prints them) carry
phase timings (load, compile, instantiate, first frame) and terminal counters. `cargo
xtask bench` runs a rendering and request latency benchmark; see `docs/perf.md`.
**Headless.** `--headless 80x24 --script keys.txt` runs the app on an in-memory screen,
feeds it a script (`key k`, `key ctrl-c`, `type hello`, `paste`, `resize`, `sleep`,
`snapshot`; see `--help-script`), and prints the snapshots. This is how the repository's
end-to-end tests work, and it is a ready-made test harness for your own app.
## Embedding the host
```rust
use rattery::{App, CookiePolicy};
// One specific app against one specific backend, embedded in the binary
// (rattery-build compiled it in build.rs).
let report = App::from_bytes(rattery::embed!().to_vec())
.origin("https://api.example.com")
.cookies(CookiePolicy::File(config_dir.join("cookies.json")))
.run_blocking()?;
std::process::exit(report.exit_code());
// A subcommand of an existing async CLI that opens a remote TUI.
let report = App::from_url("https://apps.example.com/dashboard/app.wasm")?
.allow_origin("https://api.example.com")
.run()
.await?;
```
`rattery_build::App` takes the app crate's path (and optionally a package name,
features, or the dev profile), compiles it for `wasm32-wasip2` into a target directory
under `OUT_DIR`, and exports the component's path as `RATTERY_APP_WASM`; changes under
the app's `src` rebuild it. The nested build needs the target installed
(`rustup target add wasm32-wasip2`).
Production controls on the builder: `limits` (see `docs/security.md`), `on_phase`
(loaded, compiled, ready, denied requests, reload, exit), `request_policy` (route
authorisation, credential injection), `extension` and `state` (extra WIT imports
backed by your own state), `from_resolver` (the embedder retrieves and validates the
bytes). `rattery::inspect` checks a component against `rattery::ABI` before it runs.
`App::headless` returns the snapshots in the `Report`, so an app's integration tests
can be a few lines:
```rust
let report = App::from_url(&url)?
.headless(HeadlessOptions { script: Script::parse("sleep 1000\nkey k\nsnapshot\nkey q")?, ..Default::default() })
.run()
.await?;
assert!(report.snapshots[0].contains("1"));
```
## Status
Working: rendering, keyboard, mouse, paste, focus and resize events; request/response,
streaming, websocket, and multipart server functions; background tasks on the component
model's async ABI with HTTP over WASI 0.3; the origin policy with allow lists;
a persistent cookie jar; hot reload; the library API; headless mode; a kill switch and
timeouts; end-to-end tests of all of it. Note that wasmtime's WASI 0.3 support is marked
experimental upstream; rattery pins wasmtime and tracks it.
## License
MIT