Saltar al contenido

Llamar a Rust desde el Frontend

Este documento incluye guías sobre cómo comunicarte con tu código Rust desde el frontend de tu aplicación. Para ver cómo comunicarte con tu frontend desde tu código Rust, consulta Llamar al Frontend desde Rust.

Tauri proporciona una primitiva de comando para alcanzar funciones de Rust con seguridad de tipos, junto con un sistema de eventos que es más dinámico.

Tauri proporciona un sistema de comandos simple pero potente para llamar a funciones de Rust desde tu aplicación web. Los comandos pueden aceptar argumentos y retornar valores. También pueden retornar errores y ser asíncronos.

Los comandos se pueden definir en tu archivo src-tauri/src/lib.rs. Para crear un comando, solo agrega una función y anótala con #[tauri::command]:

src-tauri/src/lib.rs
#[tauri::command]
fn my_custom_command() {
println!("I was invoked from JavaScript!");
}

Tendrás que proporcionar una lista de tus comandos a la función builder de esta manera:

src-tauri/src/lib.rs
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![my_custom_command])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

Ahora, puedes invocar el comando desde tu código JavaScript:

// When using the Tauri API npm package:
import { invoke } from '@tauri-apps/api/core';
// When using the Tauri global script (if not using the npm package)
// Be sure to set `app.withGlobalTauri` in `tauri.conf.json` to true
const invoke = window.__TAURI__.core.invoke;
// Invoke the command
invoke('my_custom_command');

Si tu aplicación define muchos componentes o si se pueden agrupar, puedes definir comandos en un módulo separado en lugar de recargar el archivo lib.rs.

Como ejemplo, definamos un comando en el archivo src-tauri/src/commands.rs:

src-tauri/src/commands.rs
#[tauri::command]
pub fn my_custom_command() {
println!("I was invoked from JavaScript!");
}

En el archivo lib.rs, define el módulo y proporciona la lista de tus comandos en consecuencia;

src-tauri/src/lib.rs
mod commands;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![commands::my_custom_command])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

Ten en cuenta el prefijo commands:: en la lista de comandos, el cual denota la ruta completa hacia la función del comando.

El nombre del comando en este ejemplo es my_custom_command, por lo que aún puedes llamarlo ejecutando invoke("my_custom_command") en tu frontend; el prefijo commands:: se ignora.

Al usar un frontend de Rust para llamar a invoke() sin argumentos, necesitarás adaptar el código de tu frontend como se muestra a continuación. La razón es que Rust no admite argumentos opcionales.

#[wasm_bindgen]
extern "C" {
// invoke without arguments
#[wasm_bindgen(js_namespace = ["window", "__TAURI__", "core"], js_name = invoke)]
async fn invoke_without_args(cmd: &str) -> JsValue;
// invoke with arguments (default)
#[wasm_bindgen(js_namespace = ["window", "__TAURI__", "core"])]
async fn invoke(cmd: &str, args: JsValue) -> JsValue;
// They need to have different names!
}

Tus manejadores de comandos pueden aceptar argumentos:

#[tauri::command]
fn my_custom_command(invoke_message: String) {
println!("I was invoked from JavaScript, with this message: {}", invoke_message);
}

Los argumentos deben pasarse como un objeto JSON con claves en camelCase:

invoke('my_custom_command', { invokeMessage: 'Hello!' });

Los argumentos pueden ser de cualquier tipo, siempre que implementen serde::Deserialize.

Los manejadores de comandos también pueden retornar datos:

#[tauri::command]
fn my_custom_command() -> String {
"Hello from Rust!".into()
}

La función invoke retorna una promesa que se resuelve con el valor retornado:

invoke('my_custom_command').then((message) => console.log(message));

Los datos retornados pueden ser de cualquier tipo, siempre que implementen serde::Serialize.

Los valores de retorno que implementan serde::Serialize se serializan a JSON cuando la respuesta se envía al frontend. Esto puede ralentizar tu aplicación si intentas retornar datos grandes, como un archivo o la respuesta HTTP de una descarga. Para retornar array buffers de forma optimizada, usa tauri::ipc::Response:

use tauri::ipc::Response;
#[tauri::command]
fn read_file() -> Response {
let data = std::fs::read("/path/to/file").unwrap();
tauri::ipc::Response::new(data)
}

