Blog de Obi Madu
Volver a todos los artículos
System DesignDesktopBackendFrontend

Una base de código, Web y Escritorio

Cómo diseñar una app de Tauri para que el mismo código React corra en el navegador y en el escritorio, poniendo las diferencias de runtime detrás de una interfaz de servicio.

Una base de código, Web y Escritorio

Si leíste la introducción a Tauri, sabes qué es Tauri y cómo funciona: una aplicación de escritorio nativa donde la UI es tecnología web y un backend en Rust maneja el lado del sistema operativo a través de IPC. La siguiente pregunta que suele surgir es: ¿cómo escribes una base de código que corra tanto ahí como en un navegador normal?

Esa pregunta aparece temprano. Tus componentes de React necesitan leer y escribir archivos, mostrar notificaciones y hablar con el sistema operativo. En Tauri, todo eso pasa por invoke() y el backend de Rust. Pero también quieres correr el mismo frontend en un navegador durante el desarrollo, o enviarlo como una app web junto con la versión de escritorio. En el navegador, invoke() no hace nada. No hay Rust al otro lado.

El enfoque ingenuo es esparcir comprobaciones isTauri() por todos tus componentes. Un componente llama a invoke() cuando está en Tauri y a fetch() cuando está en el navegador. Otro hace lo mismo para notificaciones. Un tercero para guardar archivos. Después de un rato, cada componente sabe sobre ambos runtimes, y el código está lleno de ramas que hacen cosas distintas según dónde corra la app.

Eso funciona, pero escala mal. Cada nuevo componente que necesita una capacidad nativa tiene que aprender la misma lección. Cada componente carga el peso de dos runtimes. Y en el momento en que quieras añadir un tercer runtime, digamos un build móvil, estás editando componentes de nuevo.

Hay una forma mejor para esto.

Poner el runtime detrás de una interfaz

El patrón es dejar de dejar que tus componentes sepan sobre el runtime. En su lugar, defines una interfaz y cambias la implementación según dónde corre la app.

Toma las operaciones de archivos. Tus componentes necesitan guardar documentos, leer archivos y listar directorios. Define cómo se ve eso sin importar cómo ocurre:

interface FileService {
  saveDocument(doc: Document): Promise<void>;
  getFiles(): Promise<string[]>;
  readFile(path: string): Promise<string>;
}

Tus componentes de React dependen de esta interfaz. Nunca llaman a invoke(). Nunca llaman a fetch(). Llaman a fileService.saveDocument(doc) y siguen adelante.

function SaveButton({ doc }: { doc: Document }) {
  const fileService = useFileService();

  return (
    <button onClick={() => fileService.saveDocument(doc)}>
      Save
    </button>
  );
}

El componente no sabe si ese guardado va a Rust, a una API HTTP, o a un mock en una prueba. No necesita saberlo.

Dos implementaciones

Escribes dos implementaciones de esa interfaz. Una para el navegador, otra para Tauri.

La versión del navegador habla con tu backend sobre HTTP:

class WebFileService implements FileService {
  async saveDocument(doc: Document): Promise<void> {
    await fetch("/api/documents", {
      method: "POST",
      body: JSON.stringify(doc),
    });
  }

  async getFiles(): Promise<string[]> {
    const res = await fetch("/api/files");
    return res.json();
  }

  async readFile(path: string): Promise<string> {
    const res = await fetch(`/api/files/${encodeURIComponent(path)}`);
    return res.text();
  }
}

La versión de Tauri envía mensajes a Rust a través de invoke():

class TauriFileService implements FileService {
  async saveDocument(doc: Document): Promise<void> {
    await invoke("save_document", { doc });
  }

  async getFiles(): Promise<string[]> {
    return invoke<string[]>("get_files");
  }

  async readFile(path: string): Promise<string> {
    return invoke<string>("read_file", { path });
  }
}

Misma interfaz, dos mecanismos completamente distintos por debajo. Uno va por la red. El otro va a través de IPC a una función Rust compilada que escribe al sistema de archivos real.

Elegir una en el límite

Eliges la implementación una vez, en el límite donde tu app arranca, y todo lo que está aguas abajo recibe la correcta:

function createFileService(): FileService {
  if (isTauri()) {
    return new TauriFileService();
  }
  return new WebFileService();
}

O con un provider, si prefieres el contexto de React:

