Ir al contenido

Comunicación

Las apps nunca se importan entre sí. Todo cruza por un sustrato que vive en la ventana del shell y se comporta igual en todos los modos de sandbox.

┌─ App A (React) ───┐ ┌─ App B (Vue) ─────┐
│ wu.emit('cart:*') │──── bus ────►│ wu.on('cart:*') │
│ wu.request('c:t') │──── canal ──►│ wu.handle('c:t') │
│ ◄──────────│─ respuesta ──│────────── │
│ wu.store.set(…) │──── store ──►│ wu.store.on(…) │
│ wu.provide('cart')│── contrato ─►│ wu.consume('cart')│
└───────────────────┘ └───────────────────┘
▲ ▲
└──── wu.update(name, props) ─────┘
(enviadas por el shell)

No hay negociación de versiones para los módulos de framework. Conviene ser exacto aquí, porque se suele contar mal en las dos direcciones.

Wu sí reutiliza el runtime del framework si está en el global. Seis adapters —React, Vue, Preact, Solid, Angular y Alpine— lo comprueban antes de importar nada:

src/adapters/react/index.js
if (typeof window !== 'undefined' && window.React && window.ReactDOM) {
adapterState.React = window.React; // reutiliza el del shell
...
return true;
}
// solo si no está, importa el suyo
const [React, ReactDOMClient] = await Promise.all([
import('react'), import('react-dom/client'),
]);

Así que si el shell expone window.React y las apps marcan React como external en su build, hay una sola copia en la página. La afirmación de que “N apps son N Reacts” solo es cierta cuando nadie expone el global.

Lo que Wu no tiene es la otra mitad: nada compara versiones. La condición es window.React && window.ReactDOM y ya está. Si el shell publica React 18 y una app se compiló contra React 19, esa app se monta contra el 18 sin un solo aviso, y el fallo aparece después — al tocar una API que en esa versión no existe. El requiredVersion de Module Federation existe precisamente para cazar eso.

Wu Module Federation
Reutilizar el runtime Sí, si el shell lo expone Sí, automático
Cómo se configura Global + external en cada build Declarativo en el config
Versiones distintas No se detecta, rompe en runtime Negocia o avisa
Sin nadie exponiendo Cada app trae la suya Igual

A veces se confunden los contratos de capacidades con un sustituto de todo esto. No lo son: comparten objetos vivos, no módulos. wu.consume('cart', '^2.0') te da un proxy a un objeto que creó la app del carrito — no te da la instancia de react de esa app, y no te ahorra ningún byte. Lo que sí hace es negociar semver de verdad, pero para capacidades entre apps, que es otro problema.

wu.emit('cart:item-added', { sku: 'SKU-42' });
const off = wu.on('cart:*', (event) => {
event.name; // 'cart:item-added'
event.data; // { sku: 'SKU-42' }
event.appName; // quién lo emitió
event.verified; // ¿estaba registrado el emisor?
event.timestamp;
});
off();
Método Notas
emit(name, data, opts) Devuelve false si se rechaza, true si se despacha
on(name, cb) Devuelve una función para desuscribirse
off(name, cb)
once(name, cb) Devuelve una función para desuscribirse
eventBus.replay(pattern, cb) Ejecuta cb por cada evento pasado que coincida
eventBus.tap(fn) Manguera: todos los eventos, sin comodines ni autorización
eventBus.clearHistory(pattern) Sin argumento borra todo

Opciones de emit: { appName, timestamp, meta, token, history }. Pasar history: false despacha a los listeners sin registrar el evento.

* coincide con cualquier carácter, incluidos : y .:

wu.on('cart:*', cb); // cart:added, cart:removed, cart:sync:done
wu.on('*', cb); // todo

Un patrón sin * nunca pasa por la vía de los comodines, así que un listener exacto nunca se dispara dos veces.

Fíjate en la diferencia con el store, donde * coincide con un solo segmento. Son dos comparadores distintos con semánticas distintas.

El historial está limitado a 100 eventos y solo se registra si el replay está habilitado (lo está por defecto) y el emit no pasó history: false.

wu.eventBus.replay('user:*', (event) => {
// se llama una vez por cada evento coincidente ya en el historial
});

Útil para una app que se monta tarde y necesita ponerse al día con el estado que se perdió.

strictMode está desactivado por defecto, siempre. No hay ninguna comprobación de NODE_ENV ni heurística por nombre de host: un bundle de navegador no puede deducir el entorno de forma fiable en tiempo de ejecución, así que el bus no lo intenta. Se pide explícitamente, en el init o sobre el bus:

await wu.init({ strictEvents: true, apps: [ /* … */ ] });
wu.eventBus.enableStrictMode();
wu.eventBus.disableStrictMode();
wu.eventBus.configure({ strictMode: true, validateOrigin: true });