Si tu manejador pudiera fallar y necesita poder retornar un error, haz que la función retorne un Result:

#[tauri::command]
fn login(user: String, password: String) -> Result<String, String> {
if user == "tauri" && password == "tauri" {
// resolve
Ok("logged_in".to_string())
} else {
// reject
Err("invalid credentials".to_string())
}
}

Si el comando retorna un error, la promesa se rechazará; de lo contrario, se resuelve:

invoke('login', { user: 'tauri', password: '0j4rijw8=' })
.then((message) => console.log(message))
.catch((error) => console.error(error));

Como se mencionó anteriormente, todo lo que se retorne desde los comandos debe implementar serde::Serialize, incluidos los errores. Esto puede ser problemático si estás trabajando con tipos de error de la biblioteca estándar de Rust o de crates externos, ya que la mayoría de los tipos de error no lo implementan. En escenarios sencillos, puedes usar map_err para convertir estos errores a String:

#[tauri::command]
fn my_custom_command() -> Result<(), String> {
std::fs::File::open("path/to/file").map_err(|err| err.to_string())?;
// Return `null` on success
Ok(())
}

Dado que esto no es muy idiomático, es posible que quieras crear tu propio tipo de error que implemente serde::Serialize. En el siguiente ejemplo, usamos el crate thiserror para ayudar a crear el tipo de error. Te permite convertir enums en tipos de error derivando el trait thiserror::Error. Puedes consultar su documentación para más detalles.

