WU / DOCSUNIVERSAL MICROFRONTENDS
ES

Un contador entre React y Vue

Conecta las dos apps del tutorial mediante el store de WU, con archivos completos, permisos, suscripciones y una prueba de desmontaje.

Lo que vas a construir

Continúa en mi-plataforma, el mismo workspace de Añadir una segunda app. Vas a reemplazar sus contadores locales por un contador compartido: sumar desde React también cambia Vue, y sumar desde Vue también cambia React.

La página Astro /contador/ mostrará las dos apps juntas. Seguirán teniendo sus propios componentes y renderers. El dato común estará en wu.store, en el path demo.counter.

Necesitas Node.js 24 y el workspace creado con WU CLI 1.2.0, con mi-plataforma en React e inspector en Vue. El código usa las APIs publicadas de wu-framework 4.0.0 y las dependencias que ya instalaste con wu install.

1. Aplicar los archivos al mismo proyecto

Detén el servidor con Ctrl+C. Descarga los archivos completos del contador y copia su contenido dentro de la raíz de mi-plataforma/, junto a su wu.config.json.

El ZIP contiene archivos para incorporar al workspace del tutorial; no se instala ni se ejecuta por separado. Reemplaza los archivos con el mismo nombre y conserva las otras carpetas del proyecto. Si ya personalizaste estos archivos, guarda tus cambios antes de sustituirlos.

Archivo Responsabilidad
mf-mi-plataforma/src/App.tsx Mostrar y actualizar el dato desde React.
mf-mi-plataforma/src/main.tsx Conectar el montaje y desmontaje de React con WU.
mf-inspector/src/App.vue Mostrar y actualizar el mismo dato desde Vue.
mf-inspector/src/main.ts Conectar el montaje y desmontaje de Vue con WU.
wu.json dentro de cada app Solicitar acceso únicamente a demo.counter.
vite.config.js dentro de cada app Llevar el manifest fuente al build de esa app.
wu.config.json Conservar las dos apps y conceder permisos al usar el shell generado.
shell/src/pages/contador.astro Montar ambas apps, conceder permisos e inicializar el dato.
shell/astro.config.mjs Emitir el CSS del shell en archivos que wu serve puede servir.

Si ya agregaste más apps al workspace, conserva tu wu.config.json y añade "grants": ["store.read:demo.counter", "store.write:demo.counter"] solo a las entradas existentes mi-plataforma e inspector. El archivo del ZIP corresponde al tutorial de dos apps.

También puedes copiar manualmente los once archivos completos que aparecen más abajo. No necesitas añadir una biblioteca de componentes ni instalar nuevos paquetes.

2. Construir y ejecutar

Desde la raíz del workspace:

npm --prefix shell run build
wu dev --native --port 3000 --no-open

Espera a que npm --prefix shell run build termine sin errores. Prepara la nueva página Astro antes de arrancar, porque un shell ya construido puede reutilizarse. Mientras wu dev --native está activo, CLI 1.2.0 observa las fuentes del shell, las reconstruye y recarga la página al cambiarlas. Para seguir editando no hace falta detener el servidor; revisa la terminal si Astro informa de un error.

Abre http://127.0.0.1:3000/contador/. Debes ver Un contador, React y Vue, las dos tarjetas y un cero en cada una.

  1. Pulsa Sumar desde React: ambos valores pasan de 0 a 1.
  2. Pulsa Sumar desde Vue: ambos pasan de 1 a 2.
  3. Pulsa Desmontar Vue: desaparece su tarjeta y React conserva el 2.
  4. Pulsa Sumar desde React: React muestra 3.
  5. Pulsa Montar Vue: Vue lee el dato actual y también muestra 3.
  6. Pulsa Restar desde Vue: ambas apps muestran 2.

Desmontar la app elimina su interfaz y su suscripción; no borra el dato compartido. Recargar toda la página sí inicia una sesión nueva con cero: este ejemplo guarda el dato en memoria, sin persistencia en el navegador ni en un servidor.

3. Cómo llega un cambio a ambas apps

El shell inicializa demo.counter una sola vez. Cada componente lee su valor inicial y se suscribe con store.on. Un botón llama a store.update; WU escribe el nuevo valor y notifica a los dos componentes. React actualiza su useState y Vue actualiza su ref.

update calcula el nuevo valor a partir del dato actual del store. Así, los botones no dependen de que la última notificación ya se haya pintado en pantalla. Las notificaciones llegan en una microtask; on no entrega automáticamente el valor inicial.