const FileServiceContext = createContext<FileService>(
  isTauri() ? new TauriFileService() : new WebFileService()
);

function useFileService(): FileService {
  return useContext(FileServiceContext);
}

Ahora cada componente en la app recibe la implementación correcta sin saber nunca cuál es. La comprobación isTauri() ocurre en un solo lugar. Los componentes están limpios.

Qué se comparte, qué se intercambia

Este patrón funciona porque la mayor parte de tu aplicación no le importa el runtime. La UI, la gestión de estado, las reglas de negocio, la validación, el enrutamiento, el árbol de componentes, todo eso es idéntico sin importar dónde corra la app.

CompartidoIntercambiado
ComponentesAcceso a archivos
Gestión de estadoNotificaciones
Reglas de negocioIntegraciones con el OS
ValidaciónMétodo de autenticación
EnrutamientoAlmacenamiento
Árbol de componentesCapa de red

La columna compartida es la mayor parte de tu base de código. La columna intercambiada es la superficie donde tu app toca el mundo exterior, y esa superficie es donde el runtime realmente importa. Al poner cada pieza intercambiada detrás de una interfaz, mantienes las diferencias de runtime en un lugar en vez de esparcidas por cada componente.

Por qué este es el patrón correcto para Tauri

Tauri no te da esto gratis. Si solo empiezas a llamar invoke() en tus componentes, terminas con una app de escritorio que resulta contener código web. Ya no correrá en un navegador, porque cada componente depende de que el puente de Rust esté ahí. Has bloqueado tu frontend al runtime de escritorio.

El patrón de interfaz invierte eso. Obtienes una app web que puede convertirse en una app de escritorio, porque la app web se construyó sin saber sobre el runtime de escritorio. Las capacidades de escritorio se añadieron en los límites, y la aplicación principal nunca dependió de ellas.

Esa distinción importa para más que solo correr en un navegador. Significa que puedes probar tus componentes de forma aislada, porque dependen de una interfaz, no de invoke(). Significa que puedes añadir un nuevo runtime sin tocar componentes, escribiendo una nueva implementación de la interfaz. Significa que la decisión de "¿es esta una app web o una app de escritorio?" es una elección de configuración, no una estructural.

Un ejemplo real

Dices que estás construyendo una app de notas. En el navegador, las notas se guardan en tu API de backend. En Tauri, las notas se guardan en el sistema de archivos local a través de Rust. La UI es la misma en ambos: una lista de notas, un editor, un botón de guardar.

                    SaveButton
                        |
                  FileService (interfaz)
                   /            \
                  /              \
    WebFileService          TauriFileService
         |                        |
    HTTP POST /api/notes    invoke("save_note")
         |                        |
    Servidor backend         Función Rust
         |                        |
    Base de datos            Sistema de archivos local

El SaveButton no sabe de qué lado está. El Editor no lo sabe. La NoteList no lo sabe. Lo único que lo sabe es la única línea de código que elige la implementación cuando la app arranca.

Cuando el usuario hace clic en guardar en el navegador, la petición va a tu backend. Cuando hacen clic en guardar en la app de escritorio, la petición va a Rust, que escribe al disco. El código del componente es idéntico. La experiencia es diferente. La ubicación de almacenamiento es diferente. El límite de confianza es diferente. Pero el código React con el que el usuario interactúa es el mismo.

Dónde trazar las líneas

Las interfaces que definas deben coincidir con las necesidades de tu aplicación, no con las capacidades del runtime. No crees un TauriService que envuelva cada llamada a invoke(). Eso solo mueve el problema. Crea un FileService para operaciones de archivos, un NotificationService para notificaciones, un AuthService para autenticación. Cada interfaz describe lo que tu aplicación necesita hacer, y cada runtime proporciona su propia implementación de esa necesidad.

La pregunta a hacer cuando decides si algo va detrás de una interfaz es: ¿esto funciona diferente en el navegador que en Tauri? Si sí, va detrás de una interfaz. Si no, es código compartido y ambos runtimes usan la misma implementación.

El acceso a archivos es diferente. Las notificaciones son diferentes. Las integraciones con el OS son diferentes. La validación de formularios es la misma. La gestión de estado es la misma. El enrutamiento es el mismo. La línea se traza donde el runtime realmente diverge, y una vez trazada, la divergencia vive en un lugar.