// create the error type that represents all errors possible in our program
#[derive(Debug, thiserror::Error)]
enum Error {
#[error(transparent)]
Io(#[from] std::io::Error)
}
// we must manually implement serde::Serialize
impl serde::Serialize for Error {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::ser::Serializer,
{
serializer.serialize_str(self.to_string().as_ref())
}
}
#[tauri::command]
fn my_custom_command() -> Result<(), Error> {
// This will return an error
std::fs::File::open("path/that/does/not/exist")?;
// Return `null` on success
Ok(())
}

Un tipo de error personalizado tiene la ventaja de hacer explícitos todos los errores posibles para que los lectores puedan identificar rápidamente qué errores pueden ocurrir. Esto ahorra a otras personas (y a ti mismo) una enorme cantidad de tiempo al revisar y refactorizar el código más adelante.
También te otorga un control total sobre la forma en que se serializa tu tipo de error. En el ejemplo anterior, simplemente retornamos el mensaje de error como una cadena de texto, pero podrías asignar a cada error un código para poder mapearlo más fácilmente a un enum de errores de TypeScript similar, por ejemplo:

#[derive(Debug, thiserror::Error)]
enum Error {
#[error(transparent)]
Io(#[from] std::io::Error),
#[error("failed to parse as string: {0}")]
Utf8(#[from] std::str::Utf8Error),
}
#[derive(serde::Serialize)]
#[serde(tag = "kind", content = "message")]
#[serde(rename_all = "camelCase")]
enum ErrorKind {
Io(String),
Utf8(String),
}
impl serde::Serialize for Error {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::ser::Serializer,
{
let error_message = self.to_string();
let error_kind = match self {
Self::Io(_) => ErrorKind::Io(error_message),
Self::Utf8(_) => ErrorKind::Utf8(error_message),
};
error_kind.serialize(serializer)
}
}
#[tauri::command]
fn read() -> Result<Vec<u8>, Error> {
let data = std::fs::read("/path/to/file")?;
Ok(data)
}

En tu frontend ahora obtienes un objeto de error { kind: 'io' | 'utf8', message: string }:

type ErrorKind = {
kind: 'io' | 'utf8';
message: string;
};
invoke('read').catch((e: ErrorKind) => {});

Los comandos asíncronos son preferidos en Tauri para realizar trabajo pesado de forma que no provoque congelamientos o ralentizaciones de la UI.

Si tu comando necesita ejecutarse de forma asíncrona, simplemente decláralo como async.

Al trabajar con tipos prestados, debes realizar cambios adicionales. Estas son tus dos opciones principales:

Opción 1: Convierte el tipo, como &str, a un tipo similar que no sea prestado, como String. Es posible que esto no funcione para todos los tipos, por ejemplo State<'_, Data>.

Example:

// Declare the async function using String instead of &str, as &str is borrowed and thus unsupported
#[tauri::command]
async fn my_custom_command(value: String) -> String {
// Call another async function and wait for it to finish
some_async_function().await;
value
}

Opción 2: Envuelve el tipo de retorno en un Result. Esta opción es un poco más difícil de implementar, pero funciona para todos los tipos.

Usa el tipo de retorno Result<a, b>, reemplazando a con el tipo que deseas retornar, o () si deseas retornar null, y reemplazando b con un tipo de error a retornar si algo sale mal, o () si no deseas retornar un error opcional. Por ejemplo:

  • Result<String, ()> para retornar un String y ningún error.
  • Result<(), ()> para retornar null.
  • Result<bool, Error> para retornar un booleano o un error como se muestra en la sección Manejo de errores anterior.

Example:

// Return a Result<String, ()> to bypass the borrowing issue
#[tauri::command]
async fn my_custom_command(value: &str) -> Result<String, ()> {
// Call another async function and wait for it to finish
some_async_function().await;
// Note that the return value must be wrapped in `Ok()` now.
Ok(format!(value))
}

Dado que invocar el comando desde JavaScript ya retorna una promesa, funciona exactamente igual que cualquier otro comando:

invoke('my_custom_command', { value: 'Hello, Async!' }).then(() =>
console.log('Completed!')
);

El canal de Tauri es el mecanismo recomendado para transmitir datos en streaming, como respuestas HTTP en streaming hacia el frontend. El siguiente ejemplo lee un archivo y notifica al frontend sobre el progreso en bloques de 4096 bytes:

use tokio::io::AsyncReadExt;
#[tauri::command]
async fn load_image(path: std::path::PathBuf, reader: tauri::ipc::Channel<&[u8]>) {
// for simplicity this example does not include error handling
let mut file = tokio::fs::File::open(path).await.unwrap();
let mut chunk = vec![0; 4096];
loop {
let len = file.read(&mut chunk).await.unwrap();
if len == 0 {
// Length of zero means end of file.
break;
}
reader.send(&chunk).unwrap();
}
}

Consulta la documentación de canales para obtener más información.

Los comandos pueden acceder a la instancia de WebviewWindow que invocó el mensaje:

src-tauri/src/lib.rs
#[tauri::command]
async fn my_custom_command(webview_window: tauri::WebviewWindow) {
println!("WebviewWindow: {}", webview_window.label());
}

Los comandos pueden acceder a una instancia de AppHandle:

src-tauri/src/lib.rs
#[tauri::command]
async fn my_custom_command(app_handle: tauri::AppHandle) {
let app_dir = app_handle.path().app_dir();
use tauri::GlobalShortcutManager;
app_handle.global_shortcut_manager().register("CTRL + U", move || {});
}

Tauri puede gestionar el estado mediante la función manage en tauri::Builder. Se puede acceder al estado en un comando usando tauri::State:

src-tauri/src/lib.rs
struct MyState(String);
#[tauri::command]
fn my_custom_command(state: tauri::State<MyState>) {
assert_eq!(state.0 == "some state value", true);
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.manage(MyState("some state value".into()))
.invoke_handler(tauri::generate_handler![my_custom_command])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

Los comandos de Tauri también pueden acceder al objeto completo tauri::ipc::Request, el cual incluye el cuerpo de la solicitud raw y los encabezados.

#[derive(Debug, thiserror::Error)]
enum Error {
#[error("unexpected request body")]
RequestBodyMustBeRaw,
#[error("missing `{0}` header")]
MissingHeader(&'static str),
}
impl serde::Serialize for Error {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::ser::Serializer,
{
serializer.serialize_str(self.to_string().as_ref())
}
}
#[tauri::command]
fn upload(request: tauri::ipc::Request) -> Result<(), Error> {
let tauri::ipc::InvokeBody::Raw(upload_data) = request.body() else {
return Err(Error::RequestBodyMustBeRaw);
};
let Some(authorization_header) = request.headers().get("Authorization") else {
return Err(Error::MissingHeader("Authorization"));
};
// upload...
Ok(())
}

En el frontend puedes llamar a invoke() enviando un cuerpo de solicitud raw proporcionando un ArrayBuffer o Uint8Array en el argumento payload, e incluir los encabezados de la solicitud en el tercer argumento:

const data = new Uint8Array([1, 2, 3]);
await __TAURI__.core.invoke('upload', data, {
headers: {
Authorization: 'apikey',
},
});

La macro tauri::generate_handler! acepta un arreglo de comandos. Para registrar múltiples comandos, no puedes llamar a invoke_handler varias veces. Solo se utilizará la última llamada. Debes pasar cada comando a una única llamada de tauri::generate_handler!.

src-tauri/src/lib.rs
#[tauri::command]
fn cmd_a() -> String {
"Command a"
}
#[tauri::command]
fn cmd_b() -> String {
"Command b"
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![cmd_a, cmd_b])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

Cualquiera o todas las características anteriores se pueden combinar:

src-tauri/src/lib.rs
struct Database;
#[derive(serde::Serialize)]
struct CustomResponse {
message: String,
other_val: usize,
}
async fn some_other_function() -> Option<String> {
Some("response".into())
}
#[tauri::command]
async fn my_custom_command(
window: tauri::WebviewWindow,
number: usize,
database: tauri::State<'_, Database>,
) -> Result<CustomResponse, String> {
println!("Called from {}", window.label());
let result: Option<String> = some_other_function().await;
if let Some(message) = result {
Ok(CustomResponse {
message,
other_val: 42 + number,
})
} else {
Err("No result".into())
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.manage(Database {})
.invoke_handler(tauri::generate_handler![my_custom_command])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
import { invoke } from '@tauri-apps/api/core';
// Invocation from JavaScript
invoke('my_custom_command', {
number: 42,
})
.then((res) =>
console.log(`Message: ${res.message}, Other Val: ${res.other_val}`)
)
.catch((e) => console.error(e));

El sistema de eventos es un mecanismo de comunicación más simple entre tu frontend y Rust. A diferencia de los comandos, los eventos no tienen seguridad de tipos, siempre son asíncronos, no pueden retornar valores y solo admiten cargas útiles (payloads) JSON.

Para activar un evento global puedes usar las funciones event.emit o WebviewWindow#emit:

import { emit } from '@tauri-apps/api/event';
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
// emit(eventName, payload)
emit('file-selected', '/path/to/file');
const appWebview = getCurrentWebviewWindow();
appWebview.emit('route-changed', { url: window.location.href });

Para activar un evento hacia un oyente registrado por un webview específico puedes usar las funciones event.emitTo o WebviewWindow#emitTo:

import { emitTo } from '@tauri-apps/api/event';
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
// emitTo(webviewLabel, eventName, payload)
emitTo('settings', 'settings-update-requested', {
key: 'notification',
value: 'all',
});
const appWebview = getCurrentWebviewWindow();
appWebview.emitTo('editor', 'file-changed', {
path: '/path/to/file',
contents: 'file contents',
});

El paquete NPM @tauri-apps/api ofrece API para escuchar eventos globales y específicos de webview.

  • Escuchar eventos globales

    import { listen } from '@tauri-apps/api/event';
    type DownloadStarted = {
    url: string;
    downloadId: number;
    contentLength: number;
    };
    listen<DownloadStarted>('download-started', (event) => {
    console.log(
    `downloading ${event.payload.contentLength} bytes from ${event.payload.url}`
    );
    });
  • Escuchar eventos específicos de webview

    import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
    const appWebview = getCurrentWebviewWindow();
    appWebview.listen<string>('logged-in', (event) => {
    localStorage.setItem('session-token', event.payload);
    });

La función listen mantiene el oyente de eventos registrado durante todo el ciclo de vida de la aplicación. Para dejar de escuchar un evento puedes usar la función unlisten devuelta por la función listen:

import { listen } from '@tauri-apps/api/event';
const unlisten = await listen('download-started', (event) => {});
unlisten();
No llames a unlisten() antes de que el oyente se resuelva
Sección titulada «No llames a unlisten() antes de que el oyente se resuelva»

La función listen devuelve una promesa que se resuelve con el handle unlisten. Si llamas a unlisten de forma síncrona antes de que la promesa se resuelva, el manejador se eliminará inmediatamente y no recibirás ningún evento:

// Wrong: unlisten is called before the listener is registered
const unlisten = listen('sync-complete', (event) => {
console.log('sync finished');
});
unlisten(); // unlisten is a Promise here, not a function -- the listener is not cleaned up
// Correct: await the Promise to get the unlisten handle
const unlisten = await listen('sync-complete', (event) => {
console.log('sync finished');
});
// Now you can store and call it later, e.g. in a cleanup function
unlisten();

En frameworks como React, Vue y Svelte, el hook de setup o montado se ejecuta antes de que el componente esté completamente renderizado. Si escuchas eventos durante la configuración, asegúrate de que el manejador de eventos no dependa de elementos DOM que aún no se hayan renderizado, o pospone el registro del oyente para un efecto/hook que se ejecute después del montaje.

// Wrong: DOM ref may not be available yet
function MyComponent() {
const ref = useRef(null);
listen('scroll-to', (event) => {
ref.current.scrollIntoView(); // ref.current may be null during setup
});
return <div ref={ref} />;
}
// Correct: use useEffect which runs after the component mounts
function MyComponent() {
const ref = useRef(null);
useEffect(() => {
const unlisten = listen('scroll-to', (event) => {
ref.current?.scrollIntoView();
});
return () => {
unlisten.then((fn) => fn());
};
}, []);
return <div ref={ref} />;
}

Los oyentes de eventos se llaman en el orden en que fueron registrados, pero si un oyente es asíncrono y el emisor de eventos envía múltiples eventos en rápida sucesión, los oyentes pueden procesar los eventos fuera de orden. Para una entrega de datos ordenada y de alto rendimiento, considera utilizar Canales en lugar del sistema de eventos.

Ejemplos de limpieza específicos de frameworks

Sección titulada «Ejemplos de limpieza específicos de frameworks»

Al utilizar un framework frontend, debes limpiar los oyentes de eventos cuando un componente se desmonte para evitar fugas de memoria y manejadores duplicados.

import { useEffect, useState } from 'react';
import { listen } from '@tauri-apps/api/event';
function DownloadTracker() {
const [progress, setProgress] = useState(0);
useEffect(() => {
const unlisten = listen<number>('download-progress', (event) => {
setProgress(event.payload);
});
return () => {
unlisten.then((fn) => fn());
};
}, []);
return <div>Download progress: {progress}%</div>;
}

Además, Tauri proporciona una función de utilidad para escuchar un evento exactamente una vez:

import { once } from '@tauri-apps/api/event';
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
once('ready', (event) => {});
const appWebview = getCurrentWebviewWindow();
appWebview.once('ready', () => {});

Los eventos globales y específicos de webview también se entregan a los oyentes registrados en Rust.

  • Escuchar eventos globales

    src-tauri/src/lib.rs
    use tauri::Listener;
    #[cfg_attr(mobile, tauri::mobile_entry_point)]
    pub fn run() {
    tauri::Builder::default()
    .setup(|app| {
    app.listen("download-started", |event| {
    if let Ok(payload) = serde_json::from_str::<DownloadStarted>(&event.payload()) {
    println!("downloading {}", payload.url);
    }
    });
    Ok(())
    })
    .run(tauri::generate_context!())
    .expect("error while running tauri application");
    }
  • Escuchar eventos específicos de webview

    src-tauri/src/lib.rs
    use tauri::{Listener, Manager};
    #[cfg_attr(mobile, tauri::mobile_entry_point)]
    pub fn run() {
    tauri::Builder::default()
    .setup(|app| {
    let webview = app.get_webview_window("main").unwrap();
    webview.listen("logged-in", |event| {
    let session_token = event.data;
    // save token..
    });
    Ok(())
    })
    .run(tauri::generate_context!())
    .expect("error while running tauri application");
    }

La función listen mantiene el oyente de eventos registrado durante todo el ciclo de vida de la aplicación. Para dejar de escuchar un evento puedes usar la función unlisten:

// unlisten outside of the event handler scope:
let event_id = app.listen("download-started", |event| {});
app.unlisten(event_id);
// unlisten when some event criteria is matched
let handle = app.handle().clone();
app.listen("status-changed", |event| {
if event.data == "ready" {
handle.unlisten(event.id);
}
});

Además, Tauri proporciona una función de utilidad para escuchar un evento exactamente una vez:

app.once("ready", |event| {
println!("app is ready");
});

En este caso, el oyente de eventos se desregistra inmediatamente después de su primera activación.

Para aprender a escuchar y emitir eventos desde tu código Rust, consulta la documentación del Sistema de Eventos de Rust.


© 2026 Colaboradores de Tauri. CC-BY / MIT