A sum between two microapps
Create an RPC client and provider with WU CLI and Astro: complete files, permissions, versions, errors, and verifiable unmounting.
What you will build
A form sends 7 and 8 to another microapp, which validates the numbers and returns 15. The client displays 7 + 8 = 15. Then you will stop the service, check the error, and start it again.
Continue in the mi-plataforma workspace from Adding a second app. You can also apply this recipe after the shared React and Vue counter: both apps retain their components. Here we add two small JavaScript apps to focus on RPC calls.
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»
The calculation happens in the browser. WU coordinates the request between apps; this recipe does not open an HTTP API. To study a provider with more operations and a contract discovered through provide/consume, continue afterward with A WU-connected service.
Recipe versions: Node.js 24, WU CLI 1.2.0, and wu-framework 4.0.0. Use the CLI installed in Your first microapp.
1. Add the client and provider
Stop the server with Ctrl+C. From the mi-plataforma/ root beside wu.config.json, run:
wu add vanilla sumador --no-install
wu add vanilla cliente-suma --no-install
The order is framework, name. The CLI creates mf-sumador/ and mf-cliente-suma/, with dependencies and HTML entrypoints, and registers both apps.
Download all recipe files. Copy the ZIP’s three folders into the workspace, merge existing folders, and replace files with the same names. Also copy wu.config.json. Replace each app’s index.html too: it must load src/main.js, this recipe’s JavaScript entrypoint. Keep the remaining workspace files.
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
The suma.astro page replaces the earlier host-only sum example if you created it. Workspace and counter pages remain available. If you customized your workspace, merge the following new entries and grants while preserving your other settings.
You can also copy the complete files below.
2. Request permissions and grant them
The provider requests rpc.provide:ejemplo:suma; the client requests rpc.call:ejemplo:suma. required: true makes initialization fail with diagnostics if the host does not grant that permission. The manifest describes the need; host grants supply authorization.
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 }
]
}
}
This is the complete workspace configuration after adding both apps. Counter grants preserve the demo.counter scope. The two new grants allow summation from generated-shell navigation.
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. Implement the provider
wu.define registers the sumador lifecycle. WU supplies context.wu on mount: that facade carries this app’s identity and permissions. The handler validates input, calculates the result, and shows which client called it.
version: '1.0.0' is the operation version. allow: ['cliente-suma'] adds an identity check: only that client can invoke it, even if another app has a grant for the same channel. The function returned by handle removes the handler; we execute it in 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. Implement the client
The client does not import the provider function. It sends both numbers through context.wu.request, awaits the response, and updates its own DOM. It requests a version compatible with ^1.0.0 and limits waiting to 1000 ms.
An empty field is sent as null so you can observe provider validation rejection. try/catch presents errors without leaving the interface blocked. Unmounting the client removes its listeners and cancels any pending request.
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. Mount both apps from Astro
This page configures its own apps and grants. It mounts the service first, then the client. Buttons let you unmount and mount the provider without reloading the client, so you can verify that it really depends on an available handler.
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 locates the shell runtime to register each app through define. RPC calls use the mount facade with identity, version and allowlist. Framework 4 also preserves these options in wu.handle; use the scoped facade inside a microapp.
6. Preserve the entrypoint and manifest during build
Each app’s index.html is also its Vite build entrypoint. The following two files point to src/main.js. They replace template files so development and production execute the same provider and client.
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>
Serving styles as files
Astro generates an external stylesheet with inlineStylesheets: 'never'. Each microapp loads its own styles.css from its Shadow DOM. Styles therefore comply with style-src 'self' without inline styles or server CSP changes. If you already applied the counter, keep this same shell configuration option.
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; }
There is one source wu.json per app, at its root. CLI native mode serves it directly. Vite must also include it in output: this plugin emits both the manifest and styles.css without manually maintaining extra copies. When collecting output, wu build preserves permissions and replaces entry with compiled JavaScript.
Complete dependency and build files included in the 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. Run and recognize the result
After copying files, from the workspace root:
wu install
npm --prefix shell run build
wu dev --native --port 3000 --no-open
This initial build includes suma.astro in shell/dist/ after copying the files with the server stopped. If shell output already exists, CLI 1.2.0 may reuse it at startup. Once wu dev --native is running, the watcher rebuilds the shell when its sources change and then reloads the page; you do not need to stop it and build manually after every edit. If compilation fails, inspect the terminal error.
Open http://127.0.0.1:3000/suma/. You should see Servicio de suma, Cliente de suma, and the numbers 7 and 8.
| Action | Visible result |
|---|---|
| Click Calcular | The client displays 7 + 8 = 15; the service reports responding to cliente-suma. |
Change the second number to 3 and calculate |
7 + 3 = 10. |
| Empty the first number and calculate | No se pudo calcular: Escribe dos números finitos. |
Restore 7 and 8, click Detener servicio, and calculate |
The client explains that the service stopped and how to retry. |
| Click Iniciar servicio and calculate | 7 + 8 = 15 again. |
The new apps also appear in navigation when you return to the main page. Open sumador before calculating from cliente-suma; this navigation mounts each app when visited. The /suma/ page performs that order automatically.
To check versioning, temporarily change the handler version to 2.0.0, reload /suma/, and calculate. The ^1.0.0 request is rejected as incompatible. Restore 1.0.0 afterward.
8. Check the version you will publish
Stop development with Ctrl+C and run:
wu build
wu serve 4100 --mode static
Open http://127.0.0.1:4100/suma/index.html. Repeat summation, service removal, and remounting; then reload that URL. The same results should work with compiled files. Publishable output is dist/ at the workspace root.
wu serve 4100 --mode static resolves directory indexes and returns real 404 responses for missing routes. Use --mode spa only when client routes need shell fallback.
If something fails
| Symptom | What to check |
|---|---|
/suma/ is missing or displays the old sum |
If you copied the page while development was stopped, run npm --prefix shell run build before starting. During development, check that the automatic shell rebuild has no errors. |
| WU reports a missing required permission | Compare the wu.json permission with the corresponding app grant in suma.astro; for the main page, check wu.config.json. |
| The client says the service is stopped | Click Iniciar servicio. In main navigation, open sumador first. |
| The page works but loses styles | Keep build.inlineStylesheets: 'never' in shell/astro.config.mjs; check both styles.css files exist in dist/mf-*/ and run wu build. |
| The Vanilla JS counter reappears in production | Replace both index.html files with this recipe’s versions; their scripts must load /src/main.js. Run wu build again. |
| Works in development but fails in production | Check that you copied both vite.config.js files and that dist/mf-sumador/wu.json and dist/mf-cliente-suma/wu.json preserve permissions with a production entry. |
| The request is rejected for version mismatch | The handler publishes 1.0.0 and the client requests ^1.0.0; they must be compatible. |
Continue with the APIs
You have used init, define, mount, unmount, handle, and request between two apps with different permissions. Events, contracts, and RPC explains signatures and variants; the communication reference lists APIs. To add contract discovery, observable cancellation, and other operations, continue with A WU-connected service.