Docs
Building with Rust
Native Shell apps are written in TSX, in Rust, or in both. All three run on the same engine: the reactive graph, the
renderer, Taffy layout and the nsh/ui/mobile components are Rust in every case, and every platform renders with its
own UI. What differs is the language of your own code, and with it whether the app carries a JavaScript VM.
| TSX | Rust | Rust with TSX | |
|---|---|---|---|
| Your UI, state and handlers | TSX on QuickJS | Rust | Rust components and modules, TSX screens that use them |
| The app runs | QuickJS and the engine | the engine and your crate only, on every platform and in the browser | QuickJS and the engine, with your crate |
| Suits | Apps and modules delivered at run time, quick iteration | Medium and large apps with demanding business logic | A Rust core with parts that change often |
| Edits | Hot updates | Rebuild and restart | Hot updates for TSX, rebuild and restart for Rust |
| Start | nsh init my-app | nsh init my-app --rust | nsh init my-app --rust, then nsh run App.tsx |
#Choosing
Rust suits apps whose business logic is large and demanding: trading, editors, data-heavy tools. You get Rust’s
type system and compile-time checks across the whole app, the crates.io ecosystem, native performance for your own
code (not only for NSH’s), and headless tests of the real UI with cargo test. The app is Rust and native code from
start to end: no JavaScript VM ships with it or runs in it, on iOS, Android, macOS, Windows, Linux, GPUI and the web.
Components are functions that run once and bind signals to what they show, as in TSX (Reactivity),
written with typed styles instead of class strings.
Rust with TSX keeps that core and adds TSX where it pays off: screens, settings, onboarding, experiments, anything you iterate on with hot updates or deliver at run time as a signed package. TSX imports your Rust components and modules like any NSH module, so the two halves meet at typed interfaces. The TSX runs on QuickJS, which the app then carries.
#An app written in Rust
nsh init my-app --rust
cd my-app
nsh run # this machine's backend; --target ios, android, web, macos, linux, windows or gpui
nsh package --target web # or ios, ios-simulator, android, macos, linux, windows, gpui
src/lib.rs holds the app: components, state, handlers, tasks.
use nsh_core::ui::prelude::*;
use nsh_ui_mobile::{Button, ButtonEvent, ButtonProps};
/// The components available to TSX through `app/native`.
#[native_module("app/native")]
fn native(registry: &mut Registry) {
registry.register::<App>();
}
#[component]
pub fn App() -> impl IntoElement {
let count = signal(0_u32);
let quote = signal(SharedString::from("…"));
// Async work runs in tasks; requests go through NSH's network stack.
spawn(async move {
if let Ok(response) = fetch("https://api.example.com/quote").await {
quote.set(response.text().into());
}
});
view()
.flex_1()
.items_center()
.justify_center()
.gap_4()
.child(text(move || format!("Count: {}", count.get())))
.child(text(quote))
.child(
Button::element(ButtonProps { title: "Tap".into(), ..ButtonProps::default() })
.on_event(move |_: &ButtonEvent| count.update(|c| *c += 1)),
)
}
nsh.json names the root, and nsh mounts it:
{
"name": "My App",
"rust": ".",
"root": "App",
"theme": "theme.json",
"appearance": "system"
}
"root" is the component’s path in the crate (App, shell::Gallery; in the first one with
several crates); "theme" and "appearance" apply to it as they do to a TSX app. With it,
nsh run and nsh package of the app’s directory build the app’s own runner for the target, with the root mounted
and no QuickJS linked in:
| Target | What runs |
|---|---|
| macOS, Windows, Linux, GPUI | The release runner with your crate, in a window; nsh run runs a debug build and shows the app’s logs in the terminal |
| iOS, the iOS simulator, Mac Catalyst | The iOS runner with your crate, in an app |
| Android | The app’s libnsh.so with your crate, started by the runtime’s activity |
| Web | nsh.wasm with the engine and your crate (3.3 MB for the whole Rust gallery, 0.75 MB with brotli); the page mounts the root |
During nsh run, a change to the crate’s Rust code builds the runner again and restarts the app; in the browser,
the open page loads the new runtime by itself. cargo run starts the same root directly on the desktop backend
(src/main.rs), and native apps that embed NSH mount a Rust root with AppSource::Rust on the AppView of macOS,
Windows, Linux, GPUI and iOS.
Code for every platform, the web included, uses NSH’s services: fetch for HTTP
(Networking), timer, set_timeout and set_interval for waiting,
nsh_core::time::Instant and nsh_core::time::unix_ms() for the clock. background and blocking run work on the
native targets’ threads.
#Extending a Rust app with TSX
The crate’s #[native_module("app/native")] function names what TSX may import: components
(registry.register::<Chart>()), modules of functions (registry.module::<Portfolio>(), a #[module] impl) and
assets. Their TypeScript declarations are generated from the Rust types into .nsh/types, so TSX is checked against
them by nsh check and your editor:
import { mount, signal, View, Text } from "nsh";
import { Chart, Portfolio } from "app/native"; // Rust
function Overview() {
const total = signal(0);
Portfolio.total().then((value) => total.set(value)); // a Rust function: a Promise in TSX
return (
<View className="p-4 gap-2">
<Text>Total: {total()}</Text>
<Chart symbol="NSH" range="1M" onSelect={(e) => console.log(e.price)} />
</View>
);
}
mount(() => <Overview />);
Props are the fields of the component’s Props struct and events the variants of its Event enum (onSelect),
both in camel case. A Rust component’s own content, events and state stay in Rust; TSX passes props in and hears
events. Name the TSX entry to run it, nsh run App.tsx or nsh package App.tsx --target …: TSX edits are hot
updates, Rust edits rebuild and restart the app.
examples/gallery-rs in the repository is a complete Rust app with both entries: every demo is Rust,
nsh run examples/gallery-rs --target web (or ios, android, …) runs its root, cargo run -p gallery-native
starts it on the desktop backend, and nsh run examples/gallery-rs/App.tsx runs it under its TSX entry.
#Several crates
An app’s Rust code can be split into crates, each imported by its own specifier: "rust" takes a list of crate
paths, and each crate in it has its own #[native_module] function.
{
"rust": ["native/charts", "native/storage"],
"root": "App"
}
import { Chart } from "app/charts"; // native/charts: #[native_module("app/charts")]
import { Store } from "app/storage"; // native/storage: #[native_module("app/storage")]
Every runner and host links all of them, and .nsh/types declares each specifier. Crates share code through Cargo
path dependencies (shared = { path = "../shared" }): a crate of company components can be used by several crates
and listed by several apps, and only the crates listed in "rust" export to TSX. Each listed crate has a package
name and a specifier of its own; nsh reports a repeated one with the two paths that share it. A Rust app’s
"root" is a component of the first crate listed. During nsh run and nsh dev, a change to any of them, or to a
crate they depend on by path, rebuilds the app and restarts it.
#See also
- Project structure: every key of
nsh.json. - Web: Rust apps in the browser.
- Networking:
fetchfrom Rust. - CLI:
nsh init --rust.