rattery 0.2.0

rattery: a sandboxed terminal host that runs ratatui apps delivered over HTTP, as a CLI and as a library
docs.rs failed to build rattery-0.2.0
Please check the build logs for more information.
See Builds for ideas on how to fix a failed build, or Metadata for how to configure docs.rs builds.
If you believe this is docs.rs' fault, open an issue.

rattery

Ship a ratatui 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:

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

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

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:

// 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())
}
[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:

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:

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

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:

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