两个微应用之间求和
用 WU CLI 和 Astro 创建 RPC 客户端与提供方,包含完整文件、权限、版本、错误和可验证的卸载。
将要构建的内容
表单将 7 和 8 发送给另一微应用,由它验证数字并返回 15。客户端显示 7 + 8 = 15。随后停止服务、检查错误,再重新启动。
继续使用添加第二个应用中的 mi-plataforma workspace。也可在完成 React 与 Vue 共享计数器后使用本示例,原有组件保持不变。这里添加两个小型 JavaScript 应用,集中学习 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»
计算发生在浏览器,WU 协调应用间请求;本示例不打开 HTTP API。要学习更多操作以及通过 provide/consume 发现的契约,之后可阅读使用 WU 连接服务。
本示例版本: Node.js 24、WU CLI 1.2.0 和 wu-framework 4.0.0。使用第一个微应用安装的 CLI。
1. 添加客户端与提供方
按 Ctrl+C 停止服务器。在 wu.config.json 所在的 mi-plataforma/ 根目录运行:
wu add vanilla sumador --no-install
wu add vanilla cliente-suma --no-install
顺序是框架、名称。CLI 创建 mf-sumador/ 和 mf-cliente-suma/,包含依赖及 HTML 入口,并注册两个应用。
下载本示例全部文件。将 ZIP 的三个目录复制进 workspace,合并现有目录并替换同名文件,也复制 wu.config.json。还需替换各应用的 index.html,使其加载本示例 JavaScript 入口 src/main.js。保留 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
若创建过宿主内直接求和的旧示例,suma.astro 将替换它。workspace 首页和计数器页仍可访问。如果自定义过 workspace,请合并下方配置的新条目和 grants,同时保留其他设置。
也可以复制下方展示的完整文件。
2. 申请并授予权限
提供方申请 rpc.provide:ejemplo:suma,客户端申请 rpc.call:ejemplo:suma。required: true 使宿主未授予权限时初始化失败并报告诊断。manifest 描述需求,宿主 grants 提供授权。
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 }
]
}
}
这是添加两应用后的完整 workspace 配置。计数器 grants 保留 demo.counter 范围,两个新 grants 允许从生成 shell 的导航执行求和。
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. 实现提供方
wu.define 注册 sumador 生命周期。WU 挂载时提供 context.wu,此 facade 带有该应用身份和权限。处理函数验证输入、计算结果,并显示调用的客户端。
version: '1.0.0' 是操作版本。allow: ['cliente-suma'] 添加身份检查,即使其他应用拥有同一通道 grant,也只有这个客户端能调用。handle 返回移除处理函数的函数,我们在 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. 实现客户端
客户端不导入提供方函数,而是通过 context.wu.request 发送两个数字、等待响应并更新自己的 DOM。它请求与 ^1.0.0 兼容的版本,最多等待 1000 ms。
空字段以 null 发送,便于观察提供方验证拒绝。try/catch 显示错误并避免界面卡住。客户端卸载时移除监听器,取消所有待处理请求。
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. 从 Astro 挂载两个应用
此页面配置自己的 apps 和 grants,先挂载服务,再挂载客户端。按钮可以在不刷新客户端的情况下卸载或挂载提供方,从而验证客户端确实依赖可用 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 定位 shell 运行时以通过 define 注册各应用。RPC 使用 mount facade,携带身份、版本和允许列表。Framework 4 的 wu.handle 也保留这些选项;在微应用内请使用作用域 facade。
6. 构建时保留入口与 manifest
各应用的 index.html 也是 Vite 构建入口。以下两文件指向 src/main.js,替换模板文件,保证开发和生产执行相同提供方与客户端。
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>
以文件提供样式
Astro 通过 inlineStylesheets: 'never' 生成外部 CSS。各微应用从自身 Shadow DOM 加载 styles.css,符合 style-src 'self',无需内联样式或修改服务器 CSP。若已应用计数器示例,保留 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; }
每个应用根目录只有一份源 wu.json,CLI 原生模式直接提供它。Vite 也必须将它放入输出:此插件同时输出 manifest 和 styles.css,无需手动维护额外副本。收集结果时,wu build 保留权限,并将 entry 替换为编译后的 JavaScript。
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. 运行并确认结果
复制文件后,在 workspace 根目录运行:
wu install
npm --prefix shell run build
wu dev --native --port 3000 --no-open
在服务器停止时复制文件后,此次初始构建将 suma.astro 加入 shell/dist/。如果 shell 输出已存在,CLI 1.2.0 启动时可能复用它。wu dev --native 运行后,watcher 会在源文件更改时重新构建 shell,然后重新加载页面;无需每次编辑都停止服务并手动构建。如果编译失败,请查看终端错误。
打开 http://127.0.0.1:3000/suma/,应看到 Servicio de suma、Cliente de suma 以及数字 7 和 8。
| 操作 | 可见结果 |
|---|---|
| 点击 Calcular | 客户端显示 7 + 8 = 15,服务显示已响应 cliente-suma。 |
把第二个数字改为 3 并计算 |
7 + 3 = 10。 |
| 清空第一个数字并计算 | No se pudo calcular: Escribe dos números finitos. |
恢复 7 和 8,点击 Detener servicio 后计算 |
客户端说明服务已停止,以及如何重试。 |
| 点击 Iniciar servicio 并计算 | 再次显示 7 + 8 = 15。 |
返回首页后,导航也会出现新应用。从 cliente-suma 计算前先打开 sumador,因为该导航在访问应用时才挂载。/suma/ 页面自动按此顺序挂载。
验证版本时,临时把 handler 版本改为 2.0.0,刷新 /suma/ 并计算。^1.0.0 请求因不兼容被拒绝,完成后恢复 1.0.0。
8. 检查待发布版本
按 Ctrl+C 停止开发并运行:
wu build
wu serve 4100 --mode static
打开 http://127.0.0.1:4100/suma/index.html,重复求和、移除和重新挂载服务,再刷新该 URL。编译文件应产生相同结果。可发布输出是 workspace 根目录的 dist/。
wu serve 4100 --mode static 解析目录索引,并为不存在的路径返回真实 404。仅在客户端路由需要 shell 回退时使用 --mode spa。
故障排查
| 现象 | 检查重点 |
|---|---|
/suma/ 不出现或显示旧求和示例 |
如果在开发服务停止时复制了页面,请在启动前运行 npm --prefix shell run build。开发期间,请确认 shell 自动重建没有错误。 |
| WU 报告缺少必需权限 | 对照 wu.json 权限与 suma.astro 中对应应用 grant;首页则检查 wu.config.json。 |
| 客户端提示服务停止 | 点击 Iniciar servicio;主导航中先打开 sumador。 |
| 页面可用但样式丢失 | 保留 shell/astro.config.mjs 的 build.inlineStylesheets: 'never',确认 dist/mf-*/ 中存在两份 styles.css,再运行 wu build。 |
| 生产页面又出现 Vanilla JS 计数器 | 将两个 index.html 替换为本示例文件,script 必须加载 /src/main.js,再运行 wu build。 |
| 开发可用,生产失败 | 确认复制了两个 vite.config.js,并确认 dist/mf-sumador/wu.json 和 dist/mf-cliente-suma/wu.json 保留权限且使用生产 entry。 |
| 请求因版本被拒绝 | handler 发布 1.0.0,客户端请求 ^1.0.0,两者必须兼容。 |
继续学习 API
你已在两个权限不同的应用间使用 init、define、mount、unmount、handle 和 request。事件、契约与 RPC解释签名和变体,通信参考列出 API。需要契约发现、可观察取消和更多操作时,继续使用 WU 连接服务。