Cuando está desactivado, el primer emit() registra un aviso único indicándolo.

Con strictMode activo, emit() se valida:

  1. Los eventos de sistema siempre pasan: los nombres que empiezan por wu:, system: o app:.
  2. Los subsistemas internos (wu-core, wu-ai, wu-mcp-bridge, plugin, wu-event-bus) deben presentar su token interno. Reclamar uno de esos nombres sin el token se rechaza: los nombres no se pueden suplantar.
  3. Todo lo demás debe ser una app registrada, con un token coincidente si se proporcionó uno, y un permiso que cubra el nombre del evento.
const token = wu.eventBus.registerApp('cart'); // los permisos son ['*'] por defecto
wu.eventBus.registerApp('cart', { permissions: ['cart:*'] });
wu.eventBus.unregisterApp('cart');

Un emit rechazado no lanza error. Incrementa un contador, registra Event rejected: <name> from <app> (unauthorized) y devuelve false. No se añade nada al historial, no se disparan taps, no se ejecuta ningún listener. Comprueba el valor de retorno si necesitas saberlo.

Los tokens se generan con WebCrypto: crypto.randomUUID(), con respaldo en crypto.getRandomValues (el escalón que importa en despliegues internos por HTTP plano, donde randomUUID no existe), y solo se llega a Math.random() si WebCrypto falta por completo — con un aviso cuando ocurre.

2. Petición-respuesta — “necesito una respuesta”

Sección titulada «2. Petición-respuesta — “necesito una respuesta”»
// Quien atiende: un solo responsable por canal
const baja = wu.handle('users:page', async ({ page }) => {
const res = await fetch(`/api/users?page=${page}`);
return res.json(); // esto es lo que recibe quien preguntó
});
// Quien pregunta
const usuarios = await wu.request('users:page', { page: 3 });
wu.hasHandler('users:page'); // ¿hay alguien atendiendo?
baja();
Método Notas
handle(canal, fn) Devuelve una función de baja. Un segundo handle en el mismo canal sustituye al primero y avisa
request(canal, data, opts) Promise con la respuesta. opts.timeout en ms; sin él, la espera no tiene límite
hasHandler(canal) boolean

No tienen import nombrado: se usan como wu.request / wu.handle / wu.hasHandler.

El evento avisa; el canal pregunta. Un evento sin oyente no da error — la vista se queda cargando y no hay nada en la consola. Un canal que nadie atiende revienta al primer intento y te dice qué canales sí están abiertos:

[WuEventBus] nobody is handling 'users:page'. Open channels: cart:total.
Did the app that calls handle() mount?

El handler se invoca directamente, en la misma pila de llamadas, no dando la vuelta por el bus. Las apps comparten una única instancia de wu en la página, así que no hay nada que serializar — y si el que responde falla, el error llega con sus propios frames dentro en vez de un { ok: false } sin origen. Se emiten igual <canal>:request y <canal>:response como traza para devtools y la línea de tiempo, pero la respuesta no viaja por ahí.

Los handlers no están atados al ciclo de vida. A diferencia de las capacidades, nadie los revoca cuando la app que llamó a handle() se desmonta: guarda la función de baja y llámala en tu unmount.

Mira Eventos → handle() / request().

wu.store.set('user.name', 'Luis'); // → número de secuencia
wu.store.get('user.name'); // 'Luis'
wu.store.get(); // el objeto de estado completo
const off = wu.store.on('user.*', ({ path, value }) => { … });
wu.store.batch({ 'a.b': 1, 'c': 2 }); // → array de números de secuencia

Rutas con puntos. Los niveles intermedios que falten se crean al escribir y se acceden con encadenamiento opcional al leer, así que get('a.b.c') sobre un store vacío devuelve undefined en vez de lanzar un error.

La notificación es asíncrona: los suscriptores se llaman en una microtarea después de la escritura, no de forma síncrona dentro de set(). Los listeners de ruta exacta reciben (value, path); la escritura además burbujea hacia los ancestros, así que escribir user.profile.name también notifica a user.profile y a user. Los listeners de patrón reciben un único objeto { path, value }.

Los comodines coinciden con un solo segmento. user.* coincide con user.name pero no con user.profile.name. Es lo contrario que en el bus de eventos. '*' a secas coincide con todo.

set() devuelve un número de secuencia monótono de un búfer circular de 256 huecos (ese búfer es de donde leen el timeline y la sincronización CRDT). Los huecos están preasignados y se reutilizan, así que las escrituras no reservan memoria.

La contaminación de prototipos lanza error. __proto__, constructor y prototype se rechazan como segmentos de ruta:

wu.store.set('__proto__.polluted', 1);
// Error: [WuStore] Unsafe key in path: "__proto__" (path: "__proto__.polluted")