Las apps usan context.wu, la fachada que WU entrega durante su montaje. waitForWu localiza el runtime del shell para registrar el lifecycle con wu.define; las lecturas y escrituras del componente pasan por la fachada de su propia app.

El componente React

mf-mi-plataforma/src/App.tsx · Abrir archivo completo

import { useEffect, useState } from 'react';
import type { WuAppFacade } from 'wu-framework';

export default function App({ appWu }: { appWu: WuAppFacade }) {
  const [count, setCount] = useState(() => appWu.store.get('demo.counter') ?? 0);

  useEffect(() => {
    const stop = appWu.store.on('demo.counter', (value) => setCount(value));
    setCount(appWu.store.get('demo.counter') ?? 0);
    return stop;
  }, [appWu]);

  function change(delta: number) {
    appWu.store.update('demo.counter', (current) => (current ?? 0) + delta);
  }

  return (
    <section aria-label="Contador React">
      <h2>React · mi-plataforma</h2>
      <p>Valor compartido: <output aria-live="polite">{count}</output></p>
      <button type="button" onClick={() => change(-1)}>Restar desde React</button>{' '}
      <button type="button" onClick={() => change(1)}>Sumar desde React</button>
    </section>
  );
}

El efecto se suscribe y vuelve a leer el dato para cubrir el tiempo entre el primer render y la conexión. Su return stop libera la suscripción cuando React desmonta el componente.

mf-mi-plataforma/src/main.tsx · Abrir archivo completo

import React from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { waitForWu } from 'wu-framework/adapters/shared';
import App from './App.tsx';

const wu = await waitForWu();
let root: Root | undefined;

wu.define('mi-plataforma', {
  mount(container, context) {
    if (!context?.wu) throw new Error('Esta app necesita el contexto de WU.');
    root = createRoot(container);
    root.render(<App appWu={context.wu} />);
  },
  unmount() {
    root?.unmount();
    root = undefined;
  },
});

El lifecycle crea el root de React y le pasa context.wu como prop. Al desmontar la microapp, root.unmount() ejecuta la limpieza del efecto.

El componente Vue

mf-inspector/src/App.vue · Abrir archivo completo

<script setup lang="ts">
import { onUnmounted, ref } from 'vue';
import type { WuAppFacade } from 'wu-framework';

const { appWu } = defineProps<{ appWu: WuAppFacade }>();
const count = ref(appWu.store.get('demo.counter') ?? 0);
const stop = appWu.store.on('demo.counter', (value) => { count.value = value; });
onUnmounted(stop);

function change(delta: number) {
  appWu.store.update('demo.counter', (current) => (current ?? 0) + delta);
}
</script>

<template>
  <section aria-label="Contador Vue">
    <h2>Vue · inspector</h2>
    <p>Valor compartido: <output aria-live="polite">{{ count }}</output></p>
    <button type="button" @click="change(-1)">Restar desde Vue</button>
    {{ ' ' }}
    <button type="button" @click="change(1)">Sumar desde Vue</button>
  </section>
</template>

Vue conserva un ref para pintar el dato. onUnmounted(stop) libera la suscripción cuando se retira el componente.

mf-inspector/src/main.ts · Abrir archivo completo

import { createApp, type App as VueApp } from 'vue';
import { waitForWu } from 'wu-framework/adapters/shared';
import App from './App.vue';

const wu = await waitForWu();
let app: VueApp | undefined;

wu.define('inspector', {
  mount(container, context) {
    if (!context?.wu) throw new Error('Esta app necesita el contexto de WU.');
    app = createApp(App, { appWu: context.wu });
    app.mount(container);
  },
  unmount() {
    app?.unmount();
    app = undefined;
  },
});

app.unmount() ejecuta la limpieza de Vue. Al montar de nuevo, se crea otra instancia y se lee el valor actual de WU; el componente no reinicia el contador.

4. Qué permisos se conceden

Cada manifest solicita store.read y store.write solo para demo.counter. La lectura permite consultar y suscribirse; update necesita tanto lectura como escritura. El shell concede esos mismos recursos mediante grants.

Una app no obtiene acceso al store completo por conocer un path. WU calcula los permisos efectivos a partir de lo solicitado por la app y lo concedido por el host. required: true hace que una solicitud sin cobertura impida registrar la app.

mf-mi-plataforma/wu.json · Abrir archivo completo

