Tauri Architecture: The Rust-TS Bridge
"A bridge isn't just a path; it's a contract between two distinct lands."
You have calibrated your Rust tools. Now, we must understand the "map" of a Tauri application.
A Tauri app is not one single program. It is two applications running in harmony:
- The Backend (Rust): A native, high-performance system process. This is the "Engine."
- The Frontend (WebView): A modern web browser view running your HTML, CSS, and TypeScript. This is the "Cockpit."
This document explains the "medium" or "bridge" that allows them to communicate.
1. The Project Structure​
When you create a Tauri project, you will see two primary folders. Understanding this separation is key.
my-text-editor/
├── src/
│ ├── main.ts # Your Frontend (TypeScript)
│ ├── index.html # Your Frontend (HTML)
│ └── ... # (CSS, other TS files)
│
├── src-tauri/
│ ├── src/
│ │ └── main.rs # Your Backend (Rust)
│ ├── Cargo.toml # Your Backend's dependencies
│ └── tauri.conf.json # The "Manifest" that links them
│
└── ...
src/: This is a standard web project. It has no access to the filesystem or any native features until you ask Rust for them.src-tauri/: This is a standard Rust project. It has no UI until you attach the WebView to it.tauri.conf.json: This file is the architect. It defines the app's window, sets security permissions, and tells the Rust backend which frontend to load.
2. The Backend: Exposing a Command​
You cannot call a Rust function from TypeScript directly. You must "expose" it as a Tauri Command.
This is done with a macro: #[tauri::command].
Let's look at a simple example in src-tauri/src/main.rs. We'll use the Result type you learned about in the "Rust Essentials" module.
// In src-tauri/src/main.rs
// This macro transforms the Rust function into something Tauri can manage
#[tauri::command]
fn greet(name: String) -> Result<String, String> {
if name.is_empty() {
// If we return an Err, it rejects the promise in TypeScript
Err("Name cannot be empty!".to_string())
} else {
// If we return Ok, it resolves the promise in TypeScript
Ok(format!("Hello, {}!", name))
}
}
3. The Handler: The Rust "Switchboard"​
Just defining the command isn't enough. We must register it with Tauri's Invoke Handler in our main() function.
Think of the handler as a "switchboard" or "function router." It listens for string-based messages from the frontend and routes them to the correct Rust function.
// In src-tauri/src/main.rs
fn main() {
tauri::Builder::default()
// This line registers our 'greet' command
.invoke_handler(tauri::generate_handler![
greet
// All other commands you write will be listed here
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
4. The Frontend: Calling a Command​
Now for the final piece: calling our greet command from TypeScript. We use the invoke function from the @tauri-apps/api library.
invoke is an async function. It sends a message to Rust and waits for a response.
// In src/main.ts
import { invoke } from '@tauri-apps/api';
async function sayHello() {
try {
// 1. We call 'invoke' with the Rust function's name (snake_case)
// 2. We pass arguments in an object (camelCase keys)
const response: string = await invoke('greet', { name: 'World' });
// This runs if Rust returned Ok()
console.log(response); // "Hello, World!"
} catch (error) {
// This runs if Rust returned Err()
console.error(error); // "Name cannot be empty!"
}
}
// Example of the error case
sayHello();
await invoke('greet', { name: '' }); // This will trigger the catch block
The Contract: Result ↔ Promise​
You have just seen the "medium" in action. It's a simple, powerful contract:
- A Rust
Result::Ok(value)resolves the JavaScriptPromise(value).- A Rust
Result::Err(error)rejects the JavaScriptPromise(error).
This allows you to write safe, robust Rust code (as seen in "Rust Essentials") and handle errors gracefully in your frontend using standard try...catch blocks.
"The bridge is built. Now, let's send traffic." Ready to build the editor's core? 🔧