# Wasmeld Console Web

管理面使用 Solid 和 Tailwind CSS v4。它不是传统单文档 SPA，而是由一个常驻 Host
文档和一个可替换 Page iframe 组成：

```text
index.html / src/host
├── 导航、搜索、弹窗、通知
├── 管理 API 轮询和共享状态
└── page.html?view=<view>&instance=<n>
    └── src/pages/<view>
```

普通页面切换时，Host 先发送 `dispose`，再销毁 iframe 并创建新的文档。页面所属的
Solid reactive owner、事件监听器、第三方 UI 库和页面局部缓存会随文档一起释放，避免
长期导航后把所有页面资源都留在同一个 JavaScript realm 中。

需要保留表单、筛选器或昂贵页面状态时，可以在 `src/host/app.tsx` 的 `NAVIGATION`
配置中设置 `keepAlive: true`。当前服务版本和运行设置启用保活。Host 会隐藏而不是销毁
这些 iframe，并保持其 DOM、JavaScript realm 和 Solid 状态：

- 离开时发送 `deactivate`，页面应暂停轮询、媒体、动画或其它后台工作。
- 返回时发送 `activate`，恢复页面任务，iframe 的 `instance` 和 `timeOrigin` 不变。
- LRU 淘汰、关闭 Host 或普通页面离开时发送 `dispose`，随后销毁文档。
- `MAX_KEEP_ALIVE_IFRAMES` 限制保活文档数量；活动的非保活页面最多临时多占一个 iframe。

页面可以使用 `PageProps.active` 响应生命周期，也可以监听
`wasmeld:activate`、`wasmeld:deactivate`、`wasmeld:dispose` 或统一的
`wasmeld:lifecycle` 事件。页面自身不应通过 `display: none` 判断状态，因为 Host
可能改变具体的隐藏实现。

```tsx
export default function StreamPage(props: PageProps) {
  createEffect(() => {
    if (props.active()) {
      resumeStream();
    } else {
      pauseStream();
    }
  });
  onCleanup(closeStream);
  // ...
}
```

iframe 是资源生命周期边界，不是安全边界。Host 和 Page 都是 Wasmeld 自己构建并同源
发布的可信代码；Wasm 服务的安全边界仍然在后端 Wasmtime Sandbox 和 Host Capability
Registry 中。

## 目录

```text
src/host/          常驻 Host Shell、iframe LRU 和 Host-owned dialogs
src/components/ui/ 跨页面复用的无业务 UI
src/lib/           API client、领域模型和纯函数
src/primitives/    Solid reactive primitives；Solid 不使用 React Hooks 约定
src/pages/         每个 iframe 页面一个目录和独立懒加载 chunk
src/sdk/           Host/Page postMessage 协议与两侧 client
src/styles/        Tailwind v4 theme 和共享组件样式
```

`src/sdk/protocol.ts` 中的 `SDK_VERSION` 是文档间协议版本。新增可选消息可以保持原版本；
删除字段、改变字段语义或产生不兼容状态时必须升级版本，并让两侧同时发布。消息接收端
同时检查 origin、source、channel 和 version，不接收任意窗口的控制命令。

Host 会继续向隐藏的保活页面发送只读状态快照，但拒绝其管理命令。这样页面可以在恢复时
立即显示最新 Runtime 状态，同时停用后的定时器不能意外触发注册、启停或 Deployment
操作。

## 浏览器 Tab 状态

同源普通 Tab 和已安装 PWA 窗口通过 `BroadcastChannel` 共享 Host 全局状态。状态按
所有权分为三层：

| 状态                                       | 跨 Tab   | Host 到 Page          | 生命周期        |
| ------------------------------------------ | -------- | --------------------- | --------------- |
| Runtime 快照、连接、API 地址、控制操作状态 | 始终共享 | 分发给已存在的 iframe | Host Tab        |
| 当前路由、搜索、弹窗、toast、iframe LRU    | 不共享   | 当前 Tab 自己管理     | Browser Tab     |
| 页面筛选、表单、滚动位置、页面资源         | 不共享   | 不进入 Host           | iframe document |

`src/host/tab-sync.ts` 使用独立版本的 Host Tab 协议：

- 每个 Host Tab 有随机 `sender` 和单调递增 `sequence`。
- 新 Tab 发送 `hello`，已打开 Tab 立即返回当前 Host 全局状态。
- 所有 Host Tab 都可以发布，不依赖可能失效的 leader。
- 接收方拒绝自身消息、旧序列和字段不合法的消息。
- 应用远端状态时抑制本地广播 effect，避免 Tab 间回声循环。
- 浏览器不支持 `BroadcastChannel` 时退化为单 Tab，不影响页面和 API 操作。

Host 收到本地或远端全局状态后，只遍历当前 Tab 的 FrameCache。活动 iframe 和隐藏的
keep-alive iframe 会收到最新快照；从未打开或已被销毁的 Page 没有同步目标，创建并
发送 `ready` 后才取得当时的最新状态。Page 私有信号永远不会上传到 Host Tab channel。

## 开发

先在仓库根目录启动 Rust 后端：

```bash
cargo +stable run -p wasmeld-console
```

再启动 Web 开发服务器：

```bash
cd crates/wasmeld-console/web
npm ci
npm run dev
```

打开 `http://127.0.0.1:3000`。Vite 开发环境默认连接
`http://127.0.0.1:8080`；设置页可以覆盖该地址。Debug Cargo 构建不会调用 npm，
因此 Rust 与 Web 的增量开发互不阻塞。

常用检查：

```bash
npm run format:check
npm run lint
npm test
npm audit --audit-level=high
```

## 发布与嵌入

Release 构建由 `../build.rs` 自动执行：

```bash
cargo +stable build --release -p wasmeld-console
```

构建脚本使用 `npm ci` 严格读取 `package-lock.json`，将 Vite 输出写入 Cargo
`OUT_DIR`，再由 Rust `include_dir!` 嵌入可执行文件。发布机器必须提供 Node.js 22.13+
和 npm。可以用 `NPM=/absolute/path/to/npm` 指定 npm；只有需要调试嵌入行为时，才在
非 Release 构建中设置 `WASMELD_BUILD_WEB=1`。

Release 管理 Listener 同时服务 `/`、`/page.html`、静态资源、PWA manifest 和
`/sw.js`。资源路径必须精确存在，不做 SPA fallback。带内容哈希的 `assets/` 使用长期
immutable 缓存，其它入口使用 `no-cache`。

## PWA 边界

一个 Host-owned service worker 管理整个应用：

- 构建时预缓存 Host、Page 和带哈希静态资源。
- 文档请求使用 network-first，离线时分别回退到 `index.html` 或 `page.html`。
- `/api/*` 和 `/healthz` 永远走网络，不缓存运行状态或管理操作。
- 新 worker 激活时删除旧的 `wasmeld-*` 缓存。

PWA 只提供安装和静态壳离线能力。没有后端连接时页面会显示离线状态，不会用旧 API
响应伪造 Runtime 状态。