{
  "name": "mi-plataforma",
  "version": "1.0.0",
  "entry": "src/main.tsx",
  "styleMode": "shared",
  "wu": {
    "permissions": [
      { "capability": "store.read", "resource": "demo.counter", "required": true },
      { "capability": "store.write", "resource": "demo.counter", "required": true }
    ]
  }
}

mf-inspector/wu.json · Abrir archivo completo

{
  "name": "inspector",
  "version": "1.0.0",
  "entry": "src/main.ts",
  "styleMode": "shared",
  "wu": {
    "permissions": [
      { "capability": "store.read", "resource": "demo.counter", "required": true },
      { "capability": "store.write", "resource": "demo.counter", "required": true }
    ]
  }
}

Conservar el manifest al compilar

En desarrollo nativo, la CLI sirve el wu.json que está en la raíz de cada microapp. Cada configuración Vite añade un pequeño plugin que escribe ese mismo archivo en su dist/. Después, wu build conserva sus permisos y actualiza entry para apuntar al JavaScript compilado. No mantienes una segunda copia del manifest a mano.

mf-mi-plataforma/vite.config.js · Abrir archivo completo

import { readFileSync } from 'node:fs';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [
    react(),
    {
      name: 'emit-wu-manifest',
      generateBundle() {
        this.emitFile({
          type: 'asset',
          fileName: 'wu.json',
          source: readFileSync(new URL('./wu.json', import.meta.url), 'utf8'),
        });
      },
    },
  ],
  build: { target: 'esnext' },
});

mf-inspector/vite.config.js · Abrir archivo completo

import { readFileSync } from 'node:fs';
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
  plugins: [
    vue(),
    {
      name: 'emit-wu-manifest',
      generateBundle() {
        this.emitFile({
          type: 'asset',
          fileName: 'wu.json',
          source: readFileSync(new URL('./wu.json', import.meta.url), 'utf8'),
        });
      },
    },
  ],
  build: { target: 'esnext' },
});

Mantener el shell generado

Esta configuración mantiene las dos apps del tutorial y añade sus grants. Así, también puedes abrirlas desde la navegación de la página principal del workspace. Esa navegación muestra una app a la vez; /contador/ permite ver las dos simultáneamente.

wu.config.json · Abrir archivo completo

{
  "name": "mi-plataforma",
  "version": "1.1.0",
  "shell": {
    "dir": "shell", "port": 4321, "framework": "astro",
    "dev_cmd": "npx astro dev", "build_cmd": "npx astro build"
  },
  "apps": [
    {
      "name": "mi-plataforma", "dir": "mf-mi-plataforma", "framework": "react", "port": 5001,
      "grants": ["store.read:demo.counter", "store.write:demo.counter"]
    },
    {
      "name": "inspector", "dir": "mf-inspector", "framework": "vue", "port": 5002,
      "grants": ["store.read:demo.counter", "store.write:demo.counter"]
    }
  ],
  "proxy": { "port": 3000, "open_browser": true }
}

5. La página Astro que las conecta

Esta es la página completa. Su script se ejecuta en el navegador, inicializa WU y monta cada app en su contenedor. Declara los grants de esta página explícitamente porque es un host adicional al shell generado.

La inicialización del dato está en el host y se ejecuta antes del montaje. El botón de Vue utiliza unmount con force: true para esperar la limpieza inmediata antes de permitir otro montaje.

shell/src/pages/contador.astro · Abrir archivo completo

