Una suma entre dos microapps
Crea un cliente y un proveedor RPC con WU CLI y Astro: archivos completos, permisos, versiones, errores y desmontaje comprobables.
Lo que vas a construir
Un formulario envía 7 y 8 a otra microapp, que valida los números y devuelve 15. El cliente muestra 7 + 8 = 15. Después detendrás el servicio, comprobarás el error y volverás a iniciarlo.
Continúa en el workspace mi-plataforma de Añadir una segunda app. También puedes aplicar esta receta después del contador compartido entre React y Vue: las dos apps conservan sus componentes. Aquí añadiremos dos apps pequeñas en JavaScript para concentrarnos en las llamadas RPC.
Shell Astro: concede permisos y monta las dos apps
cliente-suma → request('ejemplo:suma', { a: 7, b: 8 })
sumador → handle('ejemplo:suma', ...) → 15
cliente-suma → muestra «7 + 8 = 15»
El cálculo sucede en el navegador. WU coordina la solicitud entre las apps; esta receta no abre una API HTTP. Para estudiar un proveedor con más operaciones y un contrato que se descubre con provide/consume, continúa después con Un servicio conectado con WU.
Versiones de esta receta: Node.js 24, WU CLI 1.2.0 y wu-framework 4.0.0. Utiliza el CLI que instalaste en Tu primera microapp.
1. Añadir el cliente y el proveedor
Detén el servidor con Ctrl+C. Desde la raíz mi-plataforma/, junto a wu.config.json, ejecuta:
wu add vanilla sumador --no-install
wu add vanilla cliente-suma --no-install
El orden es framework, nombre. El CLI crea mf-sumador/ y mf-cliente-suma/, con sus dependencias y entradas HTML, y registra ambas apps.
Descarga todos los archivos de la receta. Copia las tres carpetas del ZIP dentro del workspace, combina las carpetas existentes y reemplaza los archivos del mismo nombre. Copia también wu.config.json. Reemplaza también el index.html de cada app: debe cargar src/main.js, la entrada JavaScript de esta receta. Conserva los demás archivos del workspace.
mi-plataforma/
├── wu.config.json
├── mf-sumador/
│ ├── index.html
│ ├── package.json
│ ├── vite.config.js
│ ├── styles.css
│ ├── wu.json
│ └── src/main.js
├── mf-cliente-suma/
│ ├── index.html
│ ├── package.json
│ ├── vite.config.js
│ ├── styles.css
│ ├── wu.json
│ └── src/main.js
└── shell/
├── astro.config.mjs
└── src/pages/suma.astro
La página suma.astro reemplaza el ejemplo anterior que hacía la suma dentro del propio host, si lo creaste. Las páginas del workspace y del contador permanecen disponibles. Si personalizaste tu workspace, incorpora las nuevas entradas y grants de la configuración siguiente conservando tus demás ajustes.
También puedes copiar los archivos completos que aparecen a continuación.
2. Solicitar permisos y concederlos
El proveedor solicita rpc.provide:ejemplo:suma; el cliente solicita rpc.call:ejemplo:suma. required: true hace que la inicialización falle con un diagnóstico si el host no concede ese permiso. El manifest describe la necesidad; los grants del host dan la autorización.
mf-sumador/wu.json
{
"name": "sumador",
"version": "1.0.0",
"entry": "src/main.js",
"styleMode": "isolated",
"wu": {
"permissions": [
{ "capability": "rpc.provide", "resource": "ejemplo:suma", "required": true }
]
}
}
mf-cliente-suma/wu.json
{
"name": "cliente-suma",
"version": "1.0.0",
"entry": "src/main.js",
"styleMode": "isolated",
"wu": {
"permissions": [
{ "capability": "rpc.call", "resource": "ejemplo:suma", "required": true }
]
}
}
Esta es la configuración completa del workspace después de añadir las dos apps. Los grants del contador conservan el alcance demo.counter. Los dos nuevos grants permiten la operación de suma desde la navegación del shell generado.
wu.config.json
{
"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"
]
},
{
"name": "sumador",
"dir": "mf-sumador",
"framework": "vanilla",
"port": 5003,
"grants": [
"rpc.provide:ejemplo:suma"
]
},
{
"name": "cliente-suma",
"dir": "mf-cliente-suma",
"framework": "vanilla",
"port": 5004,
"grants": [
"rpc.call:ejemplo:suma"
]
}
],
"proxy": {
"port": 3000,
"open_browser": true
}
}
3. Implementar el proveedor
wu.define registra el lifecycle de sumador. WU entrega context.wu al montarlo: esa facade lleva la identidad y los permisos de esta app. El handler valida la entrada, calcula el resultado y muestra qué cliente lo llamó.
version: '1.0.0' es la versión de la operación. allow: ['cliente-suma'] añade una comprobación de identidad: solo ese cliente puede invocarla, aunque otra app tenga un grant para el mismo canal. La función devuelta por handle retira el handler; la ejecutamos en unmount.
mf-sumador/src/main.js
import { waitForWu } from 'wu-framework/adapters/shared';
const wu = await waitForWu();
let release;
wu.define('sumador', {
mount(container, context) {
container.innerHTML = `
<link rel="stylesheet" href="/mf-sumador/styles.css">
<section aria-labelledby="service-title">
<h2 id="service-title">Servicio de suma</h2>
<p role="status">Listo para recibir solicitudes.</p>
</section>
`;
const status = container.querySelector('[role="status"]');
release = context.wu.handle('ejemplo:suma', ({ a, b }, request) => {
if (!Number.isFinite(a) || !Number.isFinite(b)) {
throw new TypeError('Escribe dos números finitos.');
}
const total = a + b;
if (!Number.isFinite(total)) {
throw new RangeError('La suma excede el rango numérico.');
}
status.textContent = `Respondí a ${request.caller.appName}: ${a} + ${b} = ${total}`;
return total;
}, {
version: '1.0.0',
allow: ['cliente-suma'],
});
},
unmount(container) {
release?.();
release = undefined;
container.replaceChildren();
},
});
4. Implementar el cliente
El cliente no importa la función del proveedor. Envía los dos números mediante context.wu.request, espera la respuesta y actualiza su propio DOM. Solicita una versión compatible con ^1.0.0 y limita la espera a 1000 ms.
Un campo vacío se envía como null para que puedas observar el rechazo de la validación del proveedor. try/catch presenta los errores sin dejar la interfaz bloqueada. Al desmontar el cliente, se retiran sus listeners y se cancela cualquier solicitud pendiente.
mf-cliente-suma/src/main.js
import { waitForWu } from 'wu-framework/adapters/shared';
const wu = await waitForWu();
let dispose;
wu.define('cliente-suma', {
mount(container, context) {
container.innerHTML = `
<link rel="stylesheet" href="/mf-cliente-suma/styles.css">
<section aria-labelledby="client-title">
<h2 id="client-title">Cliente de suma</h2>
<form>
<label>Primer número<input name="a" type="number" step="any" value="7"></label>
<label>Segundo número<input name="b" type="number" step="any" value="8"></label>
<button type="submit">Calcular</button>
</form>
<output aria-live="polite">Pulsa Calcular para solicitar una suma.</output>
</section>
`;
const form = container.querySelector('form');
const first = form.elements.namedItem('a');
const second = form.elements.namedItem('b');
const button = form.querySelector('button');
const output = container.querySelector('output');
const listeners = new AbortController();
let pending;
let active = true;
form.addEventListener('submit', async (event) => {
event.preventDefault();
if (pending) return;
const a = first.value === '' ? null : first.valueAsNumber;
const b = second.value === '' ? null : second.valueAsNumber;
pending = new AbortController();
button.disabled = true;
output.textContent = 'Calculando…';
try {
const total = await context.wu.request('ejemplo:suma', { a, b }, {
version: '^1.0.0',
timeout: 1000,
signal: pending.signal,
});
if (active) output.textContent = `${a} + ${b} = ${total}`;
} catch (error) {
if (!active) return;
output.textContent = error.code === 'WU_RPC_NO_HANDLER'
? 'El servicio está detenido. Inícialo y vuelve a pulsar Calcular.'
: `No se pudo calcular: ${error.message}`;
} finally {
pending = undefined;
if (active) button.disabled = false;
}
}, { signal: listeners.signal });
dispose = () => {
active = false;
listeners.abort();
pending?.abort();
};
},
unmount(container) {
dispose?.();
dispose = undefined;
container.replaceChildren();
},
});
5. Montar ambas apps desde Astro
Esta página configura sus propias apps y grants. Monta primero el servicio y después el cliente. Los botones permiten desmontar y montar el proveedor sin recargar el cliente: podrás verificar que realmente depende de un handler disponible.
shell/src/pages/suma.astro
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Una suma entre dos microapps WU</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
</head>
<body>
<main>
<a href="/">Volver al workspace</a>
<h1>Una suma entre dos microapps</h1>
<p>El cliente pide el resultado y el servicio calcula la suma.</p>
<div class="controls">
<button id="start" disabled>Iniciar servicio</button>
<button id="stop" disabled>Detener servicio</button>
</div>
<p id="status" role="status">Iniciando WU…</p>
<div id="service"></div>
<div id="client"></div>
</main>
<script>
import { wu } from 'wu-framework';
const start = document.querySelector<HTMLButtonElement>('#start')!;
const stop = document.querySelector<HTMLButtonElement>('#stop')!;
const status = document.querySelector<HTMLElement>('#status')!;
let running = false;
async function setService(enabled: boolean) {
start.disabled = true;
stop.disabled = true;
try {
if (enabled) await wu.mount('sumador', '#service');
else await wu.unmount('sumador', { force: true });
running = enabled;
status.textContent = enabled
? 'Servicio disponible. Pulsa Calcular.'
: 'Servicio detenido. Pulsa Calcular para comprobar el error.';
} catch (error) {
status.textContent = `No se pudo cambiar el servicio: ${error}`;
} finally {
start.disabled = running;
stop.disabled = !running;
}
}
try {
await wu.init({
sandbox: 'module',
apps: [
{
name: 'sumador',
url: `${location.origin}/mf-sumador`,
grants: ['rpc.provide:ejemplo:suma'],
},
{
name: 'cliente-suma',
url: `${location.origin}/mf-cliente-suma`,
grants: ['rpc.call:ejemplo:suma'],
},
],
});
await wu.mount('sumador', '#service');
running = true;
await wu.mount('cliente-suma', '#client');
stop.disabled = false;
status.textContent = 'Servicio disponible. Pulsa Calcular.';
start.addEventListener('click', () => { void setService(true); });
stop.addEventListener('click', () => { void setService(false); });
} catch (error) {
status.textContent = `No se pudo iniciar WU: ${error}`;
}
</script>
</body>
</html>
<style>
:global(body) { margin: 0; background: #f4f7f6; color: #172b34; font: 1rem/1.6 system-ui, sans-serif; }
main { max-width: 48rem; margin: 0 auto; padding: 2rem 1rem; }
h1 { font-size: clamp(1.6rem, 5vw, 2.5rem); line-height: 1.2; }
a { color: #175f56; }
.controls { display: flex; flex-wrap: wrap; gap: .75rem; }
button { border: 1px solid #536f74; border-radius: .5rem; padding: .6rem 1rem; color: #172b34; background: white; font: inherit; cursor: pointer; }
button:disabled { opacity: .55; cursor: default; }
:focus-visible { outline: 3px solid #174bac; outline-offset: 3px; }
#client { margin-top: 1rem; }
</style>
waitForWu localiza el runtime del shell para registrar cada app con define. Las operaciones RPC usan la facade de mount, con identidad, versión y allowlist. Framework 4 conserva esas opciones también en wu.handle; usa la facade scoped dentro de una microapp.
6. Conservar la entrada y el manifest al construir
El index.html de cada app es también la entrada del build de Vite. Los dos archivos siguientes apuntan a src/main.js. Sustituyen los de la plantilla para que desarrollo y producción ejecuten el mismo proveedor y el mismo cliente.
mf-sumador/index.html
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Servicio de suma · WU</title>
</head>
<body><script type="module" src="/src/main.js"></script></body>
</html>
mf-cliente-suma/index.html
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Cliente de suma · WU</title>
</head>
<body><script type="module" src="/src/main.js"></script></body>
</html>
Servir los estilos como archivos
Astro genera una hoja CSS externa con inlineStylesheets: 'never'. Cada microapp carga su propio styles.css desde su Shadow DOM. Así los estilos cumplen una política style-src 'self', sin necesitar estilos inline ni cambiar la CSP del servidor. Si ya aplicaste el contador, conserva esta misma opción en la configuración del shell.
shell/astro.config.mjs
import { defineConfig } from 'astro/config';
export default defineConfig({
server: { port: 4321 },
build: { inlineStylesheets: 'never' },
});
mf-sumador/styles.css
:host { display: block; font: 1rem/1.6 system-ui, sans-serif; color: #172b34; }
section { padding: 1.5rem; background: #edf7f1; border-radius: 1rem; }
h2 { margin-top: 0; font-size: 1.25rem; }
p { margin-bottom: 0; }
mf-cliente-suma/styles.css
:host { display: block; font: 1rem/1.6 system-ui, sans-serif; color: #172b34; }
section { padding: 1.5rem; background: white; border: 1px solid #cbd9d9; border-radius: 1rem; }
h2 { margin-top: 0; font-size: 1.25rem; }
form { display: flex; gap: 1rem; flex-wrap: wrap; align-items: end; }
label { display: grid; gap: .25rem; }
input { box-sizing: border-box; width: 7rem; padding: .65rem; font: inherit; border: 1px solid #6e838b; border-radius: .4rem; }
button { padding: .7rem 1rem; font: inherit; color: white; background: #126153; border: 0; border-radius: .4rem; cursor: pointer; }
button:disabled { opacity: .65; cursor: wait; }
:focus-visible { outline: 3px solid #174bac; outline-offset: 3px; }
output { display: block; margin-top: 1rem; font-weight: 650; }
Hay un solo wu.json fuente por app, en su raíz. El modo nativo del CLI lo sirve directamente. Vite debe incluirlo también en su salida: este plugin emite tanto el manifest como styles.css sin mantener copias adicionales a mano. Al recoger el resultado, wu build conserva los permisos y sustituye entry por el JavaScript compilado.
Archivos completos de dependencias y build incluidos en el ZIP
mf-sumador/package.json
{
"name": "wu-sumador",
"private": true,
"type": "module",
"engines": { "node": ">=24" },
"scripts": { "dev": "vite --port 5003", "build": "vite build" },
"dependencies": { "wu-framework": "4.0.0" },
"devDependencies": { "vite": "7.3.6" }
}
mf-cliente-suma/package.json
{
"name": "wu-cliente-suma",
"private": true,
"type": "module",
"engines": { "node": ">=24" },
"scripts": { "dev": "vite --port 5004", "build": "vite build" },
"dependencies": { "wu-framework": "4.0.0" },
"devDependencies": { "vite": "7.3.6" }
}
mf-sumador/vite.config.js
import { readFileSync } from 'node:fs';
import { defineConfig } from 'vite';
export default defineConfig({
base: './',
build: { target: 'esnext' },
plugins: [{
name: 'wu-assets',
generateBundle() {
for (const fileName of ['wu.json', 'styles.css']) {
this.emitFile({
type: 'asset',
fileName,
source: readFileSync(new URL(fileName, import.meta.url), 'utf8'),
});
}
},
}],
});
mf-cliente-suma/vite.config.js
import { readFileSync } from 'node:fs';
import { defineConfig } from 'vite';
export default defineConfig({
base: './',
build: { target: 'esnext' },
plugins: [{
name: 'wu-assets',
generateBundle() {
for (const fileName of ['wu.json', 'styles.css']) {
this.emitFile({
type: 'asset',
fileName,
source: readFileSync(new URL(fileName, import.meta.url), 'utf8'),
});
}
},
}],
});
7. Ejecutar y reconocer el resultado
Después de copiar los archivos, desde la raíz del workspace:
wu install
npm --prefix shell run build
wu dev --native --port 3000 --no-open
Este build inicial incorpora suma.astro a shell/dist/ después de copiar los archivos con el servidor detenido. Si ya existe una salida del shell, CLI 1.2.0 puede reutilizarla al arrancar. Una vez activo wu dev --native, el watcher reconstruye el shell cuando cambian sus fuentes y después recarga la página; no necesitas detenerlo y compilar a mano en cada edición. Si la compilación falla, revisa el error en la terminal.
Abre http://127.0.0.1:3000/suma/. Debes ver Servicio de suma, Cliente de suma y los números 7 y 8.
| Acción | Resultado visible |
|---|---|
| Pulsa Calcular | El cliente muestra 7 + 8 = 15; el servicio indica que respondió a cliente-suma. |
Cambia el segundo número a 3 y calcula |
7 + 3 = 10. |
| Vacía el primer número y calcula | No se pudo calcular: Escribe dos números finitos. |
Restablece 7 y 8, pulsa Detener servicio y calcula |
El cliente explica que el servicio está detenido y cómo volver a intentarlo. |
| Pulsa Iniciar servicio y calcula | 7 + 8 = 15 otra vez. |
Al regresar a la página principal también aparecen las nuevas apps en la navegación. Abre sumador antes de calcular desde cliente-suma; esa navegación monta cada app cuando la visitas. La página /suma/ realiza ese orden automáticamente.
Para comprobar la versión, cambia temporalmente la versión del handler a 2.0.0, recarga /suma/ y calcula. La solicitud ^1.0.0 se rechaza por incompatibilidad. Restaura 1.0.0 al terminar.
8. Comprobar la versión que publicarás
Detén desarrollo con Ctrl+C y ejecuta:
wu build
wu serve 4100 --mode static
Abre http://127.0.0.1:4100/suma/index.html. Repite la suma, la retirada y el nuevo montaje del servicio; después recarga esa URL. Los mismos resultados deben funcionar con los archivos compilados. La salida publicable es dist/ en la raíz 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 falla
| Síntoma | Qué comprobar |
|---|---|
/suma/ no aparece o muestra la suma antigua |
Si copiaste la página con desarrollo detenido, ejecuta npm --prefix shell run build antes de arrancar. Durante desarrollo, comprueba que la reconstrucción automática del shell no tenga errores. |
| WU informa de un permiso obligatorio ausente | Compara el permiso del wu.json con el grant de la app correspondiente en suma.astro; para la página principal, comprueba wu.config.json. |
| El cliente dice que el servicio está detenido | Pulsa Iniciar servicio. En la navegación principal, abre primero sumador. |
| La página funciona pero pierde sus estilos | Conserva build.inlineStylesheets: 'never' en shell/astro.config.mjs; comprueba que existen los dos styles.css en dist/mf-*/ y ejecuta wu build. |
| En producción reaparece el contador Vanilla JS | Reemplaza ambos index.html por los de esta receta; sus scripts deben cargar /src/main.js. Ejecuta wu build otra vez. |
| Funciona en desarrollo y falla en producción | Comprueba que copiaste ambos vite.config.js y que dist/mf-sumador/wu.json y dist/mf-cliente-suma/wu.json conservan sus permisos con un entry de producción. |
| La solicitud se rechaza por versión | El handler publica 1.0.0 y el cliente solicita ^1.0.0; deben ser compatibles. |
Continuar con las APIs
Ya utilizaste init, define, mount, unmount, handle y request entre dos apps con permisos diferentes. Eventos, contratos y RPC explica las firmas y las variantes; la referencia de comunicación enumera las APIs. Para añadir descubrimiento de contratos, cancelación observable y otras operaciones, continúa con Un servicio conectado con WU.