get() deliberadamente no lanza error con esas mismas claves: devuelve undefined, ya que una lectura no puede contaminar nada.

Sincronización entre pestañas y multiusuario

Sección titulada «Sincronización entre pestañas y multiusuario»
const handle = wu.store.sync({ transport: 'broadcast', room: 'default' });
await handle.ready();
handle.status(); // { connected, site, lamport, peers, tracked, sent, received, … }
handle.stop();

Transportes: 'broadcast' (BroadcastChannel, entre pestañas), una instancia de WebSocket o una URL wss://, o el tuyo propio con { send, onMessage, close }.

La resolución de conflictos es el último que escribe gana, por ruta, ordenada por un reloj de Lamport con el id de sitio como desempate, de modo que las réplicas convergen sin importar el orden de entrega. Quien se une tarde intercambia una instantánea. De los valores no serializables (BigInt, Function, Symbol) se avisa y se contabilizan como descartados, nunca se tragan en silencio. Las escrituras remotas pasan por la misma protección contra contaminación de prototipos y una operación rechazada se cuenta como ignorada.

Solo puede haber una sincronización activa por store; una segunda llamada a sync() avisa y devuelve el handle existente.

4. Contratos de capacidades — “llama a esta función”

Sección titulada «4. Contratos de capacidades — “llama a esta función”»

Los eventos son de disparar y olvidar; el store es estado. Cuando una app necesita llamar a otra, usa un contrato.

// Proveedor
const handle = wu.provide('cart', cartApi, {
version: '2.1.0',
shape: { add: 'function', remove: 'function' },
app: 'cart', // se revoca automáticamente cuando esta app se desmonta
});
// Consumidor
const cart = wu.consume('cart', '^2.0');
cart.add('SKU-42'); // se vuelve a resolver en vivo en cada acceso

provide() lanza un error si la implementación no cumple la shape declarada. La presencia se comprueba recorriendo la cadena de prototipos de la propia implementación — así que una instancia de clase cuenta —, pero el recorrido se detiene antes de Object.prototype, de modo que un {} vacío no puede satisfacer un contrato heredando toString.

consume() devuelve un proxy vivo, no una instantánea. Cada acceso a una propiedad vuelve a resolver el proveedor, así que cambiar de proveedor es invisible para los consumidores. Lanza el error en el acceso, no en consume():

[WuContracts] no provider for capability 'cart'. Did the providing app mount
and call wu.provide('cart', …)?
[WuContracts] capability 'cart@1.4.0' does not satisfy '^2.0'.

Detalles que conviene conocer:

  • La identidad de los métodos es estable. proxy.fn === proxy.fn, así que los métodos enlazados funcionan como dependencias de efectos de React y como referencias para addEventListener/removeEventListener. Un cambio de proveedor produce un enlace nuevo.
  • El proxy es de solo lectura. Las escrituras se ignoran con un aviso.
  • Nunca es thenable. proxy.then siempre devuelve undefined, así que await consume(...) no puede quedarse colgado por accidente. Un método de la capacidad que se llame literalmente then es inaccesible a través del proxy.
  • Sondas que no lanzan error: proxy.__wuAvailable, proxy.__wuCapability, proxy.__wuRange y wu.contracts.has(name, range).

Los consumidores tardíos pueden esperar:

const cart = await wu.consume('cart', '^2.0', { wait: true, timeout: 5000 });

La revocación al desmontar la dirige el core con el nombre de app verificado por el framework, no el evento de bus app:unmounted — porque los eventos app: son de confianza del sistema y, si no, una app rival podría falsificarlos para revocar las capacidades de una competidora.

Eventos emitidos: wu:capability:provided y wu:capability:revoked. Las capacidades vivas aparecen en wu.inspect().capabilities.

await wu.update('cart', { currency: 'EUR' }); // → boolean

Envía props a una app montada sin remontarla, llamando al slot opcional update(container, props) de la app. Los adaptadores lo exponen solo cuando su framework puede volver a renderizar en el sitio.

Honestamente no hace nada cuando no puede funcionar: si la app no está montada, está a mitad de desmontaje o su adaptador nunca anunció un slot update, registra un aviso y devuelve false. Nunca lanza un error y nunca finge. Comprueba el valor de retorno si te importa.

Mira Ciclo de vida.

Lo que necesitas Usa
Anunciar que ha pasado algo Evento
Pedir un dato y esperar la respuesta wu.request() / wu.handle()
Compartir estado que otros leen después Store
Dejar que otra app llame a tus funciones Contrato de capacidad
Enviar props nuevas a una app montada wu.update()
Mantener el estado sincronizado entre pestañas wu.store.sync()
Ponerte al día con eventos que te perdiste eventBus.replay()

Guía aproximada: eventos para verbos, canales para preguntas, store para sustantivos, contratos para servicios.