<!doctype html>
<html lang="es">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Un contador compartido · WU</title>
    <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
  </head>
  <body>
    <main>
      <a href="/">Volver al workspace</a>
      <h1>Un contador, React y Vue</h1>
      <p>Las dos apps leen y escriben el mismo dato en WU.</p>
      <p id="estado" role="status">Cargando las apps…</p>
      <div class="contadores">
        <div id="react-counter"></div>
        <div id="vue-counter"></div>
      </div>
      <button id="toggle-vue" type="button" disabled>Desmontar Vue</button>
      <p>Desmonta Vue, cambia el valor desde React y vuelve a montar Vue.</p>
    </main>
    <script>
      import { wu } from 'wu-framework';

      const status = document.querySelector<HTMLElement>('#estado')!;
      const toggle = document.querySelector<HTMLButtonElement>('#toggle-vue')!;
      let vueMounted = false;

      try {
        await wu.init({
          sandbox: 'module',
          apps: [
            {
              name: 'mi-plataforma', url: '/mf-mi-plataforma',
              grants: ['store.read:demo.counter', 'store.write:demo.counter'],
            },
            {
              name: 'inspector', url: '/mf-inspector',
              grants: ['store.read:demo.counter', 'store.write:demo.counter'],
            },
          ],
        });
        if (wu.store.get('demo.counter') === undefined) wu.store.set('demo.counter', 0);
        await wu.mount('mi-plataforma', '#react-counter');
        await wu.mount('inspector', '#vue-counter');
        vueMounted = true;
        toggle.disabled = false;
        status.textContent = 'React y Vue conectados.';
      } catch (error) {
        status.textContent = `No se pudo iniciar: ${error}`;
      }

      toggle.addEventListener('click', async () => {
        toggle.disabled = true;
        try {
          if (vueMounted) {
            await wu.unmount('inspector', { force: true });
          } else {
            await wu.mount('inspector', '#vue-counter');
          }
          vueMounted = !vueMounted;
          toggle.textContent = vueMounted ? 'Desmontar Vue' : 'Montar Vue';
          status.textContent = vueMounted ? 'React y Vue conectados.' : 'Vue desmontado. React sigue conectado.';
        } catch (error) {
          status.textContent = `No se pudo cambiar Vue: ${error}`;
        } finally {
          toggle.disabled = false;
        }
      });
    </script>
    <style is:global>
      body { margin: 0; font: 1rem/1.6 system-ui, sans-serif; color: #192a3b; background: #f5f7fb; }
      main { max-width: 64rem; margin: auto; padding: 2rem 1rem; }
      a { color: #124ca6; }
      .contadores { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr)); gap: 1rem; margin: 1.5rem 0; }
      section { padding: 1.5rem; border: 1px solid #bac7d6; border-radius: 1rem; background: white; }
      h2 { margin-top: 0; font-size: 1.25rem; }
      output { font-size: 2rem; font-weight: 700; }
      button { font: inherit; padding: .6rem .9rem; margin-block: .25rem; border: 1px solid #617387; border-radius: .4rem; background: white; color: #192a3b; cursor: pointer; }
      button:focus-visible, a:focus-visible { outline: 3px solid #155fc9; outline-offset: 3px; }
      button:disabled { cursor: wait; opacity: .6; }
    </style>
  </body>
</html>

Emitir el CSS del shell en archivos

Esta configuración hace que Astro genere archivos CSS externos, incluidos los estilos de /contador/. Así se cargan con la política de estilos de wu serve, que permite recursos del mismo origen. Conserva esta opción al personalizar la configuración del shell.

shell/astro.config.mjs · Abrir archivo completo

import { defineConfig } from 'astro/config';

export default defineConfig({
  server: { port: 4321 },
  build: { inlineStylesheets: 'never' },
});

6. Comprobar la salida de producción

Detén el servidor con Ctrl+C y ejecuta desde la raíz:

wu build
wu serve 4100 --mode static

Abre http://127.0.0.1:4100/contador/index.html y repite las seis interacciones. Recarga esa misma ruta: deben cargarse otra vez las dos apps y empezar en cero. Estás sirviendo el dist/ combinado del workspace.

wu serve 4100 --mode static resuelve los índices de directorio y devuelve 404 reales para rutas inexistentes. Usa --mode spa solo si necesitas fallback al shell para rutas del cliente.

Si algo no coincide

Lo que observas Qué revisar
/contador/ muestra la página anterior o no existe Confirma shell/src/pages/contador.astro y revisa el build del shell en la terminal. Si copiaste los archivos con desarrollo detenido, ejecuta npm --prefix shell run build antes de arrancar.
Se deniega un permiso Cada app debe tener su wu.json; los grants del host deben cubrir store.read y store.write de demo.counter.
Funciona en desarrollo pero se deniega en producción Comprueba que copiaste ambos vite.config.js y que dist/mf-mi-plataforma/wu.json y dist/mf-inspector/wu.json conservan wu.permissions.
Solo cambia uno de los contadores Reemplaza tanto App como main en cada app; los cuatro archivos deben corresponder a esta receta.
No aparece el valor inicial La página debe inicializar el store antes de montar; cada componente debe leer al conectarse.

El siguiente paso es Pedir una suma a otra app: una app ofrece una operación y otra pide su resultado. Para profundizar en paths, snapshots y notificaciones, consulta Estado compartido.

WU SE CONSTRUYE CONTIGO

¿Encontraste un bug? ¿Tienes una idea?

Tu experiencia mejora WU. Cuéntanos qué falla o ayuda a sostener su desarrollo.