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
new.build;
// main.rs
let report = from_bytes
.origin
.run_blocking?;
exit;
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]isserver_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::Appis the product: a builder your CLI calls. There is no daemon and no required command;examples/rattery-clishows a general-purpose runner in 150 lines if you want one. - Deploy by URL, optionally.
App::from_urlfetches 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.witis the entire contract. It mirrors ratatui'sBackendtrait (draw a list of changed cells, cursor, size, flush) plus a crossterm-shaped event stream and awasi:iopollable so an app canawaitkey 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/ratteryis 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::Appis the entry point.crates/rattery-buildbuilds an app to a component frombuild.rsso a shim can embed it withrattery::embed!().crates/rattery-appis what apps depend on: a ratatuiBackendover the WIT interface, the event API, background tasks, timers, websockets, and aserver_fnclient that speakswasi:http@0.3. On native targets it provides only what the server build of a shared crate needs.crates/rattery-macrosprovides#[rattery_app::server].examples/rattery-cliis 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
# in another terminal, run the app like a browser would; --watch reloads it
# in place every time the component is rebuilt
Or by hand:
Writing an app
A shared crate holds the server functions and any types they exchange:
// counter-shared/src/lib.rs
use ;
use ;
pub async
/// A streaming response: the server pushes lines for as long as the app reads.
pub async
/// A websocket: a stream in, a stream out, for as long as the connection lives.
pub async
/// A file upload. The app builds a `rattery_app::multipart::FormData`; the server
/// gets the parsed parts as a `multer` stream.
pub async
[]
= ["rattery/ssr"]
= ["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 *;
use ;
app!;
async
The server depends on the shared crate with the axum feature and mounts two routes:
new
.route
.route
Updates work as they do on the web: when the host finds a new version it sends
Event::UpdateAvailable, and the app calls rattery_app::reload() when it is a good
moment (after saving a draft to storage, say); under the default policy the host
reloads it anyway once a grace period passes.
Two more things a browser gives a page, the host gives an app. rattery_app::storage
is localStorage: a small key-value store scoped to the app's origin that survives
runs, with a quota (get, set, remove, keys, clear, usage, plus
get_string/set_string). The log facade is wired to the host, so log::info!
and friends leave the sandbox as records the embedder can write wherever it likes,
instead of scribbling on the terminal.
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, a launch counter in storage, 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]
[--storage-dir DIR | --no-storage] [--log-file FILE]
[--watch [--reload-grace SECS|none]] [--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.
Storage. Each origin gets a private key-value file under the local data directory
(StoragePolicy::Persistent), bounded by Limits::storage_bytes and
storage_entries; --incognito keeps it in memory, --no-storage refuses writes,
--storage-dir picks the directory. An app loaded from a file without --origin
has no origin and so gets ephemeral storage.
Logs. Records from the app's log macros arrive as Phase::Log on the
on_phase hook, sanitised, size-capped, and rate-limited (Limits::logs_per_second).
--log-file appends them to a file you can tail -f in another terminal while the
app has the screen.
Reload. --watch polls the URL with If-None-Match. What happens when a new
component appears is a ReloadPolicy, the way a browser leaves reloading to the page:
Immediate replaces the app at once (the dev loop; --reload-grace 0), AppControlled
delivers Event::UpdateAvailable and waits for the app to call
rattery_app::reload() (none), and Deferred does the same but reloads anyway once
the grace period passes (the library default, five minutes; the event carries the
deadline). An app can save its state to storage first, and the server should keep the
old routes working for the grace period, since the old app keeps calling them.
ABI transitions. Every component fetch carries a rattery-abi header with the
host's rattery::ABI, so a server can serve the build that matches each client during
a transition, or answer 426 Upgrade Required with the ABI it needs in the same
header. A component that needs another ABI, or such an answer, is never applied to a
running app: it surfaces as Phase::UpdateRejected with requires_abi set, and at
startup as an error naming both versions. The counter shim prints an upgrade hint on
exit; --require-abi-file on the example server demonstrates the server side.
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.
Input. Pointer movements and resizes the app has not read yet are merged into
the newest one, so a frame slower than the pointer never turns a burst of movement
into a backlog the app spends seconds draining; clicks, drags, scrolls, and keys are
delivered in full. docs/perf.md has the hover benchmark (cargo xtask hover), and
one finding worth repeating: build apps in release. At opt-level 0 the same widgets
run about nine times slower inside the component.
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 ;
// One specific app against one specific backend, embedded in the binary
// (rattery-build compiled it in build.rs).
let report = from_bytes
.origin
.cookies
.run_blocking?;
exit;
// A subcommand of an existing async CLI that opens a remote TUI.
let report = from_url?
.allow_origin
.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).
Precompiled components. With the precompile feature of rattery-build,
.precompile(true) also compiles the component to native code for the shim's target
and exports RATTERY_APP_CWASM; unsafe { App::from_precompiled(embed_precompiled!().to_vec()) }
then starts without a compile step or a compile cache (the unsafe is wasmtime's:
the bytes run as native code, so only embed what your own build produced).
rattery::precompile is the same step for your own pipeline. examples/counter/shim
does this.
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 = from_url?
.headless
.run
.await?;
assert!;
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