# Wasmeld Console Web 管理面使用 Solid 和 Tailwind CSS v4。它不是传统单文档 SPA,而是由一个常驻 Host 文档和一个可替换 Page iframe 组成: ```text index.html / src/host ├── 导航、搜索、弹窗、通知 ├── 管理 API 轮询和共享状态 └── page.html?view=&instance= └── src/pages/ ``` 普通页面切换时,Host 先发送 `dispose`,再销毁 iframe 并创建新的文档。页面所属的 Solid reactive owner、事件监听器、第三方 UI 库和页面局部缓存会随文档一起释放,避免 长期导航后把所有页面资源都留在同一个 JavaScript realm 中。 页面 ID、标题、导航图标、保活偏好和懒加载入口统一声明在 `src/pages/registry.ts` 的 `PAGE_DEFINITIONS`。新增页面时创建 `src/pages//index.tsx` 并添加一条注册记录即可,不需要分别维护路由、导航和加载映射。 一级导航页面默认在对应注册记录中设置 `keepAlive: true`。它们数量固定,切换时保留 DOM、JavaScript realm、Solid 状态、表单和滚动位置。未来的详情页、临时编辑页等短期 页面应设置 `keepAlive: false`,离开后立即销毁: - 离开时发送 `deactivate`,页面应暂停轮询、媒体、动画或其它后台工作。 - 返回时发送 `activate`,恢复页面任务,iframe 的 `instance` 和 `timeOrigin` 不变。 - 页面连续隐藏 30 分钟后失效,Host 发送 `dispose` 并销毁 iframe;再次访问会创建新文档。 - 失效时间从 `deactivate` 开始计算,页面在前台显示的时间不计入;到期前返回会重置计时。 - 关闭 Host 或离开非保活页面时同样发送 `dispose`,随后销毁文档。 这里不使用按页面数量淘汰的 LRU。打开另一个一级页面不会造成先前页面被立即释放, 同时长期不再使用的隐藏页面最终仍会归还内存。 Page SDK 通过 `PageProps.lifecycle` 提供框架无关的 `onShow`、`onHide` 和 `onUnload` 事件。它们分别对应 Host 协议内部的 `activate`、`deactivate` 和 `dispose`,重复消息会被过滤;浏览器 `pagehide` 还会作为 unload 兜底。每次订阅都会 返回取消函数。如果懒加载组件订阅时页面已经显示或隐藏,`onShow` 或 `onHide` 会立即 重放当前事件,避免错过首次初始化;通用的 `lifecycle.on(type, listener)` 只监听未来 变化。`PageProps.active` 继续用于 Solid 响应式渲染和 effect,页面自身不应通过 `display: none` 判断状态,因为 Host 可能改变具体的隐藏实现。 ```tsx export default function StreamPage(props: PageProps) { onCleanup(props.lifecycle.onShow(refreshData)); onCleanup(props.lifecycle.onHide(pauseStream)); onCleanup(props.lifecycle.onUnload(closeStream)); createEffect(() => { if (props.active()) { resumeStream(); } else { pauseStream(); } }); // ... } ``` `onShow` 在首次显示和保活后再次显示时触发,适合主动刷新可能已经过期的数据。调用记录 页面使用该事件请求 Host 刷新 Runtime 快照。`onUnload` 最多触发一次;收到该事件后 不得再创建定时器、连接或其它长期资源。浏览器销毁文档时不会等待异步任务,因此 `onUnload` 只应用于同步清理;必须发送的少量遥测数据应使用 `sendBeacon`。旧的 `wasmeld:activate`、 `wasmeld:deactivate`、`wasmeld:dispose` 和 `wasmeld:lifecycle` DOM 事件暂时保留, 新页面应使用类型化 SDK。 iframe 是资源生命周期边界,不是安全边界。Host 和 Page 都是 Wasmeld 自己构建并同源 发布的可信代码;Wasm 服务的安全边界仍然在后端 Wasmtime Sandbox 和 Host Capability Registry 中。 ## 目录 ```text src/host/ 常驻 Host Shell、iframe 隐藏超时管理和 Host-owned dialogs src/components/ feedback/ 空状态等反馈组合 layout/ PageHeading 等页面结构 services/ ServiceTable、StatusBadge 等服务领域组件 ui/ Button、Input、Select、Textarea、Card 等无业务基础组件 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、文档基础规则和全局 keyframes ``` `components/ui` 只能依赖通用样式和基础函数,不得引用 Runtime、Service、WIT 或具体页面 状态。领域组件可以组合 UI 基础组件,但 UI 基础组件不能反向依赖领域目录。 UI 基础组件采用与 shadcn 相同的源码内样式模式: - 组件基础样式和可枚举变体使用 Tailwind class 与 `class-variance-authority` 声明。 - 调用者的 `class` 通过 `cn()` 合并;`clsx` 处理条件值,`tailwind-merge` 解决冲突。 - Button、Input、Select、Textarea、Card、Table 等组件暴露类型化的变体属性,不依赖 `.btn`、`.select` 之类的全局语义 class。 - `src/styles/app.css` 不定义组件层,只保留主题 token、文档基础规则和无法内联的全局 keyframes。 业务特有的结构留在 `feedback`、`layout`、`services` 或页面目录中,并组合 UI primitive。 导航项等只在一个业务上下文出现的控件可以使用就地 Tailwind class,不需要为了形式统一 强行包装成通用组件。 `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 保活集 | 不共享 | 当前 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 状态。