Tauri¶
Desktop Rust Validation
A working Tauri 2.x desktop app whose renderer is a ReScript + React UI bundled by Vite+ and whose process is a tiny Rust crate under src-tauri/. The shipped sample is not “Hello World” — it shows the full request/response loop between the renderer and Rust: a greet(name) command that returns a string, and a get_info() command that returns a struct which the renderer parses through Validation.res (zod or sury) before assuming the shape. Drift between the Rust struct and the ReScript record surfaces as a UI error, not a TypeError deep inside React.
Pick this template if you want to ship a desktop app and start from a stack that already wires Tauri’s CSP, snake_case command allowlist, and a Validation.res boundary. The renderer is the same Vite-driven React surface as the Vite + React and Electron templates, so React knowledge transfers directly; the new ground is the Rust process plus the tauri::generate_handler! allowlist.
What You Get¶
my-project/
├── rescript.json # JSX enabled, depends on @rescript/react + @rescript-tauri/core
├── package.json # type: "module", @tauri-apps/cli + vite-plus dev deps
├── index.html # renderer entry — loads /src/Main.res.mjs as a module
├── vite.config.mjs # vite-plus + @vitejs/plugin-react, base: "./", strictPort 5173
├── src/
│ ├── Main.res # ReactDOM.Client.createRoot(rootEl) → <App />
│ ├── App.res # Buttons → greet / get_info → validate → render result/error
│ ├── Tauri.res # @rescript-tauri/core bindings (Core.Raw.invoke wrappers)
│ ├── Validation.res # zod or sury — parses get_info response into typed `info`
│ └── __tests__/App.test.mjs # vitest smoke test that imports App.res.mjs
├── src-tauri/
│ ├── Cargo.toml # tauri + serde + serde_json + tauri-build
│ ├── build.rs # tauri_build::build()
│ ├── tauri.conf.json # CSP, devUrl, frontendDist, beforeDev/Build (PM-aware)
│ ├── .gitignore # target/ and gen/
│ └── src/main.rs # greet / get_info commands + invoke_handler allowlist
├── README.md # IPC security model + Production Bundling section
├── LICENSE # MIT, holder = project name
├── .nvmrc # Node 24
├── .gitignore # node_modules + ReScript output + dist/ + src-tauri/target/
├── .editorconfig # 2-space indent, LF line endings
└── .github/
├── dependabot.yml # weekly npm updates
└── workflows/ci.yml # install + rescript build + vp build + vitest
Wizard Options¶
Option |
Effect |
|---|---|
Project name |
Becomes the npm |
Package manager |
npm / pnpm / yarn / bun. Sets the |
Validation library |
|
Key Dependencies¶
Package |
Purpose |
Version |
|---|---|---|
|
ReScript compiler |
|
|
Standard library |
|
|
Runtime stubs imported by compiled |
|
|
React bindings (JSX-enabled |
|
|
ReScript bindings over |
|
|
Tauri’s JS SDK (peer of |
|
|
Renderer UI library |
|
|
Validation backend (IPC payload parser) |
|
|
|
|
|
Vite-based bundler used for the renderer |
|
|
Vite+ core runtime (peer of |
|
|
Direct |
|
|
React refresh + JSX transform plugin |
|
|
Smoke test runner |
|
|
Coverage provider for |
|
Rust dependencies (src-tauri/Cargo.toml) pin tauri = "2", tauri-build = "2", serde, and serde_json only — extend per command surface as needed.
tauri-plugin-* crates are not included by default. Add the ReScript bindings (@rescript-tauri/plugin-dialog, @rescript-tauri/plugin-fs, etc. — published alongside @rescript-tauri/core) together with their Rust plugin crate when you need them.
Key Files¶
src-tauri/src/main.rs¶
The Tauri main process. Defines two #[tauri::command] functions:
greet(name: &str) -> String— plain RPC for the “Say hello” buttonget_info() -> AppInfo— returns{ name, version, platform, arch }serialised viaserde::Serialize
The invoke_handler macro registers both into the IPC allowlist:
.invoke_handler(tauri::generate_handler![greet, get_info])
Adding a new command without updating this list silently 404s on the JS side. The command name shape is snake_case Rust function names — that is exactly what Core.Raw.invoke calls from JS.
src-tauri/tauri.conf.json¶
The Tauri 2.x config. Notable fields:
build.beforeDevCommandandbuild.beforeBuildCommand— substituted at scaffold time with the package-manager-specific dev / build invocation (pnpm devvsnpm run devvsyarn devvsbun run dev).tauri devruns the renderer dev server before opening the window;tauri buildrunsvp buildfirst to producedist/.build.devUrl: "http://localhost:5173"— pairs withvite.config.mjs’sstrictPort: true. Change both together if you need a different port.build.frontendDist: "../dist"— Tauri reads the Vite+ output from this path ontauri build.app.security.csp—default-src 'self'plusasset:/https://asset.localhost(forCore.Raw.convertFileSrc) andipc:/http://ipc.localhost(for Tauri’s invoke transport on Windows / macOS). Production apps must define an explicit CSP — see Tauri’s CSP guidance.bundle.icon— intentionally omitted sotauri devruns on a fresh checkout without an icon source. Generate icons withtauri icon path/to/source.pngand add the produced list back when you’re ready fortauri build.
src/Tauri.res¶
Thin ReScript binding over @rescript-tauri/core. The shipped wrappers are intentionally typed as JSON.t for get_info — not the eventual info record — because validation lives one layer further in:
let greet = (name: string): promise<string> =>
RescriptTauriCore.Core.Raw.invoke("greet", ~args={"name": name})
let getInfoRaw = (): promise<JSON.t> =>
RescriptTauriCore.Core.Raw.invoke("get_info", ~args=Js.Obj.empty())
The Raw suffix on getInfoRaw is a deliberate convention: it signals “this value has not been validated yet” so callers know they need to run it through Validation.res before destructuring.
src/Validation.res¶
parseInfo: JSON.t => result<info, string>. The signature is identical between the zod and sury variants, so the renderer’s call site does not branch on which library shipped:
type info = {name: string, version: string, platform: string, arch: string}
let parseInfo: JSON.t => result<info, string>
Why validate at all? The renderer can trust Tauri’s allowlist to reject unknown commands, but the struct shape coming back from Rust is still just JSON until proven otherwise. Add a field to AppInfo on the Rust side and forget to add it to parseInfo? The validator catches it as a parse error before React ever sees undefined.
src/App.res¶
The interactive demo. Two buttons:
Say hello runs
Tauri.greet("ReScript")and prints the returned string.Get app info runs
Tauri.getInfoRaw(), validates the result, and either renders a<dl>of the typed record or a redIPC validation error: ...banner.
Mutate get_info in main.rs to return an unexpected payload — drop a field, rename one, change a type — and you can watch the validator catch it without the React tree blowing up.
src/Main.res¶
The renderer entry the index.html <script type="module"> loads. Mounts <App /> onto #root:
switch ReactDOM.querySelector("#root") {
| Some(rootEl) =>
ReactDOM.Client.Root.render(ReactDOM.Client.createRoot(rootEl), <App />)
| None => Console.error("Could not find root element")
}
vite.config.mjs¶
Minimal Vite+ config — defineConfig from vite-plus (not vite), the React plugin, base: "./", server.strictPort: true, server.port: 5173, and clearScreen: false. The relative base keeps the production bundle working when Tauri loads dist/index.html via file://. strictPort makes Vite fail fast if 5173 is taken — silently sliding to 5174 would leave tauri.conf.json’s devUrl pointing at the wrong place.
npm Scripts¶
Script |
Description |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Adding a New Tauri Command¶
Adding a command always touches three layers — the README ships a step-by-step walkthrough and the shape is worth memorizing because forgetting any layer is silent ("command set_window_title not found" in the renderer).
Define the command in
src-tauri/src/main.rs:#[tauri::command] fn set_window_title(window: tauri::Window, title: String) -> Result<(), String> { window.set_title(&title).map_err(|e| e.to_string()) }
Register it in
invoke_handler(still inmain.rs):.invoke_handler(tauri::generate_handler![greet, get_info, set_window_title])
Bind it in
src/Tauri.res:let setWindowTitle = (title: string): promise<unit> => RescriptTauriCore.Core.Raw.invoke( "set_window_title", ~args={"title": title}, )
Validate the response in
Validation.resif the handler returns data the UI consumes. Fire-and-forget commands (set_window_titlereturns()) skip this step.
Production Bundling¶
tauri build needs a Rust toolchain (rustup default stable) plus platform-specific deps — see the Tauri 2 prerequisites guide. Linux needs webkit2gtk + libsoup; Windows needs the MSVC C++ build tools; macOS needs Xcode CLI tools.
Before the first tauri build, generate the icon set:
tauri icon path/to/source.png
# Writes 32x32.png / 128x128.png / icon.icns / icon.ico into src-tauri/icons/
Then re-enable bundle.icon in tauri.conf.json with the produced filenames. Signing and auto-update are out of scope for the starter — see the README’s “Production Bundling” section for the recipe pointers (Developer ID notarisation on macOS, signtool on Windows, the Tauri updater plugin).
CI doesn’t run tauri build in this template — the matrix would need per-OS runners plus signing identities, which varies wildly per project. Add the workflow yourself once your distribution strategy is settled.
Day-Two Recipes¶
Create a React Component — same React conventions apply to the renderer
Optimize Imports — keep
src/Tauri.resandsrc/Validation.restidy as the command surface growsFind Dead Code — useful for pruning commands no renderer still calls
For ReScript-side editor workflows once the project is open, see Feature Overview.
Notes¶
Tauri commands use
snake_case. Rust function names map verbatim to the JS call name —greet,get_info,set_window_title. The macros do not auto-translate to camelCase.invoke_handleris the allowlist. Adding#[tauri::command]to a function does nothing on its own — it must also appear ingenerate_handler![...]. Forgetting this is silent (the JS call rejects with “command X not found”).The renderer never imports
@tauri-apps/apidirectly. Every cross-process call goes throughTauri.res→RescriptTauriCore.Core.Raw.invoke(...)→#[tauri::command]. If you find yourself wantingimport { invoke } from "@tauri-apps/api", you almost certainly want a new ReScript binding instead.Functions, class instances, and DOM nodes cannot cross the bridge. Tauri serialises arguments and return values as JSON — primitives, owned strings, plain structs (
serde::Serialize), arrays, and maps only.The CSP
connect-srcwhitelist controlsfetch/XHR, notinvoke. Tauri’s IPC uses its ownipc:andhttp://ipc.localhostorigins — both are present in the shipped CSP. Don’t remove them.@rescript-tauri/coreis pre-1.0 (0.1.x). Expect minor breaking changes between releases until 1.0 ships. The version pin is^0.1.xso patch updates flow through without auto-adopting breaking 0.minor bumps.vite-plusis also pre-1.0. The version pin is^0.1.x; the README documents the swap-to-classic-Vite fallback if a release breaks the bundler workflow.The smoke test is intentionally tiny.
App.test.mjsonly verifies thatimport("../App.res.mjs")resolves; it does not boot Tauri or exercise IPC. Add domain tests as you grow the renderer.