diff --git a/.gitignore b/.gitignore index 02930dbb82f7..dc3a255277fb 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,4 @@ playground/ .claude AGENTS.md .specify/ +.goal/ \ No newline at end of file diff --git a/docs/agent-runtime/agent-runtime-stage-summary.md b/docs/agent-runtime/agent-runtime-stage-summary.md new file mode 100644 index 000000000000..61305bc0efd8 --- /dev/null +++ b/docs/agent-runtime/agent-runtime-stage-summary.md @@ -0,0 +1,2183 @@ +# OpenRuntime 阶段性总结 + +## 名称 + +建议统一使用 **OpenRuntime**。 + +这里的 Open 不是强调开源,而是强调“应用把自己的 Runtime 开放给 Agent 访问”。应用通过 OpenRuntime 暴露结构化的 State、Action 和 Event,让 Agent 可以读取状态、等待变化、执行声明动作并验证结果。 + +它可以拆成三个层次理解: + +| 名称 | 含义 | +| --- | --- | +| OpenRuntime | 产品能力名称 | +| OpenRuntime SDK | 前端应用接入这套能力的 SDK | +| OpenRuntime API | Agent 实际读取状态、等待状态、执行动作时调用的 API | + +不建议继续使用 “diagnostics”、“inspection spec” 或 “interaction spec” 作为主名称。这些词容易把范围说窄,或者让它听起来像一个协议文档。 + +OpenRuntime 更贴近当前目标:它不是普通调试工具,而是一套让应用向 Agent 开放运行时上下文的能力。 + +## 背景 + +当前 AI coding 在前端开发里经常卡在“实现后验证”这一步。 + +Agent 可以修改代码、启动项目、打开页面,但它对页面运行状态的理解仍然主要依赖外部现象: + +- 页面 UI 是否看起来正常; +- DOM 里有没有某个元素; +- console 有没有报错; +- network 是否安静; +- 点击后页面有没有变化; +- 人是否告诉它“现在不对”。 + +这些方式能提供线索,但不稳定,也很依赖人的中途介入。 + +例如同样是页面没有按预期工作,真实原因可能分别在: + +- route 没有匹配; +- loader 没有完成或 redirect 不符合预期; +- MF remote / expose / shared 加载异常; +- Garfish 子应用没有 mount 成功; +- React root 或 route component 渲染异常; +- 业务数据没有 ready; +- 页面已经 ready,但 Agent 不知道下一步可以执行什么动作。 + +如果 Agent 只能看页面外部现象,就很难稳定进入真正的自动循环。 + +## 目的 + +OpenRuntime 的目标不是做一个新的 WebMCP,也不是做一个通用浏览器自动化工具。 + +它的目标是: + +> 让前端应用向 Agent 暴露结构化页面状态、运行事件、可等待条件和受控操作 API,让 Agent 在 AI coding 过程中可以自主观察页面、操作页面并验证结果,减少人的中途介入。 + +最终希望支持这样的 agent loop: + +```txt +聊清楚目标和方案 + ↓ +Agent 修改代码 + ↓ +Agent 启动页面 + ↓ +Agent 读取页面状态 + ↓ +Agent 执行页面声明过的动作 + ↓ +Agent 验证结果 + ↓ +如果失败,继续修正 + ↓ +输出最终结果 +``` + +也就是说,人的角色尽量收敛到: + +- 开始时确认目标; +- 关键方案需要取舍时参与; +- 最后 review 结果。 + +中间的运行、观察、操作、验证,尽量交给 OpenRuntime 支撑。 + +## 产品形态 + +OpenRuntime 是一套前端运行时 SDK。 + +它由三层组成: + +```txt +采集层 +Modern.js / MF / Garfish / React / 业务代码 + ↓ +核心运行时 +Runtime Center / Snapshot / Events / Ready / Blockers / Actions + ↓ +暴露层 +Window API / Bridge Server / CLI / WebMCP / 平台原生能力 +``` + +### 稳定的是运行时语义 + +OpenRuntime 应该稳定定义这些概念: + +- `snapshot`:页面当前状态; +- `events`:页面历史过程; +- `ready`:某个页面、路由、组件或业务目标是否可用; +- `blockers`:当前为什么还不能认为 ready; +- `actions`:页面声明给 Agent 的安全动作; +- `evidence`:Agent 判断结果成立的证据。 + +这些是 OpenRuntime 的核心价值。 + +### 可替换的是接入原子 + +后续随着浏览器、Agent 平台和各大厂商能力发展,底层接入方式可以替换。 + +例如: + +- 现在可以先用 `window.__OPEN_RUNTIME__` 暴露; +- 内部平台可以通过自己的 bridge 访问; +- 后续 Chrome WebMCP 成熟后,可以新增 WebMCP adapter; +- 移动端容器或平台原生能力也可以提供自己的 adapter。 + +关键原则是: + +> Runtime Center 不关心自己被哪种方式访问。WebMCP、CLI、Bridge Server、Window API 都只是 transport,不是核心协议。 + +### 和 WebMCP 的关系 + +WebMCP 可以作为未来的一个重要出口,但不是 OpenRuntime 的替代品。 + +WebMCP 更像是: + +```txt +Agent 如何发现和调用页面能力 +``` + +OpenRuntime 要解决的是: + +```txt +前端应用如何产出 Agent 需要的页面状态、运行事件、ready 条件、blockers 和 actions +``` + +所以更合理的关系是: + +```txt +Modern.js / MF / Garfish / 业务代码 + ↓ +OpenRuntime + ↓ +WebMCP adapter / CLI / Bridge / Window API + ↓ +Agent +``` + +后续如果 WebMCP 成熟,可以把 OpenRuntime 的能力注册成 WebMCP tools,例如: + +- `openRuntime.getSnapshot` +- `openRuntime.getEvents` +- `openRuntime.getActions` +- `openRuntime.waitFor` +- `openRuntime.runAction` + +这样不是被 WebMCP 替代,而是把 WebMCP 作为标准出口。 + +## 接入和访问方式 + +OpenRuntime 的接入和访问需要分开看: + +```txt +页面如何接入 OpenRuntime +页面外的 Agent 如何访问 OpenRuntime +``` + +页面内接入依赖 SDK。页面外访问依赖 Window API、HTTP Bridge、CLI 或未来的 WebMCP adapter。 + +### 基础 SDK + +第一版只提供一个基础包: + +```txt +@open-runtime/core +``` + +Modern.js、MF 和业务代码都依赖这个包: + +```txt +Modern.js 内置接入 -> @open-runtime/core +MF 内置接入 -> @open-runtime/core +业务手动接入 -> @open-runtime/core +``` + +第一期不单独提供: + +```txt +@open-runtime/modern-js +@open-runtime/module-federation +@open-runtime/react +``` + +原因是: + +- Modern.js 和 MF 可以直接在各自实现里内置 OpenRuntime; +- React tree 暂时不是判断业务 ready 的核心依据; +- 过早拆包会增加理解成本和维护成本。 + +### Window API + +Window API 是最小访问出口: + +```ts +window.__OPEN_RUNTIME__ +``` + +它适合: + +- 本地调试; +- Agent 通过浏览器上下文直接读取; +- 没有 HTTP Bridge 时作为兜底; +- 验证 SDK 是否正常工作。 + +Window API 不负责跨进程通信。页面外的 Agent 如果要稳定访问页面运行态,仍然需要 HTTP Bridge。 + +### HTTP Bridge + +HTTP Bridge 是本地服务,不是 SDK 本体。它负责把页面里的 OpenRuntime 暴露给页面外的 Agent、CLI 或平台。 + +核心链路: + +```txt +页面 OpenRuntime + ↓ WebSocket 主动连接 +本地 HTTP Bridge + ↑ HTTP API +Agent / CLI / 平台 +``` + +页面需要主动连接 Bridge,因为浏览器页面不能自己启动 HTTP 服务,页面外的 Agent 也不能直接请求某个页面实例。 + +第一版 HTTP Bridge 可以提供: + +```txt +GET /runtimes +GET /runtimes/:runtimeId/targets +GET /runtimes/:runtimeId/snapshot +GET /runtimes/:runtimeId/events +GET /runtimes/:runtimeId/actions +GET /runtimes/:runtimeId/actions/:name/options +POST /runtimes/:runtimeId/actions/:name/run +POST /runtimes/:runtimeId/wait-for +GET /runtimes/:runtimeId/events/stream +``` + +其中: + +- `runtimes` 表示当前连接到 Bridge 的页面 Runtime 实例; +- `targets` 表示某个 Runtime 里可以被 Agent 引用或等待的目标; +- `snapshot` 表示页面当前状态; +- `events` 表示页面历史变化; +- `actions` 表示页面声明的可执行动作; +- `options` 表示动态参数选项; +- `run` 表示执行页面声明过的 action; +- `wait-for` 表示等待页面状态变化; +- `events/stream` 表示持续监听事件。 + +页面里的 OpenRuntime 仍然是事实来源。Bridge 可以缓存最近的 snapshot 和 events,但不能成为主状态源。页面断开后,Bridge 可以返回最后一次看到的状态,但必须标记为 disconnected。 + +Bridge 接受页面 Runtime 连接后自动生成 `runtimeId`。`runtimeId` 表示这一次连接实例,不由页面自定义,也不跨刷新复用。 + +```ts +type ConnectedRuntime = { + runtimeId: string; + url: string; + appName?: string; + runtimeVersion: string; + status: 'connected' | 'disconnected'; + connectedAt: number; + lastSeenAt: number; + disconnectedAt?: number; +}; +``` + +`url` 来自页面当前地址。`appName` 可以由框架配置带上,`runtimeVersion` 由 SDK 自动带上。 + +同一个 URL 可能有多个 Runtime 连接,例如同一个页面开了多个 tab、页面刷新时新旧连接短暂共存、或者 Agent 和人同时打开了同一个 URL。因此 Bridge 必须用 `runtimeId` 区分连接实例。 + +### Bridge 开启方式 + +Bridge 连接需要三层控制: + +```txt +构建期注入:决定页面代码里有没有 bridge client +运行期配置:决定这次页面要不要连接 bridge +Bridge 鉴权:决定连上来的页面能不能被接受 +``` + +Modern.js 可以提供这样的配置: + +```ts +export default defineConfig({ + openRuntime: { + enabled: true, + bridge: { + enabled: 'dev', + }, + }, +}); +``` + +`bridge.enabled` 的第一版类型可以是: + +```ts +type BridgeEnabled = boolean | 'dev' | 'manual'; +``` + +含义: + +| 值 | 含义 | +| --- | --- | +| `true` | 总是尝试连接 Bridge | +| `false` | 不连接 Bridge | +| `'dev'` | 仅 dev 环境自动连接 | +| `'manual'` | 注入 bridge client,但只有 query、localStorage 或服务端配置显式打开时才连接 | + +推荐默认策略: + +| 场景 | 策略 | +| --- | --- | +| 本地 dev | `bridge.enabled = 'dev'` | +| staging / oncall | `bridge.enabled = 'manual'` | +| production | `bridge.enabled = false` | + +Bridge Server 还需要校验 origin、app name 和 runtime version。页面允许连接不代表 Bridge 一定接受。token 鉴权后续再扩展,第一版不放进 `connectBridge` 参数。 + +页面侧连接 Bridge 的第一版 API: + +```ts +runtime.connectBridge(options?: BridgeConnectOptions); + +type BridgeConnectOptions = { + port?: number; + autoReconnect?: boolean; +}; +``` + +第一版不提供 `bridgeUrl`、`token`、`name` 或 `disconnectBridge()`。`host` 固定为 `localhost`,`port` 默认可以是 `17321`。prefix、host 和鉴权能力后续再扩展。 + +`autoReconnect` 默认是 `true`。连接断开后自动尝试重连,退避间隔为: + +```txt +1s -> 2s -> 4s -> 8s -> 10s -> 10s ... +``` + +页面卸载时停止重连。页面关闭、刷新或连接断开后,Bridge 能通过连接断开事件感知到这个 Runtime 已断开,并把它标记为 `disconnected`。Bridge 可以保留最后一次 snapshot 和 events 一小段时间,例如 60s,之后再清理。页面重新连接时分配新的 `runtimeId`,不复用旧连接的 `runtimeId`。 + +### 构建插件 + +通用 Rspack / Webpack 构建插件应该和 HTTP Bridge 一起提供基础版本,但它不是 Modern.js 用户的主入口。 + +更合理的定位是: + +```txt +@open-runtime/core +Modern.js 内置接入 +通用 Rspack / Webpack 构建插件 +``` + +Modern.js 用户不应该手动配置 `OpenRuntimeWebpackPlugin` 或 `OpenRuntimeRspackPlugin`。Modern.js 只暴露框架配置,内部复用通用注入逻辑。 + +通用构建插件主要服务两个场景: + +- 非 Modern.js 项目接入 OpenRuntime; +- Modern.js 内部复用同一套初始化和 bridge client 注入逻辑。 + +### CLI + +CLI 主要配合 HTTP Bridge 使用。 + +没有 HTTP Bridge 时,CLI 只能做辅助能力: + +- 启动 Bridge; +- 检查 Bridge 状态; +- 打印接入配置; +- 打开页面; +- 读取项目里的静态配置。 + +它不能稳定完成核心运行态能力: + +- 读取页面 runtime 状态; +- 获取 actions; +- 执行 action; +- 等待 ready; +- 读取 runtime events。 + +因此第一版 CLI 的定位是: + +```txt +CLI = Bridge Server 管理器 + Bridge HTTP API 客户端 + 接入辅助工具 +``` + +CLI 可以提供: + +```bash +open-runtime bridge start --port 17321 +open-runtime bridge status +open-runtime runtimes +open-runtime targets [runtime selector] [query options] +open-runtime snapshot [runtime selector] [query options] +open-runtime events [runtime selector] [query options] +open-runtime actions [runtime selector] [query options] +open-runtime input-options [runtime selector] --action --input +open-runtime run-action [runtime selector] +open-runtime wait-for [runtime selector] +``` + +这里的 runtime selector 用于选择 Bridge 里已经连接的页面 Runtime 实例: + +```bash +--url +--runtime +``` + +`--url` 是日常主入口。CLI 会用它匹配 Bridge 当前连接的 Runtime URL;如果同一个 URL 匹配到多个 Runtime,默认选择 `lastSeenAt` 最新的。`--runtime` 是精确兜底参数,值来自: + +```bash +open-runtime runtimes +``` + +如果没有传 `--url` 或 `--runtime`,CLI 默认使用最新活跃的 Runtime,并在输出中显示本次选中的 url 和 runtime id。如果 Bridge 当前没有连接 Runtime,则命令失败。如果需要避免误选,可以显式传 `--url` 或 `--runtime`。 + +CLI 命令和 Runtime API 的对应关系: + +| CLI | 参数 | 对应 API | +| --- | --- | --- | +| `open-runtime runtimes` | 无 | Bridge 的 runtime 列表,不对应页面 Runtime API | +| `open-runtime targets` | `--type`、`--source`、`--id`、`--status`、`--query` | `getTargets(query)` | +| `open-runtime snapshot` | `--id`、`--type`、`--source`、`--status`、`--query` | `getSnapshot(query)` | +| `open-runtime events` | `--since`、`--target-id`、`--action`、`--type`、`--source`、`--status`、`--limit` | `getEvents(query)` | +| `open-runtime actions` | `--name`、`--source`、`--risk`、`--enabled`、`--query` | `getActions(query)` | +| `open-runtime input-options` | `--action`、`--input`、`--payload`、`--timeout` | `getInputOptions(actionName, inputName, currentPayload)` | +| `open-runtime run-action` | ``、`--payload` | `runAction(actionName, payload)` | +| `open-runtime wait-for` | ``、``、`--timeout` | `waitFor({ id, status }, { timeout })` | + +示例: + +```bash +open-runtime targets --url http://localhost:8080/route-a --type route + +open-runtime snapshot --url http://localhost:8080/route-a --id route:/route-a + +open-runtime events --url http://localhost:8080/route-a \ + --since 42 \ + --target-id route:/route-a \ + --limit 100 + +open-runtime actions --url http://localhost:8080/route-a \ + --enabled true \ + --risk safe + +open-runtime input-options --url http://localhost:8080/route-a \ + --action selectRegion \ + --input city \ + --payload '{"province":"zhejiang"}' \ + --timeout 5000 + +open-runtime run-action --url http://localhost:8080/route-a selectRegion \ + --payload '{"province":"zhejiang","city":"hangzhou"}' + +open-runtime wait-for --url http://localhost:8080/route-a route:/route-a ready \ + --timeout 10000 +``` + +### Agent 使用方式 + +Agent 真正知道如何使用 OpenRuntime,主要依赖 Skill 或平台工具说明。 + +推荐链路: + +```txt +Agent Skill + ↓ +CLI 或 HTTP API + ↓ +HTTP Bridge + ↓ +页面 OpenRuntime +``` + +Skill 负责告诉 Agent: + +- 优先检查是否存在 OpenRuntime; +- 如何连接 Bridge; +- 如何读取 snapshot、events 和 actions; +- 如何执行 action; +- 如何等待目标状态; +- 如何用结果验证问题; +- 没有 OpenRuntime 时再 fallback 到 UI、console、network。 + +这部分的职责边界是: + +```txt +OpenRuntime SDK:页面提供能力 +Window API:页面内最小出口 +HTTP Bridge:页面外访问通道 +CLI:Bridge 的命令行客户端和辅助工具 +Skill:教 Agent 在任务里使用这些能力 +``` + +## 核心设计 + +### 页面级 Runtime Center + +一个页面内应该有一个主 Runtime Center,负责聚合: + +- 当前 snapshot; +- 历史 events; +- ready 状态; +- blockers; +- actions; +- errors。 + +不建议第一期做多个 Runtime Center 的自动合并。多中心会带来 snapshot 合并、事件排序、action 冲突、ready 归并等复杂问题。 + +### 多来源 Runtime Client + +写入方可以有多个: + +- Modern.js Runtime Client; +- MF Runtime Client; +- Garfish Runtime Client; +- 业务 Runtime Client; +- 其他框架或自定义 runtime 的 Runtime Client。 + +这些 client 可以来自不同包、不同版本,但最终写入同一个页面级 Runtime Center。 + +### Snapshot 和 Events 的边界 + +`snapshot` 只表示当前状态,不保存完整历史。 + +例如页面从 `/` 跳转到 `/home` 后,再调用 `getSnapshot()`,应该看到 `/home` 的当前状态。旧的 `/` pending 状态不应该继续留在 snapshot 主体里。 + +历史过程通过 `getEvents()` 查询。 + +简单说: + +```txt +snapshot = 现在是什么状态 +events = 之前发生过什么 +``` + +因此第一版不建议把 Event History 合进 Snapshot。 + +Snapshot 是 Agent 的默认入口,用来快速判断当前页面是否 ready、是否 blocked、是否 error,以及当前有哪些可执行 action。它应该尽量干净,只保留当前仍然有效的状态,避免把已经过去的 route、loader、action 或 error 混入当前结论。 + +Event History 是辅助入口,用来解释 Snapshot 背后的过程。它的价值不是让 Agent 多看一份日志,而是支持这些场景: + +- 等待变化:用 event 替代固定 sleep,例如等待 route ready、loader success、component ready 或 action error; +- 解释原因:当 Snapshot 显示 blocked 或 error 时,通过相关 events 判断是 route、loader、remote、component、action 还是业务 ready 卡住; +- 提供证据:Agent 输出结论时,可以引用关键 events 说明“执行了什么动作、随后发生了什么、最终停在哪个状态”; +- 增量读取:Snapshot 带 `latestEventId`,Agent 下一次只读取这个 id 之后的新 events,避免重复扫描完整历史。 + +Agent 推荐读取顺序: + +```txt +先读 snapshot + ↓ +如果 ready,直接用 snapshot 作为结论证据 + ↓ +如果 blocked / error / pending,再围绕 blocker 或目标 id 读取相关 events + ↓ +如需操作,读取 actions 并执行声明过的 action + ↓ +执行后等待目标状态,再读取新的 snapshot 和增量 events +``` + +为了控制噪音,Event History 第一版只记录关键运行态变化,例如 action start / success / error、route change、loader start / success / redirect / error、remote / expose / shared 状态变化、component ready / error、business ready / blocked。它不应该默认收集完整 console、network、DOM mutation 或所有内部调试日志。 + +`waitFor` 超时或失败时,可以返回当前 blockers 和少量相关 events 摘要,但不应该把完整 Event History 直接塞进 Snapshot。 + +## 内部数据结构 + +OpenRuntime 内部先收敛为四类核心数据: + +```txt +Target Registry +Snapshot +Event Log +Action Registry +``` + +其中 Target Registry 保存页面里可以被 Agent 引用或等待的目标,Snapshot 用 target id 映射表达页面当前状态,Event Log 记录状态变化过程,Action Registry 保存页面声明给 Agent 的可调用能力。 + +### Snapshot + +Snapshot 表示页面当前仍然有效的运行态,不保存完整历史。 + +```ts +type RuntimeSnapshot = { + targets: Record; + latestEventId: number; + capturedAt: number; +}; +``` + +第一版复用同一套对象类型和状态类型: + +```ts +type RuntimeObjectType = + | 'app' + | 'route' + | 'loader' + | 'component' + | 'form' + | 'field' + | 'remote' + | 'expose' + | 'shared' + | 'sub-app' + | 'business' + | 'custom'; + +type RuntimeStatus = + | 'idle' + | 'loading' + | 'pending' + | 'success' + | 'ready' + | 'blocked' + | 'error' + | 'inactive'; +``` + +`targets` 表示当前页面里仍然有效的运行时对象,key 是 target id。`updateSnapshot` 按 `id` upsert 当前 target,不追加历史状态。 + +```ts +type RuntimeSnapshotTarget = { + id: string; + type: RuntimeObjectType; + status: RuntimeStatus; + source?: string; + description?: string; + data?: unknown; + error?: RuntimeError; + updatedAt: number; + + dependsOn?: string[]; + contains?: string[]; + loads?: string[]; + renders?: string[]; + provides?: string[]; + shares?: string[]; + customRelations?: RuntimeInlineRelation[]; +}; +``` + +`type` 表示对象是什么,例如 route、loader、remote、业务 ready。`status` 表示它当前处于什么状态。 + +常见关系直接作为 target 上的字段表达,方向统一是“当前 target 指向相关 target”: + +| type | 含义 | +| --- | --- | +| `contains` | 当前 target 包含哪些 target,例如 app contains route | +| `dependsOn` | 当前 target 依赖哪些 target,例如 route dependsOn loader | +| `loads` | 当前 target 加载哪些 target,例如 route loads MF remote | +| `renders` | 当前 target 渲染哪些 target,例如 expose renders component | +| `provides` | 当前 target 提供哪些 target,例如 remote provides expose | +| `shares` | 当前 target 共享或使用哪些 target,例如 MF shares react | +| `customRelations` | 业务自定义关系 | + +`loads` 的方向是当前 target 加载了谁,不是当前 target 被谁加载。例如 `route:/home` 的 `loads: ['remote:cloudConsoleProvider']` 表示 `/home` 路由加载了这个 remote。 + +业务自定义关系放到 `customRelations`,例如: + +```ts +type RuntimeInlineRelation = { + type: 'custom'; + relation: string; + to: string; + description?: string; + data?: unknown; +}; +``` + +不建议第一版直接允许任意 string relation type。稳定的内置关系字段更利于 Agent 推理,业务扩展通过 `customRelations` 表达。 + +### Target Registry + +Target Registry 表示页面里有哪些对象可以被 Agent 引用或等待。它回答的是“有什么可以等”,不是“现在是什么状态”。当前状态仍然由 Snapshot 表达。 + +写入方通过 `registerTarget` 提前注册 target: + +```ts +type RegisterTargetInput = { + id: string; + type: RuntimeObjectType; + source: string; + label?: string; + description?: string; + statuses?: RuntimeStatus[]; + params?: RuntimeTargetParam[]; + matcher?: RuntimeTargetMatcher; + data?: unknown; +}; + +type RuntimeTargetParam = { + name: string; + type: 'string' | 'number' | 'boolean'; + required?: boolean; + description?: string; +}; + +type RuntimeTargetMatcher = { + type: 'exact' | 'path-pattern' | 'custom'; + pattern?: string; +}; +``` + +必填字段只有 `id`、`type` 和 `source`。如果没有传 `statuses`,Runtime Center 按 `type` 自动补默认值。 + +第一版默认状态表: + +| type | 默认 statuses | +| --- | --- | +| `app` | `loading` / `ready` / `blocked` / `error` / `inactive` | +| `route` | `loading` / `ready` / `blocked` / `error` / `inactive` | +| `component` | `loading` / `ready` / `blocked` / `error` / `inactive` | +| `sub-app` | `idle` / `loading` / `ready` / `blocked` / `error` / `inactive` | +| `business` | `pending` / `ready` / `blocked` / `error` / `inactive` | +| `loader` | `idle` / `loading` / `pending` / `success` / `blocked` / `error` / `inactive` | +| `remote` | `idle` / `loading` / `success` / `blocked` / `error` / `inactive` | +| `expose` | `idle` / `loading` / `success` / `blocked` / `error` / `inactive` | +| `shared` | `idle` / `success` / `blocked` / `error` / `inactive` | +| `form` | `idle` / `pending` / `success` / `blocked` / `error` / `inactive` | +| `field` | `idle` / `pending` / `success` / `blocked` / `error` / `inactive` | +| `custom` | 默认允许全部 `RuntimeStatus`,接入方建议显式传 `statuses` | + +`ready` 只推荐用于能表达“现在可以继续使用”的对象,例如 `app`、`route`、`component`、`sub-app` 和 `business`。`loader`、`remote`、`expose`、`shared` 这类过程或依赖对象应该使用 `success` 表示完成,不建议注册 `ready`。 + +例如 Modern.js 在路由表生成后,可以提前注册动态路由 target: + +```ts +runtime.registerTarget({ + id: 'route-pattern:/users/:id', + type: 'route', + source: 'modern-js', + label: '/users/:id', + params: [{ name: 'id', type: 'string', required: true }], + matcher: { + type: 'path-pattern', + pattern: '/users/:id', + }, +}); +``` + +当页面实际进入 `/users/123` 后,再通过 `updateSnapshot` 更新当前状态: + +```ts +runtime.updateSnapshot({ + id: 'route:/users/123', + type: 'route', + source: 'modern-js', + status: 'ready', + data: { + matchedTarget: 'route-pattern:/users/:id', + params: { id: '123' }, + }, +}); +``` + +`unregisterTarget(targetId)` 用于目标不再可被引用或等待的场景,例如动态卸载的子应用、销毁的业务区域或失效的自定义 target。框架静态路由、固定 remote、固定 shared 这类稳定 target 通常不需要注销,只需要通过 Snapshot 更新为 `inactive`、`blocked` 或 `error`。 + +如果 `updateSnapshot` 更新了一个未注册过的 id,Runtime Center 可以临时创建 inferred target,避免状态丢失;但开发环境应该给出 warning,提醒框架或业务在更早时机注册。 + +### Event Log + +Event 记录 Snapshot 的变化过程。 + +```ts +type RuntimeEvent = { + id: number; + type: string; + source: string; + timestamp: number; + targetId?: string; + actionName?: string; + status?: RuntimeStatus; + relation?: RuntimeRelationEvent; + data?: unknown; + error?: RuntimeError; +}; + +type RuntimeRelationEvent = + | RuntimeBuiltInRelationEvent + | RuntimeCustomRelationEvent; + +type RuntimeBuiltInRelationEvent = { + from: string; + to: string; + field: + | 'dependsOn' + | 'contains' + | 'loads' + | 'renders' + | 'provides' + | 'shares'; +}; + +type RuntimeCustomRelationEvent = { + from: string; + type: 'custom'; + relation: string; + to: string; + description?: string; + data?: unknown; +}; +``` + +例如状态变化: + +```ts +{ + id: 12, + type: 'snapshot.updated', + source: 'modern-js', + targetId: 'loader:home', + status: 'success', +} +``` + +例如关系变化: + +```ts +{ + id: 18, + type: 'relation.updated', + source: 'module-federation', + relation: { + from: 'route:/home', + to: 'remote:cloudConsoleProvider', + field: 'loads', + }, +} +``` + +### Action Registry + +Action 是页面声明给 Agent 的可调用能力。Agent 不应该随便操作 DOM,而是调用页面声明过的 action。 + +第一版 action 先采用纯 action 声明,不做 DOM 自动识别、UI steps 或通用表单自动填写。 + +```ts +type RegisterActionInput = { + name: string; + description?: string; + source?: string; + risk?: RuntimeActionRisk; + availableWhen?: RuntimeCondition | RuntimeCondition[]; + inputSchema?: RuntimeJsonSchema; + getInputOptions?: RuntimeInputOptionsProvider; + handler: RuntimeActionHandler; +}; + +type RuntimeActionRisk = + | 'safe' + | 'state-changing' + | 'destructive' + | 'sensitive'; +``` + +`name` 和 `handler` 是核心。`name` 是 Agent 调用 action 时使用的公开名称,例如 `route-a.click-submit`。Runtime 内部如需稳定 id,可以自己生成,不要求使用方传入。 + +`source` 不是必填字段。Runtime Client 可以自动补默认 source,例如业务侧默认 `business`,Modern.js / MF / Garfish adapter 使用自己的 source。 + +`description` 用于解释 action 的用途,不再额外提供 `label`。`risk` 用于表达动作风险,默认值是 `state-changing`,不能默认当成 `safe`。`safe` 只用于确认不会改变持久状态或敏感状态的动作。 + +`availableWhen` 表示 action 什么时候可以执行。第一版不单独提供 `blockedBy`,因为它和 `availableWhen` 容易重复。`getActions()` 返回给 Agent 时,可以根据 `availableWhen` 和当前 Snapshot 计算 `enabled` 和 `reason`。 + +`inputSchema` 描述 action 的输入结构。第一版使用可序列化的 JSON Schema 子集,不直接依赖 zod。业务如果希望用 zod,可以通过 helper 转成 JSON Schema 后注册。 + +```ts +type RuntimeJsonSchema = { + type: 'object'; + properties?: Record; + required?: string[]; + additionalProperties?: boolean; +}; + +type RuntimeJsonSchemaProperty = { + type: 'string' | 'number' | 'boolean' | 'array' | 'object'; + description?: string; + enum?: Array; + items?: RuntimeJsonSchemaProperty; + properties?: Record; + required?: string[]; + additionalProperties?: boolean; +}; +``` + +`getInputOptions` 是注册在 action 上的动态选项 provider,用于普通 select、checkbox group、省市区级联、权限相关选项等场景。它和 Agent 读取侧的 `runtime.getInputOptions(actionName, inputName, currentPayload?)` 不是同一个类型;读取侧 API 会先通过 `actionName` 找到 action,再调用这里注册的 provider。 + +```ts +type RuntimeInputOptionsProvider = ( + inputName: string, + currentPayload?: Record, + context?: RuntimeActionContext, +) => Promise | RuntimeInputOption[]; + +type RuntimeInputOption = { + value: string | number | boolean; + description?: string; +}; +``` + +`value` 是真正传给 action 的值。`description` 是给 Agent 或人看的解释。第一版不提供 `label`、`disabled` 或 `reason`。业务侧只返回当前可用选项,CLI 可以用 `String(value)` 作为短展示。 + +`availableWhen` 使用 `RuntimeCondition` 表达执行前置条件: + +```ts +type RuntimeCondition = { + id: string; + status: RuntimeStatus; +}; +``` + +`handler` 只在页面内执行,不暴露给 Agent。第一版不要求 handler 返回明确结果;正常执行完成即视为 action success,throw 则记录 action error。`runAction` 只等待 handler 执行完成,不自动更新 Snapshot,也不自动等待 Snapshot 状态变化。业务状态变化必须由 handler 或对应框架 / adapter 调用 `updateSnapshot`。Agent 如果需要确认后置状态,应显式继续调用 `waitFor`。 + +```ts +type RuntimeActionHandler = ( + payload: unknown, + context: RuntimeActionContext, +) => Promise | unknown; + +type RuntimeActionContext = { + actionName: string; + getSnapshot: () => RuntimeSnapshot; + updateSnapshot: (input: UpdateSnapshotInput) => void; + waitFor: ( + condition: RuntimeCondition, + options?: RuntimeWaitOptions, + ) => Promise; +}; + +type RuntimeWaitOptions = { + timeout?: number; +}; + +type RuntimeWaitResult = { + success: boolean; + condition: RuntimeCondition; + snapshot: RuntimeSnapshot; + target?: RuntimeSnapshotTarget; + reason?: string; +}; +``` + +示例: + +```ts +registerAction({ + name: 'selectRegion', + description: 'Select province, city and street.', + risk: 'state-changing', + availableWhen: { + id: 'business:region-form', + status: 'ready', + }, + inputSchema: { + type: 'object', + properties: { + province: { type: 'string', description: 'Province value.' }, + city: { type: 'string', description: 'City value.' }, + street: { type: 'string', description: 'Street value.' }, + }, + required: ['province', 'city', 'street'], + additionalProperties: false, + }, + getInputOptions: async (inputName, payload) => { + if (inputName === 'province') { + return getProvinces(); + } + if (inputName === 'city') { + return getCities(payload?.province); + } + if (inputName === 'street') { + return getStreets(payload?.city); + } + return []; + }, + handler: async (payload, ctx) => { + await selectRegion(payload); + ctx.updateSnapshot({ + id: 'business:region-selection', + type: 'business', + status: 'ready', + }); + }, +}); +``` + +### 第一版公开 API + +公开 API 分成写入侧和 Agent 读取 / 执行侧。 + +写入侧只保留少量 API: + +```ts +runtime.registerTarget(target); +runtime.unregisterTarget(targetId); +runtime.updateSnapshot(input); +runtime.registerAction(action); +runtime.unregisterAction(actionName); +runtime.runAction(actionName, payload?); +``` + +其中: + +- `registerTarget` 提前注册页面里可以被 Agent 引用或等待的目标; +- `unregisterTarget` 注销不再可引用或等待的目标; +- `updateSnapshot` 更新当前页面状态; +- `registerAction` 注册页面声明给 Agent 的可调用能力; +- `unregisterAction` 通过 action `name` 注销 action; +- `runAction` 执行 action,既可以给 Agent 用,也可以给本地自测用。 + +Action 对外统一使用 `name` 作为稳定标识。`runAction(actionName)`、`getInputOptions(actionName, inputName, currentPayload?)` 和 `unregisterAction(actionName)` 都使用同一个 action `name`。如果 Runtime 内部需要生成 id,只作为内部实现细节,不暴露给使用方或 Agent。 + +`runAction` 的第一版形态保持简单: + +```ts +runAction(actionName: string, payload?: Record); +``` + +`payload` 必须符合 action 注册时的 `inputSchema`。如果 `payload` 不符合 `inputSchema`,或者 `availableWhen` 不满足,Runtime 直接返回失败,不调用 handler。 + +`runAction` 不会自动更新 Snapshot。它只会自动记录 `action.started`、`action.success` 或 `action.error`。如果 action 执行后业务状态发生变化,需要 handler 调用 `ctx.updateSnapshot(...)`,或者由框架 / adapter 自动捕获并调用 `updateSnapshot`。 + +`runAction` 不提供 `expect` 或 `waitForExpect`。如果 Agent 要验证执行后的页面状态,应拆成两步: + +```ts +await runtime.runAction('selectRegion', { + province: 'zhejiang', + city: 'hangzhou', + street: 'xihu', +}); + +await runtime.waitFor({ + id: 'business:region-selection', + status: 'ready', +}); +``` + +公开 API 不提供 `emit`。事件由 Runtime Center 自动记录: + +- 调用 `updateSnapshot` 后自动记录 snapshot 变更事件; +- 调用 `registerTarget` 后自动记录 `target.registered`; +- 调用 `unregisterTarget` 后自动记录 `target.unregistered`; +- 调用 `registerAction` 后自动记录 `action.registered`; +- 调用 `unregisterAction` 后自动记录 `action.unregistered`; +- 调用 `runAction` 后自动记录 `action.started`、`action.success` 或 `action.error`。 + +这样使用方不需要同时维护 snapshot 和 events,避免双写不一致。 + +`registerTarget` 的第一版形态: + +```ts +type RegisterTargetInput = { + id: string; + type: RuntimeObjectType; + source: string; + label?: string; + description?: string; + statuses?: RuntimeStatus[]; + params?: RuntimeTargetParam[]; + matcher?: RuntimeTargetMatcher; + data?: unknown; +}; +``` + +`id`、`type` 和 `source` 是必填字段。`statuses` 不传时,Runtime Center 根据 `type` 使用默认状态表补齐。 + +`updateSnapshot` 的第一版形态: + +```ts +type UpdateSnapshotInput = { + id: string; + status: RuntimeStatus; + type?: RuntimeObjectType; + source?: string; + description?: string; + data?: unknown; + error?: RuntimeError; + dependsOn?: string[]; + contains?: string[]; + loads?: string[]; + renders?: string[]; + provides?: string[]; + shares?: string[]; + customRelations?: RuntimeInlineRelation[]; +}; +``` + +内置关系直接写在 `updateSnapshot` 顶层,例如 `dependsOn`、`loads`、`provides`。业务自定义关系写到 `customRelations`。 + +必填字段只有 `id` 和 `status`。`type` 默认是 `business`,`source` 默认是 `business`。因此业务用户可以直接写: + +```ts +runtime.updateSnapshot({ + id: 'user-profile', + status: 'ready', +}); +``` + +框架和 MF 这类接入方建议显式传 `type` 和 `source`: + +```ts +runtime.updateSnapshot({ + id: 'route:/home', + type: 'route', + source: 'modern-js', + status: 'ready', +}); +``` + +`updateSnapshot` 必须符合 target 声明的状态范围: + +- `updateSnapshot` 按 `id` upsert `snapshot.targets[id]`。同一个 `id` 再次更新时只替换当前状态和相关字段,不追加历史 item;历史变化由 events 记录; +- 如果 `id` 已经通过 `registerTarget` 注册,`status` 必须在该 target 的 `statuses` 中; +- 如果 `id` 还没有注册,Runtime Center 可以临时创建 inferred target,并使用 `type` 对应的默认 statuses 校验; +- 如果 `status` 不在允许范围内,本次更新被拒绝,Snapshot 不变; +- 被拒绝的更新需要记录 `snapshot.update.rejected` event,说明 `id`、`type`、`status` 和拒绝原因; +- 开发环境可以额外输出 console warning; +- 第一版不引入 strict mode,也不因为校验失败 throw,避免影响页面正常运行。 + +例如 `remote` 默认不支持 `ready`。下面的更新应该被拒绝: + +```ts +runtime.updateSnapshot({ + id: 'remote:cloudConsoleProvider', + type: 'remote', + source: 'module-federation', + status: 'ready', +}); +``` + +正确写法是: + +```ts +runtime.updateSnapshot({ + id: 'remote:cloudConsoleProvider', + type: 'remote', + source: 'module-federation', + status: 'success', +}); +``` + +`getSnapshot` 返回当前状态,使用 `targets` 对象表达当前 target 状态和关系: + +```ts +type RuntimeSnapshot = { + targets: Record; + latestEventId: number; + capturedAt: number; +}; +``` + +其中: + +```ts +type RuntimeSnapshotTarget = { + id: string; + type: RuntimeObjectType; + status: RuntimeStatus; + source?: string; + description?: string; + data?: unknown; + error?: RuntimeError; + updatedAt: number; + + dependsOn?: string[]; + contains?: string[]; + loads?: string[]; + renders?: string[]; + provides?: string[]; + shares?: string[]; + customRelations?: RuntimeInlineRelation[]; +}; +``` + +`getSnapshot` 的第一版形态: + +```ts +type GetSnapshotQuery = { + id?: string | string[]; + type?: RuntimeObjectType | RuntimeObjectType[]; + source?: string | string[]; + status?: RuntimeStatus | RuntimeStatus[]; + query?: string; +}; + +getSnapshot(query?: GetSnapshotQuery): RuntimeSnapshot; +``` + +不传参数时,`getSnapshot()` 返回当前页面完整 Snapshot。传入 `query` 时,只过滤当前 Snapshot 里已经存在的 target。 + +如果某个 target 只是通过 `registerTarget` 声明过,但还没有任何当前状态,它不会出现在 `snapshot.targets` 里。要发现“有哪些 target 可以被引用或等待”,应该使用 `getTargets()`。 + +`GetSnapshotQuery.query` 是轻量文本查询,第一版只匹配 target 的 `id` 和 `description`,不匹配 `data`,避免把业务数据纳入搜索。 + +`latestEventId` 表示这份 Snapshot 截止到哪个 event,方便 Agent 后续通过 `getEvents({ since: latestEventId })` 读取增量事件。`capturedAt` 表示这份 Snapshot 生成的时间,Bridge 返回断开页面的最后状态时也可以让 Agent 判断这份状态是否过期。 + +Agent 读取 / 执行侧 API: + +```ts +getTargets(query?); +getSnapshot(query?); +getEvents(query?); +getActions(); +getInputOptions(actionName, inputName, currentPayload?); +runAction(actionName, payload?); +waitFor(condition, options?); +``` + +其中: + +- `getTargets` 读取页面声明过、可以被 Agent 引用或等待的目标; +- `getSnapshot` 读取当前状态,可以按 query 过滤; +- `getEvents` 读取历史变化; +- `getActions` 读取页面声明过的动作; +- `getInputOptions` 读取动态参数选项; +- `runAction` 执行动作; +- `waitFor` 等待目标状态。 + +`getTargets` 的第一版形态: + +```ts +type GetTargetsQuery = { + type?: RuntimeObjectType | RuntimeObjectType[]; + source?: string | string[]; + id?: string | string[]; + status?: RuntimeStatus | RuntimeStatus[]; + query?: string; +}; + +type RuntimeTargetDescriptor = { + id: string; + type: RuntimeObjectType; + source: string; + label?: string; + description?: string; + statuses: RuntimeStatus[]; + params?: RuntimeTargetParam[]; + matcher?: RuntimeTargetMatcher; + data?: unknown; + inferred?: boolean; + registeredAt: number; + updatedAt: number; +}; +``` + +`getTargets(query?)` 返回的是 Target Registry 里的可发现目标,不是当前状态。它告诉 Agent “有哪些 target 可以被引用或等待”。当前状态仍然通过 `getSnapshot(query?)` 或 `waitFor()` 判断。 + +`GetTargetsQuery` 是可序列化查询条件,不是 JS 函数。这样 CLI、HTTP Bridge 和 Agent 都可以稳定传参,例如: + +```bash +open-runtime targets --type route --query users +``` + +对应: + +```ts +runtime.getTargets({ type: 'route', query: 'users' }); +``` + +`query` 字段是轻量文本查询,第一版只匹配 target 的 `id`、`label` 和 `description`: + +- 大小写不敏感; +- 使用 `includes` 匹配; +- 不做正则、分词、拼音或模糊评分; +- 不匹配 `data`,避免把业务数据也纳入搜索。 + +`getEvents` 的第一版形态: + +```ts +type GetEventsQuery = { + since?: number; + targetId?: string | string[]; + actionName?: string | string[]; + type?: string | string[]; + source?: string | string[]; + status?: RuntimeStatus | RuntimeStatus[]; + limit?: number; +}; + +type GetEventsResult = { + events: RuntimeEvent[]; + latestEventId: number; + truncated: boolean; +}; + +getEvents(query?: GetEventsQuery): GetEventsResult; +``` + +`since` 表示只读取 `id > since` 的事件。`targetId` 会匹配普通事件里的 `targetId`,也会匹配关系事件里的 `from` 和 `to`。 + +默认按事件发生顺序返回。如果没有传 `since`,默认只取最近一批事件,第一版默认 `limit` 可以是 100,避免 Agent 一次拿到太多历史。`truncated: true` 表示结果被截断,Agent 应该缩小查询条件或继续增量读取。 + +`latestEventId` 表示当前 Event Log 最新 event id。即使本次查询没有返回事件,也可以用它作为下一次增量读取的起点。 + +`getEvents` 不做全文搜索,也不匹配 `data`。它只用于读取关键运行态变化,避免把业务数据、调试日志或底层噪音混进 Agent 默认上下文。 + +示例: + +```ts +const snapshot = await runtime.getSnapshot(); + +await runtime.runAction('selectRegion', { + province: 'zhejiang', + city: 'hangzhou', + street: 'xihu', +}); + +await runtime.waitFor({ + id: 'business:region-selection', + status: 'ready', +}); + +const result = await runtime.getEvents({ + since: snapshot.latestEventId, + targetId: 'business:region-selection', +}); +``` + +`getActions` 的第一版形态: + +```ts +type GetActionsQuery = { + name?: string | string[]; + source?: string | string[]; + risk?: RuntimeActionRisk | RuntimeActionRisk[]; + enabled?: boolean; + query?: string; +}; + +type RuntimeActionDescriptor = { + name: string; + description?: string; + source: string; + risk: RuntimeActionRisk; + availableWhen?: RuntimeCondition | RuntimeCondition[]; + inputSchema?: RuntimeJsonSchema; + hasInputOptions: boolean; + enabled: boolean; + reason?: string; + registeredAt: number; + updatedAt: number; +}; + +getActions(query?: GetActionsQuery): RuntimeActionDescriptor[]; +``` + +`getActions` 返回给 Agent 的是 action 描述,不包含 `handler`,也不包含 `getInputOptions` 函数。Agent 如果需要读取动态选项,应继续调用 `getInputOptions(actionName, inputName, currentPayload?)`。 + +`risk` 没有显式注册时返回默认值 `state-changing`。`source` 没有显式注册时返回 Runtime Client 补齐后的默认 source。 + +`enabled` 根据当前 Snapshot 和 `availableWhen` 计算。`reason` 只在 `enabled: false` 时返回,用于说明为什么当前不能执行。 + +`GetActionsQuery.query` 第一版只匹配 action 的 `name` 和 `description`,不匹配业务数据。 + +示例: + +```ts +const actions = await runtime.getActions({ + enabled: true, + risk: 'safe', +}); +``` + +返回示例: + +```ts +[ + { + name: 'retryOrderDetail', + description: 'Retry order detail request.', + source: 'business', + risk: 'safe', + hasInputOptions: false, + enabled: true, + registeredAt: 1710000000000, + updatedAt: 1710000000000, + }, +]; +``` + +`getInputOptions` 的第一版形态: + +```ts +getInputOptions( + actionName: string, + inputName: string, + currentPayload?: Record, +): Promise | RuntimeInputOption[]; +``` + +`actionName` 是 action 的 `name`。`inputName` 是 `inputSchema.properties` 里的字段名。`currentPayload` 表示 Agent 当前已经填好的参数,主要用于级联选项,例如先选 province,再读取 city。 + +如果 action 没有注册 `getInputOptions`,返回空数组。如果 `inputName` 不在 `inputSchema.properties` 里,也返回空数组,并可以记录 warning 或 rejected event。 + +`getInputOptions` 只返回当前可用选项,不返回不可选项,也不返回禁用原因。业务侧负责决定哪些选项当前可用。 + +`getInputOptions` 支持异步 provider。HTTP Bridge 和 CLI 调用时必须等待 provider 完成后再返回最终 options。CLI 默认可以给这次等待设置 5s 超时;超时后返回失败,不返回半成品。CLI 可以通过 `--timeout` 覆盖这次等待时间。 + +示例: + +```ts +const cities = await runtime.getInputOptions( + 'selectRegion', + 'city', + { province: 'zhejiang' }, +); +``` + +返回示例: + +```ts +[ + { + value: 'hangzhou', + description: 'Hangzhou city.', + }, + { + value: 'ningbo', + description: 'Ningbo city.', + }, +]; +``` + +`waitFor` 的第一版形态: + +```ts +waitFor( + condition: RuntimeCondition, + options?: RuntimeWaitOptions, +): Promise; + +type RuntimeWaitOptions = { + timeout?: number; +}; + +type RuntimeWaitResult = { + success: boolean; + condition: RuntimeCondition; + snapshot: RuntimeSnapshot; + target?: RuntimeSnapshotTarget; + reason?: string; +}; +``` + +`waitFor` 只等待某个 target 到达某个状态,不返回 events,也不解释完整过程。Agent 如果需要失败原因,应在 `waitFor` 失败后继续调用 `getEvents`。 + +实现规则: + +- 调用时先检查当前 Snapshot。如果 `snapshot.targets[id]?.status === status`,立即返回成功; +- 如果 target 当前不在 Snapshot,但已经存在于 Target Registry,说明它只是还没进入当前状态,可以继续等待; +- 如果 target 在 Snapshot 和 Target Registry 里都不存在,直接返回失败,避免 target id 写错后一直等到超时; +- Runtime Center 维护等待任务列表,每个任务记录 `id`、`status`、`timeout` 和对应的 resolve; +- 每次 `updateSnapshot` 后,只检查等待这个 target 的任务;状态匹配时返回成功; +- 超时不 throw,返回 `success: false`,并带上当前 Snapshot 和失败原因; +- 如果 target 被 `unregisterTarget` 注销,相关等待任务直接返回失败。 + +示例: + +```ts +const result = await runtime.waitFor( + { id: 'route:/route-a', status: 'ready' }, + { timeout: 10000 }, +); + +if (!result.success) { + const events = await runtime.getEvents({ + targetId: 'route:/route-a', + }); +} +``` + +第一版不做: + +- DOM 自动识别; +- UI steps; +- 通用表单自动填写; +- workflow engine; +- 多 Runtime Center 聚合; +- 从 DOM 自动推断 `inputSchema` 或输入参数。 + +## 如何使用 + +### 框架接入方 + +Modern.js 这类框架应该自动写入自己能确定的信息: + +- app root 生命周期; +- route location; +- route match; +- basename; +- navigation; +- loader start / success / redirect / error; +- SSR 初始状态; +- hydration 状态; +- route component mounted / error。 + +框架还应该在真实 navigation、loader 和组件挂载开始前,提前注册自己已经知道的 target。例如 Modern.js 在路由表生成并完成 `modifyRoutes` 后,可以注册 route target、loader target 和 route component target;后续运行时再通过 `updateSnapshot` 更新这些 target 的当前状态。 + +框架负责提供框架层 ready,但不负责猜业务是否成功。 + +### MF 接入方 + +MF 应该自动写入模块加载相关信息: + +- consumer name / role; +- instance name; +- remote name; +- manifest / remoteEntry; +- expose; +- shared dependency; +- uniqueName; +- build id; +- runtime error。 + +MF Adapter 应该在 MF instance 初始化和 remotes 配置可用后,提前注册 consumer、remote、expose 和 shared target;随后把 manifest、remoteEntry、expose、shared 的加载过程更新到 snapshot 和 events。 + +MF 负责说明 remote / expose / shared 是否正常,不判断业务组件是否真正 ready。 + +MF 自身状态优先复用 MF Observability Plugin。OpenRuntime 不重复实现一套 MF 加载追踪,而是在 MF 侧提供一个 OpenRuntime MF Adapter: + +```txt +MF Observability Plugin + ↓ +OpenRuntime MF Adapter + ↓ +OpenRuntime +``` + +这个 Adapter 负责把 MF hooks 和 Observability report 转成 OpenRuntime 的 snapshot target 状态、target 关系字段和 events。 + +第一版接入方式分三类: + +- 使用 MF 构建插件时,由构建插件自动注入 OpenRuntime MF Adapter,并自动标注当前应用是哪个 MF consumer; +- 使用 MF runtime API 时,用户注册 `openRuntimeMFPlugin()`,由 runtime plugin 从 MF instance 里读取 consumer、remotes、shared 和加载过程; +- 非标准封装场景,提供 `registerMFConsumer()` helper,让用户显式声明当前 consumer。 + +`registerMFConsumer()` 不是新的底层核心 API,只是 MF Adapter 的便捷入口。它内部仍然写入 OpenRuntime snapshot。 + +### 业务接入方 + +业务只补充框架无法判断的信息。 + +业务不需要直接手写复杂 target。业务侧可以通过 `useAgentReady`、`AgentReady` 或更轻量的 helper 自动注册 `business` target,并在业务状态变化时更新 snapshot。只有特殊业务对象才需要直接调用 `registerTarget`。 + +例如业务用户可以用默认 `business` 类型直接声明状态: + +```ts +runtime.updateSnapshot({ + id: 'order-detail-page', + status: Boolean(order) ? 'ready' : 'pending', +}); +``` + +也可以注册安全动作: + +```ts +runtime.registerAction({ + name: 'retryOrderLoader', + description: 'Retry order loader.', + risk: 'safe', + handler: () => refetch(), +}); +``` + +业务不应该需要理解 Runtime Center 内部结构。 + +### 三类接入方视角 + +下面示例说明不同接入方如何使用第一版 API。具体实现时还需要继续补充类型细节和边界,但函数职责先按本节收敛。 + +#### Modern.js:自动注册框架运行状态 + +Modern.js 负责把框架自己已经知道的信息写入 OpenRuntime。业务用户不应该为了基础路由状态手动埋点。 + +Modern.js runtime 初始化时创建或获取页面级 Runtime Center: + +```ts +const runtime = getOrCreateOpenRuntime({ + app: appConfig.name, + framework: 'modern-js', +}); +``` + +应用启动时注册 app root 状态: + +```ts +runtime.updateSnapshot({ + id: `app:${appConfig.name}`, + type: 'app', + source: 'modern-js', + status: 'ready', + data: { + framework: 'modern-js', + }, +}); +``` + +路由跳转时写入当前 route 状态: + +```ts +router.subscribe(state => { + runtime.updateSnapshot({ + id: `route:${state.location.pathname}`, + type: 'route', + source: 'modern-js', + status: state.navigation.state === 'idle' ? 'ready' : 'loading', + data: { + location: state.location.pathname, + matches: state.matches.map(match => match.route.id), + }, + }); +}); +``` + +loader 由框架包装已有 loader,不自动创建新的 loader: + +```ts +function wrapLoader(routeId: string, loader: LoaderFunction) { + return async args => { + runtime.updateSnapshot({ + id: `loader:${routeId}`, + type: 'loader', + source: 'modern-js', + status: 'loading', + }); + + try { + const result = await loader(args); + + runtime.updateSnapshot({ + id: `loader:${routeId}`, + type: 'loader', + source: 'modern-js', + status: 'success', + data: isRedirect(result) ? { redirect: true } : undefined, + }); + + return result; + } catch (error) { + runtime.updateSnapshot({ + id: `loader:${routeId}`, + type: 'loader', + source: 'modern-js', + status: 'error', + error, + }); + throw error; + } + }; +} +``` + +route component 挂载时,框架可以记录组件生命周期: + +```tsx +function AgentRouteBoundary({ routeId, children }) { + useEffect(() => { + runtime.updateSnapshot({ + id: `component:route:${routeId}`, + type: 'component', + source: 'modern-js', + status: 'ready', + data: { + routeId, + }, + }); + + return () => { + runtime.updateSnapshot({ + id: `component:route:${routeId}`, + type: 'component', + source: 'modern-js', + status: 'inactive', + }); + }; + }, [routeId]); + + return children; +} +``` + +Modern.js 产出的结果是:Agent 可以直接知道当前 route、matched routes、navigation、loader、root mounted、hydration 等框架状态。 + +#### MF:自动注册模块加载状态 + +MF 负责把模块联邦运行时已经知道的信息写入 OpenRuntime。这里不要求业务用户直接调用 `updateSnapshot`,而是通过 MF Adapter 自动转换。 + +构建插件接入时,消费者身份可以从 MF 配置自动获得: + +```ts +pluginModuleFederation({ + name: 'federation_consumer', + remotes: { + cloudConsoleProvider: + 'cloudConsoleProvider@http://localhost:4351/mf-manifest.json', + }, + runtimePlugins: [ + openRuntimeMFPlugin(), + observabilityPlugin(), + ], +}); +``` + +MF Adapter 在初始化阶段写入当前消费者: + +```ts +runtime.updateSnapshot({ + id: 'app:federation_consumer', + type: 'app', + source: 'module-federation', + status: 'ready', + data: { + role: 'consumer', + name: 'federation_consumer', + integration: 'build-plugin', + }, +}); +``` + +并把 consumer 和 remote 建立关系: + +```ts +runtime.updateSnapshot({ + id: 'app:federation_consumer', + type: 'app', + source: 'module-federation', + status: 'ready', + loads: ['remote:cloudConsoleProvider'], +}); +``` + +如果用户直接使用 MF runtime API,则注册 runtime plugin: + +```ts +createInstance({ + name: 'federation_consumer', + remotes: [ + { + name: 'cloudConsoleProvider', + entry: 'http://localhost:4351/mf-manifest.json', + }, + ], + plugins: [ + observabilityPlugin(), + openRuntimeMFPlugin(), + ], +}); +``` + +特殊封装场景下,可以使用 helper 显式声明消费者: + +```ts +registerMFConsumer({ + name: 'federation_consumer', + remotes: [ + { + name: 'cloudConsoleProvider', + entry: 'http://localhost:4351/mf-manifest.json', + }, + ], +}); +``` + +MF runtime 初始化时也可以更新当前 consumer instance 的细节: + +```ts +runtime.updateSnapshot({ + id: `app:${instance.name}`, + type: 'app', + source: 'module-federation', + status: 'ready', + data: { + role: 'consumer', + version: instance.version, + buildId: instance.buildId, + }, +}); +``` + +remote 开始加载时写入状态: + +```ts +runtime.updateSnapshot({ + id: 'remote:cloudConsoleProvider', + type: 'remote', + source: 'module-federation', + status: 'loading', + data: { + manifestUrl: 'http://localhost:4351/mf-manifest.json', + }, +}); +``` + +remote 加载成功后写入 snapshot: + +```ts +runtime.updateSnapshot({ + id: 'remote:cloudConsoleProvider', + type: 'remote', + source: 'module-federation', + status: 'success', + data: { + manifestUrl, + remoteEntryUrl, + exposes: ['./RemotePanel'], + }, + provides: ['expose:cloudConsoleProvider/RemotePanel'], +}); +``` + +expose 解析失败时写入错误: + +```ts +runtime.updateSnapshot({ + id: 'expose:cloudConsoleProvider/RemotePanel', + type: 'expose', + source: 'module-federation', + status: 'error', + error, +}); +``` + +shared 依赖解析时记录来源: + +```ts +runtime.updateSnapshot({ + id: 'shared:react', + type: 'shared', + source: 'module-federation', + status: 'success', + data: { + version: '19.2.6', + from: 'host', + singleton: true, + }, +}); +``` + +Observability report 到 OpenRuntime 的转换规则可以先按下面收敛: + +| MF 信息 | OpenRuntime 表达 | +| --- | --- | +| consumer / instance | consumer 写成 `type: 'app'`,instance 细节写入 `data` | +| remote loading / success / pending | `type: 'remote'` + 对应 `status` | +| remote failed | `type: 'remote'` + `status: 'error'` | +| remote recovered | `type: 'remote'` + `status: 'success'`,并在 `data` 里记录 recovered | +| expose loaded / failed | `type: 'expose'` + `status: 'success'` 或 `status: 'error'`,remote 通过 `provides` 字段关联 expose | +| shared provider / selected version / available versions | `type: 'shared'`,consumer 或 remote 通过 `shares` 字段关联 shared | +| manifest / remoteEntry / assets | 写入 `data` | +| loading trace events | 自动进入 `events` | +| loadedBefore / reused | 写入 `data`,用于判断是否复用了其他 consumer 的加载结果 | + +MF 产出的结果是:Agent 可以知道当前应用是哪一个 consumer、它加载了哪些生产者、remote 是否加载、expose 是否解析、shared 依赖从哪里来、是否存在 React 多实例或 shared 冲突。 + +#### Modern.js 应用结合 MF:描述当前页面加载了哪些生产者 + +Modern.js 应用中,route 状态和 MF 状态需要组合起来看。 + +例如当前页面是 `/home`,它加载了 `cloudConsoleProvider/RemotePanel`: + +```ts +runtime.updateSnapshot({ + id: 'route:/home', + type: 'route', + source: 'modern-js', + status: 'ready', + data: { + matches: ['root', 'home'], + }, + dependsOn: ['loader:home'], + loads: ['remote:cloudConsoleProvider'], + contains: ['business:cloud-console.dashboard'], +}); +``` + +这样 Agent 读 snapshot 时,不只知道“当前路由是 `/home`”,还知道: + +- 当前 route 依赖哪个 MF 生产者; +- 使用了哪个 expose; +- remote / expose / shared 是否成功; +- 如果页面没 ready,问题是在 route、loader、MF 加载,还是业务 ready。 + +一个更接近 Agent 读取结果的 snapshot 可以是: + +```json +{ + "targets": { + "route:/home": { + "id": "route:/home", + "type": "route", + "status": "ready", + "dependsOn": ["loader:home"], + "loads": ["remote:cloudConsoleProvider"], + "contains": ["business:cloud-console.dashboard"] + }, + "loader:home": { + "id": "loader:home", + "type": "loader", + "status": "success" + }, + "remote:cloudConsoleProvider": { + "id": "remote:cloudConsoleProvider", + "type": "remote", + "status": "success", + "provides": ["expose:cloudConsoleProvider/RemotePanel"] + }, + "business:cloud-console.dashboard": { + "id": "business:cloud-console.dashboard", + "type": "business", + "status": "ready" + } + }, + "latestEventId": 42, + "capturedAt": 1710000000000 +} +``` + +#### 用户:标记业务组件和声明安全动作 + +用户只需要在框架无法自动判断的地方补充信息。 + +例如业务组件加载完关键数据后,声明状态: + +```tsx +export function OrderDetailPage() { + const { data, loading, error } = useOrderDetail(); + + runtime.updateSnapshot({ + id: 'order-detail-page', + status: error ? 'error' : !loading && data ? 'ready' : 'pending', + data: { + orderId: data?.id, + }, + error, + }); + + if (loading) { + return ; + } + + if (error) { + return ; + } + + return ; +} +``` + +用户也可以声明 Agent 能安全执行的动作: + +```tsx +export function OrderDetailPage() { + const { refetch } = useOrderDetail(); + + runtime.registerAction({ + name: 'retryOrderDetail', + description: 'Retry order detail request.', + risk: 'safe', + handler: () => refetch(), + }); + + return ; +} +``` + +这样 Agent 不需要猜页面上哪个按钮可以点,而是先读取 actions: + +```ts +const actions = await openRuntime.getActions(); +``` + +然后执行页面声明过的安全动作: + +```ts +await openRuntime.runAction('retryOrderDetail'); +``` + +执行后再读取 snapshot 和 events 验证结果: + +```ts +const snapshot = await openRuntime.getSnapshot(); +const events = await openRuntime.getEvents(); +``` + +用户侧的目标不是“多写埋点”,而是只在业务成功标准和安全动作上补充框架无法自动知道的信息。 + +### Agent 使用方 + +Agent 打开页面后,优先读取 OpenRuntime: + +```ts +const snapshot = await openRuntime.getSnapshot(); +const events = await openRuntime.getEvents(); +const actions = await openRuntime.getActions(); +``` + +如果页面声明了安全动作,Agent 可以执行: + +```ts +await openRuntime.runAction('cloud-console.run-client-fallback'); +``` + +执行后再读取: + +```ts +const nextSnapshot = await openRuntime.getSnapshot(); +const nextEvents = await openRuntime.getEvents({ since: snapshot.latestEventId }); +``` + +Agent 应该用前后状态和事件作为验证证据,而不是只说“页面看起来正常”。 + +### 第一期验证方式 + +第一期可以先用 `cloud-console` 这类 demo 验证: + +1. 页面初始停在 `/`; +2. OpenRuntime snapshot 显示 route pending、loader pending、business pending; +3. Agent 读取 actions,发现可执行 fallback action; +4. Agent 执行 action; +5. 页面进入 `/home`; +6. snapshot 更新为 route success、loader success、business ready; +7. events 记录完整变化过程。 + +这能验证 OpenRuntime 是否真的帮助 Agent 完成: + +```txt +观察 → 操作 → 验证 +``` + +## 阶段规划 + +整体规划收敛为三期。 + +### 第一期:OpenRuntime 最小可用闭环 + +目标: + +> Agent 能在 demo 和本地开发场景里,通过 OpenRuntime 读取状态、执行动作、等待结果,并输出有证据的判断。 + +第一期需要提供: + +| 方向 | 能力 | +| --- | --- | +| Core SDK | `connectBridge`、`updateSnapshot`、`registerAction`、`unregisterAction`、`runAction`、`waitFor`、`getInputOptions`、`getSnapshot`、`getEvents`、`getActions` | +| Window API | `window.__OPEN_RUNTIME__` | +| HTTP Bridge | 页面主动连接 Bridge;提供 runtimes、targets、snapshot、events、actions、run-action、wait-for | +| CLI | 启动 Bridge、查看 runtimes / targets、读取 snapshot / events / actions、执行 action、等待状态 | +| Skill | 告诉 Agent 优先读取 OpenRuntime,没有 runtime 时再 fallback 到 UI、console、network | +| 构建插件 | 提供 Rspack / Webpack 基础版本,用于注入 runtime 初始化和 bridge client;Modern.js 内部可以复用 | +| Modern.js | app/root、route location、basename、match、navigation、redirect、loader、route component、hydration、render error | +| MF | 基于 Observability Plugin 记录 consumer、remote、expose、shared、manifest、remoteEntry、uniqueName、build id、chunk / asset trace、loadedBefore / reused | +| Goofy | app、env、release、commit、branch、artifact、asset base、sourcemap 是否存在 | + +第一期对应 demo 能解决的问题: + +| 问题类型 | 对应 demo | OpenRuntime 帮助 | +| --- | --- | --- | +| 路由路径或 basename 错误 | `account-flow` | 直接看到 location、basename、matched routes、route component 状态 | +| Router 嵌套或渲染异常 | `workspace-shell` | 看到 route 已匹配,但 route component 或 render error 异常 | +| loader / redirect 状态不清楚 | `cloud-console` | 看到 loader start / success / error / redirect,以及最终 route 状态 | +| MF shared 冲突 | `order-adapter` | 看到 React shared 的 provider、version、singleton、来源 | +| MF remote / expose 加载失败 | `creative-hub` | 看到 consumer、remote、manifest、expose、shared 的加载状态 | +| async chunk / uniqueName / 资源复用问题 | `rivendell-workbench` | 看到 chunk / asset trace、uniqueName、build id、loadedBefore / reused | +| Garfish 子应用 mount 失败 | `creative-hub`、`rivendell-workbench` | 看到 sub-app load / mount / error 状态,并能区分 MF、Garfish 和业务 ready | +| 部署版本错配 | MF / chunk 类 demo | 通过 Goofy 看到 release、commit、artifact、asset base,并关联 consumer / producer 版本 | + +第一期完成后,Agent 至少能把问题归因到: + +```txt +route / loader / render / MF remote / MF shared / Garfish mount / chunk asset / deployment version +``` + +### 第二期:编译产物到源码定位 + +目标: + +> Agent 只看到线上或构建后页面,也能结合 Goofy、sourcemap 和 MF 信息,定位到源码文件和行号。 + +第二期需要打通: + +- 根据页面 URL / asset URL 找到 Goofy release; +- 根据 chunk 找到 artifact; +- 根据 artifact 找到 sourcemap; +- 根据 sourcemap 还原源码位置; +- 结合 MF 信息判断问题属于 consumer 还是 producer; +- 结合 expose / shared / chunk trace 定位具体模块。 + +第二期希望 Agent 输出完整证据链: + +```txt +哪个应用 +哪个 release +哪个 chunk +哪个 sourcemap +哪个源码文件 +哪一行代码 +为什么判断是这里 +``` + +这期主要解决: + +- 编译后代码报错,但不知道源码在哪里; +- MF producer 报错,但 consumer 页面只看到 runtime error; +- chunk 404、shared 冲突、expose error 无法快速关联源码; +- oncall 里需要人手动查发布、产物、sourcemap、源码行号。 + +### 第三期:生产可用和平台化 + +目标: + +> 让这套能力能安全地用于 staging / oncall / 平台任务,而不只是本地 demo。 + +第三期需要补齐: + +- Bridge 登录态场景打磨; +- token / origin / app / runtime version 校验; +- action 权限控制; +- sensitive / destructive action 默认禁止; +- staging / oncall 手动开启模式; +- 多 target 管理; +- 平台模板集成; +- 标准评测集; +- 效果数据统计; +- WebMCP adapter 或平台原生工具出口。 + +这期主要解决: + +- 能不能在真实登录态页面用; +- 能不能放心让 Agent 操作; +- 能不能在内部平台批量跑; +- 能不能长期评估效果; +- 能不能接未来标准出口。 + +三期关系可以概括为: + +| 阶段 | 重点 | +| --- | --- | +| 第一期 | 最小可用闭环:Core API + Modern.js + MF + Goofy 基础上下文 + Bridge + CLI + Skill + 构建插件 | +| 第二期 | 源码定位:Goofy + sourcemap + MF 信息定位到源码文件和行号 | +| 第三期 | 生产可用:登录态、权限、安全、平台化、评测、标准出口 | + +## 边界 + +OpenRuntime 不做: + +- 不做 WebMCP 的竞争协议; +- 不做通用浏览器自动化框架; +- 不做任意 DOM 操作系统; +- 不做生产监控或 APM; +- 不自动猜业务成功标准; +- 不让 Agent 执行未声明的危险动作; +- 第一期不做跨 tab、跨 iframe、跨 worker、多 Runtime Center 聚合; +- 不要求所有生产者强依赖同一个 SDK 包版本。 + +OpenRuntime 要做的是: + +> 让前端应用具备对 Agent 友好的运行时能力,使 Agent 能够读取状态、等待变化、执行声明动作并验证结果。 + +## 当前结论 + +阶段性结论: + +- 名称使用 **OpenRuntime**。 +- 它是一套让应用向 Agent 开放 State、Action 和 Event 的前端运行时能力。 +- 核心目标是减少 AI coding 过程中的人工介入,支撑真正的 agent loop。 +- 稳定的是页面状态、事件、ready、blockers、actions 这些运行时语义。 +- 可替换的是 WebMCP、CLI、Bridge、Window API、平台原生能力等暴露方式。 +- Modern.js 和 MF 是首批内置接入方。 +- 业务只需要补充框架无法自动判断的 ready 和 action。 diff --git a/docs/agent-runtime/api-design.md b/docs/agent-runtime/api-design.md new file mode 100644 index 000000000000..9c7a2e9d25af --- /dev/null +++ b/docs/agent-runtime/api-design.md @@ -0,0 +1,562 @@ +# Agent Runtime API Design + +## 目标 + +这份文档总结第一批 oncall case 里需要补的页面运行态能力,以及对应应该准备的 demo。 + +现有 MF / Module Federation 观测插件已经能覆盖很多加载层问题,例如 remote、manifest、shared、snapshot、remoteEntry、runtime error。这里要补的是另一层:当加载层看起来成功后,页面、路由、loader、React 组件、Garfish 子应用和业务流程到底有没有真正 ready。 + +## 设计原则 + +### 不做一堆 `getXXStatus` + +不要把能力拆成很多碎片接口,例如: + +```ts +getRouteStatus(); +getLoaderStatus(); +getComponentStatus(); +getGarfishStatus(); +``` + +更推荐做成一个统一状态模型: + +```ts +getSnapshot(); +getEvents(filter?); +subscribeEvents(filter, listener); +waitForEvent(filter, options?); +getActions(filter?); +runAction(actionId, payload?); +``` + +其中: + +- `snapshot` 表示当前页面状态。 +- `events` 表示历史过程。 +- `actions` 表示当前可以执行的动作。 + +一句话:snapshot 是结果,event 是过程,action 是命令。 + +### 事件和动作必须分开 + +不要设计成: + +```ts +getEvents(selector).call('event'); +``` + +事件只能用来观察“发生过什么”,不能用来执行页面动作。执行动作必须走 `runAction`,这样 Agent 才能清楚区分“读状态”和“改页面”。 + +## 核心 API + +### `getSnapshot(filter?)` + +读取当前页面的完整结构化状态。 + +```ts +const snapshot = await agentRuntime.getSnapshot(); +``` + +建议返回结构: + +```ts +type RuntimeSnapshot = { + page: PageState; + route?: RouteState; + loaders?: LoaderState[]; + components?: ComponentState[]; + remotes?: RemoteState[]; + shared?: SharedState[]; + garfish?: GarfishState; + build?: BuildRuntimeInfo; + actions?: ActionDescriptor[]; + errors?: RuntimeError[]; +}; +``` + +第一版可以不用一次性填满所有字段,但结构要先稳定下来。 + +### `getEvents(filter?)` + +读取页面运行过程中已经发生的事件。 + +```ts +const events = await agentRuntime.getEvents({ + type: ['route.started', 'component.ready'], + since: startTime, +}); +``` + +事件用于回答: + +- 哪一步开始了? +- 哪一步完成了? +- 哪一步报错了? +- 最后停在哪个事件? + +### `subscribeEvents(filter, listener)` + +持续监听页面事件。 + +```ts +const unsubscribe = agentRuntime.subscribeEvents( + { target: 'route:/features' }, + event => { + console.log(event); + }, +); +``` + +监听主要给 Agent 做稳定等待,不是给人看的主入口。它可以替代固定 sleep,避免 Agent 一直等或误判成功。 + +### `waitForEvent(filter, options?)` + +等待某个明确事件出现。 + +```ts +await agentRuntime.waitForEvent( + { type: 'component.ready', target: 'page:features' }, + { timeout: 10_000 }, +); +``` + +适合验证跳转、loader、组件挂载、业务 ready。 + +### `getActions(filter?)` + +读取当前页面可执行动作。 + +```ts +const actions = await agentRuntime.getActions({ + kind: 'navigation', +}); +``` + +返回示例: + +```ts +[ + { + id: 'go-to-features', + label: 'Go to features', + kind: 'navigation', + enabled: true, + reason: null, + }, +]; +``` + +### `runAction(actionId, payload?)` + +执行页面声明过的动作。 + +```ts +await agentRuntime.runAction('go-to-features'); +``` + +动作执行后,Agent 应该再读取 `getSnapshot()` 或等待 `waitForEvent()` 来判断结果。 + +第一版可以先只支持 demo 内动作,不急着做通用点击所有 DOM。 + +## 状态模型 + +### 通用 phase + +route、loader、remote、Garfish、业务流程都可以用同一套 phase: + +```ts +type Phase = + | 'idle' + | 'started' + | 'pending' + | 'loading' + | 'success' + | 'error' + | 'blocked' + | 'unknown'; +``` + +每个状态对象建议都有: + +```ts +type BaseState = { + id: string; + phase: Phase; + startedAt?: number; + updatedAt: number; + duration?: number; + reason?: string; + error?: RuntimeError; +}; +``` + +### React component lifecycle + +React 组件不能只用 `success` 表示。组件需要单独生命周期: + +```ts +type ComponentLifecycle = + | 'created' + | 'rendering' + | 'mounted' + | 'ready' + | 'error' + | 'unmounted'; +``` + +必须区分: + +- `remote.loaded`:MF / MF 层加载成功。 +- `component.mounted`:React 组件真的挂载。 +- `component.ready`:业务声明首屏或关键数据 ready。 +- `component.error`:React 渲染阶段报错。 + +## 需要提供的能力 + +### 1. 页面状态 + +用于判断页面是不是 ready,是否卡住,以及卡住原因。 + +建议字段: + +```ts +type PageState = BaseState & { + url: string; + title?: string; + ready: boolean; + pendingReason?: string; + degraded?: boolean; +}; +``` + +对应场景: + +- redirect 卡住 +- loader 卡住 +- 页面一直 loading +- Agent 一直等待 + +### 2. 路由状态 + +用于判断当前路径、basename、match、redirect、导航是否正常。 + +建议字段: + +```ts +type RouteState = BaseState & { + location: string; + basename?: string; + matched?: boolean; + matchedRouteIds?: string[]; + redirecting?: boolean; + redirectTarget?: string; + navigationType?: 'push' | 'replace' | 'pop' | 'unknown'; +}; +``` + +对应场景: + +- `useNavigate` 首次生效后续跳转空白 +- nested Router +- Router inside Router +- basename mismatch +- redirect 卡住 + +### 3. Loader 状态 + +用于判断 loader 是否执行、执行到哪一步、是否 redirect、是否 pending。 + +建议字段: + +```ts +type LoaderState = BaseState & { + routeId?: string; + name?: string; + redirectTarget?: string; + pendingReason?: string; +}; +``` + +对应场景: + +- `page.loader.ts` 没执行 +- SSR redirect 没发生 +- loader 还在 pending +- Agent 不知道页面为什么一直等待 + +### 4. 组件状态和组件树 + +用于判断 React 组件是否真的挂载、是否 ready、是否报错。 + +建议字段: + +```ts +type ComponentState = { + id: string; + name?: string; + framework: 'react' | 'vue' | 'unknown'; + ownerRemote?: string; + routeId?: string; + lifecycle: ComponentLifecycle; + mountedAt?: number; + readyAt?: number; + error?: RuntimeError; +}; +``` + +对应场景: + +- remote 加载成功但页面白屏 +- nested Router +- React Context Provider warning +- Invalid hook call +- 组件已 mounted 但业务未 ready + +第一版可以先做关键组件注册,不必完整复刻 React DevTools。 + +### 5. MF 组件树关联 + +用于把 React 组件和 MF remote 关联起来。 + +建议字段: + +```ts +type RemoteComponentRelation = { + remote: string; + expose?: string; + componentId?: string; + lifecycle: ComponentLifecycle; +}; +``` + +对应场景: + +- 查询当前 React tree,并结合 MF 组件判断树是否正常 +- 区分 remote loaded 和 remote component ready + +### 6. 入口和 provider 状态 + +用于判断入口文件是否加载、导出的 provider 是否存在。 + +建议字段: + +```ts +type EntryState = BaseState & { + entryUrl?: string; + loaded: boolean; + exports?: string[]; + providerFound?: boolean; +}; +``` + +对应场景: + +- Garfish provider undefined 白屏 +- 提供出去的入口文件未加载 +- provider 导出识别失败 + +### 7. Garfish 子应用状态 + +Garfish 场景需要单独暴露子应用加载状态。 + +建议字段: + +```ts +type GarfishState = { + apps: Array<{ + name: string; + phase: Phase; + entry?: string; + providerLoaded?: boolean; + mounted?: boolean; + error?: RuntimeError; + }>; +}; +``` + +对应场景: + +- Garfish 子应用是否加载 +- provider 是否 ready +- 子应用是否 mounted + +### 8. 构建运行信息 + +用于读取页面当前使用的关键构建信息。 + +建议字段: + +```ts +type BuildRuntimeInfo = { + uniqueName?: string; + chunkLoadingGlobal?: string; + publicPath?: string; + runtimeChunk?: string; + entryUrl?: string; +}; +``` + +对应场景: + +- async chunk 404 +- async chunk 变成 `undefined.js` +- 多套产物 runtime 没隔离 +- 需要读取 `chunkLoadingGlobal` / `uniqueName` + +### 9. Shared 实际来源 + +用于判断 shared 看起来一致时,实际运行是不是同一份 React。 + +建议字段: + +```ts +type SharedState = { + name: string; + version?: string; + scope?: string; + owner?: string; + singleton?: boolean; + instanceId?: string; + usedBy?: string[]; + source?: DependencySource; +}; +``` + +对应场景: + +- shared 配了 React,但仍然 Invalid hook call +- shared 层显示一致,但组件库自己又打包 React +- 需要判断消费者和生产者最终是不是同一个 React 实例 + +### 10. 依赖来源追踪 + +用于定位某个依赖是从哪里进入产物的。 + +建议字段: + +```ts +type DependencySource = { + packageName: string; + version?: string; + bundled?: boolean; + issuer?: string; + sourceFile?: string; + packagePath?: string; +}; +``` + +对应场景: + +- 第三方组件库把 React 打包进产物 +- 需要查询编译前源码文件地址 +- 需要证明 React 来源不是宿主 shared + +### 11. Proxy SDK 状态 + +用于 Agent 验证 proxy 改造是否生效。 + +建议字段: + +```ts +type ProxyState = { + enabled: boolean; + rules: Array<{ + from: string; + to: string; + matched: boolean; + }>; + lastMatchedRule?: string; +}; +``` + +对应场景: + +- proxy sdk + 状态获取 +- 改造子应用后直接验证 +- 判断是否仍然加载线上代码 + +### 12. Agent 动作能力 + +用于让 Agent 自己执行验证流程。 + +动作能力不要依赖脆弱 DOM selector,应该由页面或框架声明。 + +建议字段: + +```ts +type ActionDescriptor = { + id: string; + label: string; + kind: 'navigation' | 'click' | 'input' | 'retry' | 'custom'; + enabled: boolean; + reason?: string | null; + payloadSchema?: unknown; +}; +``` + +对应场景: + +- useNavigate 跳转验证 +- 点击后观察 route / component 是否 ready +- Agent 自行完成复现流程 + +## Demo 关联 + +Demo 的具体测试信息放在 [Agent Runtime MF Demos](../../tests/integration/agent-runtime-mf/README.md)。这里仅保留 API 设计和 demo 之间的对应关系。 + +| Demo | 来源 case | 主要验证能力 | +| --- | --- | --- | +| [React 多实例检测](../../tests/integration/agent-runtime-mf/README.md#react-multi-version) | `03-zustand-react-version-check`、`13-react-multi-version-invalid-hook` | `shared`、`DependencySource`、`component.error`、`proxy` | +| [nested Router / React tree](../../tests/integration/agent-runtime-mf/README.md#nested-router-tree) | `04-volcengine-nested-router-hmr` | `route`、`components`、`RemoteComponentRelation` | +| [async chunk 404 / runtime 隔离](../../tests/integration/agent-runtime-mf/README.md#async-chunk-runtime) | `08-rivendell-async-chunk-404` | `build.uniqueName`、`build.chunkLoadingGlobal`、`build.publicPath`、`events` | +| [Garfish provider 白屏](../../tests/integration/agent-runtime-mf/README.md#garfish-provider) | `10-garfish-provider-white-screen` | `EntryState`、`GarfishState`、`component.ready` | +| [redirect / loader 卡住](../../tests/integration/agent-runtime-mf/README.md#redirect-loader) | `12-cloud-engine-redirect-stuck` | `page`、`route`、`loaders`、`pendingReason` | +| [useNavigate 跳转空白](../../tests/integration/agent-runtime-mf/README.md#usenavigate-blank) | `14-usenavigate-jump-blank` | `route`、`components`、`actions`、`events` | + +## 第一阶段建议实现范围 + +第一阶段先做: + +1. `getSnapshot()` +2. `getEvents(filter?)` +3. `waitForEvent(filter, options?)` +4. `getActions(filter?)` +5. `runAction(actionId, payload?)` + +第一阶段 snapshot 先覆盖: + +- `page` +- `route` +- `loaders` +- `components` +- `build` +- `shared` +- `garfish` +- `actions` +- `errors` + +第一阶段 demo 先做: + +1. redirect / loader 卡住 +2. useNavigate 跳转空白 +3. Garfish provider 白屏 +4. async chunk 404 +5. React 多实例检测 + +## 暂不做 + +这些暂时不放第一阶段: + +- 完整复刻 React DevTools 的组件树。 +- 通用 DOM selector 点击。 +- 支持所有 Vue 场景。 +- MF v1 专项兼容。 +- 发布、构建、权限、平台流程类问题。 + +## 待确认 + +1. `getComponentTree()` 第一阶段做到什么深度:只暴露关键组件,还是尽量接近 React tree。 +2. `DependencySource` 的数据来源:运行时能拿到多少,构建期需要补多少。 +3. `runAction()` 第一阶段是否只服务 demo,还是设计成可被业务页面正式声明。 +4. Garfish 的状态由 Garfish 自己提供,还是先由外层 Agent Runtime 兼容采集。 diff --git a/docs/agent-runtime/assets/agent-runtime-architecture.png b/docs/agent-runtime/assets/agent-runtime-architecture.png new file mode 100644 index 000000000000..473262736071 Binary files /dev/null and b/docs/agent-runtime/assets/agent-runtime-architecture.png differ diff --git a/docs/agent-runtime/assets/architecture.png b/docs/agent-runtime/assets/architecture.png new file mode 100644 index 000000000000..cfafa01b8967 Binary files /dev/null and b/docs/agent-runtime/assets/architecture.png differ diff --git a/docs/agent-runtime/assets/openruntime-architecture.png b/docs/agent-runtime/assets/openruntime-architecture.png new file mode 100644 index 000000000000..d4838a09ba5a Binary files /dev/null and b/docs/agent-runtime/assets/openruntime-architecture.png differ diff --git a/docs/agent-runtime/evaluation-process.md b/docs/agent-runtime/evaluation-process.md new file mode 100644 index 000000000000..f4eb8913f470 --- /dev/null +++ b/docs/agent-runtime/evaluation-process.md @@ -0,0 +1,378 @@ +# Agent Runtime 测试流程 + +## 目标 + +这份流程用于评估 Agent Runtime 能否让 Agent 更快、更稳定地完成 Modern.js 页面调试。 + +后续每次测试都应该用同一套流程执行: + +1. 当前能力下跑一次,作为 baseline。 +2. 接入 Agent Runtime 能力后,用同一批 case 复跑。 +3. 对比耗时、判断准确率、证据完整度和人工辅助次数。 + +重点不是证明某个 demo 能不能打开,而是衡量 Agent 是否能从“靠人辅助排查”变成“自己打开页面、观察状态、触发动作、等待结果、给出结论”。 + +## 测试范围 + +第一批固定测集使用 `tests/integration/agent-runtime-mf` 下的 6 个 demo。下表只给评估人员做统计和分组,不应该原样放进被测 Agent 的 prompt。 + +| Demo | 覆盖方向 | 是否包含页面动作 | +| --- | --- | --- | +| `react-multi-version` | MF shared / React runtime / component render | 否 | +| `nested-router-tree` | route / React render | 否 | +| `async-chunk-runtime` | build runtime / MF / Garfish / async chunk | 是 | +| `garfish-provider` | Garfish / provider / context | 视 demo 状态而定 | +| `redirect-loader` | route / loader / redirect | 是 | +| `usenavigate-blank` | route action / navigation / blank state | 是 | + +扩展测集使用 `.goal/vmok-oncall-reviewed-cases` 下的 14 个真实 case。扩展测集可以按登录态、是否可本地复现、是否需要补充上下文分批执行。 + +## 首批 Demo 启动信息 + +这部分只给测试执行者准备环境使用。被测 Agent 默认只拿 `target_url` 和当前 case 的源码入口,不拿启动命令和覆盖方向。 + +| Demo | Provider | Consumer | Target URL | 备注 | +| --- | --- | --- | --- | --- | +| `react-multi-version` | `pnpm --dir tests/integration/agent-runtime-mf/cases/react-multi-version/provider dev` | `pnpm --dir tests/integration/agent-runtime-mf/cases/react-multi-version/consumer dev` | `http://localhost:4312` | 常规 dev 启动 | +| `nested-router-tree` | `pnpm --dir tests/integration/agent-runtime-mf/cases/nested-router-tree/provider dev` | `pnpm --dir tests/integration/agent-runtime-mf/cases/nested-router-tree/consumer dev` | `http://localhost:4322` | 常规 dev 启动 | +| `async-chunk-runtime` | `pnpm --dir tests/integration/agent-runtime-mf/cases/async-chunk-runtime/provider build`,然后 `pnpm --dir tests/integration/agent-runtime-mf/cases/async-chunk-runtime/provider serve` | `pnpm --dir tests/integration/agent-runtime-mf/cases/async-chunk-runtime/consumer dev` | `http://localhost:4332` | provider 需要先 build 再 serve | +| `garfish-provider` | `pnpm --dir tests/integration/agent-runtime-mf/cases/garfish-provider/provider dev` | `pnpm --dir tests/integration/agent-runtime-mf/cases/garfish-provider/consumer dev` | `http://localhost:4342` | 常规 dev 启动 | +| `redirect-loader` | `pnpm --dir tests/integration/agent-runtime-mf/cases/redirect-loader/provider dev` | `pnpm --dir tests/integration/agent-runtime-mf/cases/redirect-loader/consumer dev` | `http://localhost:4352` | 常规 dev 启动 | +| `usenavigate-blank` | `pnpm --dir tests/integration/agent-runtime-mf/cases/usenavigate-blank/provider dev` | `pnpm --dir tests/integration/agent-runtime-mf/cases/usenavigate-blank/consumer dev` | `http://localhost:4362` | 常规 dev 启动 | + +## 启动 Codex 测试 + +后续新建会话时,可以直接让 Agent 使用这个文档和脚本执行测试: + +```txt +请按照 docs/agent-runtime/evaluation-process.md, +使用 scripts/agent-runtime/run-codex-eval.sh 跑 测试。 +目标页面是 。 +``` + +也可以直接在命令行执行脚本: + +```bash +scripts/agent-runtime/run-codex-eval.sh \ + --case async-chunk-runtime \ + --round baseline +``` + +接入 Agent Runtime 后复跑: + +```bash +scripts/agent-runtime/run-codex-eval.sh \ + --case async-chunk-runtime \ + --round runtime +``` + +首批 demo 的 URL 会由脚本根据 `--case` 自动推断: + +| Case | 自动 URL | +| --- | --- | +| `react-multi-version` | `http://localhost:4312` | +| `nested-router-tree` | `http://localhost:4322` | +| `async-chunk-runtime` | `http://localhost:4332` | +| `garfish-provider` | `http://localhost:4342` | +| `redirect-loader` | `http://localhost:4352` | +| `usenavigate-blank` | `http://localhost:4362` | + +只有真实页面、登录态页面,或者不在首批 demo 列表里的 case,才需要手动传 `--url`。 + +脚本会自动使用: + +```bash +codex \ + --ask-for-approval never \ + exec \ + --ephemeral \ + --disable memories \ + --sandbox workspace-write +``` + +其中: + +- `--ephemeral` 用于避免测试会话持久化。 +- `--disable memories` 用于避免 Codex 读取历史记忆。 +- `--ask-for-approval never` 用于避免非交互测试卡在确认步骤。 +- `--output-last-message` 会把最终回答保存到结果目录。 +- `--json` 会把执行事件保存为 JSONL,方便后续统计。 + +默认使用 `repo-assisted` 模式。这个模式更接近真实开发排查:Agent 可以看源码,但脚本会把 Codex 工作目录限制在当前 demo case 目录,而不是整个 Modern.js 仓库。 + +例如 `async-chunk-runtime` 默认入口是: + +```txt +tests/integration/agent-runtime-mf/cases/async-chunk-runtime +``` + +如果要手动指定源码入口,可以传 `--case-dir`: + +```bash +scripts/agent-runtime/run-codex-eval.sh \ + --case async-chunk-runtime \ + --round baseline \ + --case-dir tests/integration/agent-runtime-mf/cases/async-chunk-runtime +``` + +如果要测纯黑盒页面调试能力,可以使用 `black-box`。这个模式会把 Codex 工作目录放到临时空目录,只给页面 URL,不给仓库上下文: + +```bash +scripts/agent-runtime/run-codex-eval.sh \ + --case usenavigate-blank \ + --round baseline \ + --access-mode black-box +``` + +真实页面或登录态页面如果源码入口不在首批 demo 目录下,建议显式传 `--case-dir`: + +```bash +scripts/agent-runtime/run-codex-eval.sh \ + --case some-real-page \ + --round baseline \ + --url \ + --mode real-page \ + --case-dir +``` + +如果没有传 `--case-dir`,且 `--case` 能匹配到首批 demo 目录,脚本会自动使用对应 demo 目录作为入口。 + +每次执行会生成一个结果目录: + +```txt +docs/agent-runtime/evaluation-results/--/ +``` + +目录里包含: + +- `prompt.txt`:实际发给 Codex 的 prompt。 +- `answer.md`:Codex 最终回答。 +- `events.jsonl`:Codex 执行事件。 +- `metadata.md`:case、round、URL、分支、commit、耗时等信息。 + +## 测试轮次 + +每个 case 至少跑两轮。 + +| 轮次 | 能力状态 | 允许使用的手段 | 目的 | +| --- | --- | --- | --- | +| Baseline | 未接入 Agent Runtime | 页面、console、network、人工提供的必要环境信息;是否允许读源码由 `access_mode` 决定 | 记录当前 Agent 靠外部信号排查的表现 | +| Runtime | 接入 Agent Runtime 后 | Baseline 手段 + snapshot / events / actions / ready / blockers | 记录结构化运行态是否提升排查效率和自动化程度 | + +两轮测试必须尽量保持一致: + +- 使用同一个 case。 +- 使用同一个入口 URL。 +- 使用同一个复现顺序。 +- 使用同一类 Agent prompt。 +- 不在 prompt 中提前暴露预期根因。 +- 每个 case 使用独立上下文,避免前一个 case 的结论污染下一个 case。 + +## 上下文隔离 + +测试要分清“测试执行者”和“被测 Agent”。 + +- 测试执行者可以阅读本流程、demo README 和预期结果,用来准备环境和评分。 +- 被测 Agent 只应该收到目标页面、任务要求和允许操作,不应该收到预期根因。 +- 如果要测黑盒调试能力,被测 Agent 不应该读取 demo README、评估流程、源码或历史结果。 +- 如果要测仓库辅助调试能力,被测 Agent 可以读当前 case 的源码,但仍不应该读取评估答案、历史结果或人整理好的 case 结论。 +- 每次报告里需要注明本次是 `black-box` 还是 `repo-assisted`。 + +建议默认跑 `repo-assisted`,因为真实开发排查通常需要结合源码。为了避免上下文过大或提前看到答案,脚本默认会把源码入口限制到当前 demo case 目录。`black-box` 可以作为补充,用来单独评估 Runtime 对纯页面观测的提升。 + +## 执行前准备 + +每轮测试前记录: + +| 字段 | 说明 | +| --- | --- | +| `round` | `baseline` 或 `runtime` | +| `case_id` | demo 名称或 oncall case 文件名 | +| `branch` | 当前 git 分支 | +| `commit` | 当前 git commit | +| `mode` | `local-demo`、`login-page`、`real-page` | +| `access_mode` | `black-box` 或 `repo-assisted` | +| `case_dir` | repo-assisted 模式下的源码入口目录 | +| `provider_command` | provider 启动命令 | +| `consumer_command` | consumer 启动命令 | +| `target_url` | Agent 需要打开的页面 | +| `expected_layer` | 只记录在结果表中,不放进 Agent prompt | + +如果 case 依赖登录态,测试人员只负责准备登录环境,不直接提示问题根因。登录态准备也要记录为人工辅助。 + +## Agent Prompt 规则 + +Prompt 应只描述目标,不暴露预期结论。 + +推荐模板: + +```txt +请打开下面页面并判断它是否按预期工作。 + +页面地址: +目标:复现页面问题,判断页面卡在哪一层,并给出可验证证据。 + +要求: +1. 你可以打开页面、点击页面上的复现按钮、查看控制台和运行状态。 +2. 不要只说页面报错,要判断问题属于 route、loader、MF、Garfish、React 渲染、业务 ready 或其他层级。 +3. 如果需要人工辅助,请明确说明需要什么信息,并继续记录当前已经确认的证据。 +4. 最后给出结论、证据和剩余不确定点。 +``` + +接入 Agent Runtime 后,增加一句: + +```txt +如果页面提供 Agent Runtime 能力,请优先使用 snapshot、events、ready、blockers 和 actions 来判断状态。 +``` + +## 计时规则 + +计时从 Agent 收到 case prompt 后开始。 + +| 时间点 | 含义 | +| --- | --- | +| `t0_started` | Agent 收到 prompt,开始执行 | +| `t1_page_opened` | 首次打开目标页面 | +| `t2_issue_reproduced` | 首次确认问题现象已复现 | +| `t3_layer_identified` | 首次判断出问题所属层级 | +| `t4_evidence_collected` | 首次拿到足够支撑结论的证据 | +| `t5_final_answered` | 输出最终结论 | + +核心耗时: + +| 指标 | 计算方式 | +| --- | --- | +| 页面打开耗时 | `t1_page_opened - t0_started` | +| 复现耗时 | `t2_issue_reproduced - t0_started` | +| 分层判断耗时 | `t3_layer_identified - t0_started` | +| 证据收集耗时 | `t4_evidence_collected - t0_started` | +| 总耗时 | `t5_final_answered - t0_started` | + +如果某个时间点无法达成,记录为 `not reached`,不要用最终时间硬填。 + +## 人工辅助分级 + +有些 case 当前需要人辅助排查。测试时不要忽略这件事,而是把它量化。 + +| 等级 | 含义 | 示例 | +| --- | --- | --- | +| H0 | 无人工辅助 | Agent 自己完成打开、复现、判断和取证 | +| H1 | 环境辅助 | 人只负责登录、启动服务、提供账号状态 | +| H2 | 操作辅助 | 人需要告诉 Agent 点哪个按钮、切哪个路由、按什么顺序复现 | +| H3 | 观测辅助 | 人需要提供 console、network、截图、日志等运行证据 | +| H4 | 判断辅助 | 人直接提示根因方向或关键代码位置 | + +目标是: + +- Baseline 允许出现 H2 / H3 / H4。 +- Runtime 后理想状态是降到 H0 或 H1。 +- 依赖登录态的真实页面可以保留 H1,但不应该继续依赖 H2 / H3 / H4。 + +## 自动化程度评分 + +每个 case 最后给一个自动化评分。 + +| 分数 | 含义 | +| --- | --- | +| A0 | 无法打开或无法复现 | +| A1 | 能复现现象,但无法判断层级 | +| A2 | 能判断层级,但证据不足 | +| A3 | 能判断层级,并给出可验证证据 | +| A4 | 能自动执行复现动作、等待状态变化、给出结论和证据 | + +第一阶段目标: + +- 6 个 demo 至少达到 A3。 +- 有页面动作的 demo 目标达到 A4。 +- 登录态真实页面至少减少 H2 / H3 / H4 类型辅助。 + +## 结果记录模板 + +脚本每次执行都会创建一个结果目录: + +```txt +docs/agent-runtime/evaluation-results/--/ +``` + +其中 `metadata.md`、`prompt.txt`、`answer.md` 和 `events.jsonl` 会由脚本自动生成。如果需要人工复盘,可以在同一个目录下补充 `summary.md`。 + +`summary.md` 建议使用下面格式: + +```md +## + +| 字段 | 结果 | +| --- | --- | +| round | baseline / runtime | +| target_url | | +| expected_layer | | +| detected_layer | | +| access_mode | black-box / repo-assisted | +| case_dir | | +| automation_score | A0 / A1 / A2 / A3 / A4 | +| human_help_level | H0 / H1 / H2 / H3 / H4 | +| page_open_time | | +| reproduce_time | | +| layer_identify_time | | +| evidence_time | | +| total_time | | +| result | pass / partial / fail | + +### Agent 结论 + +记录 Agent 最终判断。 + +### 证据 + +记录页面现象、console、network、snapshot、events、blockers、截图或日志。 + +### 人工辅助 + +记录人提供了什么信息,以及如果接入 Runtime 后是否应该自动完成。 + +### 复盘 + +- 判断是否正确: +- 慢在哪一步: +- 缺少什么结构化信号: +- 接入 Agent Runtime 后预期如何改善: +``` + +## 通过标准 + +单个 case 的 `pass` 标准: + +1. 能复现问题,或明确说明为什么当前环境无法复现。 +2. 能判断问题所属层级。 +3. 能给出支撑结论的证据。 +4. 不把 remote 加载成功误判成页面业务可用。 +5. 如果页面没有 ready,能说明 blocker,而不是只说 timeout。 + +整轮测试的通过标准: + +1. 6 个 demo 都有结果记录。 +2. 每个 demo 都有耗时数据。 +3. 每个 demo 都有自动化评分和人工辅助等级。 +4. Runtime 轮次相比 baseline 至少能减少人工辅助或缩短判断耗时。 +5. 对未改善的 case,能明确缺少哪类 runtime 信号。 + +## 对比报告 + +完成 baseline 和 Runtime 两轮后,输出汇总表: + +| Case | Baseline score | Runtime score | Baseline help | Runtime help | Baseline total | Runtime total | 是否改善 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| `react-multi-version` | | | | | | | | +| `nested-router-tree` | | | | | | | | +| `async-chunk-runtime` | | | | | | | | +| `garfish-provider` | | | | | | | | +| `redirect-loader` | | | | | | | | +| `usenavigate-blank` | | | | | | | | + +汇总结论需要回答: + +1. 哪些 case 已经能全自动完成。 +2. 哪些 case 仍然需要人工辅助。 +3. 人工辅助卡在哪一类:环境、操作、观测还是判断。 +4. Agent Runtime 新增了哪些有效证据。 +5. 下一阶段应该补哪些 API 或事件。 diff --git a/docs/agent-runtime/rfc-agent-runtime.md b/docs/agent-runtime/rfc-agent-runtime.md new file mode 100644 index 000000000000..7a036be3e835 --- /dev/null +++ b/docs/agent-runtime/rfc-agent-runtime.md @@ -0,0 +1,1144 @@ +# RFC: Agent Runtime - 面向 AI Agent 的前端运行时能力 + +> 本 RFC 已被 [RFC: OpenRuntime](./rfc-openruntime.md) 替代。 + +## 状态 + +Draft + +## 摘要 + +本文定义一套面向 AI Agent 的前端运行时能力,统一命名为 **Agent Runtime**。 + +Agent Runtime 的目标是让前端应用向 Agent 暴露结构化页面状态、运行事件、可等待条件和受控操作 API。Agent 在开发、调试和 oncall 场景中,可以通过这些能力自主观察页面、执行页面声明过的动作,并验证结果,从而减少人的中途介入。 + +更深层的目标是适应未来应用产品形态的变化:应用不再只面向人直接点击和填写,也需要能被 AI Agent 理解和操控。上层 AI 产品可以接收人的自然语言需求,将其拆解成具体动作和参数,再通过应用暴露的结构化运行时能力完成操作。Agent Runtime 是让前端应用具备这种 Agent 可操作性的基础能力。 + +核心思路: + +- 提供一个通用 `@agent-runtime/core`,维护页面级 Runtime Center。 +- Modern.js、MF、Garfish、Goofy 和业务代码把各自掌握的信息写入 Agent Runtime。 +- Agent 通过 snapshot、events、actions、waitFor 等 API 理解和操作页面。 +- Window API 是页面内最小出口。 +- HTTP Bridge 是页面外访问通道,CLI 是 Bridge 的命令行客户端。 +- Skill 负责告诉 Agent 如何在任务中使用这些能力。 + +最终希望让 AI coding 从: + +```txt +Agent 修改代码 +人盯着页面 +人告诉 Agent 哪里不对 +Agent 再尝试修复 +``` + +逐步变成: + +```txt +Agent 修改代码 +Agent 启动页面 +Agent 读取运行态 +Agent 执行动作 +Agent 等待结果 +Agent 用证据验证 +Agent 自主继续修复 +``` + +## 背景 + +当前 AI coding 在前端开发里经常卡在“实现后验证”这一步。 + +Agent 可以修改代码、启动项目、打开页面,但它理解页面运行状态时,仍然主要依赖外部现象: + +- 页面 UI 是否看起来正常; +- DOM 里有没有某个元素; +- console 有没有报错; +- network 是否安静; +- 点击后页面有没有变化; +- 人是否告诉它“现在不对”。 + +这些方式能提供线索,但不稳定,也很依赖人的中途介入。 + +同样是页面没有按预期工作,真实原因可能分布在完全不同的层: + +| 问题层级 | 例子 | +| --- | --- | +| route | basename 错误、route 没匹配、跳转后空白 | +| loader | loader pending、loader error、redirect 不符合预期 | +| render | root 没挂载、hydration 异常、route component 报错 | +| MF remote | manifest / remoteEntry 加载失败 | +| MF expose | expose 解析失败、远程组件不可用 | +| MF shared | React 多实例、shared 版本冲突、invalid hook call | +| chunk / asset | async chunk 404、uniqueName 冲突、资源复用错误 | +| Garfish | 子应用 entry、provider、mount 失败 | +| deploy | consumer / producer 版本错配、资源来自错误 release | +| business | 框架都成功了,但业务数据还没 ready | + +如果 Agent 只能看页面外部现象,就很难稳定进入真正的自动循环。 + +## 长期产品形态:应用需要面向 Agent 可操作 + +今天大多数前端应用默认只面向人设计。页面通过按钮、表单、列表、弹窗和提示文案,把能力暴露给人;人理解页面状态后,再决定点击哪里、填写什么、等待什么结果。 + +AI Agent 进入应用之后,产品形态会发生变化。人不一定直接操作页面,而是向上层 AI 产品描述自己的需求,例如“帮我检查这个订单是否异常”、“把这个项目切到灰度环境”、“重新加载失败的远程模块”。上层 AI 产品负责理解意图、拆解步骤、补齐参数,再调用具体应用暴露的能力完成任务。 + +这意味着应用除了 UI 之外,还需要提供一层面向 Agent 的操作面: + +| 面向对象 | 主要入口 | 应用需要提供什么 | +| --- | --- | --- | +| 人 | UI、文案、交互、反馈 | 可读、可点、可填写、可理解 | +| Agent | snapshot、events、actions、waitFor、input options | 可查询、可执行、可等待、可验证 | + +Agent Runtime 的 API 正是为这层操作面设计的。 + +它不是让 Agent 模拟人去点击 DOM,也不是把页面 UI 简单包装成自动化脚本,而是让应用把关键运行时语义直接暴露出来: + +- 当前页面是什么状态; +- 当前有哪些可执行动作; +- 每个动作需要哪些参数; +- 参数有哪些合法选项; +- 执行动作后应该等待什么状态; +- 执行结果是否真的成功; +- 如果失败,问题落在哪一层。 + +因此,Agent Runtime 的长期价值不是“多一个调试接口”,而是让应用具备 Agent 可操作性。未来上层 AI 产品可以基于这层能力,把人的自然语言需求转换成稳定、可验证的应用操作。 + +## 目标 + +Agent Runtime 的目标是: + +> 让前端应用具备对 Agent 友好的运行时能力,使 Agent 能够读取状态、等待变化、执行声明动作并验证结果。 + +本 RFC 目标包括: + +- 让前端应用在 UI 之外,提供一层面向 Agent 的结构化操作面。 +- 支持上层 AI 产品把人的自然语言需求转换成具体 action、参数和验证条件。 +- 定义 Agent Runtime 的产品边界和核心概念。 +- 定义页面级 Runtime Center、Snapshot、Event Log、Action Registry。 +- 定义第一版 Runtime API。 +- 定义 Modern.js、MF、Goofy 和业务代码的接入方式。 +- 定义 Window API、HTTP Bridge、CLI、Skill 的职责边界。 +- 定义第一期能力范围和后续三期规划。 + +## 边界 + +Agent Runtime 不做: + +- 不做 WebMCP 的竞争协议; +- 不做通用浏览器自动化框架; +- 不负责理解人的自然语言需求,也不负责上层任务规划; +- 不做任意 DOM 操作系统; +- 不做生产监控或 APM; +- 不自动猜业务成功标准; +- 不让 Agent 执行未声明的危险动作; +- 第一期不做跨 tab、跨 iframe、跨 worker、多 Runtime Center 聚合; +- 不要求所有生产者强依赖同一个 SDK 包版本。 + +## 术语 + +| 名称 | 含义 | +| --- | --- | +| Agent Runtime | 产品能力名称,表示面向 Agent 的前端运行时能力 | +| Agent Runtime SDK | 前端应用接入 Agent Runtime 的 SDK | +| Runtime Center | 页面内状态中心,负责维护 snapshot、events、actions | +| Runtime Client | Modern.js、MF、业务代码等写入方 | +| Snapshot | 页面当前状态 | +| Event Log | 页面历史变化过程 | +| Action Registry | 页面声明给 Agent 的可执行动作 | +| Window API | `window.__AGENT_RUNTIME__`,页面内最小访问出口 | +| HTTP Bridge | 页面外访问 Agent Runtime 的本地服务 | +| CLI | HTTP Bridge 的命令行客户端和辅助工具 | +| Skill | 教 Agent 如何使用 Agent Runtime 的任务说明或能力包 | + +## 产品形态 + +Agent Runtime 是一套前端运行时 SDK。 + +它由三层组成: + +```txt +采集层 +Modern.js / MF / Garfish / Goofy / 业务代码 + ↓ +核心运行时 +Runtime Center / Snapshot / Events / Ready / Blockers / Actions + ↓ +暴露层 +Window API / Bridge Server / CLI / WebMCP / 平台原生能力 +``` + +稳定的是运行时语义: + +- `snapshot`:页面当前状态; +- `events`:页面历史过程; +- `ready`:某个页面、路由、组件或业务目标是否可用; +- `blockers`:当前为什么还不能认为 ready; +- `actions`:页面声明给 Agent 的安全动作; +- `evidence`:Agent 判断结果成立的证据。 + +可替换的是访问方式: + +- 现在可以先用 `window.__AGENT_RUNTIME__` 暴露; +- 内部平台可以通过自己的 bridge 访问; +- HTTP Bridge 可以给 CLI 和 Agent 使用; +- 后续 WebMCP 成熟后,可以新增 WebMCP adapter; +- 移动端容器或平台原生能力也可以提供自己的 adapter。 + +关键原则: + +> Runtime Center 不关心自己被哪种方式访问。WebMCP、CLI、Bridge Server、Window API 都只是 transport,不是核心协议。 + +## 和 WebMCP 的关系 + +WebMCP 可以作为未来的一个重要出口,但不是 Agent Runtime 的替代品。 + +WebMCP 更像是: + +```txt +Agent 如何发现和调用页面能力 +``` + +Agent Runtime 要解决的是: + +```txt +前端应用如何产出 Agent 需要的页面状态、运行事件、ready 条件、blockers 和 actions +``` + +所以更合理的关系是: + +```txt +Modern.js / MF / Garfish / Goofy / 业务代码 + ↓ +Agent Runtime + ↓ +WebMCP adapter / CLI / Bridge / Window API + ↓ +Agent +``` + +后续如果 WebMCP 成熟,可以把 Agent Runtime 的能力注册成 WebMCP tools: + +- `agentRuntime.getSnapshot` +- `agentRuntime.getEvents` +- `agentRuntime.getActions` +- `agentRuntime.waitFor` +- `agentRuntime.runAction` + +这样不是被 WebMCP 替代,而是把 WebMCP 作为标准出口。 + +## 总体架构 + +![架构图](./assets/agent-runtime-architecture.png) + +核心关系: + +```txt +Modern.js / MF / Garfish / Goofy / 业务代码 + ↓ +Runtime Client + ↓ +页面级 Runtime Center + ↓ +Window API / Bridge Client + ↓ +HTTP Bridge + ↓ +CLI / Skill / Agent / 平台 +``` + +一个页面内应该有一个主 Runtime Center,负责聚合: + +- 当前 snapshot; +- 历史 events; +- ready 状态; +- blockers; +- actions; +- errors。 + +写入方可以有多个: + +- Modern.js Runtime Client; +- MF Runtime Client; +- Garfish Runtime Client; +- Goofy Runtime Client; +- 业务 Runtime Client; +- 其他框架或自定义 runtime 的 Runtime Client。 + +这些 client 可以来自不同包、不同版本,但最终写入同一个页面级 Runtime Center。 + +第一期不做多个 Runtime Center 的自动合并。多中心会带来 snapshot 合并、事件排序、action 冲突、ready 归并等复杂问题。 + +## 接入和访问方式 + +Agent Runtime 的接入和访问需要分开看: + +```txt +页面如何接入 Agent Runtime +页面外的 Agent 如何访问 Agent Runtime +``` + +页面内接入依赖 SDK。页面外访问依赖 Window API、HTTP Bridge、CLI 或未来的 WebMCP adapter。 + +### 基础 SDK + +第一版只提供一个基础包: + +```txt +@agent-runtime/core +``` + +Modern.js、MF 和业务代码都依赖这个包: + +```txt +Modern.js 内置接入 -> @agent-runtime/core +MF 内置接入 -> @agent-runtime/core +业务手动接入 -> @agent-runtime/core +``` + +第一期不单独提供: + +```txt +@agent-runtime/modern-js +@agent-runtime/module-federation +@agent-runtime/react +``` + +原因是: + +- Modern.js 和 MF 可以直接在各自实现里内置 Agent Runtime; +- React tree 暂时不是判断业务 ready 的核心依据; +- 过早拆包会增加理解成本和维护成本。 + +### Window API + +Window API 是最小访问出口: + +```ts +window.__AGENT_RUNTIME__ +``` + +它适合: + +- 本地调试; +- Agent 通过浏览器上下文直接读取; +- 没有 HTTP Bridge 时作为兜底; +- 验证 SDK 是否正常工作。 + +Window API 不负责跨进程通信。页面外的 Agent 如果要稳定访问页面运行态,仍然需要 HTTP Bridge。 + +### HTTP Bridge + +HTTP Bridge 是本地服务,不是 SDK 本体。它负责把页面里的 Agent Runtime 暴露给页面外的 Agent、CLI 或平台。 + +核心链路: + +```txt +页面 Agent Runtime + ↓ WebSocket 主动连接 +本地 HTTP Bridge + ↑ HTTP API +Agent / CLI / 平台 +``` + +页面需要主动连接 Bridge,因为浏览器页面不能自己启动 HTTP 服务,页面外的 Agent 也不能直接请求某个页面实例。 + +第一版 HTTP Bridge 可以提供: + +```txt +GET /targets +GET /targets/:id/snapshot +GET /targets/:id/events +GET /targets/:id/actions +GET /targets/:id/actions/:name/options +POST /targets/:id/actions/:name/run +POST /targets/:id/wait-for +GET /targets/:id/events/stream +``` + +其中: + +- `targets` 表示当前连接到 Bridge 的页面; +- `snapshot` 表示页面当前状态; +- `events` 表示页面历史变化; +- `actions` 表示页面声明的可执行动作; +- `options` 表示动态参数选项; +- `run` 表示执行页面声明过的 action; +- `wait-for` 表示等待页面状态变化; +- `events/stream` 表示持续监听事件。 + +页面里的 Agent Runtime 仍然是事实来源。Bridge 可以缓存最近的 snapshot 和 events,但不能成为主状态源。页面断开后,Bridge 可以返回最后一次看到的状态,但必须标记为 disconnected。 + +### Bridge 开启方式 + +Bridge 连接需要三层控制: + +```txt +构建期注入:决定页面代码里有没有 bridge client +运行期配置:决定这次页面要不要连接 bridge +Bridge 鉴权:决定连上来的页面能不能被接受 +``` + +Modern.js 可以提供这样的配置: + +```ts +export default defineConfig({ + agentRuntime: { + enabled: true, + bridge: { + enabled: 'dev', + }, + }, +}); +``` + +`bridge.enabled` 的第一版类型可以是: + +```ts +type BridgeEnabled = boolean | 'dev' | 'manual'; +``` + +含义: + +| 值 | 含义 | +| --- | --- | +| `true` | 总是尝试连接 Bridge | +| `false` | 不连接 Bridge | +| `'dev'` | 仅 dev 环境自动连接 | +| `'manual'` | 注入 bridge client,但只有 query、localStorage 或服务端配置显式打开时才连接 | + +推荐默认策略: + +| 场景 | 策略 | +| --- | --- | +| 本地 dev | `bridge.enabled = 'dev'` | +| staging / oncall | `bridge.enabled = 'manual'` | +| production | `bridge.enabled = false` | + +Bridge Server 还需要校验 token、origin、app name 和 runtime version。页面允许连接不代表 Bridge 一定接受。 + +### 构建插件 + +通用 Rspack / Webpack 构建插件应该和 HTTP Bridge 一起提供基础版本,但它不是 Modern.js 用户的主入口。 + +更合理的定位是: + +```txt +@agent-runtime/core +Modern.js 内置接入 +通用 Rspack / Webpack 构建插件 +``` + +Modern.js 用户不应该手动配置 `AgentRuntimeWebpackPlugin` 或 `AgentRuntimeRspackPlugin`。Modern.js 只暴露框架配置,内部复用通用注入逻辑。 + +通用构建插件主要服务两个场景: + +- 非 Modern.js 项目接入 Agent Runtime; +- Modern.js 内部复用同一套初始化和 bridge client 注入逻辑。 + +### CLI + +CLI 主要配合 HTTP Bridge 使用。 + +没有 HTTP Bridge 时,CLI 只能做辅助能力: + +- 启动 Bridge; +- 检查 Bridge 状态; +- 打印接入配置; +- 打开页面; +- 读取项目里的静态配置。 + +它不能稳定完成核心运行态能力: + +- 读取页面 runtime 状态; +- 获取 actions; +- 执行 action; +- 等待 ready; +- 读取 runtime events。 + +因此第一版 CLI 的定位是: + +```txt +CLI = Bridge Server 管理器 + Bridge HTTP API 客户端 + 接入辅助工具 +``` + +CLI 可以提供: + +```bash +agent-runtime bridge +agent-runtime status +agent-runtime targets +agent-runtime snapshot --target +agent-runtime events --target +agent-runtime actions --target +agent-runtime run-action --target +agent-runtime wait-for --target +``` + +### Skill + +Agent 真正知道如何使用 Agent Runtime,主要依赖 Skill 或平台工具说明。 + +推荐链路: + +```txt +Agent Skill + ↓ +CLI 或 HTTP API + ↓ +HTTP Bridge + ↓ +页面 Agent Runtime +``` + +Skill 负责告诉 Agent: + +- 优先检查是否存在 Agent Runtime; +- 如何连接 Bridge; +- 如何读取 snapshot、events 和 actions; +- 如何执行 action; +- 如何等待目标状态; +- 如何用结果验证问题; +- 没有 Agent Runtime 时再 fallback 到 UI、console、network。 + +## 数据模型 + +Agent Runtime 内部先收敛为三类核心数据: + +```txt +Snapshot +Event Log +Action Registry +``` + +### Snapshot + +Snapshot 表示页面当前仍然有效的运行态,不保存完整历史。 + +```ts +type RuntimeSnapshot = { + items: RuntimeSnapshotItem[]; + relations: RuntimeSnapshotRelation[]; + latestEventId: number; +}; +``` + +`items` 表示当前页面里的运行时对象: + +```ts +type RuntimeSnapshotItem = { + id: string; + type: + | 'app' + | 'route' + | 'loader' + | 'component' + | 'form' + | 'field' + | 'remote' + | 'expose' + | 'shared' + | 'sub-app' + | 'business' + | 'custom'; + status: + | 'idle' + | 'loading' + | 'pending' + | 'success' + | 'error' + | 'ready' + | 'blocked' + | 'inactive'; + source: string; + label?: string; + data?: unknown; + error?: RuntimeError; + updatedAt: number; +}; +``` + +`relations` 表示对象关系: + +```ts +type RuntimeSnapshotRelation = { + from: string; + to: string; + type: + | 'contains' + | 'depends-on' + | 'loads' + | 'renders' + | 'provides' + | 'shares' + | 'custom'; + relation?: string; + label?: string; + data?: unknown; +}; +``` + +内置 relation type 的默认含义: + +| type | 含义 | +| --- | --- | +| `contains` | 包含关系,例如 app contains route | +| `depends-on` | 依赖关系,例如 route depends-on loader | +| `loads` | 加载关系,例如 route loads MF remote | +| `renders` | 渲染关系,例如 expose renders component | +| `provides` | 提供关系,例如 remote provides expose | +| `shares` | shared 依赖关系,例如 MF shares react | +| `custom` | 业务自定义关系 | + +`custom` 用于业务特殊关系,但需要配合 `relation` 或 `label` 使用: + +```ts +{ + from: 'field:province', + to: 'field:city', + type: 'custom', + relation: 'controls-options', + label: 'Province controls city options', +} +``` + +不建议第一版直接允许任意 string type。稳定的内置类型更利于 Agent 推理,业务扩展通过 `custom + relation` 表达。 + +### Event Log + +Event 记录 Snapshot 的变化过程。 + +```ts +type RuntimeEvent = { + id: number; + type: string; + source: string; + timestamp: number; + itemId?: string; + relation?: RuntimeSnapshotRelation; + status?: RuntimeSnapshotItem['status']; + data?: unknown; + error?: RuntimeError; +}; +``` + +事件由 Runtime Center 自动记录,不对外提供公开 `emit`。 + +自动事件包括: + +- `snapshot.updated` +- `relation.updated` +- `action.registered` +- `action.unregistered` +- `action.started` +- `action.success` +- `action.error` +- `wait.timeout` + +这样使用方不需要同时维护 snapshot 和 events,避免双写不一致。 + +### Action Registry + +Action 是页面声明给 Agent 的可调用能力。Agent 不应该随便操作 DOM,而是调用页面声明过的 action。 + +第一版 action 先采用纯 action 声明,不做 DOM 自动识别、UI steps 或通用表单自动填写。 + +```ts +type RuntimeAction = { + name: string; + description?: string; + params?: RuntimeActionParam[]; + getInputOptions?: RuntimeInputOptionsProvider; + expect?: RuntimeCondition | RuntimeCondition[]; + enabled?: boolean; + blockedBy?: RuntimeCondition[]; + risk?: 'safe' | 'state-changing' | 'destructive' | 'sensitive'; + handler: RuntimeActionHandler; +}; +``` + +`name` 和 `handler` 是核心。`params`、`getInputOptions`、`expect` 用于帮助 Agent 更稳定地传参和验证结果。 + +```ts +type RuntimeActionParam = { + name: string; + type: 'string' | 'number' | 'boolean' | 'option'; + required?: boolean; + description?: string; + dependsOn?: string[]; +}; + +type RuntimeInputOptionsProvider = ( + inputName: string, + currentPayload?: Record, +) => Promise | RuntimeOption[]; + +type RuntimeOption = { + label: string; + value: string | number | boolean; + description?: string; +}; + +type RuntimeCondition = { + id: string; + status: RuntimeSnapshotItem['status']; +}; +``` + +## 第一版公开 API + +公开 API 分成写入侧和 Agent 读取 / 执行侧。 + +写入侧: + +```ts +runtime.updateSnapshot(input); +runtime.registerAction(action); +runtime.unregisterAction(actionName); +runtime.runAction(actionName, payload?); +``` + +其中: + +- `updateSnapshot` 更新当前页面状态; +- `registerAction` 注册页面声明给 Agent 的可调用能力; +- `unregisterAction` 注销 action; +- `runAction` 执行 action,既可以给 Agent 用,也可以给本地自测用。 + +`updateSnapshot` 的第一版形态: + +```ts +type UpdateSnapshotInput = { + id: string; + status: RuntimeStatus; + type?: RuntimeObjectType; + source?: string; + label?: string; + data?: unknown; + error?: RuntimeError; + relations?: RuntimeRelationInput[]; +}; +``` + +必填字段只有 `id` 和 `status`。`type` 默认是 `business`,`source` 默认是 `business`。 + +业务用户可以直接写: + +```ts +runtime.updateSnapshot({ + id: 'user-profile', + status: 'ready', +}); +``` + +框架和 MF 这类接入方建议显式传 `type` 和 `source`: + +```ts +runtime.updateSnapshot({ + id: 'route:/home', + type: 'route', + source: 'modern-js', + status: 'success', +}); +``` + +Agent 读取 / 执行侧 API: + +```ts +getSnapshot(); +getEvents(filter?); +getActions(); +getInputOptions(actionName, inputName, currentPayload?); +runAction(actionName, payload?); +waitFor(condition, options?); +``` + +其中: + +- `getSnapshot` 读取当前状态; +- `getEvents` 读取历史变化; +- `getActions` 读取页面声明过的动作; +- `getInputOptions` 读取动态参数选项; +- `runAction` 执行动作; +- `waitFor` 等待目标状态。 + +第一版不做: + +- DOM 自动识别; +- UI steps; +- 通用表单自动填写; +- workflow engine; +- 多 Runtime Center 聚合; +- 从 DOM 自动推断 params。 + +## Modern.js 接入 + +Modern.js 负责把框架自己已经知道的信息写入 Agent Runtime。业务用户不应该为了基础路由状态手动埋点。 + +第一期 Modern.js 需要记录: + +| 记录数据 | 解决的问题 | +| --- | --- | +| app name、mode、base、runtime version | Agent 知道当前跑的是哪个 Modern.js 应用 | +| root mounted / hydration 状态 | 判断根节点没挂载、hydration 异常、白屏 | +| 当前 location、basename、matched routes | 判断路由有没有匹配错、basename 是否错误 | +| navigation 状态 | 判断页面是否还在跳转中 | +| redirect 信息 | 判断是否被 loader 或路由逻辑重定向 | +| route loader start / success / error / redirect | 判断页面卡住是 loader 问题还是渲染问题 | +| route component mounted / error | 判断路由匹配后组件是否真的渲染成功 | +| framework error boundary 信息 | 判断是不是 React render 阶段报错 | + +Modern.js 可以内置常用安全 action: + +| action | 用途 | +| --- | --- | +| `navigate` | 让 Agent 走框架路由跳转 | +| `retryLoader` | 让 Agent 重试当前路由数据加载 | + +示例: + +```ts +router.subscribe(state => { + runtime.updateSnapshot({ + id: `route:${state.location.pathname}`, + type: 'route', + source: 'modern-js', + status: state.navigation.state === 'idle' ? 'success' : 'loading', + data: { + location: state.location.pathname, + matches: state.matches.map(match => match.route.id), + }, + }); +}); +``` + +loader 由框架包装已有 loader,不自动创建新的 loader: + +```ts +function wrapLoader(routeId: string, loader: LoaderFunction) { + return async args => { + runtime.updateSnapshot({ + id: `loader:${routeId}`, + type: 'loader', + source: 'modern-js', + status: 'loading', + relations: [ + { + type: 'depends-on', + from: `route:${routeId}`, + }, + ], + }); + + try { + const result = await loader(args); + + runtime.updateSnapshot({ + id: `loader:${routeId}`, + type: 'loader', + source: 'modern-js', + status: 'success', + data: isRedirect(result) ? { redirect: true } : undefined, + }); + + return result; + } catch (error) { + runtime.updateSnapshot({ + id: `loader:${routeId}`, + type: 'loader', + source: 'modern-js', + status: 'error', + error, + }); + throw error; + } + }; +} +``` + +## MF 接入 + +MF 自身状态优先复用 MF Observability Plugin。Agent Runtime 不重复实现一套 MF 加载追踪,而是在 MF 侧提供一个 Agent Runtime MF Adapter: + +```txt +MF Observability Plugin + ↓ +Agent Runtime MF Adapter + ↓ +Agent Runtime +``` + +这个 Adapter 负责把 MF hooks 和 Observability report 转成 Agent Runtime 的 snapshot、relations 和 events。 + +第一版接入方式分三类: + +- 使用 MF 构建插件时,由构建插件自动注入 Agent Runtime MF Adapter,并自动标注当前应用是哪个 MF consumer; +- 使用 MF runtime API 时,用户注册 `agentRuntimeMFPlugin()`,由 runtime plugin 从 MF instance 里读取 consumer、remotes、shared 和加载过程; +- 非标准封装场景,提供 `registerMFConsumer()` helper,让用户显式声明当前 consumer。 + +`registerMFConsumer()` 不是新的底层核心 API,只是 MF Adapter 的便捷入口。它内部仍然写入 Agent Runtime snapshot。 + +第一期 MF 需要记录: + +| 记录数据 | 解决的问题 | +| --- | --- | +| 当前 consumer name / role | Agent 知道当前应用是谁在消费 MF | +| remotes 列表 | Agent 知道当前页面可能加载哪些生产者 | +| remote manifest / remoteEntry URL | 判断远程入口是否错误、404、跨环境 | +| remote loading / success / error | 判断生产者是否加载成功 | +| expose name / load status | 判断具体暴露模块是否解析成功 | +| shared dependency provider / version / singleton | 判断 React 多实例、shared 冲突、版本不一致 | +| uniqueName、build id | 判断 chunk 复用、运行时冲突、构建产物冲突 | +| chunk / asset loading trace | 判断 async chunk 404、publicPath 错、资源错位 | +| loadedBefore / reused | 判断是否复用了之前 consumer 加载过的模块 | + +构建插件接入时,消费者身份可以从 MF 配置自动获得: + +```ts +pluginModuleFederation({ + name: 'federation_consumer', + remotes: { + cloudConsoleProvider: + 'cloudConsoleProvider@http://localhost:4351/mf-manifest.json', + }, + runtimePlugins: [ + agentRuntimeMFPlugin(), + observabilityPlugin(), + ], +}); +``` + +Observability report 到 Agent Runtime 的转换规则: + +| MF 信息 | Agent Runtime 表达 | +| --- | --- | +| consumer / instance | consumer 写成 `type: 'app'`,instance 细节写入 `data` | +| remote loading / success / pending | `type: 'remote'` + 对应 `status` | +| remote failed | `type: 'remote'` + `status: 'error'` | +| remote recovered | `type: 'remote'` + `status: 'success'`,并在 `data` 里记录 recovered | +| expose loaded / failed | `type: 'expose'` + `status: 'success'` 或 `status: 'error'` + `provides` relation | +| shared provider / selected version / available versions | `type: 'shared'` + `shares` relation | +| manifest / remoteEntry / assets | 写入 `data` | +| loading trace events | 自动进入 `events` | +| loadedBefore / reused | 写入 `data`,用于判断是否复用了其他 consumer 的加载结果 | + +## Goofy 接入 + +Goofy 在第一期主要提供部署上下文。它不一定进入页面运行时主链路,但应该让 Agent 能把页面运行态和部署版本关联起来。 + +第一期 Goofy 需要记录或可查询: + +| 记录数据 | 解决的问题 | +| --- | --- | +| app id / app name | Agent 知道页面对应哪个部署应用 | +| env / region / cluster | 判断是不是访问错环境、错区域 | +| release id / deployment id | 判断当前页面来自哪次发布 | +| git commit / branch / build id | 把页面问题关联到源码版本 | +| artifact id / static asset base URL | 判断 JS/CSS/chunk 来自哪个构建产物 | +| deploy status | 判断页面问题是否来自发布未完成或失败 | +| rollback / previous release 信息 | 判断是不是新版本引入的问题 | +| build logs / deploy logs 链接 | 给 Agent 定位构建或部署失败证据 | +| MF manifest / remoteEntry 对应 release | 判断 consumer 和 producer 是否版本错配 | +| sourcemap 是否存在 | 为第二期源码定位做准备 | + +第一期 Goofy 更适合做只读上下文提供者,不建议提供发布、回滚等写操作 action。 + +## 业务代码接入 + +业务只补充框架无法判断的信息。 + +例如业务组件加载完关键数据后,声明状态: + +```tsx +export function OrderDetailPage() { + const { data, loading, error } = useOrderDetail(); + + runtime.updateSnapshot({ + id: 'order-detail-page', + status: error ? 'error' : !loading && data ? 'ready' : 'pending', + data: { + orderId: data?.id, + }, + error, + }); + + if (loading) { + return ; + } + + if (error) { + return ; + } + + return ; +} +``` + +用户也可以声明 Agent 能安全执行的动作: + +```tsx +export function OrderDetailPage() { + const { refetch } = useOrderDetail(); + + runtime.registerAction({ + name: 'retryOrderDetail', + description: 'Retry order detail request.', + risk: 'safe', + handler: () => refetch(), + }); + + return ; +} +``` + +用户侧目标不是“多写埋点”,而是只在业务成功标准和安全动作上补充框架无法自动知道的信息。 + +## 能解决的问题 + +结合现有 demo,第一期可以解决这些问题: + +| 问题类型 | 对应 demo | Agent Runtime 帮助 | +| --- | --- | --- | +| 路由路径或 basename 错误 | `account-flow` | 直接看到 location、basename、matched routes、route component 状态 | +| Router 嵌套或渲染异常 | `workspace-shell` | 看到 route 已匹配,但 route component 或 render error 异常 | +| loader / redirect 状态不清楚 | `cloud-console` | 看到 loader start / success / error / redirect,以及最终 route 状态 | +| MF shared 冲突 | `order-adapter` | 看到 React shared 的 provider、version、singleton、来源 | +| MF remote / expose 加载失败 | `creative-hub` | 看到 consumer、remote、manifest、expose、shared 的加载状态 | +| async chunk / uniqueName / 资源复用问题 | `rivendell-workbench` | 看到 chunk / asset trace、uniqueName、build id、loadedBefore / reused | +| Garfish 子应用 mount 失败 | `creative-hub`、`rivendell-workbench` | 看到 sub-app load / mount / error 状态,并能区分 MF、Garfish 和业务 ready | +| 部署版本错配 | MF / chunk 类 demo | 通过 Goofy 看到 release、commit、artifact、asset base,并关联 consumer / producer 版本 | + +第一期完成后,Agent 至少能把问题归因到: + +```txt +route / loader / render / MF remote / MF shared / Garfish mount / chunk asset / deployment version +``` + +## 使用流程 + +Agent 打开页面后,优先读取 Agent Runtime: + +```ts +const snapshot = await agentRuntime.getSnapshot(); +const events = await agentRuntime.getEvents(); +const actions = await agentRuntime.getActions(); +``` + +如果页面声明了安全动作,Agent 可以执行: + +```ts +await agentRuntime.runAction('cloud-console.run-client-fallback'); +``` + +执行后再读取: + +```ts +const nextSnapshot = await agentRuntime.getSnapshot(); +const nextEvents = await agentRuntime.getEvents({ since: snapshot.latestEventId }); +``` + +Agent 应该用前后状态和事件作为验证证据,而不是只说“页面看起来正常”。 + +第一期可以先用 `cloud-console` 这类 demo 验证: + +1. 页面初始停在 `/`; +2. Agent Runtime snapshot 显示 route pending、loader pending、business pending; +3. Agent 读取 actions,发现可执行 fallback action; +4. Agent 执行 action; +5. 页面进入 `/home`; +6. snapshot 更新为 route success、loader success、business ready; +7. events 记录完整变化过程。 + +这能验证 Agent Runtime 是否真的帮助 Agent 完成: + +```txt +观察 → 操作 → 验证 +``` + +## 阶段规划 + +整体规划收敛为三期。 + +### 第一期:Agent Runtime 最小可用闭环 + +目标: + +> Agent 能在 demo 和本地开发场景里,通过 Agent Runtime 读取状态、执行动作、等待结果,并输出有证据的判断。 + +第一期需要提供: + +| 方向 | 能力 | +| --- | --- | +| Core SDK | `updateSnapshot`、`registerAction`、`unregisterAction`、`runAction`、`waitFor`、`getInputOptions`、`getSnapshot`、`getEvents`、`getActions` | +| Window API | `window.__AGENT_RUNTIME__` | +| HTTP Bridge | 页面主动连接 Bridge;提供 targets、snapshot、events、actions、run-action、wait-for | +| CLI | 启动 Bridge、查看 targets、读取 snapshot / events / actions、执行 action、等待状态 | +| Skill | 告诉 Agent 优先读取 Agent Runtime,没有 runtime 时再 fallback 到 UI、console、network | +| 构建插件 | 提供 Rspack / Webpack 基础版本,用于注入 runtime 初始化和 bridge client;Modern.js 内部可以复用 | +| Modern.js | app/root、route location、basename、match、navigation、redirect、loader、route component、hydration、render error | +| MF | 基于 Observability Plugin 记录 consumer、remote、expose、shared、manifest、remoteEntry、uniqueName、build id、chunk / asset trace、loadedBefore / reused | +| Goofy | app、env、release、commit、branch、artifact、asset base、sourcemap 是否存在 | + +### 第二期:编译产物到源码定位 + +目标: + +> Agent 只看到线上或构建后页面,也能结合 Goofy、sourcemap 和 MF 信息,定位到源码文件和行号。 + +第二期需要打通: + +- 根据页面 URL / asset URL 找到 Goofy release; +- 根据 chunk 找到 artifact; +- 根据 artifact 找到 sourcemap; +- 根据 sourcemap 还原源码位置; +- 结合 MF 信息判断问题属于 consumer 还是 producer; +- 结合 expose / shared / chunk trace 定位具体模块。 + +第二期希望 Agent 输出完整证据链: + +```txt +哪个应用 +哪个 release +哪个 chunk +哪个 sourcemap +哪个源码文件 +哪一行代码 +为什么判断是这里 +``` + +### 第三期:生产可用和平台化 + +目标: + +> 让这套能力能安全地用于 staging / oncall / 平台任务,而不只是本地 demo。 + +第三期需要补齐: + +- Bridge 登录态场景打磨; +- token / origin / app / runtime version 校验; +- action 权限控制; +- sensitive / destructive action 默认禁止; +- staging / oncall 手动开启模式; +- 多 target 管理; +- 平台模板集成; +- 标准评测集; +- 效果数据统计; +- WebMCP adapter 或平台原生工具出口。 + +三期关系可以概括为: + +| 阶段 | 重点 | +| --- | --- | +| 第一期 | 最小可用闭环:Core API + Modern.js + MF + Goofy 基础上下文 + Bridge + CLI + Skill + 构建插件 | +| 第二期 | 源码定位:Goofy + sourcemap + MF 信息定位到源码文件和行号 | +| 第三期 | 生产可用:登录态、权限、安全、平台化、评测、标准出口 | + +## 待确认问题 + +- `window.__AGENT_RUNTIME__` 的精确字段和能力协商协议。 +- Bridge 的默认端口、token 生成和 token 传递方式。 +- staging / oncall 手动开启 Bridge 的具体入口,例如 query、localStorage、服务端配置或平台开关。 +- Goofy 到页面 runtime 的上下文注入方式,是构建期注入、运行期查询,还是 Bridge 侧查询。 +- sourcemap 定位的权限边界和产物访问方式。 +- action 的权限模型,尤其是 `sensitive` 和 `destructive` action 的确认机制。 +- 多 target 场景下的默认选择策略。 +- WebMCP adapter 的标准出口形态。 diff --git a/docs/agent-runtime/rfc-openruntime.md b/docs/agent-runtime/rfc-openruntime.md new file mode 100644 index 000000000000..12c13f25d76e --- /dev/null +++ b/docs/agent-runtime/rfc-openruntime.md @@ -0,0 +1,865 @@ +# RFC: OpenRuntime - 面向 Agent 开放应用运行时 + +## 状态 + +Draft + +## 摘要 + +OpenRuntime 是一套让应用向 Agent 开放运行时状态、事件和动作的能力,用来提速 AI coding 中的页面验证、问题定位和修复闭环。 + +这里的 Open 不是强调开源,而是强调“应用把自己的 Runtime 开放给 Agent 访问”。应用通过 OpenRuntime 暴露结构化的 State、Action 和 Event,让 Agent 可以读取状态、等待变化、执行声明动作并验证结果。 + +## 背景 + +前端场景里的 Agent 经常卡在“实现后验证”这一步。它可以改代码、启动项目、打开页面,但判断页面是否真的可用时,仍然主要依赖 UI、DOM、console、network 或人的反馈。 + +这些外部信号不稳定,也很难告诉 Agent 问题具体卡在哪一层。相同的“页面不对”可能来自 route、loader、render、MF remote、shared、Garfish 子应用、chunk asset、部署版本或业务 ready。 + +OpenRuntime 要解决的是:应用主动把关键运行时语义开放出来,让 Agent 不再只看页面外观,而是能直接读取当前状态、关键变化过程和可执行动作。 + +## 目标 + +- 定义 OpenRuntime 的核心语义:targets、snapshot、events、actions、waitFor。 +- 定义页面内 Runtime Center 的数据模型和 API。 +- 定义页面如何连接本地 Bridge。 +- 定义 Agent / CLI 如何读取状态、执行动作和等待结果。 +- 定义 Modern.js、Module Federation、Garfish 和业务代码如何接入。 +- 通过以上能力,提速 AI coding 中的页面验证、问题定位和修复闭环。 + +## 非目标 + +OpenRuntime 第一版不做: + +- 不做 WebMCP 的竞争协议。 +- 不做通用浏览器自动化框架。 +- 不做任意 DOM 操作系统。 +- 不自动猜业务成功标准。 +- 不让 Agent 执行未声明的危险动作。 +- 不默认采集完整 console、network、DOM mutation 或所有调试日志。 +- 不做跨 tab、跨 iframe、跨 worker 或多 Runtime Center 聚合。 +- 不要求所有生产者强依赖同一个 SDK 包版本。 + +## 使用示例 + +### 使用 API + +Agent 打开页面后,优先读取 OpenRuntime: + +```ts +const snapshot = await openRuntime.getSnapshot(); +const actions = await openRuntime.getActions(); +``` + +如果页面声明了安全动作,Agent 可以执行: + +```ts +await openRuntime.runAction('cloud-console.run-client-fallback'); +``` + +执行后等待目标状态: + +```ts +await openRuntime.waitFor({ + id: 'business:cloud-console.dashboard', + status: 'ready', +}); +``` + +如果等待失败,再读取相关 events: + +```ts +const events = await openRuntime.getEvents({ + since: snapshot.latestEventId, + targetId: 'business:cloud-console.dashboard', +}); +``` + +### 使用 CLI + +Agent 或开发者也可以通过 CLI 访问页面 Runtime: + +```bash +open-runtime wait-for --url http://localhost:8080/route-a route:/route-a ready \ + --timeout 10000 + +open-runtime run-action --url http://localhost:8080/route-a selectRegion \ + --payload '{"province":"zhejiang","city":"hangzhou"}' +``` + +CLI 通过 HTTP Bridge 找到对应页面 Runtime,再调用同一套 Runtime API。 + +## 产品形态 + +| 产物 | 使用方 | 包含内容 | +| --- | --- | --- | +| SDK | 框架、adapter、业务代码 | Runtime Center、写入 API、读取 API、Window API、Modern.js / MF / Garfish 接入、业务 helper | +| CLI | Agent、开发者 | Bridge 管理、Runtime 选择、读取状态、执行 action、waitFor | +| Skill | Agent | 告诉 Agent 什么时候使用 OpenRuntime、如何使用 API / CLI、失败时如何 fallback | + +## 架构图 + +![OpenRuntime 架构图](./assets/openruntime-architecture.png) + +## Runtime Center + +每个页面只维护一个页面级 Runtime Center。 + +```txt +Runtime Client + - modern-js + - module-federation + - garfish + - business + ↓ +Runtime Center + ↓ +Target Registry / Snapshot / Event Log / Action Registry +``` + +多来源可以写入同一个 Runtime Center,但第一期不做多个 Runtime Center 的自动合并。 + +## 核心数据结构 + +| 数据结构 | 回答的问题 | 主要能力 | +| --- | --- | --- | +| Target Registry | 页面里有什么可以被引用或等待 | `registerTarget`、`unregisterTarget`、`getTargets`、`waitFor` 的目标发现 | +| Snapshot | 页面当前是什么状态 | `updateSnapshot`、`getSnapshot`、`waitFor` 的状态判断 | +| Event Log | 页面之前发生过什么 | `getEvents`、失败解释、增量读取 | +| Action Registry | 页面声明了哪些动作可以被 Agent 执行 | `registerAction`、`unregisterAction`、`getActions`、`runAction`、`getInputOptions` | + +## Target Registry + +Target Registry 表示页面里有哪些对象可以被 Agent 引用或等待。 + +```ts +type RuntimeObjectType = string; + +type RuntimeStatus = string; + +type RuntimeError = { + message: string; + code?: string; + stack?: string; + data?: unknown; +}; + +type RegisterTargetInput = { + id: string; + type: RuntimeObjectType; + source: string; + label?: string; + description?: string; + statuses: RuntimeStatus[]; + params?: RuntimeTargetParam[]; + matcher?: RuntimeTargetMatcher; + data?: unknown; +}; + +type RuntimeTargetParam = { + name: string; + type: 'string' | 'number' | 'boolean'; + required?: boolean; + description?: string; +}; + +type RuntimeTargetMatcher = { + type: 'exact' | 'path-pattern' | 'custom'; + pattern?: string; +}; +``` + +OpenRuntime Core 不内置固定的 `RuntimeObjectType` 和 `RuntimeStatus` 枚举。`type` 和 `statuses` 都由 `registerTarget` 显式声明,Runtime Center 只把它们当作字符串处理。`statuses` 不能为空。 + +因此同样是 route、remote、business,不同框架或业务可以声明更准确的状态集合: + +```ts +runtime.registerTarget({ + id: 'route:/route-a', + type: 'modern.route', + source: 'modern-js', + statuses: ['loading', 'ready', 'blocked', 'error', 'inactive'], +}); + +runtime.registerTarget({ + id: 'remote:cloud-console', + type: 'mf.remote', + source: 'module-federation', + statuses: ['idle', 'loading', 'success', 'error'], +}); +``` + +SDK 可以提供常用 helper 或常量,降低接入成本;但这些只是 helper,不是 Core 的内置校验规则。最终允许哪些 `type` 和 `status`,以 `registerTarget` 的声明为准。 + +`unregisterTarget(targetId)` 用于目标不再可被引用或等待的场景,例如动态卸载的子应用、销毁的业务区域或失效的自定义 target。框架静态路由、固定 remote、固定 shared 通常不需要注销,只需要通过 Snapshot 更新为 `inactive`、`blocked` 或 `error`。 + +如果 `updateSnapshot` 更新了未注册过的 id,Runtime Center 应拒绝本次更新,并在开发环境给出 warning,提醒框架或业务在更早时机调用 `registerTarget`。 + +## Snapshot + +Snapshot 表示页面当前仍然有效的运行态,不保存完整历史。 + +```ts +type RuntimeSnapshot = { + targets: Record; + latestEventId: number; + capturedAt: number; +}; + +type RuntimeSnapshotTarget = { + id: string; + type: RuntimeObjectType; + status: RuntimeStatus; + source?: string; + description?: string; + data?: unknown; + error?: RuntimeError; + updatedAt: number; + + dependsOn?: string[]; +}; +``` + +`targets` 使用 target id 映射当前状态。`updateSnapshot` 按 `id` upsert,同一个 `id` 再次更新时只替换当前状态和相关字段,不追加历史 item。历史过程由 events 记录。 + +`dependsOn` 表示当前 target 是否 ready 依赖哪些其他 target。Agent 可以沿着 `dependsOn` 查找 blocker。 + +`latestEventId` 表示这份 Snapshot 截止到哪个 event,Agent 后续可以用 `getEvents({ since: latestEventId })` 读取增量事件。`capturedAt` 表示 Snapshot 生成时间,Bridge 返回断开页面的最后状态时也可以让 Agent 判断状态是否过期。 + +## Event Log + +Event 记录 Snapshot 和 Action 的关键变化过程。 + +```ts +type RuntimeEvent = { + id: number; + type: string; + source: string; + timestamp: number; + targetId?: string; + actionName?: string; + status?: RuntimeStatus; + payload?: unknown; + error?: RuntimeError; +}; +``` + +`payload` 的结构由 `type` 决定。例如 `snapshot.updated` 的 payload 是被接受的 `UpdateSnapshotInput`,`action.started` 的 payload 是 action 入参,`action.success` 的 payload 是 action 返回值。 + +例如: + +```ts +runtime.updateSnapshot({ + id: 'route:/a', + type: 'modern.route', + source: 'modern-js', + status: 'loading', + dependsOn: ['remote:B'], +}); +``` + +会自动记录: + +```json +{ + "id": 1, + "type": "snapshot.updated", + "source": "modern-js", + "timestamp": 1780000001, + "targetId": "route:/a", + "status": "loading", + "payload": { + "id": "route:/a", + "type": "modern.route", + "source": "modern-js", + "status": "loading", + "dependsOn": ["remote:B"] + } +} +``` + +`runAction` 也会自动记录 event: + +```ts +await runtime.runAction('selectRegion', { + province: 'zhejiang', + city: 'hangzhou', +}); +``` + +会自动记录: + +```json +[ + { + "id": 2, + "type": "action.started", + "source": "business", + "timestamp": 1780000002, + "actionName": "selectRegion", + "payload": { + "province": "zhejiang", + "city": "hangzhou" + } + }, + { + "id": 3, + "type": "action.success", + "source": "business", + "timestamp": 1780000003, + "actionName": "selectRegion", + "payload": { + "selected": true + } + } +] +``` + +第一版只记录关键运行态变化,例如 action start / success / error、route change、loader start / success / redirect / error、remote / expose / shared 状态变化、component ready / error、business ready / blocked。 + +Event History 不合并进 Snapshot。Snapshot 是当前状态,Events 是过程解释。 + +## Action Registry + +Action 是页面声明给 Agent 的可调用能力。Agent 不应该随便操作 DOM,而是调用页面声明过的 action。 + +```ts +type RegisterActionInput = { + name: string; + description?: string; + source?: string; + risk?: RuntimeActionRisk; + availableWhen?: RuntimeCondition | RuntimeCondition[]; + inputSchema?: RuntimeJsonSchema; + getInputOptions?: RuntimeInputOptionsProvider; + handler: RuntimeActionHandler; +}; + +type RuntimeActionRisk = + | 'safe' + | 'state-changing' + | 'destructive' + | 'sensitive'; +``` + +`name` 和 `handler` 是核心。`name` 是 Agent 调用 action 时使用的公开名称,例如 `route-a.click-submit`。Runtime 内部如需稳定 id,可以自己生成,不要求使用方传入。 + +`source` 不是必填字段。Runtime Client 可以自动补默认 source,例如业务侧默认 `business`,Modern.js / MF / Garfish adapter 使用自己的 source。 + +`description` 用于解释 action 的用途,不再额外提供 `label`。`risk` 默认值是 `state-changing`,不能默认当成 `safe`。`safe` 只用于确认不会改变持久状态或敏感状态的动作。 + +`availableWhen` 表示 action 什么时候可以执行。第一版不提供 `blockedBy`,因为它和 `availableWhen` 容易重复。 + +```ts +type RuntimeCondition = { + id: string; + status: RuntimeStatus; +}; +``` + +`inputSchema` 描述 action 输入结构。第一版使用可序列化 JSON Schema 子集,不直接依赖 zod。业务如果希望用 zod,可以通过 helper 转成 JSON Schema 后注册。 + +```ts +type RuntimeJsonSchema = { + type: 'object'; + properties?: Record; + required?: string[]; + additionalProperties?: boolean; +}; + +type RuntimeJsonSchemaProperty = { + type: 'string' | 'number' | 'boolean' | 'array' | 'object'; + description?: string; + enum?: Array; + items?: RuntimeJsonSchemaProperty; + properties?: Record; + required?: string[]; + additionalProperties?: boolean; +}; +``` + +动态选项 provider: + +```ts +type RuntimeInputOptionsProvider = ( + inputName: string, + currentPayload?: Record, + context?: RuntimeActionContext, +) => Promise | RuntimeInputOption[]; + +type RuntimeInputOption = { + value: string | number | boolean; + description?: string; +}; +``` + +`RuntimeInputOption` 第一版不提供 `label`、`disabled` 或 `reason`。业务只返回当前可用选项,CLI 可以用 `String(value)` 作为短展示。 + +Action handler: + +```ts +type RuntimeActionHandler = ( + payload: unknown, + context: RuntimeActionContext, +) => Promise | unknown; + +type RuntimeActionContext = { + actionName: string; + getSnapshot: () => RuntimeSnapshot; + updateSnapshot: (input: UpdateSnapshotInput) => void; + waitFor: ( + condition: RuntimeCondition, + options?: RuntimeWaitOptions, + ) => Promise; +}; +``` + +`handler` 只在页面内执行,不暴露给 Agent。正常执行完成即视为 action success,throw 则记录 action error。 + +`runAction` 不会自动更新 Snapshot。它只会自动记录 `action.started`、`action.success` 或 `action.error`。如果 action 执行后业务状态发生变化,需要 handler 调用 `ctx.updateSnapshot(...)`,或者由框架 / adapter 自动捕获并调用 `updateSnapshot`。 + +## API + +### 写入侧 API + +适用于页面内 SDK、框架 adapter 和业务代码,用来注册目标、更新状态、声明动作和连接 Bridge。 + +```ts +runtime.connectBridge(options?); +runtime.registerTarget(target); +runtime.unregisterTarget(targetId); +runtime.updateSnapshot(input); +runtime.registerAction(action); +runtime.unregisterAction(actionName); +runtime.runAction(actionName, payload?); +``` + +#### registerTarget + +```ts +runtime.registerTarget(target: RegisterTargetInput); +runtime.unregisterTarget(targetId: string); +``` + +`registerTarget` 用来提前声明页面里可以被 Agent 引用或等待的目标。它回答的是“有什么可以等”,不是“现在是什么状态”。 + +调用方必须在注册时声明 `type` 和 `statuses`。OpenRuntime Core 不会为 route、remote、business 等对象补内置状态。 + +框架 adapter 应该在自己已经知道目标的时候尽早注册,例如 Modern.js 在路由表生成后注册 route target,MF 在 remotes 配置可用后注册 remote / expose / shared target,Garfish 在子应用注册信息可用后注册 sub-app target。 + +调用 `registerTarget` 不会让这个 target 出现在 Snapshot 里。只有后续调用 `updateSnapshot` 写入当前状态后,它才会出现在 `snapshot.targets` 中。 + +`unregisterTarget` 用来注销不再可引用或等待的目标。如果这个 target 上还有等待任务,相关 `waitFor` 应直接返回失败。 + +#### connectBridge + +页面侧连接 Bridge 的第一版 API: + +```ts +runtime.connectBridge(options?: BridgeConnectOptions); + +type BridgeConnectOptions = { + port?: number; + autoReconnect?: boolean; +}; +``` + +第一版不提供 `bridgeUrl`、`token`、`name` 或 `disconnectBridge()`。`host` 固定为 `localhost`,`port` 默认可以是 `17321`。prefix、host 和鉴权能力后续再扩展。 + +`autoReconnect` 默认是 `true`。连接断开后自动尝试重连,退避间隔为: + +```txt +1s -> 2s -> 4s -> 8s -> 10s -> 10s ... +``` + +页面卸载时停止重连。页面关闭、刷新或连接断开后,Bridge 通过连接断开事件感知,并把 Runtime 标记为 `disconnected`。Bridge 可以保留最后一次 snapshot 和 events 一小段时间,例如 60s,之后再清理。页面重新连接时分配新的 `runtimeId`,不复用旧连接的 `runtimeId`。 + +#### updateSnapshot + +```ts +type UpdateSnapshotInput = { + id: string; + status: RuntimeStatus; + type?: RuntimeObjectType; + source?: string; + description?: string; + data?: unknown; + error?: RuntimeError; + dependsOn?: string[]; +}; +``` + +必填字段只有 `id` 和 `status`。`type` 不再有默认值;如果传入 `type`,必须和已注册 target 的 `type` 一致。`source` 默认可以由 Runtime Client 补齐,例如业务侧默认 `business`,Modern.js / MF / Garfish adapter 使用自己的 source。 + +`updateSnapshot` 必须符合 target 声明的状态范围: + +- 如果 `id` 已注册,`status` 必须在该 target 的 `statuses` 中。 +- 如果 `id` 未注册,本次更新被拒绝,Snapshot 不变。 +- 如果 `status` 不在允许范围内,本次更新被拒绝,Snapshot 不变。 +- 被拒绝的更新需要记录 `snapshot.update.rejected` event。 +- 第一版不引入 strict mode,也不因为校验失败 throw,避免影响页面正常运行。 + +### Agent 读取 / 执行 API + +适用于 Agent、CLI、HTTP Bridge 和 Window API 读取侧,用来读取状态、读取事件、发现动作、执行动作和等待结果。 + +```ts +getTargets(query?); +getSnapshot(query?); +getEvents(query?); +getActions(query?); +getInputOptions(actionName, inputName, currentPayload?); +runAction(actionName, payload?); +waitFor(condition, options?); +``` + +#### getTargets + +```ts +type GetTargetsQuery = { + type?: RuntimeObjectType | RuntimeObjectType[]; + source?: string | string[]; + id?: string | string[]; + status?: RuntimeStatus | RuntimeStatus[]; + query?: string; +}; + +type RuntimeTargetDescriptor = { + id: string; + type: RuntimeObjectType; + source: string; + label?: string; + description?: string; + statuses: RuntimeStatus[]; + params?: RuntimeTargetParam[]; + matcher?: RuntimeTargetMatcher; + data?: unknown; + registeredAt: number; + updatedAt: number; +}; + +getTargets(query?: GetTargetsQuery): RuntimeTargetDescriptor[]; +``` + +`getTargets(query?)` 返回 Target Registry 里的可发现目标,不是当前状态。它告诉 Agent “有哪些 target 可以被引用或等待”。当前状态仍然通过 `getSnapshot(query?)` 或 `waitFor()` 判断。 + +`status` 在 `getTargets` 中匹配的是 target 注册时声明的 `statuses`,不是当前运行状态。如果要按当前状态过滤,应使用 `getSnapshot(query?)`。 + +`query` 第一版只匹配 target 的 `id`、`label` 和 `description`,大小写不敏感,使用 `includes` 匹配,不匹配 `data`。 + +#### getSnapshot + +```ts +type GetSnapshotQuery = { + id?: string | string[]; + type?: RuntimeObjectType | RuntimeObjectType[]; + source?: string | string[]; + status?: RuntimeStatus | RuntimeStatus[]; + query?: string; +}; + +getSnapshot(query?: GetSnapshotQuery): RuntimeSnapshot; +``` + +不传参数时,`getSnapshot()` 返回当前页面完整 Snapshot。传入 `query` 时,只过滤当前 Snapshot 里已经存在的 target。 + +如果某个 target 只是通过 `registerTarget` 声明过,但还没有任何当前状态,它不会出现在 `snapshot.targets` 里。要发现“有哪些 target 可以被引用或等待”,应该使用 `getTargets()`。 + +`query` 第一版只匹配 target 的 `id` 和 `description`,不匹配 `data`。 + +#### getEvents + +```ts +type GetEventsQuery = { + since?: number; + targetId?: string | string[]; + actionName?: string | string[]; + type?: string | string[]; + source?: string | string[]; + status?: RuntimeStatus | RuntimeStatus[]; + limit?: number; +}; + +type GetEventsResult = { + events: RuntimeEvent[]; + latestEventId: number; + truncated: boolean; +}; + +getEvents(query?: GetEventsQuery): GetEventsResult; +``` + +`since` 表示只读取 `id > since` 的事件。`targetId` 只匹配事件里的 `targetId`。第一版不单独记录关系事件,依赖变化会出现在 `snapshot.updated` 的 `payload.dependsOn` 中。 + +默认按事件发生顺序返回。如果没有传 `since`,默认只取最近一批事件,第一版默认 `limit` 可以是 100。`truncated: true` 表示结果被截断。 + +`latestEventId` 表示当前 Event Log 最新 event id。即使本次查询没有返回事件,也可以用它作为下一次增量读取的起点。 + +`getEvents` 不做全文搜索,也不匹配 `payload`。 + +#### getActions + +```ts +type GetActionsQuery = { + name?: string | string[]; + source?: string | string[]; + risk?: RuntimeActionRisk | RuntimeActionRisk[]; + enabled?: boolean; + query?: string; +}; + +type RuntimeActionDescriptor = { + name: string; + description?: string; + source: string; + risk: RuntimeActionRisk; + availableWhen?: RuntimeCondition | RuntimeCondition[]; + inputSchema?: RuntimeJsonSchema; + hasInputOptions: boolean; + enabled: boolean; + reason?: string; + registeredAt: number; + updatedAt: number; +}; + +getActions(query?: GetActionsQuery): RuntimeActionDescriptor[]; +``` + +`getActions` 返回 action 描述,不包含 `handler`,也不包含 `getInputOptions` 函数。Agent 如果需要读取动态选项,应继续调用 `getInputOptions(actionName, inputName, currentPayload?)`。 + +`risk` 没有显式注册时返回默认值 `state-changing`。`source` 没有显式注册时返回 Runtime Client 补齐后的默认 source。 + +`enabled` 根据当前 Snapshot 和 `availableWhen` 计算。`reason` 只在 `enabled: false` 时返回。 + +`query` 第一版只匹配 action 的 `name` 和 `description`。 + +#### getInputOptions + +```ts +getInputOptions( + actionName: string, + inputName: string, + currentPayload?: Record, +): Promise | RuntimeInputOption[]; +``` + +`actionName` 是 action 的 `name`。`inputName` 是 `inputSchema.properties` 里的字段名。`currentPayload` 表示 Agent 当前已经填好的参数,主要用于级联选项。 + +如果 action 没有注册 `getInputOptions`,返回空数组。如果 `inputName` 不在 `inputSchema.properties` 里,也返回空数组,并可以记录 warning 或 rejected event。 + +`getInputOptions` 只返回当前可用选项,不返回不可选项,也不返回禁用原因。业务侧负责决定哪些选项当前可用。 + +`getInputOptions` 支持异步 provider。HTTP Bridge 和 CLI 调用时必须等待 provider 完成后再返回最终 options。CLI 默认等待超时可以是 5s;超时后返回失败,不返回半成品。CLI 可以通过 `--timeout` 覆盖等待时间。 + +#### runAction + +```ts +runAction(actionName: string, payload?: Record); +``` + +`payload` 必须符合 action 注册时的 `inputSchema`。如果 `payload` 不符合 `inputSchema`,或者 `availableWhen` 不满足,Runtime 直接返回失败,不调用 handler。 + +`runAction` 只执行 action,不提供 `expect` 或 `waitForExpect`,也不会自动更新 Snapshot。它只会自动记录 `action.started`、`action.success` 或 `action.error`。 + +如果 Agent 要验证执行后的页面状态,应拆成两步: + +```ts +await runtime.runAction('selectRegion', { + province: 'zhejiang', + city: 'hangzhou', +}); + +await runtime.waitFor({ + id: 'business:region-selection', + status: 'ready', +}); +``` + +#### waitFor + +```ts +waitFor( + condition: RuntimeCondition, + options?: RuntimeWaitOptions, +): Promise; + +type RuntimeWaitOptions = { + timeout?: number; +}; + +type RuntimeWaitResult = { + success: boolean; + condition: RuntimeCondition; + snapshot: RuntimeSnapshot; + target?: RuntimeSnapshotTarget; + reason?: string; +}; +``` + +`waitFor` 只等待某个 target 到达某个状态,不返回 events,也不解释完整过程。Agent 如果需要失败原因,应在 `waitFor` 失败后继续调用 `getEvents`。 + +实现规则: + +- 调用时先检查当前 Snapshot。如果 `snapshot.targets[id]?.status === status`,立即返回成功。 +- 如果 target 当前不在 Snapshot,但已经存在于 Target Registry,继续等待。 +- 如果 target 在 Snapshot 和 Target Registry 里都不存在,直接返回失败,避免 target id 写错后一直等到超时。 +- Runtime Center 维护等待任务列表,每个任务记录 `id`、`status`、`timeout` 和 resolve。 +- 每次 `updateSnapshot` 后,只检查等待这个 target 的任务。状态匹配时返回成功。 +- 超时不 throw,返回 `success: false`,并带当前 Snapshot 和失败原因。 +- 如果 target 被 `unregisterTarget` 注销,相关等待任务直接返回失败。 + +### Window API + +适用于页面内调试、Agent 通过浏览器上下文直接读取、没有 HTTP Bridge 时兜底,以及验证 SDK 是否正常工作。 + +```ts +window.__OPEN_RUNTIME__ +``` + +Window API 不负责跨进程通信。页面外的 Agent 如果要稳定访问页面运行态,仍然需要 HTTP Bridge。 + +## CLI + +CLI 主要配合 HTTP Bridge 使用。它是 Bridge Server 管理器、Bridge HTTP API 客户端和接入辅助工具。 + +### HTTP Bridge + +HTTP Bridge 是本地服务,不是 SDK 本体。页面 Runtime 主动连接 Bridge,页面外的 Agent / CLI 通过 Bridge 访问页面 Runtime。 + +第一版 HTTP Bridge 可以提供: + +```txt +GET /runtimes +GET /runtimes/:runtimeId/targets +GET /runtimes/:runtimeId/snapshot +GET /runtimes/:runtimeId/events +GET /runtimes/:runtimeId/actions +GET /runtimes/:runtimeId/actions/:name/options +POST /runtimes/:runtimeId/actions/:name/run +POST /runtimes/:runtimeId/wait-for +GET /runtimes/:runtimeId/events/stream +``` + +Bridge 接受页面 Runtime 连接后自动生成 `runtimeId`。`runtimeId` 表示这一次连接实例,不由页面自定义,也不跨刷新复用。 + +```ts +type ConnectedRuntime = { + runtimeId: string; + url: string; + appName?: string; + runtimeVersion: string; + status: 'connected' | 'disconnected'; + connectedAt: number; + lastSeenAt: number; + disconnectedAt?: number; +}; +``` + +同一个 URL 可能有多个 Runtime 连接,例如同一个页面开了多个 tab、页面刷新时新旧连接短暂共存、或者 Agent 和人同时打开了同一个 URL。因此 Bridge 必须用 `runtimeId` 区分连接实例。 + +### CLI 命令 + +第一版 CLI: + +```bash +open-runtime bridge start --port 17321 +open-runtime bridge status +open-runtime runtimes +open-runtime targets [runtime selector] [query options] +open-runtime snapshot [runtime selector] [query options] +open-runtime events [runtime selector] [query options] +open-runtime actions [runtime selector] [query options] +open-runtime input-options [runtime selector] --action --input +open-runtime run-action [runtime selector] +open-runtime wait-for [runtime selector] +``` + +runtime selector 用于选择 Bridge 里已经连接的页面 Runtime 实例: + +```bash +--url +--runtime +``` + +`--url` 是日常主入口。CLI 用它匹配 Bridge 当前连接的 Runtime URL;如果同一个 URL 匹配到多个 Runtime,默认选择 `lastSeenAt` 最新的。`--runtime` 是精确兜底参数,值来自: + +```bash +open-runtime runtimes +``` + +如果没有传 `--url` 或 `--runtime`,CLI 默认使用最新活跃的 Runtime,并在输出中显示本次选中的 url 和 runtime id。如果 Bridge 当前没有连接 Runtime,则命令失败。 + +CLI 和 API 对应关系: + +| CLI | 参数 | 对应 API | +| --- | --- | --- | +| `open-runtime runtimes` | 无 | Bridge runtime 列表,不对应页面 Runtime API | +| `open-runtime targets` | `--type`、`--source`、`--id`、`--status`、`--query` | `getTargets(query)` | +| `open-runtime snapshot` | `--id`、`--type`、`--source`、`--status`、`--query` | `getSnapshot(query)` | +| `open-runtime events` | `--since`、`--target-id`、`--action`、`--type`、`--source`、`--status`、`--limit` | `getEvents(query)` | +| `open-runtime actions` | `--name`、`--source`、`--risk`、`--enabled`、`--query` | `getActions(query)` | +| `open-runtime input-options` | `--action`、`--input`、`--payload`、`--timeout` | `getInputOptions(actionName, inputName, currentPayload)` | +| `open-runtime run-action` | ``、`--payload` | `runAction(actionName, payload)` | +| `open-runtime wait-for` | ``、``、`--timeout` | `waitFor({ id, status }, { timeout })` | + +## 生态能力接入 + +### Modern.js + +Modern.js 自动写入自己能确定的信息: + +- app root 生命周期; +- route location; +- route match; +- basename; +- navigation; +- loader start / success / redirect / error; +- SSR 初始状态; +- hydration 状态; +- route component mounted / error。 + +Modern.js 还应该在真实 navigation、loader 和组件挂载开始前,提前注册自己已经知道的 target。例如在路由表生成并完成 `modifyRoutes` 后,可以注册 route target、loader target 和 route component target;后续运行时再通过 `updateSnapshot` 更新当前状态。 + +Modern.js 负责提供框架层 ready,但不负责猜业务是否成功。 + +### Module Federation + +MF 自动写入模块加载相关信息: + +- consumer name / role; +- instance name; +- remote name; +- manifest / remoteEntry; +- expose; +- shared dependency; +- uniqueName; +- build id; +- runtime error。 + +MF Adapter 应该在 MF instance 初始化和 remotes 配置可用后,提前注册 consumer、remote、expose 和 shared target;随后把 manifest、remoteEntry、expose、shared 的加载过程更新到 snapshot 和 events。 + +MF 自身状态优先复用 MF Observability Plugin。OpenRuntime 不重复实现一套 MF 加载追踪,而是在 MF 侧提供一个 OpenRuntime MF Adapter。 + +### Garfish + +Garfish adapter 自动写入子应用加载和挂载相关信息: + +- sub-app 注册信息; +- entry / provider 加载状态; +- bootstrap / mount / unmount 状态; +- 子应用 runtime error; +- 子应用与当前 route 的关系。 + +Garfish 负责说明子应用是否加载和挂载成功,不判断子应用内部业务是否真正 ready。 + +### 业务代码 + +业务只补充框架无法判断的信息: + +- 业务数据是否 ready; +- 某个复杂面板是否可用; +- 某个操作是否安全可执行; +- action 参数 schema; +- action 动态选项。 + +业务不需要直接手写复杂 target。业务侧可以通过 `useOpenRuntimeReady`、`OpenRuntimeReady` 或更轻量 helper 自动注册 `business` target,并在业务状态变化时更新 snapshot。只有特殊业务对象才需要直接调用 `registerTarget`。 diff --git a/docs/agent-runtime/rfc-runtime-center.md b/docs/agent-runtime/rfc-runtime-center.md new file mode 100644 index 000000000000..6340ddde1a92 --- /dev/null +++ b/docs/agent-runtime/rfc-runtime-center.md @@ -0,0 +1,1082 @@ +# RFC: Support Runtime Inspection and Actions for Agents in Modern.js + +## 状态 + +Draft + +## 摘要 + +本文定义一套 Modern.js 面向 AI Agent 的运行态观测和受控操作能力。它相当于给 Agent 提供观察页面的“眼”和执行动作的“手”,帮助 Agent 在开发、调试和 oncall 场景中更快理解页面状态、定位阻塞原因,并执行页面声明过的安全动作。 + +核心思路: + +- 框架自动采集 route、loader、渲染、MF、Garfish、错误等运行态信息。 +- 业务用轻量标注补充框架无法自动判断的 ready 条件和安全动作。 +- 这些信息统一进入页面内运行态中心,并对外提供 snapshot、events 和 actions。 +- Agent 通过这套接口读取状态、订阅事件、执行声明过的动作。 +- 页面 runtime 只负责采集和维护状态,不在浏览器页面里启动 server。 +- HTTP / SSE / CLI 由外部 Node Bridge Server 提供。 + +目标是把 Agent 过去依赖 UI、DOM、请求是否安静这类脆弱判断,改成可读取、可订阅、可验证、可操作的结构化能力。 + +## 背景 + +AI Agent 在开发、调试和 oncall Modern.js 应用时,通常需要先判断页面是否可用,以及不可用时卡在哪一层。当前主要依赖页面外部信号: + +| 判断方式 | 能看到什么 | +| --- | --- | +| 看页面 UI | 页面是否白屏、是否出现 loading、内容是否符合预期 | +| 查 DOM | 某个关键元素是否出现 | +| 等请求安静 | 网络请求是否停止 | +| 看 console | 页面是否有运行时报错 | +| 手动点击页面 | 某个流程是否能继续走下去 | + +这些方式能提供线索,但在真实开发和 oncall 场景里有明显限制: + +| 限制 | 影响 | +| --- | --- | +| 只能看到外部结果 | Agent 容易知道“页面不对”,但不知道卡在 route、loader、MF、Garfish、React 渲染还是业务 ready | +| 判断质量不稳定 | DOM 出现不代表业务可用,请求停止不代表页面 ready,console 报错也不一定是根因 | +| 定位速度慢 | Agent 需要反复等待、观察、扫 DOM、看日志和试点击 | +| 操作不可靠 | Agent 不知道哪些动作安全,也不知道执行后应该等待什么状态 | + +同样是“页面没按预期工作”,外部现象可能很像,但实际问题分布在不同运行层: + +| 场景 | 外部现象 | 实际需要判断的运行态 | +| --- | --- | --- | +| React 依赖或实例异常 | 页面报错或组件不可用 | shared 依赖来源、React 实例、组件错误 | +| 嵌套路由或远程组件关系异常 | 页面局部异常或远程组件关系不清 | route match、组件关系、渲染错误 | +| 异步 chunk 或运行时隔离异常 | 点击后 chunk 404 或子应用异常 | build 信息、remote 事件、Garfish 事件、chunk 错误 | +| Garfish 子应用白屏 | 子应用白屏或挂载失败 | entry 是否加载、provider 是否存在、mount 是否成功 | +| redirect / loader 卡住 | 页面一直 pending 或跳转不完整 | loader 是否执行、redirect 是否发生、route 是否卡住 | +| 程序化跳转后空白 | 跳转后空白 | route 是否匹配、basename 是否正确、action 是否执行 | + +因此希望在 Modern.js 中提供一套面向 AI Agent 的运行态观测和受控操作能力:通过 snapshot 读取页面当前状态,通过 events 订阅运行过程,通过 ready / blockers 判断页面是否可用和卡在哪,通过 actions 执行页面声明过的安全动作。 + +最终让 Agent 从“观察外部现象并猜测”,变成“读取结构化运行态并按声明能力操作”,提升开发、调试和 oncall 的质量和速度。 + +## 目标 + +本 RFC 目标是在 Modern.js 中定义一套面向 AI Agent 的页面运行态能力,包含: + +- 提供页面当前状态快照,覆盖 route、loader、React 渲染、MF、Garfish、错误和用户 ready 标注。 +- 提供运行时事件流,让 Agent 可以订阅 route、loader、组件 ready、remote 加载、子应用挂载等状态变化。 +- 提供页面 ready 判断,返回当前是否可用、已满足的证据和未完成的 blockers。 +- 提供受控 actions,让 Agent 只能执行框架或业务声明过的安全动作。 +- 提供统一访问方式,支持页面内 API、CLI、HTTP API 和事件流。 +- 明确框架自动采集和业务手动标注的边界。 + +## 使用示例 + +### 获取页面当前快照 + +Agent 打开页面后,先通过 CLI 或 HTTP API 读取目标页面的 snapshot,判断页面卡在哪一层。 + +```bash +agent-runtime snapshot --target http://localhost:4332 +``` + +或: + +```http +GET /snapshot +``` + +返回信息包含: + +- 当前 URL、route match 和 navigation 状态; +- app root 是否 mounted; +- 当前 route loader 是否完成; +- 当前页面依赖的 remote / expose / shared 是否成功,视项目类型而定; +- Garfish 子应用是否 mounted,视项目类型而定; +- 当前 fatal error 和最近 runtime error; +- 当前页面声明过哪些 actions。 + +这类能力用于 oncall 排障时,先回答“页面现在是什么状态”,而不是直接猜。 + +### 等待页面 ready + +Agent 可以等待页面达到指定 ready level。 + +```bash +agent-runtime wait page.ready --target route:/trade/order --timeout 10000 +``` + +或: + +```http +POST /wait +Content-Type: application/json + +{ + "type": "page.ready", + "target": "route:/trade/order", + "timeout": 10000 +} +``` + +如果超时,返回当前 blockers: + +```json +{ + "ready": false, + "phase": "blocked", + "blockers": [ + "remote expose failed: provider/Button", + "component not ready: order-form" + ] +} +``` + +这类能力用于区分: + +- 路由没匹配; +- loader 没完成; +- remote 没加载成功; +- 组件已经 mounted,但业务 ready 没触发。 + +### 精准等待业务组件加载完成 + +如果业务已经在代码里声明了某个组件的加载完成条件,Agent 就可以在页面加载前开始等待这个信号,并精准捕获组件真正可用的时机。 + +例如,业务组件声明 `user-profile` 什么时候算加载完成: + +```tsx +useAgentReady('user-profile', !loading && Boolean(data)); +``` + +这里的 `useAgentReady` 由业务组件调用,不是 Agent 调用,也不是 runtime 主动执行。第二个参数是业务自己定义的完成条件。 + +Agent 等待这个信号: + +```bash +agent-runtime events --type component.ready --target component:user-profile +``` + +或: + +```http +GET /events/stream?type=component.ready&target=component:user-profile +``` + +当 `loading` 结束且 `data` 有值时,runtime 记录 `user-profile` 已完成,并发出 `component.ready` 事件。Agent 会立即收到事件,不需要反复轮询 DOM。 + +### 执行页面声明过的动作 + +页面或框架可以声明安全动作,例如加载 remote、进入某个路由、重试失败请求。 + +```bash +agent-runtime actions +agent-runtime action load-provider-remote +``` + +或: + +```http +GET /actions +POST /actions/load-provider-remote +``` + +event 只能观察,action 才能执行。Agent 不能通过订阅事件来触发页面行为。 + +### 调试需要登录态的页面 + +调试需要登录态的页面时,可以让 CLI 连接已有浏览器 tab,复用当前登录状态。 + +```bash +agent-runtime snapshot --target http://localhost:4332 +agent-runtime wait page.ready --timeout 10000 +agent-runtime events --since 42 +agent-runtime action load-provider-remote +``` + +典型流程: + +1. 读取 snapshot,确认当前页面是否 ready; +2. 如果不 ready,查看 blockers; +3. 订阅后续 events; +4. 只执行页面声明过的 actions; +5. 把 snapshot 和 events 作为调试结论证据。 + +## 术语 + +### Runtime Center + +页面内的状态聚合模块,负责维护当前 snapshot、历史 events、订阅关系和 actions。它是内部实现,不是独立服务,也不是对外产品名。 + +### Runtime Client + +页面端 API。框架插件、MF、Garfish 和业务代码通过它写入运行态信息、声明 ready 条件、注册 actions。 + +### Bridge Server + +运行在页面外的本地服务,对 Agent 暴露 CLI / HTTP / SSE 能力,并通过浏览器连接访问目标页面的 Runtime Center。 + +### Browser Connector + +Bridge Server 和浏览器 tab 之间的连接层。第一阶段推荐使用 CDP。 + +### Snapshot + +页面当前运行态快照,用来回答“页面现在是什么状态”。 + +### Event + +运行过程中已经发生的事实,例如 `route.started`、`loader.success`、`component.ready`。Event 只用于观察,不能触发页面行为。 + +### Action + +页面或框架声明过的可执行动作,例如 `load-provider-remote`、`enter-default-page`。Agent 只能执行已声明的 actions。 + +### Ready + +页面、路由、组件或子应用达到可用状态的信号。框架可以判断基础 ready,业务 ready 需要用户显式声明。 + +### Blocker + +导致页面暂时不可用或无法判断 ready 的阻塞原因,例如 loader 未完成、remote 加载失败、组件 ready 未触发。 + +## 架构图 + +![架构图](./assets/architecture.png) + +图中需要注意几个边界: + +- 页面内 Runtime Center 只负责维护 snapshot、events、actions 和 blockers,不在浏览器页面里启动 HTTP server。 +- Modern.js 框架、MF、Garfish 和业务标注都通过 Runtime Client 写入 Runtime Center。 +- Agent 通过 CLI / HTTP / SSE 访问能力;这些入口由页面外的 Bridge Server 提供。 +- Bridge Server 通过 Browser Connector 连接目标浏览器 tab,再访问页面内 Runtime API。 +- snapshot / events 是观测路径,从页面返回给 Agent;actions 是执行路径,只能触发页面声明过的动作。 + +## 实现方案 + +实现方案按模块拆分。各模块通过统一的数据结构和 API 协作,Runtime Center 负责页面内状态聚合,Modern.js、MF、Garfish 和业务代码负责写入运行态信息,Agent 访问层负责从页面外读取和操作这些能力。 + +| 模块 | 主要产出 | 主要依赖 | 并行关系 | +| --- | --- | --- | --- | +| 1. 数据结构和 API | 统一的数据结构和页面端 API | 无 | 需要优先稳定 | +| 2. Runtime Center | 页面内状态中心 | 数据结构和 API | 可独立开发 | +| 3. Modern.js 运行链路改造 | 框架自动写入运行态 | Runtime Client | 可和 Agent 访问层并行 | +| 4. 生态能力接入 | MF / Garfish 状态写入 | Runtime Client、事件 schema | 可由生态 owner 并行开发 | +| 5. 业务拓展 API | 业务 ready 和 actions 声明方式 | Runtime Client、Action schema | 可和框架改造并行 | +| 6. Agent 访问层 | Bridge Server、CLI、HTTP / SSE | 页面端 Runtime API | 可先用 mock Runtime API 开发 | +| 7. Page Ready 组合规则 | ready / evidence / blockers | Runtime Center 和各类事件 | 依赖前面状态输入 | +| 8. 安全和验证 | 权限、脱敏、demo 和测试 | 全部模块 | 贯穿开发,最后收敛验收 | + +### 1. 数据结构和 API + +这一节定义所有模块共同使用的数据结构和 API,包括 snapshot、events、actions、ready、blockers、Runtime Client 和页面端 Runtime API。 + +三类核心对象的职责如下: + +| 对象 | 使用方 | 作用 | +| --- | --- | --- | +| `RuntimeClient` | Modern.js、MF Adapter、Garfish Adapter、业务拓展 API | 写入运行态信息,例如 route、loader、remote、ready、action | +| `RuntimeCenter` | 页面内 runtime | 存储 snapshot、events、actions,并计算 ready / blockers | +| `AgentRuntime` | Bridge Server、CLI、DevTools、Agent | 读取 snapshot、订阅 events、等待 ready、执行已声明 actions | + +`RuntimeClient` 和 `AgentRuntime` 都是 `RuntimeCenter` 暴露出的不同入口:前者用于写入,后者用于读取和受控操作。 + +对框架用户而言,主要使用的是业务拓展 API,例如 `useAgentReady`、`AgentReady`、`registerAction`。`RuntimeClient`、`RuntimeCenter` 和 `AgentRuntime` 是框架、adapter 和 Agent 访问层的内部协作对象,不建议业务代码直接使用。 + +#### 页面端 Runtime API + +页面内 runtime 暴露给 Bridge Server / DevTools / 调试脚本使用的 API: + +```ts +type AgentRuntime = { + getSnapshot(filter?: SnapshotFilter): Promise; + getEvents(filter?: EventFilter): Promise; + subscribeEvents( + filter: EventFilter, + listener: (event: RuntimeEvent) => void, + ): () => void; + waitForEvent( + filter: EventFilter, + options?: WaitOptions, + ): Promise; + getActions(filter?: ActionFilter): Promise; + runAction(actionId: string, payload?: unknown): Promise; +}; +``` + +Agent 正常不直接在业务代码里调用这个对象,而是通过 CLI / HTTP / SSE 访问。Bridge Server 再进入目标浏览器 tab,调用页面端 Runtime API。 + +这里的 `AgentRuntime` 是 Runtime Center 暴露给页面外部工具使用的 API 形态,不是 Runtime Center 本身。 + +#### Runtime Client API + +Runtime Client 是框架、生态 adapter 和业务代码写入 Runtime Center 的页面端 API。它至少需要支持: + +- 写入 snapshot 局部状态; +- 发出 runtime event; +- 注册或更新 ready marker; +- 注册 actions; +- 写入 error、evidence 和 blockers。 + +原则是:先更新 snapshot,再发 event。这样 Agent 收到 event 后,马上能读到最新状态。 + +```ts +type RuntimeClient = { + updateState(patch: RuntimeStatePatch): void; + emit(event: RuntimeEventInput): RuntimeEvent; + markReady(target: string, ready: boolean, details?: unknown): void; + registerAction(action: ActionDescriptor, handler: ActionHandler): void; + reportError(error: RuntimeError): void; +}; +``` + +#### RuntimeCenter + +`RuntimeCenter` 是页面内的内部对象。它通过 `client` 接收框架和业务写入,通过 `api` 暴露给 Bridge Server。 + +```ts +type RuntimeCenter = { + api: AgentRuntime; + client: RuntimeClient; + + getSnapshot(filter?: SnapshotFilter): RuntimeSnapshot; + appendEvent(event: RuntimeEventInput): RuntimeEvent; + updateState(patch: RuntimeStatePatch): void; + registerAction(action: ActionDescriptor, handler: ActionHandler): void; + runAction(actionId: string, payload?: unknown): Promise; + subscribeEvents( + filter: EventFilter, + listener: (event: RuntimeEvent) => void, + ): () => void; + waitForEvent( + filter: EventFilter, + options?: WaitOptions, + ): Promise; +}; +``` + +#### Snapshot + +```ts +type RuntimeSnapshot = { + page: PageState; + route?: RouteState; + loaders?: LoaderState[]; + components?: ComponentState[]; + remotes?: RemoteState[]; + shared?: SharedState[]; + garfish?: GarfishState; + build?: BuildRuntimeInfo; + actions?: ActionDescriptor[]; + errors?: RuntimeError[]; +}; +``` + +#### Event + +```ts +type RuntimeEvent = { + id: string; + type: string; + target?: string; + phase?: string; + source: 'framework' | 'user' | 'agent' | 'bridge'; + timestamp: number; + route?: string; + evidence?: string[]; + details?: unknown; + error?: RuntimeError; +}; +``` + +Event 只表示“发生了什么”,不能触发页面行为。 + +#### Action + +```ts +type ActionDescriptor = { + id: string; + label: string; + kind: 'navigation' | 'click' | 'input' | 'retry' | 'custom'; + enabled: boolean; + reason?: string | null; + payloadSchema?: unknown; + risk?: 'safe' | 'state-changing' | 'destructive' | 'sensitive'; +}; +``` + +Action 才表示“可以执行什么”。Agent 只能执行页面或框架声明过的 actions。 + +#### Ready Result + +```ts +type PageReadyLevel = 'document' | 'framework' | 'view' | 'business'; + +type ReadyResult = { + ready: boolean; + level: PageReadyLevel; + phase: 'pending' | 'success' | 'error' | 'blocked' | 'unknown'; + evidence: string[]; + blockers: string[]; +}; +``` + +### 2. Runtime Center + +Runtime Center 是 `RuntimeCenter` 类型对应的页面内实现,负责维护 snapshot、events、actions、ready 和 blockers。它不包含 Bridge Server、CLI 或 HTTP API,也不在浏览器页面里启动 server。 + +#### 需要实现的能力 + +- Snapshot Store:维护页面当前状态,支持局部更新和按 filter 读取。 +- Event History:保存最近发生的事件,事件需要递增 id,支持 `since` 增量读取。 +- Subscription Registry:管理事件订阅,支持已经发生和未来发生的事件。 +- Action Registry:保存页面声明过的 actions,并根据 `enabled`、`risk`、`payloadSchema` 执行校验。 +- Ready / Blocker Evaluator:根据 snapshot 和 events 计算 ready、evidence 和 blockers。 +- Public API Adapter:把内部能力暴露成 `AgentRuntime` API。 +- Client API Adapter:把框架、生态 adapter、业务 API 的写入请求统一转成 state update 和 event。 + +#### 初始化 + +框架插件在应用入口创建 Runtime Center,并挂载页面端 API: + +```ts +const runtimeCenter = createRuntimeCenter({ + appName, + framework: 'modern-js', + build, +}); + +window.__MODERN_AGENT_RUNTIME__ = runtimeCenter.api; +``` + +#### 客户端存储位置 + +客户端 Runtime Center 应该创建在 Modern.js runtime 初始化阶段,并挂到两个位置: + +- Modern.js 内部 runtime context:给框架插件、router、loader、React root、MF / Garfish adapter 使用。 +- 受控的 window 全局入口:给 Bridge Server 通过浏览器连接读取。 + +示例: + +```ts +const runtimeCenter = createRuntimeCenter({ + appName, + framework: 'modern-js', + build, +}); + +internalRuntimeContext.agentRuntime = runtimeCenter; +window.__MODERN_AGENT_RUNTIME__ = runtimeCenter.api; +``` + +这里 `internalRuntimeContext.agentRuntime` 给框架内部写入,`window.__MODERN_AGENT_RUNTIME__` 给页面外部工具读取。window 上只暴露 `AgentRuntime`,不暴露完整 `RuntimeCenter`。 + +不建议: + +- 放在 React state 里,因为 route、loader、MF、Garfish 不一定都在 React 生命周期里; +- 只放在 `window` 上,因为框架内部写入会变得松散,也不好测试; +- 每个插件各自维护一份 store,因为状态无法统一组合 ready / blockers。 + +#### SSR 初始状态 + +SSR 场景需要使用 request-scoped runtime store,不能使用服务端全局 singleton。 + +每个 request 创建一份临时 runtime store,用来记录: + +- 服务端 matched routes; +- 服务端 loader started / success / redirect / error; +- 初始 loaderData; +- 初始 route error; +- 是否发生 SSR 降级。 + +SSR 完成后,把服务端收集到的初始 runtime data 序列化到 HTML: + +```html + +``` + +客户端初始化 Runtime Center 时读取这份数据: + +```ts +const runtimeCenter = createRuntimeCenter({ + initialSnapshot: window.__MODERN_AGENT_RUNTIME_DATA__, +}); +``` + +之后客户端继续接管 route、loader、render、MF、Garfish 等后续事件。 + +关键边界: + +- 服务端不能用全局 Runtime Center,避免串请求; +- 客户端只有一个页面级 Runtime Center; +- SSR 只下发初始 snapshot / events,不启动 Bridge Server; +- Bridge Server 仍然是页面外部进程。 + +#### 事件历史和订阅 + +Runtime Center 需要同时支持已经发生和未来发生的事件: + +1. 先读当前 snapshot; +2. 如果目标已经满足,立即返回; +3. 如果还没满足,再进入订阅等待; +4. 超时后返回当前 blockers。 + +事件历史要求: + +- 每个 event 有递增 `eventId`; +- 支持按 `since` 增量读取; +- 保留一个有上限的 ring buffer; +- snapshot 里记录当前最新 `eventId`; +- event history 不应该无限增长。 + +#### Action registry + +Runtime Center 只执行已注册 actions。action 需要携带 `enabled`、`risk`、`payloadSchema` 等信息,方便 Agent 判断是否能执行,以及执行时需要传什么参数。 + +#### 开发边界 + +- Runtime Center 只运行在浏览器页面内。 +- Runtime Center 不启动 HTTP server。 +- Runtime Center 不直接连接浏览器调试协议。 +- Runtime Center 不主动扫描 DOM 或 React fiber tree。 +- Runtime Center 不执行未注册的 action。 +- Runtime Center 不猜业务 ready,只消费业务通过 API 声明的 ready 条件。 + +#### 完成标准 + +- `getSnapshot()` 能返回当前完整页面状态。 +- `getEvents({ since })` 能返回指定 id 之后的事件。 +- `waitForEvent()` 对已经发生和未来发生的事件都能正确返回。 +- action 只有注册后才能被 `runAction()` 执行。 +- ready 超时能返回 blockers,而不是只返回 timeout。 + +### 3. Modern.js 运行链路改造 + +这一节负责改造 Modern.js 已有运行链路,把框架能够确定的 route、loader、render、SSR、build 和错误状态写入 Runtime Center。这些能力由框架自动提供,不要求业务在每个页面手动声明。 + +#### Router + +router 接入应该基于当前框架路由,不另起一套路由监听,也不通过 DOM click 或 `useNavigate` monkey patch 来猜测跳转。 + +以 Modern.js 当前路由为例,框架已经在 runtime router 插件中完成: + +1. 生成或读取 route objects。 +2. 执行 `modifyRoutes`。 +3. 创建 `createBrowserRouter` 或 `createHashRouter`。 +4. 通过 `RouterProvider` 渲染。 + +接入点分成两类。 + +创建 router 前,递归包装 `modifiedRoutes`: + +- 如果 route 已经有 loader,包装这个已有 loader,记录 `loader.started`、`loader.success`、`loader.redirect`、`loader.error`; +- 包装 route component,记录 `component.mounted`、`component.unmounted`、`component.error`; +- 保留原始 route 行为,不改变用户代码返回值; +- 保留 route id、path、handle、hasLoader 等元信息,用于生成稳定 target; +- 如果 route 没有 loader,不创建新的 loader,只记录 route metadata。 + +创建 router 后,订阅 router state: + +- 当前 location; +- 当前 navigation state; +- 当前 matches; +- 当前 route errors; +- 当前 loaderData; +- basename 是否匹配; +- navigation 从 loading 回到 idle 后,框架层 route 是否 ready。 + +建议 route 事件: + +| 框架生命周期 | Runtime event | Snapshot 更新 | +| --- | --- | --- | +| 开始跳转 | `route.started` | `route.phase = 'started'` | +| 路由匹配完成 | `route.matched` | `route.matched = true` | +| 路由不匹配 | `route.unmatched` | `route.matched = false` | +| 路由错误 | `route.error` | `route.phase = 'error'` | +| 路由页面 mounted | `route.ready` | `route.phase = 'success'` | + +`route.ready` 只代表框架层 ready,不代表业务 ready。业务成功条件仍然由业务拓展 API 显式声明。 + +#### Loader + +Modern.js 约定式路由里的 `page.loader.ts`、`layout.loader.ts`、`page.data.ts`、`layout.data.ts` 都归到同一类能力:route data loader。 + +| 约定文件 | Runtime kind | 说明 | +| --- | --- | --- | +| `page.loader.ts` | `route-loader` | 页面 route loader | +| `layout.loader.ts` | `route-loader` | layout route loader | +| `page.data.ts` | `route-loader` | 页面约定式 data loader | +| `layout.data.ts` | `route-loader` | layout 约定式 data loader | +| `page.data.client.ts` | `route-loader` | 页面 client data loader | +| `layout.data.client.ts` | `route-loader` | layout client data loader | + +单个 route object 通常最多对应一个 route data loader,但一次 navigation 会命中多个 routes,所以一次页面跳转可能有多个 loader 同时参与。 + +建议 loader 事件: + +| 状态 | Runtime event | +| --- | --- | +| 开始执行 | `loader.started` | +| 成功返回 | `loader.success` | +| 返回 redirect | `loader.redirect` | +| 仍在等待 | `loader.pending` | +| 执行失败 | `loader.error` | +| 执行被取消 | `loader.aborted` | +| deferred 数据返回 | `loader.deferred` | +| 预期应执行但未执行 | `loader.missing` | + +实现边界: + +- 只包装已有 loader / data,不自动创建业务 loader; +- 原 loader 返回什么、抛什么、redirect 什么,都必须原样保留; +- `loader.redirect` 不能当作 `loader.error`; +- `page.data.client.ts` 这类 client loader 要和 server loader 用同一个 `routeId` 关联,但用 `execution` 区分; +- `loader.missing` 只是诊断信号,不会补执行 loader。 + +`loader.missing` 用于判断“框架知道这里应该有 loader,但它没有执行”。例如当前 matched route 标记了 `hasLoader`,但 event history 里没有对应 `loader.started`,snapshot 里也没有对应 loaderData,并且 navigation 已经不在 loading。 + +#### React root 和 route component + +React 层不以复刻完整 React tree 为目标。第一阶段只记录框架能够稳定确认的生命周期,以及业务显式声明的 ready marker。后续如果需要组件级诊断,应作为独立增强能力评估,而不是默认纳入本 RFC 范围。 + +范围分成三类: + +1. React root 生命周期; +2. route component 生命周期; +3. 用户声明的 ready marker。 + +默认不包含: + +- 遍历完整 React fiber tree; +- 输出页面所有组件列表; +- 自动判断任意业务组件是否 ready; +- monkey patch 所有 hooks; +- 自动识别 `Loading` / `UserProfile` 谁代表成功态。 + +建议 React root 事件: + +| 状态 | Runtime event | +| --- | --- | +| root 开始 render | `react.root.render.started` | +| root mounted | `react.root.mounted` | +| hydrate 开始 | `react.root.hydrate.started` | +| hydrate 成功 | `react.root.hydrate.success` | +| hydrate 失败或降级 CSR | `react.root.hydrate.fallback-client-render` | +| root 级错误 | `react.root.error` | + +route component 生命周期来自 router 包装,不来自全量 React tree 扫描。建议事件包括 `component.mounted`、`component.unmounted`、`component.pending`、`component.resolved`、`component.error`。 + +#### SSR 初始状态 + +SSR 场景需要补充服务端侧状态。如果 loader / redirect 在服务端执行,只在客户端订阅 router state 会丢失服务端过程。因此 SSR 应该把这些信息写进初始 runtime 数据,随 HTML 一起下发: + +- 服务端 matched routes; +- 服务端 loader started / success / redirect / error; +- 初始 loaderData; +- 初始 route error; +- 是否发生 SSR 降级。 + +客户端 Runtime Center 初始化时合并这份初始状态,再继续监听后续客户端路由变化。 + +#### Build 信息和 fatal error + +构建插件应在产物中注入可读的运行信息: + +```ts +runtimeCenter.updateState({ + build: { + uniqueName, + chunkLoadingGlobal, + publicPath, + runtimeChunk, + entryUrl, + }, +}); +``` + +这些信息用于 async chunk 404、`undefined.js`、多套产物 runtime 冲突等问题。 + +Runtime Client 还需要记录影响页面 ready 的 fatal browser error,例如 root render error、资源加载失败、不可恢复的 runtime error。 + +### 4. 生态能力接入 + +生态能力接入负责把 Modern.js 之外的运行态来源写入 Runtime Center。第一阶段主要包括 MF 和 Garfish。 + +#### MF Adapter + +MF 侧应该提供通用的运行态观测能力。它不应该直接依赖 Modern.js、React、Garfish 或具体 Agent。 + +Runtime Center 只消费这些通用信息,并把它们和框架 route、loader、React mounted、业务 ready marker 组合起来判断页面状态。 + +MF 侧建议提供稳定 API: + +```ts +type MFRuntimeObserver = { + getMFSnapshot(): MFSnapshot; + getMFEvents(filter?: MFEventFilter): MFEvent[]; + subscribeMFEvents( + filter: MFEventFilter, + listener: (event: MFEvent) => void, + ): () => void; +}; +``` + +最小闭环: + +- `getMFSnapshot()`:拿当前 MF 状态; +- `getMFEvents({ since })`:补齐历史事件; +- `subscribeMFEvents()`:订阅后续事件; +- 稳定 event schema:保证不同工具可以消费同一套数据。 + +MF snapshot 至少需要包含:runtime instance、remote、manifest、entry、expose、shared 解析结果、最近错误、重试、fallback、timeout,以及 manifest / stats 中能帮助定位问题的信息。 + +建议事件: + +| 状态 | MF event | Runtime Center event | +| --- | --- | --- | +| manifest 开始加载 | `mf.manifest.started` | `remote.manifest.started` | +| manifest 成功 | `mf.manifest.success` | `remote.manifest.success` | +| manifest 失败 | `mf.manifest.error` | `remote.error` | +| remote entry 开始加载 | `mf.entry.started` | `remote.entry.started` | +| remote entry 成功 | `mf.entry.success` | `remote.entry.success` | +| remote entry 失败 | `mf.entry.error` | `remote.error` | +| expose 开始加载 | `mf.expose.started` | `remote.expose.started` | +| expose 成功 | `mf.expose.success` | `remote.expose.success` | +| expose 失败 | `mf.expose.error` | `remote.error` | +| shared 依赖解析成功 | `mf.shared.resolve.success` | `shared.resolve.success` | +| shared 依赖解析失败 | `mf.shared.resolve.error` | `shared.resolve.error` | +| chunk 加载失败 | `mf.chunk.error` | `remote.error` | + +MF 错误结构需要稳定区分 manifest 拉取失败、entry 拉取失败、container 初始化失败、expose 不存在、shared 版本不匹配、singleton 冲突、chunk 加载失败、timeout、fallback 生效等类型。 + +当前 Observability Plugin 可以作为第一阶段数据来源。Runtime Center 可以通过 MF adapter 消费它已有的 report / trace 信息,再标准化成 Runtime Center 事件。长期看,MF 侧应该沉淀稳定的 runtime observer API,而不是让框架或 Agent 依赖插件内部实现、页面全局变量或特定 DevTools 面板的数据格式。 + +MF 侧只负责判断 MF 加载链路,不负责判断 React 组件是否 mounted、remote 组件业务是否 ready、Garfish 子应用是否 mounted 或页面是否最终可用。 + +#### Garfish Adapter + +Garfish 层应把子应用生命周期写入 Runtime Center。 + +| 状态 | Runtime event | +| --- | --- | +| 子应用注册 | `garfish.app.registered` | +| 入口开始加载 | `garfish.entry.started` | +| 入口加载成功 | `garfish.entry.success` | +| provider 找到 | `garfish.provider.ready` | +| provider 缺失 | `garfish.provider.missing` | +| 子应用 mounted | `garfish.app.mounted` | +| 子应用 ready | `garfish.app.ready` | +| 子应用失败 | `garfish.app.error` | + +`garfish.app.mounted` 可以自动判断,`garfish.app.ready` 可以由子应用业务标注触发。 + +### 5. 业务拓展 API + +业务拓展 API 用来补充框架无法自动判断的信息。框架可以知道 route、loader、render 等基础状态,但业务成功条件、关键组件完成状态、可执行动作、脱敏规则和可忽略错误需要业务显式提供。 + +业务可以声明: + +- 关键组件 ready; +- 页面业务 ready; +- Agent 可以执行的 actions; +- 需要关注的 route 或 component; +- 可忽略错误; +- 敏感数据脱敏规则。 + +#### API 获取方式 + +业务拓展 API 由 Modern.js runtime 包导出,用户不需要手动获取 Runtime Center 或 Runtime Client。 + +```ts +import { + AgentReady, + registerAction, + useAgentReady, +} from '@modern-js/runtime/agent'; +``` + +这些 API 内部通过 Modern.js runtime context 获取 Runtime Client,并写入 Runtime Center。 + +业务代码不应该直接访问 `window.__MODERN_AGENT_RUNTIME__`,也不应该直接调用 `RuntimeClient`。 + +示例: + +```tsx +function UserProfile() { + const { data, loading } = useUser(); + + useAgentReady('user-profile', !loading && Boolean(data)); + + if (loading) { + return ; + } + + return ; +} +``` + +这里的 `useAgentReady` 由业务组件调用,不是 Agent 调用,也不是 runtime 主动执行。第二个参数是业务自己定义的完成条件。 + +边界: + +- 业务自己用 SWR、React Query、fetch 等发出的请求不属于 route data loader; +- 业务请求如果要参与 Agent 判断,应通过业务拓展 API 或后续 data dependency 标注进入 Runtime Center; +- Action 必须由页面或框架声明,Agent 不能通过 event 订阅触发页面行为。 + +### 6. Agent 访问层 + +Agent 访问层负责让 Agent 从页面外访问页面内 Runtime Center。它包括 Bridge Server、Browser Connector、HTTP API、SSE / long polling 和 CLI。 + +第一阶段页面 runtime 不主动连接 Bridge Server。Bridge Server 通过 Browser Connector 进入目标浏览器 tab,并调用页面上的 `AgentRuntime` API。页面 runtime 只维护状态和暴露 API,不感知 Bridge Server 的端口,也不负责重连。 + +连接路径: + +```txt +Bridge Server + -> Browser Connector / CDP + -> 目标浏览器 tab + -> window.__MODERN_AGENT_RUNTIME__ + -> AgentRuntime API + -> Runtime Center +``` + +#### Bridge Server 协议 + +第一阶段推荐: + +```txt +HTTP API + SSE events + long polling fallback +``` + +推荐端点: + +```txt +GET /snapshot +GET /events?since= +GET /events/stream +GET /actions +POST /actions/:id +POST /wait +GET /health +``` + +动作必须通过 action 端点触发,不能通过 event 订阅触发。 + +#### Bridge Server 启动位置 + +Bridge Server 是页面外部的本地 Node 进程,不运行在浏览器页面内。 + +第一阶段不建议由 Modern.js dev server 默认自动启动。推荐通过显式开关启用: + +```bash +modern dev --agent-runtime +``` + +或: + +```ts +export default defineConfig({ + dev: { + agentRuntime: true, + }, +}); +``` + +启用后,dev server 会额外启动本地 Agent Runtime API。例如: + +```txt +Modern.js dev server: http://localhost:4332 +Agent Runtime API: http://localhost:7332 +``` + +生产页面默认不启动 Bridge Server。 + +#### CLI 和 dev server 的关系 + +CLI 也可以按需启动临时 Bridge Server。CLI 启动前应先探测当前项目是否已有 Bridge Server: + +- 如果已有 Bridge Server,优先复用; +- 如果没有,CLI 启动临时 Bridge Server; +- 如果端口被占用但不是 Bridge Server,换端口或提示用户; +- 如果已有 Bridge Server 绑定同一个目标 tab,复用已有绑定; +- 如果已有 Bridge Server 绑定不同目标 tab,要求用户指定 target 或 session; +- 如果用户显式传 `--port`,使用指定端口; +- 如果用户显式传 `--new-session`,创建新会话,但不能抢占已有 tab 绑定。 + +```txt +CLI + -> detect existing Bridge Server + -> found: reuse + -> not found: start local temporary Bridge Server +``` + +#### CLI + +CLI 是 Bridge Server 的薄封装。 + +```bash +agent-runtime open http://localhost:4332 +agent-runtime snapshot +agent-runtime wait page.ready --timeout 10000 +agent-runtime wait component.ready --target component:user-profile +agent-runtime actions +agent-runtime action load-provider-remote +agent-runtime events --since 42 +``` + +CLI 支持两种浏览器模式: + +| 模式 | 场景 | +| --- | --- | +| 启动隔离浏览器 | 本地 demo,不依赖登录态 | +| 连接已有浏览器 | 需要复用当前登录状态的页面 | + +#### 连接页面 Runtime + +CLI 不是直接请求业务页面,也不是等待页面 runtime 主动连接,而是通过浏览器调试通道进入目标 tab。 + +```txt +CLI + -> CDP + -> 目标浏览器 tab + -> window.__MODERN_AGENT_RUNTIME__ + -> Runtime Center +``` + +事件流第一版可以用事件历史增量拉取: + +1. 页面 runtime 维护带递增 id 的 event history; +2. Bridge Server 通过 CDP 调 `getEvents({ since })`; +3. Bridge Server 把新增 events 转成 SSE; +4. long polling 复用同一套 event history。 + +这样第一版不需要处理跨进程 callback。 + +#### Server 生命周期和传输协议 + +HTTP / SSE server 在浏览器页面外部运行。CLI 模式下,CLI 启动或复用本地 Bridge Server,打开或连接浏览器,并绑定目标 tab。 + +| 协议 | 第一阶段建议 | 原因 | +| --- | --- | --- | +| HTTP | 用于 snapshot 和 action | 简单、好调试 | +| SSE | 用于 event stream | 适合服务端持续推事件给 Agent | +| Long polling | 兜底 | 流式连接不可用时仍能工作 | +| WebSocket | 暂不默认 | 双向能力强,但连接管理和调试成本更高 | + +只有在需要高频双向通信、页面主动向 Agent 请求能力、或未来做复杂远程调试时,才需要把 WebSocket 作为 adapter 加进来。 + +### 7. Page Ready 组合规则 + +Page Ready 组合规则负责把 route、loader、React、MF、Garfish、业务 ready 等信息组合成最终页面状态,输出 ready、level、phase、evidence 和 blockers。 + +推荐层级: + +| Level | 含义 | 来源 | +| --- | --- | --- | +| `document` | HTML document 已加载 | 浏览器 | +| `framework` | route 命中、loader 完成、无框架级 fatal error | 框架 | +| `view` | app root、route component、remote 或 Garfish app 已挂载 | 框架 + runtime | +| `business` | 页面业务成功条件达成 | 业务拓展 API | + +默认 ready 判断: + +1. document 已加载; +2. 当前 route 命中; +3. loader 没有 pending; +4. 没有 fatal error; +5. app root mounted; +6. 如果当前页面依赖 remote / Garfish,需要对应挂载完成; +7. 如果用户声明了 ready marker,需要 ready marker 触发。 + +有 loader 的页面: + +```txt +route matched ++ loaders settled ++ route component mounted ++ no render error ++ user ready marker if configured += page ready +``` + +没有 loader 的页面: + +```txt +route matched ++ navigation idle ++ route component mounted ++ no render error += framework ready +``` + +请求是否安静可以作为 evidence,但不应该作为必须满足的 ready 条件。 + +### 8. 安全和验证 + +#### 安全边界 + +- Bridge Server 默认只监听 localhost; +- 生产页面默认不启动 server; +- Agent 只能执行声明过的 actions; +- actions 应带 risk 标记; +- snapshot 中敏感字段需要支持脱敏; +- Bridge Server 默认绑定单个目标 tab; +- 连接已有浏览器时,必须由用户明确选择或授权。 + +#### 自动能力和业务边界 + +| 能力 | 归属 | +| --- | --- | +| route location / match | Modern.js 运行链路改造 | +| loader lifecycle | Modern.js 运行链路改造 | +| app root mounted | Modern.js 运行链路改造 | +| render error | Modern.js 运行链路改造 | +| remote load state | MF Adapter | +| shared runtime state | MF Adapter | +| build runtime info | Modern.js 运行链路改造 | +| Garfish app lifecycle | Garfish Adapter | +| fatal browser error | Runtime Client | +| business ready | 业务拓展 API | +| safe actions | 业务拓展 API | +| sensitive data masking | 业务拓展 API | +| ignored errors | 业务拓展 API | + +#### Demo 验证 + +| Demo | 主要运行态信号 | +| --- | --- | +| `react-multi-version` | shared state、dependency source、component error | +| `nested-router-tree` | route state、component relation、render error | +| `async-chunk-runtime` | build info、remote event、Garfish event、chunk error | +| `garfish-provider` | entry loaded、provider missing、Garfish mount failed | +| `redirect-loader` | loader not executed、redirect missing、page pending | +| `usenavigate-blank` | route mismatch、basename issue、action result、blank state | + +验证时需要确认: + +- demo 页面只负责复现真实问题,不混入未来 API 面板; +- 每个 demo 都能通过 snapshot / events / actions 给出结构化证据; +- Page Ready 超时时返回 blockers,而不是只返回 timeout; +- Agent 执行 action 后,可以通过 events 看到后续状态变化。 + +## 待确认问题 + +1. action 的 `risk` 标记是否在第一阶段强制校验,还是先作为描述字段返回给 Agent。 +2. Garfish 状态由 Garfish 提供稳定 observer API,还是第一阶段先由 Modern.js / Agent Runtime adapter 兼容采集。 +3. Bridge Server 第一阶段是否只支持单 tab 绑定,还是支持一个进程管理多个 session / tab。 +4. MF 侧 observer API 的字段和事件 schema 是否由 MF 单独 RFC 定义。 +5. 业务拓展 API 首期是否只提供 `useAgentReady` 和 `registerAction`,还是同时提供 `AgentReady` 组件。 + +## 阶段规划 + +本文档中的“第一阶段”指先把本地开发调试闭环跑通。后续阶段不应该简单理解成继续堆能力,而是逐步把兼容实现、单页面调试、描述性策略沉淀成稳定、通用、可扩展的能力。 + +| 阶段 | 目标 | 主要范围 | 边界 | +| --- | --- | --- | --- | +| 第一阶段:本地开发调试闭环 | 让 Agent 能在 Modern.js 本地开发和带登录态调试场景中可靠拿到页面状态,并执行声明过的动作 | 数据结构和 API、Runtime Center、Modern.js 路由 / loader / React root / route component 基础状态、业务 ready marker、MF / Garfish 兼容接入、Bridge Server、CLI、HTTP API、SSE、long polling、Browser Connector 单 tab 调试 | 不复刻完整 React tree;不默认使用 WebSocket;不默认启动 Bridge Server;不默认支持多 tab / 多 session;不自动判断任意业务组件是否 ready | +| 第二阶段:稳定生态接入和协作能力 | 把第一阶段的兼容方案做成稳定接口,让多个页面、多个工具和生态运行时可以可靠接入 | MF 稳定 observer API、Garfish 稳定 observer API、多 tab / 多 session 管理、Bridge Server 会话模型、action 风险校验策略、ready blockers 标准化、dev server 显式配置和 CLI 复用策略 | 不把 React DevTools 能力内置为默认能力;不把所有业务状态自动推断为 ready;不默认接入生产环境页面 | +| 第三阶段:深度诊断和自动化 | 在稳定协议之上扩展更强的诊断和自动化调试能力 | 可选 WebSocket adapter、页面主动连接 Bridge Server、组件级诊断增强、Suspense / lazy / error boundary 等更细状态、MCP / DevTools 等外部工具接入、生产环境受控诊断 | 需要单独评估安全、性能和使用边界;不作为本 RFC 第一阶段交付要求 | + +阶段验收建议: + +- 第一阶段验收以 demo 和本地真实页面调试为主,确认 Agent 能通过 snapshot / events / actions 给出结构化证据。 +- 第二阶段验收以生态接口稳定性为主,确认 MF、Garfish、Bridge Server 多会话和 action 策略可以被独立开发和测试。 +- 第三阶段验收以可选增强能力为主,确认深度诊断能力不会影响默认开发体验和页面运行成本。 diff --git a/docs/agent-runtime/rfc-universal-runtime-sdk.md b/docs/agent-runtime/rfc-universal-runtime-sdk.md new file mode 100644 index 000000000000..feee8e3057c9 --- /dev/null +++ b/docs/agent-runtime/rfc-universal-runtime-sdk.md @@ -0,0 +1,664 @@ +# RFC: 通用 Agent Runtime SDK + +## 状态 + +Draft + +## 摘要 + +本文定义一套框架无关的 Agent Runtime SDK,用于让 AI Agent 读取页面运行态、订阅运行事件、等待页面 ready,并执行页面声明过的安全动作。 + +这套 SDK 不绑定 Modern.js。Modern.js、MF、Garfish 和业务代码都可以作为接入方,把各自掌握的运行态信息写入同一个页面级 Runtime Center。Agent 再通过统一的 snapshot、events、wait 和 actions API 读取这些信息。 + +核心设计: + +- 一个浏览器页面内只有一个主 Runtime Center。 +- Modern.js、MF、Garfish 和业务代码通过 Runtime Client 写入状态。 +- SDK 包允许多版本共存,但运行时状态要收敛到同一个页面级 Runtime Center。 +- snapshot 只表示当前状态,events 记录历史过程。 +- 框架和运行时负责自动采集,业务只补充框架无法判断的 ready 条件和安全动作。 +- 页面内 runtime 不启动 server;CLI / HTTP / SSE 由页面外 Bridge Server 提供。 + +目标是让 Agent 从“看 UI、查 DOM、等请求、猜问题”,升级为“读取结构化运行态、按声明能力调试页面”。 + +## 背景 + +Agent 调试前端页面时,通常只能依赖外部现象: + +- 页面是否白屏; +- DOM 元素是否出现; +- 请求是否安静; +- console 是否报错; +- 点击后页面是否继续变化。 + +这些信号能帮助定位问题,但无法稳定说明页面卡在哪一层。类似的页面异常,根因可能来自完全不同的运行层: + +| 场景 | 外部现象 | 真正需要知道的信息 | +| --- | --- | --- | +| 路由没有匹配 | 页面空白或停在旧页面 | 当前 location、basename、matched routes | +| loader 卡住或 redirect 异常 | 页面一直 pending 或跳转不符合预期 | loader started / success / redirect / error | +| MF remote 加载失败 | 远程组件不可用 | manifest、remoteEntry、expose、shared 依赖状态 | +| React 渲染异常 | 页面报错或局部不可用 | root mounted、route component mounted、render error | +| Garfish 子应用挂载失败 | 子应用白屏 | entry loaded、provider resolved、mount success / error | +| 业务数据没 ready | UI 看起来有内容但流程不可用 | 业务声明的 ready marker | + +如果只在 Modern.js 内实现这套能力,后续其他框架、MF runtime、Garfish 或业务自定义 runtime 接入时会受限。因此需要先定义一个通用 Agent Runtime SDK,再让 Modern.js 和 MF 内置接入,形成更完整的组合体验。 + +## 目标 + +- 定义框架无关的 Runtime Center、Runtime Client 和 AgentRuntime API。 +- 明确页面级单例 Runtime Center、多来源 Runtime Client 的运行模型。 +- 支持 SDK 多版本共存,但通过稳定协议连接到同一个页面级中心。 +- 明确 snapshot、events、wait、actions 的职责边界。 +- 支持 Modern.js 自动写入 route、loader、render、SSR、hydration 等框架状态。 +- 支持 MF 自动写入 remote、manifest、expose、shared、runtime error 等模块加载状态。 +- 支持业务用轻量 API 声明 ready 条件和安全动作。 +- 为 CLI / HTTP / SSE / DevTools / Agent 提供统一访问入口。 + +## 设计原则 + +### 页面级单例 Runtime Center + +一个浏览器页面内应该只有一个主 Runtime Center。它负责聚合当前 snapshot、历史 events、ready、blockers 和 actions。 + +不建议让每个框架、每个 remote、每个子应用都维护自己的独立中心。纯多中心会带来几个问题: + +- Agent 需要发现多个中心; +- snapshot 需要跨中心合并; +- events 需要跨中心排序; +- ready 和 blockers 需要跨中心归并; +- action id 可能冲突; +- 页面当前状态会变得不稳定。 + +第一版不做真正的多中心合并。 + +### 多来源 Runtime Client + +虽然 Runtime Center 是页面级单例,但写入方可以有多个: + +- Modern.js Runtime Client; +- MF Runtime Client; +- Garfish Runtime Client; +- 业务 Runtime Client; +- 其他框架或自定义 runtime 的 Runtime Client。 + +这些 client 可以来自不同包、不同版本、不同子应用,但最终都写入同一个页面级 Runtime Center。 + +### 通过协议共享,不通过实例共享 + +SDK 不应该要求所有接入方 import 同一个包实例,也不应该依赖 `instanceof RuntimeCenter` 这类判断。 + +不同版本的 Runtime Client 应该通过稳定协议发现并连接页面级 Runtime Center。 + +示例: + +```ts +const RUNTIME_CENTER_KEY = Symbol.for('agent-runtime.center'); + +const center = globalThis[RUNTIME_CENTER_KEY]; +``` + +中心需要暴露: + +- `protocolVersion`; +- `capabilities`; +- `emit`; +- `updateState`; +- `markReady`; +- `registerAction`; +- `getSnapshot`; +- `getEvents`; +- `waitFor`; +- `runAction`。 + +client 连接中心时必须先做能力判断: + +- 老 client 连接新 center:使用共同能力; +- 新 client 连接老 center:缺失能力降级; +- center 不存在:进入 no-op 或 standalone 模式。 + +### Snapshot 是当前态 + +snapshot 只表示页面当前仍然 active 的状态,不是历史日志。 + +例如页面从 `/orders` 跳转到 `/orders/123` 后,再调用 `getSnapshot()`,应该返回 `/orders/123` 的当前状态。旧的 `/orders` route、loader、component ready 不应该继续留在当前 snapshot 主体里。 + +历史过程应该通过 events 查询。 + +snapshot 可以保留少量辅助上下文,例如: + +- latestEventId; +- previous location; +- active scopes; +- 最近 fatal error。 + +但它不能长期保留已经失效的 route、loader、component ready,否则 Agent 会误判当前页面状态。 + +### Events 是历史过程 + +events 记录运行过程中已经发生过的事实,例如: + +- `route.navigation.start`; +- `route.matched`; +- `loader.success`; +- `mf.remote.load.error`; +- `component.mounted`; +- `business.ready`。 + +Agent 想知道“之前发生了什么”,应该读 events,而不是让 snapshot 承担历史职责。 + +### Actions 和 Events 分开 + +event 只能观察,不能触发页面行为。 + +action 才表示页面允许 Agent 执行的动作。Agent 只能执行页面、框架或业务明确注册过的 action。 + +## 总体架构 + +```txt +Modern.js / MF / Garfish / 业务代码 / 其他框架 + ↓ +多个 Runtime Client + ↓ +一个页面级 Runtime Center + ↓ +AgentRuntime API + ↓ +Bridge Server + ↓ +CLI / HTTP / SSE / DevTools / Agent +``` + +三个核心对象: + +| 对象 | 使用方 | 职责 | +| --- | --- | --- | +| Runtime Center | 页面内 runtime | 维护 snapshot、events、ready、blockers、actions | +| Runtime Client | 框架、MF、Garfish、业务代码 | 写入状态、事件、ready、action | +| AgentRuntime API | Bridge Server、CLI、DevTools、Agent | 读取状态、订阅事件、等待 ready、执行 action | + +Runtime Center 是页面内实现细节,不是对业务暴露的主要 API。业务更常用的是 `useAgentReady`、`AgentReady`、`registerAgentAction` 这类轻量 API。 + +## Runtime Center + +Runtime Center 是页面内唯一的主状态中心。 + +### 创建时机 + +主 Runtime Center 应该由宿主应用或框架创建。 + +在 Modern.js 场景中,推荐由 Modern.js runtime 初始化阶段创建,并挂到两个位置: + +```ts +internalRuntimeContext.agentRuntime = runtimeCenter; +globalThis[Symbol.for('agent-runtime.center')] = runtimeCenter.api; +``` + +- `internalRuntimeContext.agentRuntime` 给框架内部、MF adapter、Garfish adapter 写入。 +- `globalThis[Symbol.for('agent-runtime.center')]` 给不同版本的 Runtime Client 和 Bridge Server 发现。 + +window / globalThis 上只暴露受控 API,不暴露完整内部实现。 + +### Center 冲突处理 + +第一版规则: + +- 宿主框架创建的 center 优先级最高。 +- Runtime Client 默认只连接已有 center,不主动创建主 center。 +- 独立运行的应用可以由自己的框架 adapter 创建 standalone center。 +- 如果没有 center,薄 client 默认 no-op,避免因为宿主未接入而影响业务运行。 + +暂不支持多个主 center 自动合并。 + +### 存储内容 + +Runtime Center 至少维护: + +- Snapshot Store; +- Event History; +- Subscription Registry; +- Action Registry; +- Ready / Blocker Evaluator; +- Capability Registry。 + +event history 应该有上限,例如 ring buffer,避免无限增长。snapshot 中记录 `latestEventId`,方便 Agent 增量读取 events。 + +## Runtime Client + +Runtime Client 是写入侧 API。 + +框架、MF、Garfish 和业务代码都通过 Runtime Client 写入状态,但不直接操作 Runtime Center 内部 store。 + +建议基础 API: + +```ts +type RuntimeClient = { + updateState(patch: RuntimeStatePatch): void; + emit(event: RuntimeEventInput): RuntimeEvent | void; + markReady(target: string, ready: boolean, details?: unknown): void; + registerAction(action: ActionDescriptor, handler: ActionHandler): void; + reportError(error: RuntimeError): void; +}; +``` + +写入顺序建议: + +1. 先更新 snapshot; +2. 再发 event。 + +这样 Agent 收到 event 后,能马上读到最新 snapshot。 + +## AgentRuntime API + +AgentRuntime API 是读取侧和受控操作 API。 + +Agent 通常不直接在页面 JS 里调用它,而是通过 CLI、HTTP、SSE、DevTools 或其他 Agent 工具访问。Bridge Server 再连接目标浏览器 tab,调用页面内 AgentRuntime API。 + +建议 API: + +```ts +type AgentRuntime = { + getSnapshot(filter?: SnapshotFilter): Promise; + getEvents(filter?: EventFilter): Promise; + subscribeEvents( + filter: EventFilter, + listener: (event: RuntimeEvent) => void, + ): () => void; + waitFor( + condition: WaitCondition, + options?: WaitOptions, + ): Promise; + getActions(filter?: ActionFilter): Promise; + runAction(actionId: string, payload?: unknown): Promise; +}; +``` + +## 数据模型 + +### Snapshot + +```ts +type RuntimeSnapshot = { + page: PageState; + route?: RouteState; + loaders?: LoaderState[]; + components?: ComponentState[]; + mf?: MFState; + garfish?: GarfishState; + business?: BusinessState; + actions?: ActionDescriptor[]; + errors?: RuntimeError[]; + latestEventId: number; +}; +``` + +snapshot 中只保留当前 active 的状态。 + +跨路由仍然 active 的对象可以继续保留,例如: + +- app root; +- layout route; +- shell remote; +- 当前仍在使用的 MF shared dependency; +- 当前仍 mounted 的 Garfish 子应用; +- 当前页面仍有效的 business ready marker。 + +已失效的 route、loader、component ready 需要从 snapshot 主体移除,历史信息进入 events。 + +### Event + +```ts +type RuntimeEvent = { + id: number; + type: string; + source: 'framework' | 'mf' | 'garfish' | 'business' | 'agent' | 'bridge'; + scope?: RuntimeScope; + target?: string; + phase?: string; + timestamp: number; + evidence?: string[]; + details?: unknown; + error?: RuntimeError; +}; +``` + +event 是已经发生的事实。event 不执行动作。 + +### Scope + +scope 用于判断状态是否仍然 active。 + +```ts +type RuntimeScope = { + kind: 'page' | 'router' | 'route' | 'loader' | 'component' | 'mf' | 'garfish' | 'business'; + id: string; + parentId?: string; + active?: boolean; +}; +``` + +路由跳转后,旧 route scope 可以变成 inactive。Runtime Center 根据 active scopes 清理 snapshot 主体。 + +### Action + +```ts +type ActionDescriptor = { + id: string; + label: string; + kind: 'navigation' | 'click' | 'input' | 'retry' | 'custom'; + enabled: boolean; + reason?: string | null; + payloadSchema?: unknown; + risk?: 'safe' | 'state-changing' | 'destructive' | 'sensitive'; +}; +``` + +Action 必须显式注册。Agent 不能执行未注册动作。 + +### Ready Result + +```ts +type ReadyResult = { + ready: boolean; + level: 'document' | 'framework' | 'view' | 'business'; + phase: 'pending' | 'success' | 'error' | 'blocked' | 'unknown'; + evidence: string[]; + blockers: string[]; +}; +``` + +ready 结果由 Runtime Center 根据当前 snapshot 和 active scopes 计算。 + +## Modern.js 接入 + +Modern.js adapter 负责自动写入框架能够确定的状态。 + +### App 和 React Root + +记录: + +- document ready; +- app runtime initialized; +- root render start; +- root mounted; +- hydration start / success / error; +- fatal error。 + +### Router + +Modern.js 应基于当前框架路由接入,不另起一套路由监听。 + +建议采集: + +- current location; +- previous location; +- basename; +- matched routes; +- route id / path / handle; +- navigation state; +- route error; +- route component mounted / unmounted / error。 + +建议事件: + +| 框架生命周期 | Runtime event | +| --- | --- | +| 开始跳转 | `route.navigation.start` | +| 路由匹配完成 | `route.matched` | +| 路由不匹配 | `route.unmatched` | +| 路由错误 | `route.error` | +| route component mounted | `route.component.mounted` | +| 框架层 route ready | `route.ready` | + +`route.ready` 只表示框架层 ready,不代表业务 ready。 + +### Loader + +Modern.js 约定式 data loader 和 router loader 都归入 route data loader。 + +建议事件: + +| 状态 | Runtime event | +| --- | --- | +| 开始执行 | `loader.start` | +| 成功返回 | `loader.success` | +| redirect | `loader.redirect` | +| 失败 | `loader.error` | +| 被跳转取消或失效 | `loader.inactive` | + +没有 loader 的 route 不需要自动创建 loader。路由状态由 router state 和 route component lifecycle 判断。 + +### SSR + +SSR 场景不能使用服务端全局 singleton。 + +每个 request 创建 request-scoped runtime store,记录服务端初始状态: + +- matched routes; +- loader started / success / redirect / error; +- loaderData; +- route error; +- SSR fallback 或降级信息。 + +HTML 中只下发初始 runtime data: + +```html + +``` + +客户端 Runtime Center 初始化时读取这份数据,再接管后续事件。 + +## MF 接入 + +MF adapter 负责自动写入模块联邦运行时信息。 + +建议采集: + +- MF instance name; +- remote name; +- remote type; +- manifest URL; +- remoteEntry URL; +- expose name; +- shared dependency name / version / from / strategy; +- uniqueName; +- build id; +- runtime error。 + +建议事件: + +| 状态 | Runtime event | +| --- | --- | +| remote 开始加载 | `mf.remote.load.start` | +| remote 加载成功 | `mf.remote.load.success` | +| remote 加载失败 | `mf.remote.load.error` | +| expose 开始解析 | `mf.expose.resolve.start` | +| expose 解析成功 | `mf.expose.resolve.success` | +| expose 解析失败 | `mf.expose.resolve.error` | +| shared 解析成功 | `mf.shared.resolve.success` | +| shared 解析失败 | `mf.shared.resolve.error` | + +MF 可以说明 remote / expose / shared 是否正常,但不判断业务组件是否 ready。 + +生产者不需要为了加载层观测主动引用 SDK。宿主侧 MF adapter 可以采集加载层信息。 + +如果生产者或远程业务组件想声明业务 ready,可以引用薄业务 API,例如: + +```tsx +useAgentReady('order-detail-page', ready); +``` + +这个 API 应该满足: + +- 有 Runtime Center 就写入; +- 没有 Runtime Center 就 no-op; +- 不影响业务正常运行; +- 通过能力判断适配不同协议版本。 + +## 业务拓展 API + +业务只补充框架无法自动判断的信息。 + +建议 API: + +```tsx +useAgentReady('user-profile', !loading && Boolean(data)); +``` + +```tsx + +``` + +```ts +registerAgentAction({ + id: 'retry-order-loader', + label: 'Retry order loader', + kind: 'retry', + risk: 'safe', + handler: () => refetch(), +}); +``` + +业务 API 不应该要求业务理解 Runtime Center 的内部结构。 + +## Bridge Server 和 Agent 访问 + +页面内 Runtime Center 不启动 HTTP server。 + +CLI / HTTP / SSE / DevTools / Agent 通过页面外 Bridge Server 访问页面能力。 + +推荐路径: + +```txt +Agent / CLI + ↓ +Bridge Server + ↓ +Browser Connector + ↓ +目标浏览器 tab + ↓ +页面内 AgentRuntime API +``` + +第一版 Browser Connector 可以优先使用 CDP。后续可以扩展到浏览器插件、WebSocket 或其他连接方式。 + +## Page Ready 组合规则 + +Runtime Center 需要基于当前 active snapshot 计算 page ready。 + +建议分层: + +| Level | 含义 | +| --- | --- | +| document | HTML / document 基础可用 | +| framework | 框架 runtime、router、root render 基础可用 | +| view | 当前 route、loader、remote、sub app、component mounted 可用 | +| business | 业务声明的 ready marker 已满足 | + +Agent 等待页面时,可以选择不同 level: + +```ts +await agentRuntime.waitFor({ + type: 'page.ready', + level: 'business', + timeout: 10000, +}); +``` + +如果超时,返回 blockers: + +```json +{ + "ready": false, + "level": "business", + "phase": "blocked", + "blockers": [ + "loader pending: order-detail-loader", + "business ready not received: order-detail-page" + ] +} +``` + +## 版本和兼容策略 + +SDK 需要把协议版本和包版本分开。 + +- 包版本:npm package version。 +- 协议版本:Runtime Client 和 Runtime Center 通信能力版本。 + +Runtime Center 暴露: + +```ts +type RuntimeProtocolInfo = { + protocolVersion: string; + capabilities: string[]; +}; +``` + +Runtime Client 连接时: + +1. 读取 `protocolVersion`; +2. 判断 `capabilities`; +3. 只使用双方都支持的能力; +4. 缺失能力降级; +5. 不因为观测能力缺失影响业务运行。 + +## 实现阶段 + +### 阶段一:通用 SDK 基础 + +- 定义协议、数据结构和事件命名。 +- 实现页面级 Runtime Center。 +- 实现 Runtime Client。 +- 实现 `getSnapshot`、`getEvents`、`waitFor`、`getActions`、`runAction`。 +- 明确 snapshot 当前态和 events 历史过程的边界。 + +### 阶段二:Modern.js 内置接入 + +- 接入 app root 生命周期。 +- 接入 router state。 +- 接入 loader。 +- 接入 route component mounted / error。 +- 接入 SSR 初始状态和 hydration。 + +### 阶段三:MF 内置接入 + +- 接入 remote / manifest / remoteEntry。 +- 接入 expose resolve。 +- 接入 shared dependency resolve。 +- 接入 runtime error。 +- 和现有 MF observability 能力对齐字段。 + +### 阶段四:业务拓展 API + +- 提供 `useAgentReady` / `AgentReady`。 +- 提供 `registerAgentAction`。 +- 定义业务 ready marker 的 scope 和清理规则。 + +### 阶段五:Agent 访问层 + +- 提供 Bridge Server。 +- 提供 CLI。 +- 提供 HTTP snapshot API。 +- 提供 SSE / stream events API。 +- 支持连接已有浏览器 tab。 + +### 阶段六:验证和评测 + +- 用已有 MF / Modern.js case 验证问题分层。 +- 对比无 SDK 和有 SDK 时的定位耗时。 +- 记录 fixed / unresolved / not_reproduced / blocked。 +- 验证登录态页面、本地 demo、MF remote、Garfish 子应用场景。 + +## 待确认问题 + +- SDK 包名和对外定位。 +- `globalThis` key 是否使用 `Symbol.for('agent-runtime.center')`。 +- Runtime Client 默认无 center 时是 no-op,还是是否需要短暂 buffer。 +- Modern.js 内置接入默认是否只在 dev / debug 模式启用。 +- MF 接入放在 runtime 内部,还是作为 observability plugin 的扩展。 +- 第一版是否需要支持 iframe / worker / 跨 tab 的多中心聚合。 +- business ready marker 是否需要默认随 route scope 自动清理。 diff --git a/docs/agent-runtime/runtime-inspection-architecture.json b/docs/agent-runtime/runtime-inspection-architecture.json new file mode 100644 index 000000000000..f636b398fced --- /dev/null +++ b/docs/agent-runtime/runtime-inspection-architecture.json @@ -0,0 +1,572 @@ +{ + "diagram": { + "title": "Modern.js Runtime Inspection and Actions for Agents Architecture", + "layers": [ + { + "id": "agent-access-layer", + "name": "Agent 访问层", + "level": 1, + "components": [ + { + "id": "ai-agent", + "name": "AI Agent", + "type": "consumer" + }, + { + "id": "agent-access-api", + "name": "Agent 访问入口", + "type": "interface", + "contains": [ + { + "id": "agent-runtime-cli", + "name": "CLI", + "type": "command-line-interface" + }, + { + "id": "http-api", + "name": "HTTP API", + "type": "api" + }, + { + "id": "sse-event-stream", + "name": "SSE Event Stream", + "type": "event-stream" + } + ] + }, + { + "id": "bridge-server", + "name": "Bridge Server", + "type": "local-service", + "contains": [ + { + "id": "target-tab-resolver", + "name": "目标 Tab 定位", + "type": "browser-target-resolver" + }, + { + "id": "event-stream-adapter", + "name": "事件流转换", + "type": "stream-adapter" + }, + { + "id": "action-dispatcher", + "name": "Action 分发", + "type": "action-dispatcher" + } + ] + }, + { + "id": "browser-connector", + "name": "Browser Connector", + "type": "browser-connector", + "contains": [ + { + "id": "cdp-connector", + "name": "CDP Connector", + "type": "cdp-adapter" + } + ] + } + ] + }, + { + "id": "page-runtime-layer", + "name": "页面运行态接口层", + "level": 2, + "components": [ + { + "id": "page-runtime-api", + "name": "页面端 Runtime API", + "type": "browser-api", + "contains": [ + { + "id": "get-snapshot", + "name": "getSnapshot", + "type": "read-api" + }, + { + "id": "get-events", + "name": "getEvents", + "type": "read-api" + }, + { + "id": "wait-for-event", + "name": "waitForEvent", + "type": "wait-api" + }, + { + "id": "get-actions", + "name": "getActions", + "type": "read-api" + }, + { + "id": "run-action", + "name": "runAction", + "type": "execution-api" + } + ] + }, + { + "id": "runtime-center", + "name": "Runtime Center", + "type": "in-page-state-center", + "provider": "Modern.js", + "contains": [ + { + "id": "snapshot-store", + "name": "Snapshot Store", + "type": "state-store" + }, + { + "id": "event-history", + "name": "Event History", + "type": "event-buffer" + }, + { + "id": "subscription-registry", + "name": "Subscription Registry", + "type": "subscription-store" + }, + { + "id": "action-registry", + "name": "Action Registry", + "type": "action-store" + }, + { + "id": "ready-blocker-evaluator", + "name": "Ready / Blocker Evaluator", + "type": "readiness-evaluator" + } + ] + }, + { + "id": "runtime-client", + "name": "Runtime Client", + "type": "browser-sdk", + "provider": "Modern.js", + "contains": [ + { + "id": "state-writer", + "name": "状态写入", + "type": "write-api" + }, + { + "id": "event-emitter", + "name": "事件写入", + "type": "event-api" + }, + { + "id": "ready-marker-writer", + "name": "Ready 标记写入", + "type": "ready-api" + }, + { + "id": "action-registrar", + "name": "Action 注册", + "type": "action-api" + } + ] + } + ] + }, + { + "id": "modernjs-framework-integration-layer", + "name": "Modern.js 框架接入层", + "level": 3, + "components": [ + { + "id": "runtime-plugin", + "name": "Runtime 插件", + "type": "framework-plugin", + "provider": "Modern.js", + "module": "planned: packages/runtime/plugin-runtime/src/agent-runtime", + "contains": [ + { + "id": "runtime-center-creator", + "name": "创建 Runtime Center", + "type": "initialization" + }, + { + "id": "page-api-mounter", + "name": "挂载页面端 API", + "type": "browser-global-api" + } + ] + }, + { + "id": "router-instrumentation", + "name": "Router 接入", + "type": "framework-instrumentation", + "module": "packages/runtime/plugin-runtime/src/router/runtime/plugin.tsx", + "contains": [ + { + "id": "route-wrapper", + "name": "Route Wrapper", + "type": "route-wrapper" + }, + { + "id": "router-state-subscriber", + "name": "Router State Subscriber", + "type": "router-subscriber" + }, + { + "id": "route-target-normalizer", + "name": "Route Target Normalizer", + "type": "target-normalizer" + } + ] + }, + { + "id": "route-object-instrumentation", + "name": "Route Object 接入", + "type": "route-object-instrumentation", + "module": "packages/runtime/plugin-runtime/src/router/runtime/utils.tsx", + "contains": [ + { + "id": "route-metadata-reader", + "name": "Route Metadata Reader", + "type": "metadata-reader" + }, + { + "id": "route-component-wrapper", + "name": "Route Component Wrapper", + "type": "component-wrapper" + } + ] + }, + { + "id": "loader-instrumentation", + "name": "Loader 接入", + "type": "loader-instrumentation", + "module": "packages/toolkit/runtime-utils/src/browser/nestedRoutes.tsx", + "dependencies": [ + "packages/runtime/plugin-runtime/src/router/cli/code/templates.ts", + "packages/cli/plugin-data-loader/src/runtime/index.ts" + ], + "contains": [ + { + "id": "route-loader-wrapper", + "name": "Route Loader Wrapper", + "type": "loader-wrapper" + }, + { + "id": "data-loader-convention-mapper", + "name": "约定式 Data Loader 映射", + "type": "convention-mapper" + }, + { + "id": "loader-missing-detector", + "name": "Loader Missing Detector", + "type": "diagnostic" + }, + { + "id": "ssr-loader-state", + "name": "SSR Loader State", + "type": "ssr-state" + } + ] + }, + { + "id": "react-root-instrumentation", + "name": "React Root 接入", + "type": "react-instrumentation", + "module": "packages/runtime/plugin-runtime/src/core/browser/index.tsx", + "contains": [ + { + "id": "render-state-hook", + "name": "Render State Hook", + "type": "render-lifecycle" + }, + { + "id": "hydrate-state-hook", + "name": "Hydrate State Hook", + "type": "hydrate-lifecycle" + }, + { + "id": "root-error-capture", + "name": "Root Error Capture", + "type": "error-capture" + } + ] + }, + { + "id": "ssr-initial-runtime-state", + "name": "SSR 初始运行态", + "type": "ssr-instrumentation", + "module": "packages/runtime/plugin-runtime/src/router/runtime/plugin.node.tsx", + "contains": [ + { + "id": "server-route-state", + "name": "服务端 Route 状态", + "type": "ssr-route-state" + }, + { + "id": "server-loader-state", + "name": "服务端 Loader 状态", + "type": "ssr-loader-state" + }, + { + "id": "server-redirect-error-state", + "name": "服务端 Redirect / Error 状态", + "type": "ssr-result-state" + } + ] + }, + { + "id": "build-runtime-info-injection", + "name": "Build 信息注入", + "type": "build-plugin", + "provider": "Modern.js", + "contains": [ + { + "id": "unique-name", + "name": "uniqueName", + "type": "build-field" + }, + { + "id": "public-path", + "name": "publicPath", + "type": "build-field" + }, + { + "id": "chunk-loading-global", + "name": "chunkLoadingGlobal", + "type": "build-field" + } + ] + }, + { + "id": "fatal-error-instrumentation", + "name": "Fatal Error 接入", + "type": "browser-error-instrumentation", + "provider": "Modern.js" + } + ] + }, + { + "id": "application-and-ecosystem-layer", + "name": "业务与生态接入层", + "level": 4, + "components": [ + { + "id": "business-runtime-api", + "name": "用户 API", + "type": "user-facing-api", + "provider": "Modern.js", + "contains": [ + { + "id": "use-agent-ready", + "name": "useAgentReady", + "type": "react-hook" + }, + { + "id": "agent-ready-component", + "name": "AgentReady", + "type": "react-component" + }, + { + "id": "register-action", + "name": "registerAction", + "type": "action-registration-api" + } + ] + }, + { + "id": "business-page-components", + "name": "业务页面 / 组件", + "type": "application-code" + }, + { + "id": "mf-adapter", + "name": "MF Adapter", + "type": "ecosystem-adapter", + "provider": "MF", + "contains": [ + { + "id": "mf-runtime-observer", + "name": "MF Runtime Observer", + "type": "runtime-observer" + }, + { + "id": "remote-state-adapter", + "name": "Remote 状态适配", + "type": "state-adapter" + }, + { + "id": "shared-state-adapter", + "name": "Shared 状态适配", + "type": "state-adapter" + }, + { + "id": "chunk-state-adapter", + "name": "Chunk 状态适配", + "type": "state-adapter" + } + ] + }, + { + "id": "garfish-adapter", + "name": "Garfish Adapter", + "type": "ecosystem-adapter", + "provider": "Garfish", + "contains": [ + { + "id": "garfish-app-lifecycle", + "name": "子应用生命周期", + "type": "lifecycle-adapter" + }, + { + "id": "garfish-provider-state", + "name": "Provider 状态", + "type": "state-adapter" + } + ] + } + ] + } + ], + "relationships": [ + { + "from": "ai-agent", + "to": "agent-access-api", + "type": "uses" + }, + { + "from": "agent-access-api", + "to": "bridge-server", + "type": "calls" + }, + { + "from": "bridge-server", + "to": "browser-connector", + "type": "uses" + }, + { + "from": "browser-connector", + "to": "page-runtime-api", + "type": "calls" + }, + { + "from": "page-runtime-api", + "to": "runtime-center", + "type": "calls" + }, + { + "from": "runtime-client", + "to": "runtime-center", + "type": "calls" + }, + { + "from": "runtime-plugin", + "to": "runtime-center", + "type": "supports" + }, + { + "from": "runtime-plugin", + "to": "page-runtime-api", + "type": "supports" + }, + { + "from": "router-instrumentation", + "to": "runtime-client", + "type": "uses" + }, + { + "from": "route-object-instrumentation", + "to": "router-instrumentation", + "type": "integrates-with" + }, + { + "from": "loader-instrumentation", + "to": "router-instrumentation", + "type": "integrates-with" + }, + { + "from": "loader-instrumentation", + "to": "runtime-client", + "type": "uses" + }, + { + "from": "react-root-instrumentation", + "to": "runtime-client", + "type": "uses" + }, + { + "from": "ssr-initial-runtime-state", + "to": "runtime-center", + "type": "supports" + }, + { + "from": "build-runtime-info-injection", + "to": "runtime-client", + "type": "uses" + }, + { + "from": "fatal-error-instrumentation", + "to": "runtime-client", + "type": "uses" + }, + { + "from": "business-page-components", + "to": "business-runtime-api", + "type": "uses" + }, + { + "from": "business-runtime-api", + "to": "runtime-client", + "type": "calls" + }, + { + "from": "mf-adapter", + "to": "runtime-client", + "type": "calls" + }, + { + "from": "garfish-adapter", + "to": "runtime-client", + "type": "calls" + }, + { + "from": "action-dispatcher", + "to": "run-action", + "type": "calls" + }, + { + "from": "event-stream-adapter", + "to": "get-events", + "type": "calls" + }, + { + "from": "target-tab-resolver", + "to": "cdp-connector", + "type": "uses" + }, + { + "from": "ready-blocker-evaluator", + "to": "snapshot-store", + "type": "depends-on" + }, + { + "from": "ready-blocker-evaluator", + "to": "event-history", + "type": "depends-on" + }, + { + "from": "run-action", + "to": "action-registry", + "type": "calls" + }, + { + "from": "wait-for-event", + "to": "subscription-registry", + "type": "uses" + }, + { + "from": "wait-for-event", + "to": "ready-blocker-evaluator", + "type": "uses" + } + ] + } +} diff --git a/scripts/check-dependencies.js b/scripts/check-dependencies.js index 874d9c07948b..6ba5e2681263 100644 --- a/scripts/check-dependencies.js +++ b/scripts/check-dependencies.js @@ -8,8 +8,11 @@ const ignoreDeps = [ 'tsx', ]; -const command = `npx check-dependency-version-consistency@latest . ${ignoreDeps +const ignorePackages = ['@otrade/transaction_adapter']; + +const command = `check-dependency-version-consistency . ${ignoreDeps .map(dep => `--ignore-dep "${dep}"`) + .concat(ignorePackages.map(pkg => `--ignore-package "${pkg}"`)) .join(' ')}`; console.log(`> ${command}`); diff --git a/tests/integration/agent-runtime-mf/README.md b/tests/integration/agent-runtime-mf/README.md new file mode 100644 index 000000000000..aa351c02f844 --- /dev/null +++ b/tests/integration/agent-runtime-mf/README.md @@ -0,0 +1,220 @@ +# Agent Runtime MF Demos + +这里放第一批真实问题复现 demo。每个 case 都是完整的 Modern.js + Module Federation 项目,并且都包含: + +- `provider`:生产者,通过 Module Federation 暴露远程组件。 +- `consumer`:消费者,加载生产者组件,并在页面上直接展示复现状态。 + +## Demo 列表 + +| Case | Provider 端口 | Consumer 端口 | 目的 | +| --- | ---: | ---: | --- | +| [`react-multi-version`](./cases/react-multi-version) | 4311 | 4312 | React shared 状态和依赖来源检测 | +| [`nested-router-tree`](./cases/nested-router-tree) | 4321 | 4322 | Router tree 和远程组件关系检测 | +| [`async-chunk-runtime`](./cases/async-chunk-runtime) | 4331 | 4332 | async chunk 的 publicPath / uniqueName 冲突检测 | +| [`garfish-provider`](./cases/garfish-provider) | 4341 | 4342 | provider 导出和挂载状态检测 | +| [`redirect-loader`](./cases/redirect-loader) | 4351 | 4352 | redirect / loader pending 状态检测 | +| [`usenavigate-blank`](./cases/usenavigate-blank) | 4361 | 4362 | 路由动作和跳转空白检测 | + +## 运行方式 + +每个 case 都先启动 provider,再启动 consumer: + +```bash +pnpm --dir tests/integration/agent-runtime-mf/cases//provider dev +pnpm --dir tests/integration/agent-runtime-mf/cases//consumer dev +``` + +`async-chunk-runtime` 是例外。这个 case 需要先构建 provider,再用静态服务启动 provider: + +```bash +pnpm --dir tests/integration/agent-runtime-mf/cases/async-chunk-runtime/provider build +pnpm --dir tests/integration/agent-runtime-mf/cases/async-chunk-runtime/provider serve +pnpm --dir tests/integration/agent-runtime-mf/cases/async-chunk-runtime/consumer dev +``` + +不要用 `provider dev` 复现这个 case。开发构建或单份普通产物容易把 remote 和 Garfish 子应用需要的异步模块合到同一份产物里,这样加载顺序就不会触发问题。 + +打开 consumer 页面后,页面左侧会显示 `Reproduced Issue`。有些 case 打开后就是问题态,有些 case 会先进入正常页面,再通过页面动作复现问题。 + +## 测试信息 + +### react-multi-version + +来源 case: + +- `03-zustand-react-version-check` +- `13-react-multi-version-invalid-hook` + +要证明的问题: + +- shared 看起来配置了 React,但实际运行仍然有两份 React。 +- 某个依赖包把 React 打进产物,导致 Invalid hook call。 + +启动后预期现象: + +- provider remote 组件会真实渲染 `@otrade/transaction_adapter` 里的 `LegacyZustandWidget`。 +- 这个依赖包由 rslib 构建,构建时会把 React 打进产物,并在被 host React 渲染时抛出 `Cannot read properties of null (reading 'useSyncExternalStore')`。 +- consumer 的错误边界会捕获这个远端组件错误,随后 `Reproduced Issue` 显示 `Status: error`。 +- 状态里能看到错误来自 `@otrade/transaction_adapter` 这类依赖来源,而不是 remote entry 没加载。 + +复现检查点: + +1. 打开 consumer 页面。 +2. `Remote Provider` 区域开始加载 provider remote。 +3. 页面捕获远端组件错误,并在 `Reproduced Issue` 显示 `Status: error`。 +4. 错误信息来自远端组件渲染,而不是 remote entry 加载失败。 + +### nested-router-tree + +来源 case: + +- `04-volcengine-nested-router-hmr` + +要证明的问题: + +- 页面出错不是 remote 没加载,而是 React tree / Router 结构异常。 + +启动后预期现象: + +- consumer 会用真实 `react-router` 创建 Host Router。 +- provider 暴露出来的组件会继续用真实 `react-router` 创建自己的 Router root,React Router 会自然抛出:`You cannot render a inside another `。 +- consumer 的错误边界会捕获这个远端路由错误,随后 `Reproduced Issue` 显示 `Status: error`。 + +复现检查点: + +1. 打开 consumer 页面。 +2. host 自己先创建 Router。 +3. provider remote 再创建自己的 Router root。 +4. 页面捕获重复 Router 错误,并在 `Reproduced Issue` 显示 `Status: error`。 + +### async-chunk-runtime + +来源 case: + +- `08-rivendell-async-chunk-404` + +要证明的问题: + +- provider 同时是 MF provider 和 Garfish 子应用。 +- 工作台可以通过 `Load Provider Remote` 手动加载 provider 暴露的 remote 组件。 +- 也可以通过 `Load Garfish Sub App` 手动调用真实 `garfish` 依赖里的 `Garfish.registerApp` / `Garfish.loadApp`,加载 provider 作为 Garfish 子应用的入口。 +- 两套 Rivendell 产物使用相同 `uniqueName` / `chunkLoadingGlobal`,并且使用生产同类的 chunkId / moduleId 生成方式。 +- remote 暴露组件和 Garfish 子应用都会异步引用同一个依赖模块,因此构建后会产生相同 async chunk id,但两份产物里的模块内容不同。 +- 如果先加载 provider remote,再加载 Garfish 子应用,Garfish 子应用会复用前面已经加载过的同 id chunk,最后拿不到自己需要的模块。 + +启动后预期现象: + +- provider 先执行 `pnpm --dir tests/integration/agent-runtime-mf/cases/async-chunk-runtime/provider build`,这条命令会生成 `dist/remote` 和 `dist/garfish` 两份产物。 +- 再执行 `pnpm --dir tests/integration/agent-runtime-mf/cases/async-chunk-runtime/provider serve` 启动 provider 静态服务。 +- consumer 执行 `pnpm --dir tests/integration/agent-runtime-mf/cases/async-chunk-runtime/consumer dev`。 +- provider remote 地址是:`http://localhost:4331/remote/mf-manifest.json`。 +- provider Garfish 子应用入口是:`http://localhost:4331/garfish/html/index/index.html`。 +- 初始打开时是正常页面,地址会进入 `/workspace/home`,`Reproduced Issue` 显示 `Status: success`。 +- 初始不会自动加载 provider remote,`Remote Provider` 区域会先显示未加载占位。 +- 用一个新页面先点击 `Load Garfish Sub App`,会正常看到 `garfish:ready`,页面保持 `Status: success`。 +- 再开一个新页面,先点击 `Load Provider Remote`,看到 `remote:ready` 后,再点击 `Load Garfish Sub App`。 +- 这个顺序会进入 chunk 冲突错误态。 +- 复现成功时,`Reproduced Issue` 会显示 `Status: error`,pending reason 是 `Cannot read properties of undefined (reading 'call')`。 +- 错误出现时,`build.uniqueName` 和 `hostUniqueName` 相同,`chunkLoadingGlobal` 也指向同一套运行时。 +- `build.chunkIds` 和 `build.moduleIds` 都是 `deterministic`,用于贴近生产环境的 id 生成方式。 +- `splitChunks` 使用生产同类的 async vendors/default 拆包配置,但它只负责拆公共依赖;复现关键仍然是 `remote` 和 `garfish` 两份产物里存在同 id、不同内容的异步 chunk。 + +复现检查点: + +1. 打开 consumer 页面,确认初始 route 是 `/workspace/home`,页面状态是 success。 +2. 新开一个页面,先点击 `Load Garfish Sub App`,确认子应用能正常显示 `garfish:ready`。 +3. 再新开一个页面,先点击 `Load Provider Remote`,确认 provider remote 显示 `remote:ready`。 +4. 再点击 `Load Garfish Sub App`,确认页面进入 `Status: error`。 +5. 错误信息应为 `Cannot read properties of undefined (reading 'call')`。 + +### garfish-provider + +来源 case: + +- `10-garfish-provider-white-screen` + +要证明的问题: + +- 子应用入口文件可能加载了,但 provider 没有正确导出或 Garfish 没有识别到。 + +启动后预期现象: + +- 初始打开时 host 页面正常,remote 组件也能加载。 +- provider 通过 Modern.js 多入口提供 Garfish 子应用入口:`http://localhost:4341/creative-hub`。 +- 点击 `Load Creative Hub` 后,consumer 通过真实 `garfish` 依赖调用 `Garfish.registerApp` / `Garfish.loadApp`。 +- 子应用入口已加载,但没有显式注册到 `__GARFISH_EXPORTS__`,Garfish 报 `provider` is `undefined`。 +- 错误出现后 `Reproduced Issue` 显示 `Status: blank`,`providerExportFound` 和 `mounted` 都是 `false`。 + +复现检查点: + +1. 打开 consumer 页面。 +2. 点击 `Load Creative Hub`。 +3. 页面进入 `Status: blank`。 +4. 错误信息应指向 Garfish 读取不到 provider。 + +### redirect-loader + +来源 case: + +- `12-cloud-engine-redirect-stuck` + +要证明的问题: + +- 页面一直显示 redirect/loading,但 Agent 不知道是 loader 没执行、redirect 没发生,还是客户端兜底没跑。 + +启动后预期现象: + +- consumer 是 SSR 应用,根路由有真实的 `src/routes/page.loader.ts`,正常应跳到 `/home`。 +- 正常访问 `http://localhost:4352/` 会执行 SSR loader,并跳到 `/home`。 +- 访问 `http://localhost:4352/?csr=1` 会强制返回静态壳页,用来复现首屏没有执行 SSR loader 的状态。 +- consumer 页面真实显示 `Redirecting to home...`。 +- `Reproduced Issue` 显示 `Status: pending`。 +- pending reason 表示静态壳页返回后 SSR redirect loader 没有执行。 +- `loaders[0]` 是 not_run,redirect 为空。 + +复现步骤: + +1. 启动 provider:`pnpm --dir tests/integration/agent-runtime-mf/cases/redirect-loader/provider dev` +2. 启动 consumer:`pnpm --dir tests/integration/agent-runtime-mf/cases/redirect-loader/consumer dev` +3. 打开 `http://localhost:4352/?csr=1`,页面应停在 `Redirecting to home...`。 +4. 对照打开 `http://localhost:4352/`,页面应正常跳到 `/home`。 + +`csr=1` 不是线上真实原因,只是 demo 里的复现开关。它会让 consumer 返回静态壳页,从而绕过本该在首屏执行的 SSR loader;真实 case 对应的是线上首包退化为静态壳页,结果同样是根路径 loader 没执行,redirect 没发生。 + +复现检查点: + +1. 打开 consumer 的 `/?csr=1`。 +2. 页面停在 `Redirecting to home...`。 +3. `Reproduced Issue` 显示 `Status: pending`。 +4. 点击 `Run client fallback` 后,页面切到 `/home`。 + +### usenavigate-blank + +来源 case: + +- `14-usenavigate-jump-blank` + +要证明的问题: + +- 子应用内部跳转后页面空白,需要知道是路由不匹配、basename 不对、组件没挂载,还是跳转方式错。 + +启动后预期现象: + +- 初始打开时 `Status: success`,features 页面正常。 +- 点击页面里的 `Navigate to lineage` 动作后,provider 内部路由会跳到 `/lineage`。 +- 因为 Bridge basename 少了前导 `/`,`Reproduced Issue` 变成 `Status: blank`。 +- pending reason 表示 `useNavigate` 后 basename 不匹配。 + +复现检查点: + +1. 打开 consumer 页面。 +2. 点击 `Navigate to lineage`,确认页面变成 `Status: blank`。 +3. 点击 `Navigate back to features`,确认仍然是空白态。 +4. 点击 `Enter default page`,确认回到正常 features 页面。 + +## 结构校验 + +```bash +node tests/integration/agent-runtime-mf/verify.mjs +``` diff --git a/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/consumer/@mf-types/asyncChunkRuntimeProvider/RemotePanel.d.ts b/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/consumer/@mf-types/asyncChunkRuntimeProvider/RemotePanel.d.ts new file mode 100644 index 000000000000..3ff7ab54a69f --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/consumer/@mf-types/asyncChunkRuntimeProvider/RemotePanel.d.ts @@ -0,0 +1,2 @@ +export * from './compiled-types/RemotePanel'; +export { default } from './compiled-types/RemotePanel'; \ No newline at end of file diff --git a/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/consumer/@mf-types/asyncChunkRuntimeProvider/apis.d.ts b/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/consumer/@mf-types/asyncChunkRuntimeProvider/apis.d.ts new file mode 100644 index 000000000000..062609ff96a5 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/consumer/@mf-types/asyncChunkRuntimeProvider/apis.d.ts @@ -0,0 +1,3 @@ + + export type RemoteKeys = 'asyncChunkRuntimeProvider/RemotePanel'; + type PackageType = T extends 'asyncChunkRuntimeProvider/RemotePanel' ? typeof import('asyncChunkRuntimeProvider/RemotePanel') :any; \ No newline at end of file diff --git a/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/consumer/@mf-types/asyncChunkRuntimeProvider/compiled-types/RemotePanel.d.ts b/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/consumer/@mf-types/asyncChunkRuntimeProvider/compiled-types/RemotePanel.d.ts new file mode 100644 index 000000000000..39b526a1bdd9 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/consumer/@mf-types/asyncChunkRuntimeProvider/compiled-types/RemotePanel.d.ts @@ -0,0 +1 @@ +export default function RemotePanel(): import("react/jsx-runtime").JSX.Element; diff --git a/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/consumer/modern.config.ts b/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/consumer/modern.config.ts new file mode 100644 index 000000000000..7ac1f4f16bb2 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/consumer/modern.config.ts @@ -0,0 +1,35 @@ +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { appTools, defineConfig } from '@modern-js/app-tools'; +import { moduleFederationPlugin } from '@module-federation/modern-js-v3'; + +const require = createRequire(import.meta.url); +const reactRoot = dirname(require.resolve('react/package.json')); + +export default defineConfig({ + server: { + port: 4332, + }, + source: { + alias: { + react: reactRoot, + 'react/jsx-runtime': join(reactRoot, 'jsx-runtime.js'), + 'react/jsx-dev-runtime': join(reactRoot, 'jsx-dev-runtime.js'), + }, + }, + performance: { + buildCache: false, + }, + tools: { + bundlerChain(chain) { + chain.output.uniqueName('agent_runtime_mf_async_chunk_consumer'); + chain.output.chunkLoadingGlobal( + 'webpackChunk_agent_runtime_mf_async_chunk_consumer', + ); + chain.output.chunkFilename('static/js/async/[id].[contenthash:8].js'); + chain.optimization.chunkIds('deterministic'); + chain.optimization.moduleIds('deterministic'); + }, + }, + plugins: [appTools(), moduleFederationPlugin()], +}); diff --git a/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/consumer/module-federation.config.ts b/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/consumer/module-federation.config.ts new file mode 100644 index 000000000000..7ba6f5dd9d2b --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/consumer/module-federation.config.ts @@ -0,0 +1,17 @@ +import { createModuleFederationConfig } from '@module-federation/modern-js-v3'; +import { dependencies } from './package.json'; + +export default createModuleFederationConfig({ + name: 'asyncChunkRuntimeConsumer', + remotes: { + asyncChunkRuntimeProvider: + 'asyncChunkRuntimeProvider@http://localhost:4331/remote/mf-manifest.json', + }, + shared: { + react: { singleton: true, requiredVersion: dependencies.react }, + 'react-dom': { + singleton: true, + requiredVersion: dependencies['react-dom'], + }, + }, +}); diff --git a/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/consumer/package.json b/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/consumer/package.json new file mode 100644 index 000000000000..53807c29ea22 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/consumer/package.json @@ -0,0 +1,19 @@ +{ + "private": true, + "name": "agent-runtime-mf-async-chunk-runtime-consumer", + "version": "0.0.0", + "scripts": { + "dev": "modern dev", + "build": "modern build" + }, + "dependencies": { + "@modern-js/runtime": "workspace:*", + "@module-federation/modern-js-v3": "2.0.0", + "garfish": "1.19.4", + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@modern-js/app-tools": "workspace:*" + } +} diff --git a/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/consumer/src/App.tsx b/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/consumer/src/App.tsx new file mode 100644 index 000000000000..7d76f15837af --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/consumer/src/App.tsx @@ -0,0 +1,285 @@ +import Garfish from 'garfish'; +import React, { Suspense, lazy, useEffect, useState } from 'react'; +import * as ReactDom from 'react-dom'; + +declare global { + interface Window { + react?: typeof React; + 'react-dom'?: typeof ReactDom; + __ASYNC_CHUNK_RUNTIME_ERRORS__?: RuntimeErrorRecord[]; + __ASYNC_CHUNK_RUNTIME_ERROR_LISTENER__?: boolean; + } +} + +window.react = React; +window['react-dom'] = ReactDom; + +const RemotePanel = lazy(() => import('asyncChunkRuntimeProvider/RemotePanel')); + +const rivendellRuntime = { + appName: 'oceancloud_rivendell', + garfishEntry: 'http://localhost:4331/garfish/html/index/index.html', + basename: '/workspace/rivendell', +}; + +const garfishDomGetter = '#garfish-rivendell-container'; +let garfishRegistered = false; + +type RuntimeErrorRecord = { + message: string; + name?: string; + request?: string; +}; + +type PageStatus = 'success' | 'loading' | 'error'; + +const normalizeRuntimeError = (error: unknown): RuntimeErrorRecord => { + if (error instanceof Error) { + const request = + 'request' in error && typeof error.request === 'string' + ? error.request + : undefined; + return { + name: error.name, + message: error.message, + request, + }; + } + if (typeof error === 'string') { + return { message: error }; + } + return { message: JSON.stringify(error) }; +}; + +const getRuntimeErrors = () => { + window.__ASYNC_CHUNK_RUNTIME_ERRORS__ ||= []; + return window.__ASYNC_CHUNK_RUNTIME_ERRORS__; +}; + +const recordRuntimeError = (error: unknown) => { + const record = normalizeRuntimeError(error); + getRuntimeErrors().push(record); + return record; +}; + +const waitForRuntimeError = (fromIndex: number) => + new Promise(resolve => { + window.setTimeout(() => { + resolve(getRuntimeErrors().slice(fromIndex).at(-1) ?? null); + }, 1500); + }); + +const waitForGarfishRender = () => + new Promise(resolve => { + window.setTimeout(() => { + resolve( + Boolean(document.querySelector('[data-testid="garfish-swr-probe"]')), + ); + }, 1500); + }); + +if ( + typeof window !== 'undefined' && + !window.__ASYNC_CHUNK_RUNTIME_ERROR_LISTENER__ +) { + window.addEventListener('error', event => { + recordRuntimeError(event.error || event.message); + }); + window.addEventListener('unhandledrejection', event => { + recordRuntimeError(event.reason); + }); + window.__ASYNC_CHUNK_RUNTIME_ERROR_LISTENER__ = true; +} + +const ensureGarfishRegistered = () => { + if (!window.__GARFISH__) { + Garfish.run({ + basename: '/', + disablePreloadApp: true, + sandbox: false, + }); + } + if (garfishRegistered) { + return; + } + Garfish.registerApp({ + name: rivendellRuntime.appName, + entry: rivendellRuntime.garfishEntry, + basename: rivendellRuntime.basename, + domGetter: garfishDomGetter, + cache: false, + sandbox: false, + }); + Garfish.setExternal({ + react: React, + 'react-dom': ReactDom, + }); + garfishRegistered = true; +}; + +export default function App() { + const [status, setStatus] = useState('success'); + const [route, setRoute] = useState('/workspace/home'); + const [pendingReason, setPendingReason] = useState(null); + const [providerLoaded, setProviderLoaded] = useState(false); + const [garfishMounted, setGarfishMounted] = useState(false); + const [lastError, setLastError] = useState(null); + const [loadingGarfish, setLoadingGarfish] = useState(false); + + useEffect(() => { + if (window.location.pathname === '/') { + window.history.replaceState(null, '', '/workspace/home'); + } + }, []); + + const loadProviderRemote = () => { + setProviderLoaded(true); + setStatus('success'); + setPendingReason(null); + }; + + const loadGarfishSubApp = async () => { + const loadedProviderFirst = providerLoaded; + const errorStartIndex = getRuntimeErrors().length; + let loadError: RuntimeErrorRecord | null = null; + + setLoadingGarfish(true); + setStatus('loading'); + setPendingReason( + loadedProviderFirst + ? 'Loading Garfish after the provider remote.' + : 'Loading Garfish before the provider remote.', + ); + window.history.pushState(null, '', rivendellRuntime.basename); + setRoute(rivendellRuntime.basename); + + try { + ensureGarfishRegistered(); + const app = await Garfish.loadApp(rivendellRuntime.appName, { + entry: rivendellRuntime.garfishEntry, + basename: rivendellRuntime.basename, + domGetter: garfishDomGetter, + cache: false, + sandbox: false, + props: { + from: '/workspace/home', + source: 'workbench-menu', + }, + }); + if (!app) { + throw new Error('Garfish.loadApp returned empty app instance.'); + } + if (app.mounted) { + await app.show(); + } else { + await app.mount(); + } + } catch (error) { + console.error('[async-chunk-runtime] Garfish loadApp failed', error); + loadError = recordRuntimeError(error); + } + + const rendered = await waitForGarfishRender(); + const runtimeError = + loadError || (await waitForRuntimeError(errorStartIndex)); + const renderError = + !runtimeError && !rendered + ? { + name: 'GarfishRenderError', + message: + 'Garfish sub app loaded but its async content did not render.', + } + : null; + const errorRecord = runtimeError || renderError; + + if (errorRecord) { + setStatus('error'); + setPendingReason(errorRecord.message); + setLastError(errorRecord.message); + setGarfishMounted(false); + } else { + setStatus('success'); + setPendingReason(null); + setLastError(null); + setGarfishMounted(true); + } + setLoadingGarfish(false); + }; + + return ( +
+
+

Async chunk runtime MF case

+

+ Load the provider remote first, then load the Garfish sub app to + reproduce the async chunk conflict. +

+
+ +
+

Reproduced Issue

+

+ Status: {status} +

+

+ Route: {route} +

+ {pendingReason ? ( +

+ Pending reason: {pendingReason} +

+ ) : null} + {lastError ? ( +

+ Error: {lastError} +

+ ) : null} +
+ +
+ + +
+ +
+

Remote Provider

+ {providerLoaded ? ( + Loading remote provider...}> + + + ) : ( +
+ Provider remote has not been loaded. +
+ )} +
+ +
+

Garfish Sub App

+

+ Mounted: {String(garfishMounted)} +

+
+
Garfish sub app has not been mounted.
+
+
+
+ ); +} diff --git a/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/consumer/src/modern-app-env.d.ts b/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/consumer/src/modern-app-env.d.ts new file mode 100644 index 000000000000..11fd97f0e4b9 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/consumer/src/modern-app-env.d.ts @@ -0,0 +1,2 @@ +/// + diff --git a/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/consumer/src/remotes.d.ts b/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/consumer/src/remotes.d.ts new file mode 100644 index 000000000000..d53e0174657c --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/consumer/src/remotes.d.ts @@ -0,0 +1,6 @@ +declare module 'asyncChunkRuntimeProvider/RemotePanel' { + import type { ComponentType } from 'react'; + const RemotePanel: ComponentType; + export default RemotePanel; +} + diff --git a/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/consumer/tsconfig.json b/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/consumer/tsconfig.json new file mode 100644 index 000000000000..d2a56b7f1520 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/consumer/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@modern-js/tsconfig/base", + "compilerOptions": { + "declaration": false, + "jsx": "react-jsx" + }, + "include": ["src"] +} diff --git a/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/provider/modern.config.ts b/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/provider/modern.config.ts new file mode 100644 index 000000000000..91827c305f07 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/provider/modern.config.ts @@ -0,0 +1,93 @@ +import { appTools, defineConfig } from '@modern-js/app-tools'; + +const buildKind = process.env.ASYNC_CHUNK_RUNTIME_BUILD || 'unified'; +const buildPublicPath = + buildKind === 'unified' + ? 'http://localhost:4331/' + : `http://localhost:4331/${buildKind}/`; +const buildDistPath = buildKind === 'unified' ? 'dist' : `dist/${buildKind}`; + +export default defineConfig({ + server: { + port: 4331, + }, + source: { + transformImport: false, + }, + output: { + disableTsChecker: true, + distPath: { + root: buildDistPath, + }, + }, + performance: { + buildCache: false, + }, + tools: { + bundlerChain(chain) { + chain.output.uniqueName('ad_rivendell_dev'); + chain.output.chunkLoadingGlobal('webpackChunk_ad_rivendell_dev'); + chain.output.chunkFilename('static/js/async/[id].[contenthash:8].js'); + chain.optimization.chunkIds('deterministic'); + chain.optimization.moduleIds('deterministic'); + chain.optimization.runtimeChunk(false); + chain.optimization.splitChunks({ + chunks: 'async', + minSize: 20000, + minRemainingSize: 0, + minChunks: 1, + maxAsyncRequests: 30, + maxInitialRequests: 30, + enforceSizeThreshold: 50000, + cacheGroups: { + defaultVendors: { + test: /[\\/]node_modules[\\/]/, + priority: -10, + reuseExistingChunk: true, + }, + default: { + minChunks: 2, + priority: -20, + reuseExistingChunk: true, + }, + }, + }); + }, + rspack(config, { rspack }) { + config.output.publicPath = buildPublicPath; + config.plugins.push( + new rspack.DefinePlugin({ + __ASYNC_CHUNK_RUNTIME_BUILD__: JSON.stringify(buildKind), + }), + ); + config.plugins.push( + new rspack.container.ModuleFederationPlugin({ + name: 'asyncChunkRuntimeProvider', + filename: 'remoteEntry.js', + exposes: { + './RemotePanel': './src/RemotePanel.tsx', + }, + manifest: true, + // shared: { + // react: { singleton: true, import: false }, + // 'react-dom': { + // singleton: true, + // import: false, + // }, + // }, + }), + ); + + config.output.library = { + type: 'umd', + }; + config.externals = { + react: 'react', + 'react-dom': 'react-dom', + }; + config.externalsType = 'global'; + config.output.globalObject = 'window'; + }, + }, + plugins: [appTools()], +}); diff --git a/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/provider/package.json b/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/provider/package.json new file mode 100644 index 000000000000..7a99fd9f30e5 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/provider/package.json @@ -0,0 +1,22 @@ +{ + "private": true, + "name": "agent-runtime-mf-async-chunk-runtime-provider", + "version": "0.0.0", + "scripts": { + "dev": "modern dev", + "build": "pnpm run build:remote && pnpm run build:garfish", + "build:remote": "ASYNC_CHUNK_RUNTIME_BUILD=remote modern build", + "build:garfish": "ASYNC_CHUNK_RUNTIME_BUILD=garfish modern build", + "serve": "serve dist -p 4331 -C -c ../serve.json" + }, + "dependencies": { + "@modern-js/runtime": "workspace:*", + "swr": "^1.3.0", + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@modern-js/app-tools": "workspace:*", + "serve": "14.2.6" + } +} diff --git a/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/provider/serve.json b/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/provider/serve.json new file mode 100644 index 000000000000..b308df80627e --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/provider/serve.json @@ -0,0 +1,3 @@ +{ + "cleanUrls": false +} diff --git a/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/provider/src/App.tsx b/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/provider/src/App.tsx new file mode 100644 index 000000000000..76868a2426f5 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/provider/src/App.tsx @@ -0,0 +1,27 @@ +import { Suspense, lazy } from 'react'; +import type { ComponentType, LazyExoticComponent } from 'react'; + +let GarfishOrderSensitive: LazyExoticComponent | null = null; + +if (__ASYNC_CHUNK_RUNTIME_BUILD__ !== 'remote') { + GarfishOrderSensitive = lazy( + () => + import( + /* webpackChunkName: "rivendell-order-sensitive" */ + './async/GarfishOrderSensitive' + ), + ); +} + +export default function RivendellGarfishApp() { + return ( +
+

Garfish Sub

+ {GarfishOrderSensitive ? ( + Loading Garfish async chunk...}> + + + ) : null} +
+ ); +} diff --git a/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/provider/src/LazyComponent.tsx b/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/provider/src/LazyComponent.tsx new file mode 100644 index 000000000000..32de8163924c --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/provider/src/LazyComponent.tsx @@ -0,0 +1,2 @@ +export const Button = 'button'; +export const Input = 'input'; diff --git a/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/provider/src/RemotePanel.tsx b/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/provider/src/RemotePanel.tsx new file mode 100644 index 000000000000..e99b5083c18e --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/provider/src/RemotePanel.tsx @@ -0,0 +1,28 @@ +import { Suspense, lazy } from 'react'; +import type { ComponentType, LazyExoticComponent } from 'react'; + +let RemoteOrderSensitive: LazyExoticComponent | null = null; + +if (__ASYNC_CHUNK_RUNTIME_BUILD__ !== 'garfish') { + RemoteOrderSensitive = lazy( + () => + import( + /* webpackChunkName: "rivendell-order-sensitive" */ + './async/RemoteOrderSensitive' + ), + ); +} + +console.log('RemotePanel render'); +export default function RemotePanel() { + return ( +
+

MF Expose

+ {RemoteOrderSensitive ? ( + Loading remote async chunk...}> + + + ) : null} +
+ ); +} diff --git a/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/provider/src/async/GarfishOrderSensitive.tsx b/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/provider/src/async/GarfishOrderSensitive.tsx new file mode 100644 index 000000000000..0fda72933aad --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/provider/src/async/GarfishOrderSensitive.tsx @@ -0,0 +1,5 @@ +import SWRProbe from './SWRProbe'; + +export default function GarfishOrderSensitive() { + return ; +} diff --git a/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/provider/src/async/RemoteOrderSensitive.tsx b/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/provider/src/async/RemoteOrderSensitive.tsx new file mode 100644 index 000000000000..72786d2ebc28 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/provider/src/async/RemoteOrderSensitive.tsx @@ -0,0 +1,5 @@ +import SWRProbe from './SWRProbe'; + +export default function RemoteOrderSensitive() { + return ; +} diff --git a/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/provider/src/async/SWRProbe.tsx b/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/provider/src/async/SWRProbe.tsx new file mode 100644 index 000000000000..a3d5ca3b62cb --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/provider/src/async/SWRProbe.tsx @@ -0,0 +1,24 @@ +import useSWR from 'swr'; +import { Button } from '../LazyComponent'; + +type SWRProbeProps = { + owner: 'garfish' | 'remote'; +}; + +export default function SWRProbe({ owner }: SWRProbeProps) { + const { data = 'pending' } = useSWR( + `async-chunk-runtime-${owner}`, + async () => `${owner}:ready`, + { + revalidateOnFocus: false, + shouldRetryOnError: false, + }, + ); + + return ( +
+

{data}

+ {Button} +
+ ); +} diff --git a/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/provider/src/entry.tsx b/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/provider/src/entry.tsx new file mode 100644 index 000000000000..42d10d7d576c --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/provider/src/entry.tsx @@ -0,0 +1,82 @@ +import { render } from '@modern-js/runtime/browser'; +import { createRoot } from '@modern-js/runtime/react'; +import type { Root } from 'react-dom/client'; + +type GarfishPayload = { + appName?: string; + basename?: string; + dom: Document | Element | ShadowRoot; + props?: Record; +}; +type GarfishProvider = (payload?: GarfishPayload) => { + render(payload?: GarfishPayload): void; + destroy(): void; +}; + +declare const __GARFISH_EXPORTS__: + | undefined + | { + provider?: GarfishProvider; + registerProvider?: (provider: GarfishProvider) => void; + }; + +declare global { + interface Window { + __GARFISH__?: boolean; + } +} + +const isDocument = (dom: GarfishPayload['dom']): dom is Document => + dom.nodeType === Node.DOCUMENT_NODE; + +const getMountElement = (dom: GarfishPayload['dom']) => { + if (isDocument(dom)) { + return dom.getElementById('root') || dom.body; + } + const element = dom as Element; + return ( + element.querySelector('#root') || + element.querySelector('[data-garfish-root]') || + element + ); +}; +export const provider: GarfishProvider = initialPayload => { + let root: Root | null = null; + return { + render(payload = initialPayload) { + console.log('GarfishEntry render', payload); + if (!payload) { + return; + } + const mountElement = getMountElement(payload.dom); + const ModernRoot = createRoot(); + root = render(, mountElement); + }, + destroy() { + root?.unmount(); + root = null; + }, + }; +}; + +if ( + typeof window !== 'undefined' && + window.__GARFISH__ && + typeof __GARFISH_EXPORTS__ !== 'undefined' +) { + console.log(3222); + __GARFISH_EXPORTS__.provider = provider; +} + +if (typeof window !== 'undefined' && !window.__GARFISH__) { + const standaloneRoot = document.getElementById('root'); + if (standaloneRoot) { + const ModernRoot = createRoot(); + + render(); + } +} + +console.log('GarfishEntry executed', provider); + +export default provider; diff --git a/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/provider/src/modern-app-env.d.ts b/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/provider/src/modern-app-env.d.ts new file mode 100644 index 000000000000..e1baea9233c3 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/provider/src/modern-app-env.d.ts @@ -0,0 +1,3 @@ +/// + +declare const __ASYNC_CHUNK_RUNTIME_BUILD__: 'unified' | 'remote' | 'garfish'; diff --git a/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/provider/tsconfig.json b/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/provider/tsconfig.json new file mode 100644 index 000000000000..67562ff13113 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/async-chunk-runtime/provider/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@modern-js/tsconfig/base", + "compilerOptions": { + "declaration": false, + "jsx": "react-jsx" + }, + "include": ["src"] +} + diff --git a/tests/integration/agent-runtime-mf/cases/garfish-provider/consumer/@mf-types/garfishProvider/RemotePanel.d.ts b/tests/integration/agent-runtime-mf/cases/garfish-provider/consumer/@mf-types/garfishProvider/RemotePanel.d.ts new file mode 100644 index 000000000000..3ff7ab54a69f --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/garfish-provider/consumer/@mf-types/garfishProvider/RemotePanel.d.ts @@ -0,0 +1,2 @@ +export * from './compiled-types/RemotePanel'; +export { default } from './compiled-types/RemotePanel'; \ No newline at end of file diff --git a/tests/integration/agent-runtime-mf/cases/garfish-provider/consumer/@mf-types/garfishProvider/apis.d.ts b/tests/integration/agent-runtime-mf/cases/garfish-provider/consumer/@mf-types/garfishProvider/apis.d.ts new file mode 100644 index 000000000000..fe8549daf453 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/garfish-provider/consumer/@mf-types/garfishProvider/apis.d.ts @@ -0,0 +1,3 @@ + + export type RemoteKeys = 'garfishProvider/RemotePanel'; + type PackageType = T extends 'garfishProvider/RemotePanel' ? typeof import('garfishProvider/RemotePanel') :any; \ No newline at end of file diff --git a/tests/integration/agent-runtime-mf/cases/garfish-provider/consumer/@mf-types/garfishProvider/compiled-types/RemotePanel.d.ts b/tests/integration/agent-runtime-mf/cases/garfish-provider/consumer/@mf-types/garfishProvider/compiled-types/RemotePanel.d.ts new file mode 100644 index 000000000000..39b526a1bdd9 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/garfish-provider/consumer/@mf-types/garfishProvider/compiled-types/RemotePanel.d.ts @@ -0,0 +1 @@ +export default function RemotePanel(): import("react/jsx-runtime").JSX.Element; diff --git a/tests/integration/agent-runtime-mf/cases/garfish-provider/consumer/modern.config.ts b/tests/integration/agent-runtime-mf/cases/garfish-provider/consumer/modern.config.ts new file mode 100644 index 000000000000..cdd3f61672ae --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/garfish-provider/consumer/modern.config.ts @@ -0,0 +1,24 @@ +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { appTools, defineConfig } from '@modern-js/app-tools'; +import { moduleFederationPlugin } from '@module-federation/modern-js-v3'; + +const require = createRequire(import.meta.url); +const reactRoot = dirname(require.resolve('react/package.json')); + +export default defineConfig({ + server: { + port: 4342, + }, + source: { + alias: { + react: reactRoot, + 'react/jsx-runtime': join(reactRoot, 'jsx-runtime.js'), + 'react/jsx-dev-runtime': join(reactRoot, 'jsx-dev-runtime.js'), + }, + }, + performance: { + buildCache: false, + }, + plugins: [appTools(), moduleFederationPlugin()], +}); diff --git a/tests/integration/agent-runtime-mf/cases/garfish-provider/consumer/module-federation.config.ts b/tests/integration/agent-runtime-mf/cases/garfish-provider/consumer/module-federation.config.ts new file mode 100644 index 000000000000..48d2bc3be029 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/garfish-provider/consumer/module-federation.config.ts @@ -0,0 +1,16 @@ +import { createModuleFederationConfig } from '@module-federation/modern-js-v3'; +import { dependencies } from './package.json'; + +export default createModuleFederationConfig({ + name: 'garfishProviderConsumer', + remotes: { + garfishProvider: 'garfishProvider@http://localhost:4341/mf-manifest.json', + }, + shared: { + react: { singleton: true, requiredVersion: dependencies.react }, + 'react-dom': { + singleton: true, + requiredVersion: dependencies['react-dom'], + }, + }, +}); diff --git a/tests/integration/agent-runtime-mf/cases/garfish-provider/consumer/package.json b/tests/integration/agent-runtime-mf/cases/garfish-provider/consumer/package.json new file mode 100644 index 000000000000..707b3a625115 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/garfish-provider/consumer/package.json @@ -0,0 +1,19 @@ +{ + "private": true, + "name": "agent-runtime-mf-garfish-provider-consumer", + "version": "0.0.0", + "scripts": { + "dev": "modern dev", + "build": "modern build" + }, + "dependencies": { + "@modern-js/runtime": "workspace:*", + "@module-federation/modern-js-v3": "2.0.0", + "garfish": "1.19.4", + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@modern-js/app-tools": "workspace:*" + } +} diff --git a/tests/integration/agent-runtime-mf/cases/garfish-provider/consumer/src/App.tsx b/tests/integration/agent-runtime-mf/cases/garfish-provider/consumer/src/App.tsx new file mode 100644 index 000000000000..3db48eb6df57 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/garfish-provider/consumer/src/App.tsx @@ -0,0 +1,137 @@ +import Garfish from 'garfish'; +import { Suspense, lazy, useEffect, useState } from 'react'; + +const RemotePanel = lazy(() => import('garfishProvider/RemotePanel')); + +const creativeHubRuntime = { + appName: 'creative-hub', + garfishEntry: 'http://localhost:4341/creative-hub', + basename: '/container/orders', +}; + +const garfishDomGetter = '#creative-hub-container'; +let garfishRegistered = false; + +const ensureGarfishRegistered = () => { + if (!window.__GARFISH__) { + Garfish.run({ + basename: '/', + disablePreloadApp: true, + }); + } + if (garfishRegistered) { + return; + } + Garfish.registerApp({ + name: creativeHubRuntime.appName, + entry: creativeHubRuntime.garfishEntry, + basename: creativeHubRuntime.basename, + domGetter: garfishDomGetter, + cache: false, + }); + garfishRegistered = true; +}; + +export default function App() { + const [status, setStatus] = useState<'success' | 'loading' | 'blank'>( + 'success', + ); + const [route, setRoute] = useState('/container/home'); + const [errorMessage, setErrorMessage] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (window.location.pathname === '/') { + window.history.replaceState(null, '', '/container/home'); + } + }, []); + + const loadCreativeHub = async () => { + let error = '[Garfish warning]: "provider" is "undefined"'; + setLoading(true); + setStatus('loading'); + setErrorMessage(null); + window.history.pushState(null, '', creativeHubRuntime.basename); + setRoute(creativeHubRuntime.basename); + + try { + ensureGarfishRegistered(); + const app = await Garfish.loadApp(creativeHubRuntime.appName, { + entry: creativeHubRuntime.garfishEntry, + basename: creativeHubRuntime.basename, + domGetter: garfishDomGetter, + cache: false, + }); + if (!app) { + throw new Error('Garfish.loadApp returned empty app instance.'); + } + if (app.mounted) { + await app.show(); + } else { + await app.mount(); + } + } catch (loadError) { + error = + loadError instanceof Error ? loadError.message : String(loadError); + } + + setStatus('blank'); + setErrorMessage(error); + setLoading(false); + }; + + return ( +
+
+

Garfish provider MF case

+

+ The child app entry is loaded, but Garfish cannot read the expected + provider export. +

+
+ +
+

Reproduced Issue

+

+ Status: {status} +

+

+ Route: {route} +

+ {errorMessage ? ( +

+ Error: {errorMessage} +

+ ) : null} +
+ +
+ +
+ +
+

Remote Provider

+ Loading remote provider...}> + + +
+ +
+

Garfish Sub App

+
+
Garfish child app has not been mounted.
+
+
+
+ ); +} diff --git a/tests/integration/agent-runtime-mf/cases/garfish-provider/consumer/src/modern-app-env.d.ts b/tests/integration/agent-runtime-mf/cases/garfish-provider/consumer/src/modern-app-env.d.ts new file mode 100644 index 000000000000..11fd97f0e4b9 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/garfish-provider/consumer/src/modern-app-env.d.ts @@ -0,0 +1,2 @@ +/// + diff --git a/tests/integration/agent-runtime-mf/cases/garfish-provider/consumer/src/remotes.d.ts b/tests/integration/agent-runtime-mf/cases/garfish-provider/consumer/src/remotes.d.ts new file mode 100644 index 000000000000..8d64499dca1f --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/garfish-provider/consumer/src/remotes.d.ts @@ -0,0 +1,6 @@ +declare module 'garfishProvider/RemotePanel' { + import type { ComponentType } from 'react'; + const RemotePanel: ComponentType; + export default RemotePanel; +} + diff --git a/tests/integration/agent-runtime-mf/cases/garfish-provider/consumer/tsconfig.json b/tests/integration/agent-runtime-mf/cases/garfish-provider/consumer/tsconfig.json new file mode 100644 index 000000000000..d2a56b7f1520 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/garfish-provider/consumer/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@modern-js/tsconfig/base", + "compilerOptions": { + "declaration": false, + "jsx": "react-jsx" + }, + "include": ["src"] +} diff --git a/tests/integration/agent-runtime-mf/cases/garfish-provider/provider/modern.config.ts b/tests/integration/agent-runtime-mf/cases/garfish-provider/provider/modern.config.ts new file mode 100644 index 000000000000..ab8c060c5e06 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/garfish-provider/provider/modern.config.ts @@ -0,0 +1,20 @@ +import { appTools, defineConfig } from '@modern-js/app-tools'; +import { moduleFederationPlugin } from '@module-federation/modern-js-v3'; + +export default defineConfig({ + server: { + port: 4341, + }, + source: { + entries: { + 'creative-hub': { + entry: './src/garfish/CreativeHubEntry.tsx', + customEntry: true, + }, + }, + }, + performance: { + buildCache: false, + }, + plugins: [appTools(), moduleFederationPlugin()], +}); diff --git a/tests/integration/agent-runtime-mf/cases/garfish-provider/provider/module-federation.config.ts b/tests/integration/agent-runtime-mf/cases/garfish-provider/provider/module-federation.config.ts new file mode 100644 index 000000000000..c549eef24afe --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/garfish-provider/provider/module-federation.config.ts @@ -0,0 +1,18 @@ +import { createModuleFederationConfig } from '@module-federation/modern-js-v3'; +import { dependencies } from './package.json'; + +export default createModuleFederationConfig({ + name: 'garfishProvider', + filename: 'remoteEntry.js', + exposes: { + './RemotePanel': './src/RemotePanel.tsx', + }, + shared: { + react: { singleton: true, requiredVersion: dependencies.react }, + 'react-dom': { + singleton: true, + requiredVersion: dependencies['react-dom'], + }, + }, +}); + diff --git a/tests/integration/agent-runtime-mf/cases/garfish-provider/provider/package.json b/tests/integration/agent-runtime-mf/cases/garfish-provider/provider/package.json new file mode 100644 index 000000000000..63db6b359aaf --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/garfish-provider/provider/package.json @@ -0,0 +1,18 @@ +{ + "private": true, + "name": "agent-runtime-mf-garfish-provider-app", + "version": "0.0.0", + "scripts": { + "dev": "modern dev", + "build": "modern build" + }, + "dependencies": { + "@modern-js/runtime": "workspace:*", + "@module-federation/modern-js-v3": "2.0.0", + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@modern-js/app-tools": "workspace:*" + } +} diff --git a/tests/integration/agent-runtime-mf/cases/garfish-provider/provider/src/App.tsx b/tests/integration/agent-runtime-mf/cases/garfish-provider/provider/src/App.tsx new file mode 100644 index 000000000000..fd401329cc12 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/garfish-provider/provider/src/App.tsx @@ -0,0 +1,6 @@ +import RemotePanel from './RemotePanel'; + +export default function App() { + return ; +} + diff --git a/tests/integration/agent-runtime-mf/cases/garfish-provider/provider/src/RemotePanel.tsx b/tests/integration/agent-runtime-mf/cases/garfish-provider/provider/src/RemotePanel.tsx new file mode 100644 index 000000000000..c0f81354ce75 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/garfish-provider/provider/src/RemotePanel.tsx @@ -0,0 +1,12 @@ +export default function RemotePanel() { + return ( +
+ Provider: garfish child application +

+ The remote represents a child app whose entry loaded but provider export + and mounted state still need separate checks. +

+
+ ); +} + diff --git a/tests/integration/agent-runtime-mf/cases/garfish-provider/provider/src/garfish/CreativeHubEntry.tsx b/tests/integration/agent-runtime-mf/cases/garfish-provider/provider/src/garfish/CreativeHubEntry.tsx new file mode 100644 index 000000000000..678e1abf7c09 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/garfish-provider/provider/src/garfish/CreativeHubEntry.tsx @@ -0,0 +1,63 @@ +import { type Root, createRoot } from 'react-dom/client'; + +type GarfishPayload = { + basename?: string; + dom: Document | Element | ShadowRoot; +}; + +type GarfishProvider = (payload?: GarfishPayload) => { + render(payload?: GarfishPayload): void; + destroy(): void; +}; + +declare global { + interface Window { + __GARFISH__?: boolean; + } +} + +const isDocument = (dom: GarfishPayload['dom']): dom is Document => + dom.nodeType === Node.DOCUMENT_NODE; + +const getMountElement = (dom: GarfishPayload['dom']) => { + if (isDocument(dom)) { + return dom.getElementById('root') || dom.body; + } + const element = dom as Element; + return element.querySelector('#root') || element; +}; + +function CreativeHubApp() { + return ( +
+ Garfish: creative-hub +

Orders page mounted from the child app provider.

+
+ ); +} + +export const provider: GarfishProvider = initialPayload => { + let root: Root | null = null; + return { + render(payload = initialPayload) { + if (!payload) { + return; + } + root = createRoot(getMountElement(payload.dom)); + root.render(); + }, + destroy() { + root?.unmount(); + root = null; + }, + }; +}; + +if (typeof window !== 'undefined' && !window.__GARFISH__) { + const standaloneRoot = document.getElementById('root'); + if (standaloneRoot) { + createRoot(standaloneRoot).render(); + } +} + +export default provider; diff --git a/tests/integration/agent-runtime-mf/cases/garfish-provider/provider/src/modern-app-env.d.ts b/tests/integration/agent-runtime-mf/cases/garfish-provider/provider/src/modern-app-env.d.ts new file mode 100644 index 000000000000..11fd97f0e4b9 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/garfish-provider/provider/src/modern-app-env.d.ts @@ -0,0 +1,2 @@ +/// + diff --git a/tests/integration/agent-runtime-mf/cases/garfish-provider/provider/tsconfig.json b/tests/integration/agent-runtime-mf/cases/garfish-provider/provider/tsconfig.json new file mode 100644 index 000000000000..67562ff13113 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/garfish-provider/provider/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@modern-js/tsconfig/base", + "compilerOptions": { + "declaration": false, + "jsx": "react-jsx" + }, + "include": ["src"] +} + diff --git a/tests/integration/agent-runtime-mf/cases/nested-router-tree/consumer/@mf-types/index.d.ts b/tests/integration/agent-runtime-mf/cases/nested-router-tree/consumer/@mf-types/index.d.ts new file mode 100644 index 000000000000..82ae33d8d38a --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/nested-router-tree/consumer/@mf-types/index.d.ts @@ -0,0 +1,29 @@ +import type { PackageType as PackageType_0,RemoteKeys as RemoteKeys_0 } from './nestedRouterTreeProvider/apis.d.ts'; + declare module "@module-federation/runtime" { + type RemoteKeys = RemoteKeys_0; + type PackageType = T extends RemoteKeys_0 ? PackageType_0 : +Y ; + export function loadRemote(packageName: T): Promise>; + export function loadRemote(packageName: T): Promise>; + } +declare module "@module-federation/enhanced/runtime" { + type RemoteKeys = RemoteKeys_0; + type PackageType = T extends RemoteKeys_0 ? PackageType_0 : +Y ; + export function loadRemote(packageName: T): Promise>; + export function loadRemote(packageName: T): Promise>; + } +declare module "@module-federation/runtime-tools" { + type RemoteKeys = RemoteKeys_0; + type PackageType = T extends RemoteKeys_0 ? PackageType_0 : +Y ; + export function loadRemote(packageName: T): Promise>; + export function loadRemote(packageName: T): Promise>; + } +declare module "@module-federation/modern-js-v3/runtime" { + type RemoteKeys = RemoteKeys_0; + type PackageType = T extends RemoteKeys_0 ? PackageType_0 : +Y ; + export function loadRemote(packageName: T): Promise>; + export function loadRemote(packageName: T): Promise>; + } diff --git a/tests/integration/agent-runtime-mf/cases/nested-router-tree/consumer/@mf-types/nestedRouterTreeProvider/RemotePanel.d.ts b/tests/integration/agent-runtime-mf/cases/nested-router-tree/consumer/@mf-types/nestedRouterTreeProvider/RemotePanel.d.ts new file mode 100644 index 000000000000..3ff7ab54a69f --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/nested-router-tree/consumer/@mf-types/nestedRouterTreeProvider/RemotePanel.d.ts @@ -0,0 +1,2 @@ +export * from './compiled-types/RemotePanel'; +export { default } from './compiled-types/RemotePanel'; \ No newline at end of file diff --git a/tests/integration/agent-runtime-mf/cases/nested-router-tree/consumer/@mf-types/nestedRouterTreeProvider/apis.d.ts b/tests/integration/agent-runtime-mf/cases/nested-router-tree/consumer/@mf-types/nestedRouterTreeProvider/apis.d.ts new file mode 100644 index 000000000000..5bf03152ca48 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/nested-router-tree/consumer/@mf-types/nestedRouterTreeProvider/apis.d.ts @@ -0,0 +1,3 @@ + + export type RemoteKeys = 'nestedRouterTreeProvider/RemotePanel'; + type PackageType = T extends 'nestedRouterTreeProvider/RemotePanel' ? typeof import('nestedRouterTreeProvider/RemotePanel') :any; \ No newline at end of file diff --git a/tests/integration/agent-runtime-mf/cases/nested-router-tree/consumer/@mf-types/nestedRouterTreeProvider/compiled-types/RemotePanel.d.ts b/tests/integration/agent-runtime-mf/cases/nested-router-tree/consumer/@mf-types/nestedRouterTreeProvider/compiled-types/RemotePanel.d.ts new file mode 100644 index 000000000000..39b526a1bdd9 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/nested-router-tree/consumer/@mf-types/nestedRouterTreeProvider/compiled-types/RemotePanel.d.ts @@ -0,0 +1 @@ +export default function RemotePanel(): import("react/jsx-runtime").JSX.Element; diff --git a/tests/integration/agent-runtime-mf/cases/nested-router-tree/consumer/modern.config.ts b/tests/integration/agent-runtime-mf/cases/nested-router-tree/consumer/modern.config.ts new file mode 100644 index 000000000000..c737aeff90ac --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/nested-router-tree/consumer/modern.config.ts @@ -0,0 +1,24 @@ +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { appTools, defineConfig } from '@modern-js/app-tools'; +import { moduleFederationPlugin } from '@module-federation/modern-js-v3'; + +const require = createRequire(import.meta.url); +const reactRoot = dirname(require.resolve('react/package.json')); + +export default defineConfig({ + server: { + port: 4322, + }, + source: { + alias: { + react: reactRoot, + 'react/jsx-runtime': join(reactRoot, 'jsx-runtime.js'), + 'react/jsx-dev-runtime': join(reactRoot, 'jsx-dev-runtime.js'), + }, + }, + performance: { + buildCache: false, + }, + plugins: [appTools(), moduleFederationPlugin()], +}); diff --git a/tests/integration/agent-runtime-mf/cases/nested-router-tree/consumer/module-federation.config.ts b/tests/integration/agent-runtime-mf/cases/nested-router-tree/consumer/module-federation.config.ts new file mode 100644 index 000000000000..84581616b3df --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/nested-router-tree/consumer/module-federation.config.ts @@ -0,0 +1,21 @@ +import { createModuleFederationConfig } from '@module-federation/modern-js-v3'; +import { dependencies } from './package.json'; + +export default createModuleFederationConfig({ + name: 'nestedRouterTreeConsumer', + remotes: { + nestedRouterTreeProvider: + 'nestedRouterTreeProvider@http://localhost:4321/mf-manifest.json', + }, + shared: { + react: { singleton: true, requiredVersion: dependencies.react }, + 'react-dom': { + singleton: true, + requiredVersion: dependencies['react-dom'], + }, + 'react-router': { + singleton: true, + requiredVersion: dependencies['react-router'], + }, + }, +}); diff --git a/tests/integration/agent-runtime-mf/cases/nested-router-tree/consumer/package.json b/tests/integration/agent-runtime-mf/cases/nested-router-tree/consumer/package.json new file mode 100644 index 000000000000..f8b850af8160 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/nested-router-tree/consumer/package.json @@ -0,0 +1,19 @@ +{ + "private": true, + "name": "agent-runtime-mf-nested-router-tree-consumer", + "version": "0.0.0", + "scripts": { + "dev": "modern dev", + "build": "modern build" + }, + "dependencies": { + "@modern-js/runtime": "workspace:*", + "@module-federation/modern-js-v3": "2.0.0", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-router": "7.13.1" + }, + "devDependencies": { + "@modern-js/app-tools": "workspace:*" + } +} diff --git a/tests/integration/agent-runtime-mf/cases/nested-router-tree/consumer/src/App.tsx b/tests/integration/agent-runtime-mf/cases/nested-router-tree/consumer/src/App.tsx new file mode 100644 index 000000000000..605b690909d7 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/nested-router-tree/consumer/src/App.tsx @@ -0,0 +1,92 @@ +import { Component, Suspense, lazy, useCallback, useState } from 'react'; +import type { ErrorInfo, ReactNode } from 'react'; +import { BrowserRouter } from 'react-router'; + +const RemotePanel = lazy(() => import('nestedRouterTreeProvider/RemotePanel')); + +type RemoteErrorBoundaryProps = { + children: ReactNode; + onError: (error: Error, info: ErrorInfo) => void; +}; + +type RemoteErrorBoundaryState = { + error: string | null; +}; + +class RemoteErrorBoundary extends Component< + RemoteErrorBoundaryProps, + RemoteErrorBoundaryState +> { + state: RemoteErrorBoundaryState = { error: null }; + + componentDidCatch(error: Error, info: ErrorInfo) { + this.setState({ error: error.message }); + this.props.onError(error, info); + } + + render() { + if (this.state.error) { + return ( +
+ Remote route failed +

{this.state.error}

+
+ ); + } + return this.props.children; + } +} + +export default function App() { + const [status, setStatus] = useState<'loading' | 'error'>('loading'); + const [errorMessage, setErrorMessage] = useState(null); + + const onRemoteError = useCallback((error: Error, info: ErrorInfo) => { + console.error('[nested-router-tree] remote route failed', { + error, + componentStack: info.componentStack, + }); + setStatus('error'); + setErrorMessage(error.message); + }, []); + + return ( + +
+
+

Nested router MF case

+

+ The host already has a router, and the remote mounts another router + root inside it. +

+
+ +
+

Reproduced Issue

+

+ Status: {status} +

+

+ Route: /console/projects/42/detail +

+ {errorMessage ? ( +

+ Error: {errorMessage} +

+ ) : ( +

Waiting for the provider route module to render.

+ )} +
+ +
+

Remote Provider

+ + Loading remote provider...}> + + + +
+
+
+ ); +} diff --git a/tests/integration/agent-runtime-mf/cases/nested-router-tree/consumer/src/modern-app-env.d.ts b/tests/integration/agent-runtime-mf/cases/nested-router-tree/consumer/src/modern-app-env.d.ts new file mode 100644 index 000000000000..11fd97f0e4b9 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/nested-router-tree/consumer/src/modern-app-env.d.ts @@ -0,0 +1,2 @@ +/// + diff --git a/tests/integration/agent-runtime-mf/cases/nested-router-tree/consumer/src/remotes.d.ts b/tests/integration/agent-runtime-mf/cases/nested-router-tree/consumer/src/remotes.d.ts new file mode 100644 index 000000000000..d30d8fb31a9a --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/nested-router-tree/consumer/src/remotes.d.ts @@ -0,0 +1,6 @@ +declare module 'nestedRouterTreeProvider/RemotePanel' { + import type { ComponentType } from 'react'; + const RemotePanel: ComponentType; + export default RemotePanel; +} + diff --git a/tests/integration/agent-runtime-mf/cases/nested-router-tree/consumer/tsconfig.json b/tests/integration/agent-runtime-mf/cases/nested-router-tree/consumer/tsconfig.json new file mode 100644 index 000000000000..d2a56b7f1520 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/nested-router-tree/consumer/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@modern-js/tsconfig/base", + "compilerOptions": { + "declaration": false, + "jsx": "react-jsx" + }, + "include": ["src"] +} diff --git a/tests/integration/agent-runtime-mf/cases/nested-router-tree/provider/modern.config.ts b/tests/integration/agent-runtime-mf/cases/nested-router-tree/provider/modern.config.ts new file mode 100644 index 000000000000..d8583efa72b1 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/nested-router-tree/provider/modern.config.ts @@ -0,0 +1,13 @@ +import { appTools, defineConfig } from '@modern-js/app-tools'; +import { moduleFederationPlugin } from '@module-federation/modern-js-v3'; + +export default defineConfig({ + server: { + port: 4321, + }, + performance: { + buildCache: false, + }, + plugins: [appTools(), moduleFederationPlugin()], +}); + diff --git a/tests/integration/agent-runtime-mf/cases/nested-router-tree/provider/module-federation.config.ts b/tests/integration/agent-runtime-mf/cases/nested-router-tree/provider/module-federation.config.ts new file mode 100644 index 000000000000..e340c1fd0e31 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/nested-router-tree/provider/module-federation.config.ts @@ -0,0 +1,21 @@ +import { createModuleFederationConfig } from '@module-federation/modern-js-v3'; +import { dependencies } from './package.json'; + +export default createModuleFederationConfig({ + name: 'nestedRouterTreeProvider', + filename: 'remoteEntry.js', + exposes: { + './RemotePanel': './src/RemotePanel.tsx', + }, + shared: { + react: { singleton: true, requiredVersion: dependencies.react }, + 'react-dom': { + singleton: true, + requiredVersion: dependencies['react-dom'], + }, + 'react-router': { + singleton: true, + requiredVersion: dependencies['react-router'], + }, + }, +}); diff --git a/tests/integration/agent-runtime-mf/cases/nested-router-tree/provider/package.json b/tests/integration/agent-runtime-mf/cases/nested-router-tree/provider/package.json new file mode 100644 index 000000000000..a33bb1431fb5 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/nested-router-tree/provider/package.json @@ -0,0 +1,19 @@ +{ + "private": true, + "name": "agent-runtime-mf-nested-router-tree-provider", + "version": "0.0.0", + "scripts": { + "dev": "modern dev", + "build": "modern build" + }, + "dependencies": { + "@modern-js/runtime": "workspace:*", + "@module-federation/modern-js-v3": "2.0.0", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-router": "7.13.1" + }, + "devDependencies": { + "@modern-js/app-tools": "workspace:*" + } +} diff --git a/tests/integration/agent-runtime-mf/cases/nested-router-tree/provider/src/App.tsx b/tests/integration/agent-runtime-mf/cases/nested-router-tree/provider/src/App.tsx new file mode 100644 index 000000000000..fd401329cc12 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/nested-router-tree/provider/src/App.tsx @@ -0,0 +1,6 @@ +import RemotePanel from './RemotePanel'; + +export default function App() { + return ; +} + diff --git a/tests/integration/agent-runtime-mf/cases/nested-router-tree/provider/src/RemotePanel.tsx b/tests/integration/agent-runtime-mf/cases/nested-router-tree/provider/src/RemotePanel.tsx new file mode 100644 index 000000000000..1e60cf307ce7 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/nested-router-tree/provider/src/RemotePanel.tsx @@ -0,0 +1,28 @@ +import { BrowserRouter, Route, Routes } from 'react-router'; + +function ProjectDetailRoute() { + return

Project detail route is running with its own router root.

; +} + +function RemoteRouterRoot() { + return ( + + + } /> + + + ); +} + +export default function RemotePanel() { + return ( +
+ Provider: volcano console route +

+ The exposed module still renders the provider app router root instead of + a plain page component. +

+ +
+ ); +} diff --git a/tests/integration/agent-runtime-mf/cases/nested-router-tree/provider/src/modern-app-env.d.ts b/tests/integration/agent-runtime-mf/cases/nested-router-tree/provider/src/modern-app-env.d.ts new file mode 100644 index 000000000000..11fd97f0e4b9 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/nested-router-tree/provider/src/modern-app-env.d.ts @@ -0,0 +1,2 @@ +/// + diff --git a/tests/integration/agent-runtime-mf/cases/nested-router-tree/provider/tsconfig.json b/tests/integration/agent-runtime-mf/cases/nested-router-tree/provider/tsconfig.json new file mode 100644 index 000000000000..67562ff13113 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/nested-router-tree/provider/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@modern-js/tsconfig/base", + "compilerOptions": { + "declaration": false, + "jsx": "react-jsx" + }, + "include": ["src"] +} + diff --git a/tests/integration/agent-runtime-mf/cases/react-multi-version/consumer/@mf-types/reactMultiVersionProvider/RemotePanel.d.ts b/tests/integration/agent-runtime-mf/cases/react-multi-version/consumer/@mf-types/reactMultiVersionProvider/RemotePanel.d.ts new file mode 100644 index 000000000000..3ff7ab54a69f --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/react-multi-version/consumer/@mf-types/reactMultiVersionProvider/RemotePanel.d.ts @@ -0,0 +1,2 @@ +export * from './compiled-types/RemotePanel'; +export { default } from './compiled-types/RemotePanel'; \ No newline at end of file diff --git a/tests/integration/agent-runtime-mf/cases/react-multi-version/consumer/@mf-types/reactMultiVersionProvider/apis.d.ts b/tests/integration/agent-runtime-mf/cases/react-multi-version/consumer/@mf-types/reactMultiVersionProvider/apis.d.ts new file mode 100644 index 000000000000..614cc01e23ba --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/react-multi-version/consumer/@mf-types/reactMultiVersionProvider/apis.d.ts @@ -0,0 +1,3 @@ + + export type RemoteKeys = 'reactMultiVersionProvider/RemotePanel'; + type PackageType = T extends 'reactMultiVersionProvider/RemotePanel' ? typeof import('reactMultiVersionProvider/RemotePanel') :any; \ No newline at end of file diff --git a/tests/integration/agent-runtime-mf/cases/react-multi-version/consumer/@mf-types/reactMultiVersionProvider/compiled-types/RemotePanel.d.ts b/tests/integration/agent-runtime-mf/cases/react-multi-version/consumer/@mf-types/reactMultiVersionProvider/compiled-types/RemotePanel.d.ts new file mode 100644 index 000000000000..39b526a1bdd9 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/react-multi-version/consumer/@mf-types/reactMultiVersionProvider/compiled-types/RemotePanel.d.ts @@ -0,0 +1 @@ +export default function RemotePanel(): import("react/jsx-runtime").JSX.Element; diff --git a/tests/integration/agent-runtime-mf/cases/react-multi-version/consumer/modern.config.ts b/tests/integration/agent-runtime-mf/cases/react-multi-version/consumer/modern.config.ts new file mode 100644 index 000000000000..642f009aa065 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/react-multi-version/consumer/modern.config.ts @@ -0,0 +1,24 @@ +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { appTools, defineConfig } from '@modern-js/app-tools'; +import { moduleFederationPlugin } from '@module-federation/modern-js-v3'; + +const require = createRequire(import.meta.url); +const reactRoot = dirname(require.resolve('react/package.json')); + +export default defineConfig({ + server: { + port: 4312, + }, + source: { + alias: { + react: reactRoot, + 'react/jsx-runtime': join(reactRoot, 'jsx-runtime.js'), + 'react/jsx-dev-runtime': join(reactRoot, 'jsx-dev-runtime.js'), + }, + }, + performance: { + buildCache: false, + }, + plugins: [appTools(), moduleFederationPlugin()], +}); diff --git a/tests/integration/agent-runtime-mf/cases/react-multi-version/consumer/module-federation.config.ts b/tests/integration/agent-runtime-mf/cases/react-multi-version/consumer/module-federation.config.ts new file mode 100644 index 000000000000..6f925d72e45c --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/react-multi-version/consumer/module-federation.config.ts @@ -0,0 +1,17 @@ +import { createModuleFederationConfig } from '@module-federation/modern-js-v3'; +import { dependencies } from './package.json'; + +export default createModuleFederationConfig({ + name: 'reactMultiVersionConsumer', + remotes: { + reactMultiVersionProvider: + 'reactMultiVersionProvider@http://localhost:4311/mf-manifest.json', + }, + shared: { + react: { singleton: true, requiredVersion: dependencies.react }, + 'react-dom': { + singleton: true, + requiredVersion: dependencies['react-dom'], + }, + }, +}); diff --git a/tests/integration/agent-runtime-mf/cases/react-multi-version/consumer/package.json b/tests/integration/agent-runtime-mf/cases/react-multi-version/consumer/package.json new file mode 100644 index 000000000000..985541a3e95c --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/react-multi-version/consumer/package.json @@ -0,0 +1,18 @@ +{ + "private": true, + "name": "agent-runtime-mf-react-multi-version-consumer", + "version": "0.0.0", + "scripts": { + "dev": "modern dev", + "build": "modern build" + }, + "dependencies": { + "@modern-js/runtime": "workspace:*", + "@module-federation/modern-js-v3": "2.0.0", + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@modern-js/app-tools": "workspace:*" + } +} diff --git a/tests/integration/agent-runtime-mf/cases/react-multi-version/consumer/src/App.tsx b/tests/integration/agent-runtime-mf/cases/react-multi-version/consumer/src/App.tsx new file mode 100644 index 000000000000..97d6329f2390 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/react-multi-version/consumer/src/App.tsx @@ -0,0 +1,88 @@ +import { Component, Suspense, lazy, useCallback, useState } from 'react'; +import type { ErrorInfo, ReactNode } from 'react'; + +const RemotePanel = lazy(() => import('reactMultiVersionProvider/RemotePanel')); + +type RemoteErrorBoundaryProps = { + children: ReactNode; + onError: (error: Error, info: ErrorInfo) => void; +}; + +type RemoteErrorBoundaryState = { + error: string | null; +}; + +class RemoteErrorBoundary extends Component< + RemoteErrorBoundaryProps, + RemoteErrorBoundaryState +> { + state: RemoteErrorBoundaryState = { error: null }; + + componentDidCatch(error: Error, info: ErrorInfo) { + this.setState({ error: error.message }); + this.props.onError(error, info); + } + + render() { + if (this.state.error) { + return ( +
+ Remote render failed +

{this.state.error}

+
+ ); + } + return this.props.children; + } +} + +export default function App() { + const [status, setStatus] = useState<'loading' | 'error'>('loading'); + const [errorMessage, setErrorMessage] = useState(null); + + const onRemoteError = useCallback((error: Error, info: ErrorInfo) => { + console.error('[react-multi-version] remote render failed', { + error, + componentStack: info.componentStack, + }); + setStatus('error'); + setErrorMessage(error.message); + }, []); + + return ( +
+
+

React multi version MF case

+

+ The remote renders a dependency package that bundles its own React. +

+
+ +
+

Reproduced Issue

+

+ Status: {status} +

+

+ Route: /checkout +

+ {errorMessage ? ( +

+ Error: {errorMessage} +

+ ) : ( +

Waiting for the remote checkout component to render.

+ )} +
+ +
+

Remote Provider

+ + Loading remote provider...}> + + + +
+
+ ); +} diff --git a/tests/integration/agent-runtime-mf/cases/react-multi-version/consumer/src/modern-app-env.d.ts b/tests/integration/agent-runtime-mf/cases/react-multi-version/consumer/src/modern-app-env.d.ts new file mode 100644 index 000000000000..11fd97f0e4b9 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/react-multi-version/consumer/src/modern-app-env.d.ts @@ -0,0 +1,2 @@ +/// + diff --git a/tests/integration/agent-runtime-mf/cases/react-multi-version/consumer/src/remotes.d.ts b/tests/integration/agent-runtime-mf/cases/react-multi-version/consumer/src/remotes.d.ts new file mode 100644 index 000000000000..a9ab5be283ba --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/react-multi-version/consumer/src/remotes.d.ts @@ -0,0 +1,6 @@ +declare module 'reactMultiVersionProvider/RemotePanel' { + import type { ComponentType } from 'react'; + const RemotePanel: ComponentType; + export default RemotePanel; +} + diff --git a/tests/integration/agent-runtime-mf/cases/react-multi-version/consumer/tsconfig.json b/tests/integration/agent-runtime-mf/cases/react-multi-version/consumer/tsconfig.json new file mode 100644 index 000000000000..d2a56b7f1520 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/react-multi-version/consumer/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@modern-js/tsconfig/base", + "compilerOptions": { + "declaration": false, + "jsx": "react-jsx" + }, + "include": ["src"] +} diff --git a/tests/integration/agent-runtime-mf/cases/react-multi-version/provider/modern.config.ts b/tests/integration/agent-runtime-mf/cases/react-multi-version/provider/modern.config.ts new file mode 100644 index 000000000000..c7385bb57444 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/react-multi-version/provider/modern.config.ts @@ -0,0 +1,13 @@ +import { appTools, defineConfig } from '@modern-js/app-tools'; +import { moduleFederationPlugin } from '@module-federation/modern-js-v3'; + +export default defineConfig({ + server: { + port: 4311, + }, + performance: { + buildCache: false, + }, + plugins: [appTools(), moduleFederationPlugin()], +}); + diff --git a/tests/integration/agent-runtime-mf/cases/react-multi-version/provider/module-federation.config.ts b/tests/integration/agent-runtime-mf/cases/react-multi-version/provider/module-federation.config.ts new file mode 100644 index 000000000000..158b3abbf906 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/react-multi-version/provider/module-federation.config.ts @@ -0,0 +1,18 @@ +import { createModuleFederationConfig } from '@module-federation/modern-js-v3'; +import { dependencies } from './package.json'; + +export default createModuleFederationConfig({ + name: 'reactMultiVersionProvider', + filename: 'remoteEntry.js', + exposes: { + './RemotePanel': './src/RemotePanel.tsx', + }, + shared: { + react: { singleton: true, requiredVersion: dependencies.react }, + 'react-dom': { + singleton: true, + requiredVersion: dependencies['react-dom'], + }, + }, +}); + diff --git a/tests/integration/agent-runtime-mf/cases/react-multi-version/provider/package.json b/tests/integration/agent-runtime-mf/cases/react-multi-version/provider/package.json new file mode 100644 index 000000000000..611913dcd60d --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/react-multi-version/provider/package.json @@ -0,0 +1,20 @@ +{ + "private": true, + "name": "agent-runtime-mf-react-multi-version-provider", + "version": "0.0.0", + "scripts": { + "build:adapter": "pnpm --dir ./packages/transaction-adapter build", + "dev": "pnpm run build:adapter && modern dev", + "build": "pnpm run build:adapter && modern build" + }, + "dependencies": { + "@modern-js/runtime": "workspace:*", + "@module-federation/modern-js-v3": "2.0.0", + "@otrade/transaction_adapter": "workspace:*", + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@modern-js/app-tools": "workspace:*" + } +} diff --git a/tests/integration/agent-runtime-mf/cases/react-multi-version/provider/packages/transaction-adapter/package.json b/tests/integration/agent-runtime-mf/cases/react-multi-version/provider/packages/transaction-adapter/package.json new file mode 100644 index 000000000000..2880312fe3e0 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/react-multi-version/provider/packages/transaction-adapter/package.json @@ -0,0 +1,24 @@ +{ + "private": true, + "name": "@otrade/transaction_adapter", + "version": "0.0.0", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./src/index.d.ts", + "exports": { + ".": { + "types": "./src/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "rslib build" + }, + "dependencies": { + "react": "18.2.0" + }, + "devDependencies": { + "@rslib/core": "0.21.5" + } +} diff --git a/tests/integration/agent-runtime-mf/cases/react-multi-version/provider/packages/transaction-adapter/rslib.config.mjs b/tests/integration/agent-runtime-mf/cases/react-multi-version/provider/packages/transaction-adapter/rslib.config.mjs new file mode 100644 index 000000000000..7b3ef205aaa3 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/react-multi-version/provider/packages/transaction-adapter/rslib.config.mjs @@ -0,0 +1,15 @@ +import { defineConfig } from '@rslib/core'; + +export default defineConfig({ + lib: [ + { + format: 'esm', + bundle: true, + autoExternal: false, + syntax: 'es2021', + }, + ], + output: { + target: 'web', + }, +}); diff --git a/tests/integration/agent-runtime-mf/cases/react-multi-version/provider/packages/transaction-adapter/src/index.d.ts b/tests/integration/agent-runtime-mf/cases/react-multi-version/provider/packages/transaction-adapter/src/index.d.ts new file mode 100644 index 000000000000..5d1a68aa2017 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/react-multi-version/provider/packages/transaction-adapter/src/index.d.ts @@ -0,0 +1,4 @@ +import type { ComponentType } from 'react'; + +export const bundledReactVersion: string; +export const LegacyZustandWidget: ComponentType; diff --git a/tests/integration/agent-runtime-mf/cases/react-multi-version/provider/packages/transaction-adapter/src/index.js b/tests/integration/agent-runtime-mf/cases/react-multi-version/provider/packages/transaction-adapter/src/index.js new file mode 100644 index 000000000000..067d53eaa58e --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/react-multi-version/provider/packages/transaction-adapter/src/index.js @@ -0,0 +1,36 @@ +import * as React from 'react'; + +export const bundledReactVersion = React.version; + +const errorMessage = + "Cannot read properties of null (reading 'useSyncExternalStore')"; + +const reportBundledReact = () => { + if (typeof window === 'undefined') { + return; + } + window.dispatchEvent( + new CustomEvent('react-multi-version:component-error', { + detail: { + packageName: 'react', + version: bundledReactVersion, + bundled: true, + issuer: '@otrade/transaction_adapter', + sourceFile: 'provider/packages/transaction-adapter/src/index.js', + message: errorMessage, + }, + }), + ); +}; + +export function LegacyZustandWidget() { + reportBundledReact(); + + const checkoutLabel = React.useSyncExternalStore( + () => () => {}, + () => 'legacy checkout adapter is using bundled React', + () => 'legacy checkout adapter is using bundled React', + ); + + return React.createElement('p', null, checkoutLabel); +} diff --git a/tests/integration/agent-runtime-mf/cases/react-multi-version/provider/src/App.tsx b/tests/integration/agent-runtime-mf/cases/react-multi-version/provider/src/App.tsx new file mode 100644 index 000000000000..fd401329cc12 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/react-multi-version/provider/src/App.tsx @@ -0,0 +1,6 @@ +import RemotePanel from './RemotePanel'; + +export default function App() { + return ; +} + diff --git a/tests/integration/agent-runtime-mf/cases/react-multi-version/provider/src/RemotePanel.tsx b/tests/integration/agent-runtime-mf/cases/react-multi-version/provider/src/RemotePanel.tsx new file mode 100644 index 000000000000..4519da36c280 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/react-multi-version/provider/src/RemotePanel.tsx @@ -0,0 +1,14 @@ +import { LegacyZustandWidget } from '@otrade/transaction_adapter'; + +export default function RemotePanel() { + return ( +
+ Provider: billing widget +

+ The exposed checkout widget imports a legacy adapter before it renders + the actual business component. +

+ +
+ ); +} diff --git a/tests/integration/agent-runtime-mf/cases/react-multi-version/provider/src/modern-app-env.d.ts b/tests/integration/agent-runtime-mf/cases/react-multi-version/provider/src/modern-app-env.d.ts new file mode 100644 index 000000000000..11fd97f0e4b9 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/react-multi-version/provider/src/modern-app-env.d.ts @@ -0,0 +1,2 @@ +/// + diff --git a/tests/integration/agent-runtime-mf/cases/react-multi-version/provider/tsconfig.json b/tests/integration/agent-runtime-mf/cases/react-multi-version/provider/tsconfig.json new file mode 100644 index 000000000000..67562ff13113 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/react-multi-version/provider/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@modern-js/tsconfig/base", + "compilerOptions": { + "declaration": false, + "jsx": "react-jsx" + }, + "include": ["src"] +} + diff --git a/tests/integration/agent-runtime-mf/cases/redirect-loader/consumer/@mf-types/redirectLoaderProvider/RemotePanel.d.ts b/tests/integration/agent-runtime-mf/cases/redirect-loader/consumer/@mf-types/redirectLoaderProvider/RemotePanel.d.ts new file mode 100644 index 000000000000..3ff7ab54a69f --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/redirect-loader/consumer/@mf-types/redirectLoaderProvider/RemotePanel.d.ts @@ -0,0 +1,2 @@ +export * from './compiled-types/RemotePanel'; +export { default } from './compiled-types/RemotePanel'; \ No newline at end of file diff --git a/tests/integration/agent-runtime-mf/cases/redirect-loader/consumer/@mf-types/redirectLoaderProvider/apis.d.ts b/tests/integration/agent-runtime-mf/cases/redirect-loader/consumer/@mf-types/redirectLoaderProvider/apis.d.ts new file mode 100644 index 000000000000..8acb82c92824 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/redirect-loader/consumer/@mf-types/redirectLoaderProvider/apis.d.ts @@ -0,0 +1,3 @@ + + export type RemoteKeys = 'redirectLoaderProvider/RemotePanel'; + type PackageType = T extends 'redirectLoaderProvider/RemotePanel' ? typeof import('redirectLoaderProvider/RemotePanel') :any; \ No newline at end of file diff --git a/tests/integration/agent-runtime-mf/cases/redirect-loader/consumer/@mf-types/redirectLoaderProvider/compiled-types/RemotePanel.d.ts b/tests/integration/agent-runtime-mf/cases/redirect-loader/consumer/@mf-types/redirectLoaderProvider/compiled-types/RemotePanel.d.ts new file mode 100644 index 000000000000..69f89d987e6d --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/redirect-loader/consumer/@mf-types/redirectLoaderProvider/compiled-types/RemotePanel.d.ts @@ -0,0 +1,4 @@ +export declare function DashboardPage({ path }: { + path?: string; +}): import("react/jsx-runtime").JSX.Element; +export default function RemotePanel(): import("react/jsx-runtime").JSX.Element; diff --git a/tests/integration/agent-runtime-mf/cases/redirect-loader/consumer/modern.config.ts b/tests/integration/agent-runtime-mf/cases/redirect-loader/consumer/modern.config.ts new file mode 100644 index 000000000000..da6ce4a653ae --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/redirect-loader/consumer/modern.config.ts @@ -0,0 +1,32 @@ +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { appTools, defineConfig } from '@modern-js/app-tools'; +import { moduleFederationPlugin } from '@module-federation/modern-js-v3'; + +const require = createRequire(import.meta.url); +const reactRoot = dirname(require.resolve('react/package.json')); + +export default defineConfig({ + server: { + port: 4352, + ssr: { + mode: 'string', + forceCSR: true, + }, + }, + source: { + entries: { + index: 'src/routes', + }, + disableDefaultEntries: true, + alias: { + react: reactRoot, + 'react/jsx-runtime': join(reactRoot, 'jsx-runtime.js'), + 'react/jsx-dev-runtime': join(reactRoot, 'jsx-dev-runtime.js'), + }, + }, + performance: { + buildCache: false, + }, + plugins: [appTools(), moduleFederationPlugin()], +}); diff --git a/tests/integration/agent-runtime-mf/cases/redirect-loader/consumer/module-federation.config.ts b/tests/integration/agent-runtime-mf/cases/redirect-loader/consumer/module-federation.config.ts new file mode 100644 index 000000000000..6040c897326e --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/redirect-loader/consumer/module-federation.config.ts @@ -0,0 +1,17 @@ +import { createModuleFederationConfig } from '@module-federation/modern-js-v3'; +import { dependencies } from './package.json'; + +export default createModuleFederationConfig({ + name: 'redirectLoaderConsumer', + remotes: { + redirectLoaderProvider: + 'redirectLoaderProvider@http://localhost:4351/mf-manifest.json', + }, + shared: { + react: { singleton: true, requiredVersion: dependencies.react }, + 'react-dom': { + singleton: true, + requiredVersion: dependencies['react-dom'], + }, + }, +}); diff --git a/tests/integration/agent-runtime-mf/cases/redirect-loader/consumer/package.json b/tests/integration/agent-runtime-mf/cases/redirect-loader/consumer/package.json new file mode 100644 index 000000000000..868067729195 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/redirect-loader/consumer/package.json @@ -0,0 +1,18 @@ +{ + "private": true, + "name": "agent-runtime-mf-redirect-loader-consumer", + "version": "0.0.0", + "scripts": { + "dev": "modern dev", + "build": "modern build" + }, + "dependencies": { + "@modern-js/runtime": "workspace:*", + "@module-federation/modern-js-v3": "2.0.0", + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@modern-js/app-tools": "workspace:*" + } +} diff --git a/tests/integration/agent-runtime-mf/cases/redirect-loader/consumer/src/App.tsx b/tests/integration/agent-runtime-mf/cases/redirect-loader/consumer/src/App.tsx new file mode 100644 index 000000000000..f23a3a635c9f --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/redirect-loader/consumer/src/App.tsx @@ -0,0 +1,106 @@ +import { Suspense, lazy, useEffect, useState } from 'react'; + +const RemotePanel = lazy(() => import('redirectLoaderProvider/RemotePanel')); + +type AppProps = { + initialPath?: '/' | '/home'; +}; + +const loaderId = 'consumer/src/routes/page.loader.ts'; + +const getDefaultInitialPath = (): '/' | '/home' => + typeof window !== 'undefined' && window.location.pathname === '/home' + ? '/home' + : '/'; + +const RootRedirectShell = () => ( +
+ Consumer: cloud engine root +

Redirecting to home...

+

Expected SSR loader: {loaderId} redirects to /home.

+

+ static shell returned 200 and the consumer SSR redirect loader did not run +

+
+); + +const DashboardShell = () => ( +
+ Provider: cloud engine dashboard +

Dashboard route is ready at /home.

+
+); + +export default function App({ + initialPath = getDefaultInitialPath(), +}: AppProps) { + const [canLoadRemote, setCanLoadRemote] = useState(false); + const [route, setRoute] = useState(initialPath); + const [status, setStatus] = useState<'pending' | 'success'>( + initialPath === '/home' ? 'success' : 'pending', + ); + + useEffect(() => { + setCanLoadRemote(true); + }, []); + + const runClientFallback = () => { + window.history.pushState(null, '', '/home'); + setRoute('/home'); + setStatus('success'); + }; + + const isDashboard = status === 'success'; + + return ( +
+
+

Redirect loader MF case

+

+ The static shell returned 200 before the root loader redirected to + /home. +

+
+ +
+

Reproduced Issue

+

+ Status: {status} +

+

+ Route: {route} +

+ {!isDashboard ? ( +

+ Pending reason: static shell returned 200 and the + consumer SSR redirect loader did not run +

+ ) : null} +

+ Loader: {loaderId} is not_run +

+
+ +
+ +
+ +
+

Remote Provider

+ {isDashboard ? ( + canLoadRemote ? ( + }> + + + ) : ( + + ) + ) : ( + + )} +
+
+ ); +} diff --git a/tests/integration/agent-runtime-mf/cases/redirect-loader/consumer/src/modern-app-env.d.ts b/tests/integration/agent-runtime-mf/cases/redirect-loader/consumer/src/modern-app-env.d.ts new file mode 100644 index 000000000000..11fd97f0e4b9 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/redirect-loader/consumer/src/modern-app-env.d.ts @@ -0,0 +1,2 @@ +/// + diff --git a/tests/integration/agent-runtime-mf/cases/redirect-loader/consumer/src/remotes.d.ts b/tests/integration/agent-runtime-mf/cases/redirect-loader/consumer/src/remotes.d.ts new file mode 100644 index 000000000000..1ff1192d114b --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/redirect-loader/consumer/src/remotes.d.ts @@ -0,0 +1,6 @@ +declare module 'redirectLoaderProvider/RemotePanel' { + import type { ComponentType } from 'react'; + const RemotePanel: ComponentType; + export default RemotePanel; +} + diff --git a/tests/integration/agent-runtime-mf/cases/redirect-loader/consumer/src/routes/home/page.tsx b/tests/integration/agent-runtime-mf/cases/redirect-loader/consumer/src/routes/home/page.tsx new file mode 100644 index 000000000000..d026feb3c2ab --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/redirect-loader/consumer/src/routes/home/page.tsx @@ -0,0 +1,5 @@ +import App from '../../App'; + +export default function HomePage() { + return ; +} diff --git a/tests/integration/agent-runtime-mf/cases/redirect-loader/consumer/src/routes/index.tsx b/tests/integration/agent-runtime-mf/cases/redirect-loader/consumer/src/routes/index.tsx new file mode 100644 index 000000000000..74b8bbafd90e --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/redirect-loader/consumer/src/routes/index.tsx @@ -0,0 +1,5 @@ +import type { PropsWithChildren } from 'react'; + +export default function AppShell({ children }: PropsWithChildren) { + return children; +} diff --git a/tests/integration/agent-runtime-mf/cases/redirect-loader/consumer/src/routes/layout.tsx b/tests/integration/agent-runtime-mf/cases/redirect-loader/consumer/src/routes/layout.tsx new file mode 100644 index 000000000000..ae275e86c4e6 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/redirect-loader/consumer/src/routes/layout.tsx @@ -0,0 +1,5 @@ +import { Outlet } from '@modern-js/runtime/router'; + +export default function Layout() { + return ; +} diff --git a/tests/integration/agent-runtime-mf/cases/redirect-loader/consumer/src/routes/page.loader.ts b/tests/integration/agent-runtime-mf/cases/redirect-loader/consumer/src/routes/page.loader.ts new file mode 100644 index 000000000000..8c88eb10b783 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/redirect-loader/consumer/src/routes/page.loader.ts @@ -0,0 +1,11 @@ +import { type LoaderFunctionArgs, redirect } from '@modern-js/runtime/router'; + +export default function rootRedirectLoader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url); + + if (url.searchParams.has('__ssrDirect')) { + return null; + } + + return redirect('/home'); +} diff --git a/tests/integration/agent-runtime-mf/cases/redirect-loader/consumer/src/routes/page.tsx b/tests/integration/agent-runtime-mf/cases/redirect-loader/consumer/src/routes/page.tsx new file mode 100644 index 000000000000..a1d8ce4c7bf7 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/redirect-loader/consumer/src/routes/page.tsx @@ -0,0 +1,5 @@ +import App from '../App'; + +export default function RootRedirectPage() { + return ; +} diff --git a/tests/integration/agent-runtime-mf/cases/redirect-loader/consumer/tsconfig.json b/tests/integration/agent-runtime-mf/cases/redirect-loader/consumer/tsconfig.json new file mode 100644 index 000000000000..d2a56b7f1520 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/redirect-loader/consumer/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@modern-js/tsconfig/base", + "compilerOptions": { + "declaration": false, + "jsx": "react-jsx" + }, + "include": ["src"] +} diff --git a/tests/integration/agent-runtime-mf/cases/redirect-loader/provider/modern.config.ts b/tests/integration/agent-runtime-mf/cases/redirect-loader/provider/modern.config.ts new file mode 100644 index 000000000000..b93a61c0e569 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/redirect-loader/provider/modern.config.ts @@ -0,0 +1,12 @@ +import { appTools, defineConfig } from '@modern-js/app-tools'; +import { moduleFederationPlugin } from '@module-federation/modern-js-v3'; + +export default defineConfig({ + server: { + port: 4351, + }, + performance: { + buildCache: false, + }, + plugins: [appTools(), moduleFederationPlugin()], +}); diff --git a/tests/integration/agent-runtime-mf/cases/redirect-loader/provider/module-federation.config.ts b/tests/integration/agent-runtime-mf/cases/redirect-loader/provider/module-federation.config.ts new file mode 100644 index 000000000000..844459eb4454 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/redirect-loader/provider/module-federation.config.ts @@ -0,0 +1,18 @@ +import { createModuleFederationConfig } from '@module-federation/modern-js-v3'; +import { dependencies } from './package.json'; + +export default createModuleFederationConfig({ + name: 'redirectLoaderProvider', + filename: 'remoteEntry.js', + exposes: { + './RemotePanel': './src/RemotePanel.tsx', + }, + shared: { + react: { singleton: true, requiredVersion: dependencies.react }, + 'react-dom': { + singleton: true, + requiredVersion: dependencies['react-dom'], + }, + }, +}); + diff --git a/tests/integration/agent-runtime-mf/cases/redirect-loader/provider/package.json b/tests/integration/agent-runtime-mf/cases/redirect-loader/provider/package.json new file mode 100644 index 000000000000..a3bae685f461 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/redirect-loader/provider/package.json @@ -0,0 +1,18 @@ +{ + "private": true, + "name": "agent-runtime-mf-redirect-loader-provider", + "version": "0.0.0", + "scripts": { + "dev": "modern dev", + "build": "modern build" + }, + "dependencies": { + "@modern-js/runtime": "workspace:*", + "@module-federation/modern-js-v3": "2.0.0", + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@modern-js/app-tools": "workspace:*" + } +} diff --git a/tests/integration/agent-runtime-mf/cases/redirect-loader/provider/src/App.tsx b/tests/integration/agent-runtime-mf/cases/redirect-loader/provider/src/App.tsx new file mode 100644 index 000000000000..fd401329cc12 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/redirect-loader/provider/src/App.tsx @@ -0,0 +1,6 @@ +import RemotePanel from './RemotePanel'; + +export default function App() { + return ; +} + diff --git a/tests/integration/agent-runtime-mf/cases/redirect-loader/provider/src/RemotePanel.tsx b/tests/integration/agent-runtime-mf/cases/redirect-loader/provider/src/RemotePanel.tsx new file mode 100644 index 000000000000..7983699aff2d --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/redirect-loader/provider/src/RemotePanel.tsx @@ -0,0 +1,12 @@ +export function DashboardPage({ path = '/home' }: { path?: string }) { + return ( +
+ Provider: cloud engine dashboard +

Dashboard route is ready at {path}.

+
+ ); +} + +export default function RemotePanel() { + return ; +} diff --git a/tests/integration/agent-runtime-mf/cases/redirect-loader/provider/src/modern-app-env.d.ts b/tests/integration/agent-runtime-mf/cases/redirect-loader/provider/src/modern-app-env.d.ts new file mode 100644 index 000000000000..11fd97f0e4b9 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/redirect-loader/provider/src/modern-app-env.d.ts @@ -0,0 +1,2 @@ +/// + diff --git a/tests/integration/agent-runtime-mf/cases/redirect-loader/provider/tsconfig.json b/tests/integration/agent-runtime-mf/cases/redirect-loader/provider/tsconfig.json new file mode 100644 index 000000000000..67562ff13113 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/redirect-loader/provider/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@modern-js/tsconfig/base", + "compilerOptions": { + "declaration": false, + "jsx": "react-jsx" + }, + "include": ["src"] +} + diff --git a/tests/integration/agent-runtime-mf/cases/usenavigate-blank/consumer/@mf-types/index.d.ts b/tests/integration/agent-runtime-mf/cases/usenavigate-blank/consumer/@mf-types/index.d.ts new file mode 100644 index 000000000000..c626818f1b2a --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/usenavigate-blank/consumer/@mf-types/index.d.ts @@ -0,0 +1,29 @@ +import type { PackageType as PackageType_0,RemoteKeys as RemoteKeys_0 } from './useNavigateBlankProvider/apis.d.ts'; + declare module "@module-federation/runtime" { + type RemoteKeys = RemoteKeys_0; + type PackageType = T extends RemoteKeys_0 ? PackageType_0 : +Y ; + export function loadRemote(packageName: T): Promise>; + export function loadRemote(packageName: T): Promise>; + } +declare module "@module-federation/enhanced/runtime" { + type RemoteKeys = RemoteKeys_0; + type PackageType = T extends RemoteKeys_0 ? PackageType_0 : +Y ; + export function loadRemote(packageName: T): Promise>; + export function loadRemote(packageName: T): Promise>; + } +declare module "@module-federation/runtime-tools" { + type RemoteKeys = RemoteKeys_0; + type PackageType = T extends RemoteKeys_0 ? PackageType_0 : +Y ; + export function loadRemote(packageName: T): Promise>; + export function loadRemote(packageName: T): Promise>; + } +declare module "@module-federation/modern-js-v3/runtime" { + type RemoteKeys = RemoteKeys_0; + type PackageType = T extends RemoteKeys_0 ? PackageType_0 : +Y ; + export function loadRemote(packageName: T): Promise>; + export function loadRemote(packageName: T): Promise>; + } diff --git a/tests/integration/agent-runtime-mf/cases/usenavigate-blank/consumer/@mf-types/useNavigateBlankProvider/apis.d.ts b/tests/integration/agent-runtime-mf/cases/usenavigate-blank/consumer/@mf-types/useNavigateBlankProvider/apis.d.ts new file mode 100644 index 000000000000..0849b273f14c --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/usenavigate-blank/consumer/@mf-types/useNavigateBlankProvider/apis.d.ts @@ -0,0 +1,3 @@ + + export type RemoteKeys = 'useNavigateBlankProvider/export-app'; + type PackageType = T extends 'useNavigateBlankProvider/export-app' ? typeof import('useNavigateBlankProvider/export-app') :any; \ No newline at end of file diff --git a/tests/integration/agent-runtime-mf/cases/usenavigate-blank/consumer/@mf-types/useNavigateBlankProvider/compiled-types/App.d.ts b/tests/integration/agent-runtime-mf/cases/usenavigate-blank/consumer/@mf-types/useNavigateBlankProvider/compiled-types/App.d.ts new file mode 100644 index 000000000000..6d0abfa9d965 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/usenavigate-blank/consumer/@mf-types/useNavigateBlankProvider/compiled-types/App.d.ts @@ -0,0 +1,5 @@ +type AppProps = { + basename?: string; +}; +export default function App({ basename }: AppProps): import("react/jsx-runtime").JSX.Element; +export {}; diff --git a/tests/integration/agent-runtime-mf/cases/usenavigate-blank/consumer/@mf-types/useNavigateBlankProvider/compiled-types/RemotePanel.d.ts b/tests/integration/agent-runtime-mf/cases/usenavigate-blank/consumer/@mf-types/useNavigateBlankProvider/compiled-types/RemotePanel.d.ts new file mode 100644 index 000000000000..19ba39758e8a --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/usenavigate-blank/consumer/@mf-types/useNavigateBlankProvider/compiled-types/RemotePanel.d.ts @@ -0,0 +1,5 @@ +type RemotePanelProps = { + basename?: string; +}; +export default function RemotePanel({ basename }: RemotePanelProps): import("react/jsx-runtime").JSX.Element; +export {}; diff --git a/tests/integration/agent-runtime-mf/cases/usenavigate-blank/consumer/@mf-types/useNavigateBlankProvider/compiled-types/export-app.d.ts b/tests/integration/agent-runtime-mf/cases/usenavigate-blank/consumer/@mf-types/useNavigateBlankProvider/compiled-types/export-app.d.ts new file mode 100644 index 000000000000..af56f3db194c --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/usenavigate-blank/consumer/@mf-types/useNavigateBlankProvider/compiled-types/export-app.d.ts @@ -0,0 +1,5 @@ +export declare const provider: () => { + render(info: import("@module-federation/modern-js-v3/react-v19").RenderParams): Promise; + destroy(info: import("@module-federation/modern-js-v3/react-v19").DestroyParams): void; +}; +export default provider; diff --git a/tests/integration/agent-runtime-mf/cases/usenavigate-blank/consumer/@mf-types/useNavigateBlankProvider/export-app.d.ts b/tests/integration/agent-runtime-mf/cases/usenavigate-blank/consumer/@mf-types/useNavigateBlankProvider/export-app.d.ts new file mode 100644 index 000000000000..97ccc97faf77 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/usenavigate-blank/consumer/@mf-types/useNavigateBlankProvider/export-app.d.ts @@ -0,0 +1,2 @@ +export * from './compiled-types/export-app'; +export { default } from './compiled-types/export-app'; \ No newline at end of file diff --git a/tests/integration/agent-runtime-mf/cases/usenavigate-blank/consumer/modern.config.ts b/tests/integration/agent-runtime-mf/cases/usenavigate-blank/consumer/modern.config.ts new file mode 100644 index 000000000000..548de44558df --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/usenavigate-blank/consumer/modern.config.ts @@ -0,0 +1,24 @@ +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { appTools, defineConfig } from '@modern-js/app-tools'; +import { moduleFederationPlugin } from '@module-federation/modern-js-v3'; + +const require = createRequire(import.meta.url); +const reactRoot = dirname(require.resolve('react/package.json')); + +export default defineConfig({ + server: { + port: 4362, + }, + source: { + alias: { + react: reactRoot, + 'react/jsx-runtime': join(reactRoot, 'jsx-runtime.js'), + 'react/jsx-dev-runtime': join(reactRoot, 'jsx-dev-runtime.js'), + }, + }, + performance: { + buildCache: false, + }, + plugins: [appTools(), moduleFederationPlugin()], +}); diff --git a/tests/integration/agent-runtime-mf/cases/usenavigate-blank/consumer/module-federation.config.ts b/tests/integration/agent-runtime-mf/cases/usenavigate-blank/consumer/module-federation.config.ts new file mode 100644 index 000000000000..4a5f8ebcd894 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/usenavigate-blank/consumer/module-federation.config.ts @@ -0,0 +1,17 @@ +import { createModuleFederationConfig } from '@module-federation/modern-js-v3'; +import { dependencies } from './package.json'; + +export default createModuleFederationConfig({ + name: 'useNavigateBlankConsumer', + remotes: { + useNavigateBlankProvider: + 'useNavigateBlankProvider@http://localhost:4361/mf-manifest.json', + }, + shared: { + react: { singleton: true, requiredVersion: dependencies.react }, + 'react-dom': { + singleton: true, + requiredVersion: dependencies['react-dom'], + }, + }, +}); diff --git a/tests/integration/agent-runtime-mf/cases/usenavigate-blank/consumer/package.json b/tests/integration/agent-runtime-mf/cases/usenavigate-blank/consumer/package.json new file mode 100644 index 000000000000..f6d114c23a3e --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/usenavigate-blank/consumer/package.json @@ -0,0 +1,18 @@ +{ + "private": true, + "name": "agent-runtime-mf-usenavigate-blank-consumer", + "version": "0.0.0", + "scripts": { + "dev": "modern dev", + "build": "modern build" + }, + "dependencies": { + "@modern-js/runtime": "workspace:*", + "@module-federation/modern-js-v3": "2.0.0", + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@modern-js/app-tools": "workspace:*" + } +} diff --git a/tests/integration/agent-runtime-mf/cases/usenavigate-blank/consumer/src/App.tsx b/tests/integration/agent-runtime-mf/cases/usenavigate-blank/consumer/src/App.tsx new file mode 100644 index 000000000000..d0ed47badbc8 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/usenavigate-blank/consumer/src/App.tsx @@ -0,0 +1,150 @@ +import { createRemoteAppComponent } from '@module-federation/modern-js-v3/react'; +import { loadRemote } from '@module-federation/modern-js-v3/runtime'; +import { useEffect, useState } from 'react'; + +const RemoteAppFallback = ({ error }: { error: Error }) => ( +
Remote provider failed: {error.message}
+); + +const RemoteApp = createRemoteAppComponent({ + loader: () => loadRemote('useNavigateBlankProvider/export-app'), + export: 'provider' as any, + fallback: RemoteAppFallback, + loading:
Loading remote provider...
, +}); + +type NavigationResultDetail = { + path?: string; + basename?: string; + matched?: boolean; + view?: string; + lastNavigation?: string; +}; + +type PageState = { + route: string; + status: 'success' | 'blank'; + error: string | null; +}; + +const mountedBasename = 'content-understand-feature-lineage'; +const mountedRoute = `/${mountedBasename}/features`; + +export default function App() { + const [pageState, setPageState] = useState({ + route: mountedRoute, + status: 'success', + error: null, + }); + + useEffect(() => { + if (typeof window !== 'undefined' && window.location.pathname === '/') { + window.history.replaceState(null, '', mountedRoute); + } + + const onNavigationResult = (event: Event) => { + const detail = + (event as CustomEvent).detail ?? {}; + const matched = detail.matched !== false; + const route = detail.path || '/features'; + setPageState({ + route, + status: matched ? 'success' : 'blank', + error: matched + ? null + : `Route ${route} does not match host mount path.`, + }); + }; + window.addEventListener( + 'usenavigate-blank:navigation-result', + onNavigationResult, + ); + return () => { + window.removeEventListener( + 'usenavigate-blank:navigation-result', + onNavigationResult, + ); + }; + }, []); + + const enterDefaultPage = () => { + window.dispatchEvent( + new CustomEvent('usenavigate-blank:navigate', { + detail: { reset: true }, + }), + ); + setPageState({ + route: mountedRoute, + status: 'success', + error: null, + }); + }; + + const navigateLineage = () => { + window.dispatchEvent( + new CustomEvent('usenavigate-blank:navigate', { + detail: { to: '/lineage' }, + }), + ); + }; + + const navigateFeatures = () => { + window.dispatchEvent( + new CustomEvent('usenavigate-blank:navigate', { + detail: { to: '/features' }, + }), + ); + }; + + return ( +
+
+

useNavigate blank MF case

+

+ The remote app uses navigation paths that drop the host mount + basename. +

+
+ +
+

Reproduced Issue

+

+ Status: {pageState.status} +

+

+ Route: {pageState.route} +

+ {pageState.error ? ( +

+ Error: {pageState.error} +

+ ) : null} +
+ +
+ + + +
+ +
+

Remote Provider

+ +
+
+ ); +} diff --git a/tests/integration/agent-runtime-mf/cases/usenavigate-blank/consumer/src/modern-app-env.d.ts b/tests/integration/agent-runtime-mf/cases/usenavigate-blank/consumer/src/modern-app-env.d.ts new file mode 100644 index 000000000000..11fd97f0e4b9 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/usenavigate-blank/consumer/src/modern-app-env.d.ts @@ -0,0 +1,2 @@ +/// + diff --git a/tests/integration/agent-runtime-mf/cases/usenavigate-blank/consumer/src/remotes.d.ts b/tests/integration/agent-runtime-mf/cases/usenavigate-blank/consumer/src/remotes.d.ts new file mode 100644 index 000000000000..348da903346e --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/usenavigate-blank/consumer/src/remotes.d.ts @@ -0,0 +1,12 @@ +declare module 'useNavigateBlankProvider/export-app' { + export const provider: () => { + render(info: { + dom: HTMLElement; + basename?: string; + [key: string]: unknown; + }): void | Promise; + destroy(info: { dom: HTMLElement }): void; + }; + + export default provider; +} diff --git a/tests/integration/agent-runtime-mf/cases/usenavigate-blank/consumer/tsconfig.json b/tests/integration/agent-runtime-mf/cases/usenavigate-blank/consumer/tsconfig.json new file mode 100644 index 000000000000..d2a56b7f1520 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/usenavigate-blank/consumer/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@modern-js/tsconfig/base", + "compilerOptions": { + "declaration": false, + "jsx": "react-jsx" + }, + "include": ["src"] +} diff --git a/tests/integration/agent-runtime-mf/cases/usenavigate-blank/provider/modern.config.ts b/tests/integration/agent-runtime-mf/cases/usenavigate-blank/provider/modern.config.ts new file mode 100644 index 000000000000..f7a888b5a460 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/usenavigate-blank/provider/modern.config.ts @@ -0,0 +1,13 @@ +import { appTools, defineConfig } from '@modern-js/app-tools'; +import { moduleFederationPlugin } from '@module-federation/modern-js-v3'; + +export default defineConfig({ + server: { + port: 4361, + }, + performance: { + buildCache: false, + }, + plugins: [appTools(), moduleFederationPlugin()], +}); + diff --git a/tests/integration/agent-runtime-mf/cases/usenavigate-blank/provider/module-federation.config.ts b/tests/integration/agent-runtime-mf/cases/usenavigate-blank/provider/module-federation.config.ts new file mode 100644 index 000000000000..73f8c1d9a409 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/usenavigate-blank/provider/module-federation.config.ts @@ -0,0 +1,17 @@ +import { createModuleFederationConfig } from '@module-federation/modern-js-v3'; +import { dependencies } from './package.json'; + +export default createModuleFederationConfig({ + name: 'useNavigateBlankProvider', + filename: 'remoteEntry.js', + exposes: { + './export-app': './src/export-app.tsx', + }, + shared: { + react: { singleton: true, requiredVersion: dependencies.react }, + 'react-dom': { + singleton: true, + requiredVersion: dependencies['react-dom'], + }, + }, +}); diff --git a/tests/integration/agent-runtime-mf/cases/usenavigate-blank/provider/package.json b/tests/integration/agent-runtime-mf/cases/usenavigate-blank/provider/package.json new file mode 100644 index 000000000000..306354350242 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/usenavigate-blank/provider/package.json @@ -0,0 +1,18 @@ +{ + "private": true, + "name": "agent-runtime-mf-usenavigate-blank-provider", + "version": "0.0.0", + "scripts": { + "dev": "modern dev", + "build": "modern build" + }, + "dependencies": { + "@modern-js/runtime": "workspace:*", + "@module-federation/modern-js-v3": "2.0.0", + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@modern-js/app-tools": "workspace:*" + } +} diff --git a/tests/integration/agent-runtime-mf/cases/usenavigate-blank/provider/src/App.tsx b/tests/integration/agent-runtime-mf/cases/usenavigate-blank/provider/src/App.tsx new file mode 100644 index 000000000000..bca4c2e62502 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/usenavigate-blank/provider/src/App.tsx @@ -0,0 +1,9 @@ +import RemotePanel from './RemotePanel'; + +type AppProps = { + basename?: string; +}; + +export default function App({ basename }: AppProps) { + return ; +} diff --git a/tests/integration/agent-runtime-mf/cases/usenavigate-blank/provider/src/RemotePanel.tsx b/tests/integration/agent-runtime-mf/cases/usenavigate-blank/provider/src/RemotePanel.tsx new file mode 100644 index 000000000000..63fc7eed6da5 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/usenavigate-blank/provider/src/RemotePanel.tsx @@ -0,0 +1,126 @@ +import { useEffect, useState } from 'react'; + +type NavigationState = { + path: string; + basename: string; + matched: boolean; + view: 'FeatureList' | 'LineagePage' | 'BlankPage'; + lastNavigation: string; +}; + +const mountPath = '/content-understand-feature-lineage'; +const defaultBadBasename = 'content-understand-feature-lineage'; + +type RemotePanelProps = { + basename?: string; +}; + +const readBasename = (basename?: string) => basename || defaultBadBasename; + +const reportNavigation = (state: NavigationState) => { + if (typeof window === 'undefined') { + return; + } + window.dispatchEvent( + new CustomEvent('usenavigate-blank:navigation-result', { + detail: state, + }), + ); +}; + +const createInitialState = (basename?: string): NavigationState => ({ + path: `${mountPath}/features`, + basename: readBasename(basename), + matched: true, + view: 'FeatureList', + lastNavigation: 'initial', +}); + +const resolveNavigation = ( + to: string, + basename: string, + lastNavigation: string, +): NavigationState => { + const path = basename.startsWith('/') ? `${basename}${to}` : to; + const matched = path.startsWith(`${mountPath}/`); + const view = matched + ? to.includes('lineage') + ? 'LineagePage' + : 'FeatureList' + : 'BlankPage'; + + return { + path, + basename, + matched, + view, + lastNavigation, + }; +}; + +export default function RemotePanel({ basename }: RemotePanelProps) { + const [state, setState] = useState(() => createInitialState(basename)); + + useEffect(() => { + reportNavigation(state); + }, [state]); + + useEffect(() => { + const onNavigate = (event: Event) => { + const detail = + (event as CustomEvent<{ to?: string; reset?: boolean }>).detail ?? {}; + if (detail.reset) { + setState(createInitialState(basename)); + window.history.pushState(null, '', `${mountPath}/features`); + return; + } + + const next = resolveNavigation( + detail.to || '/features', + readBasename(basename), + detail.to || '/features', + ); + window.history.pushState(null, '', next.path); + setState(next); + }; + window.addEventListener('usenavigate-blank:navigate', onNavigate); + return () => { + window.removeEventListener('usenavigate-blank:navigate', onNavigate); + }; + }, [basename]); + + const navigate = (to: string) => { + window.dispatchEvent( + new CustomEvent('usenavigate-blank:navigate', { + detail: { to }, + }), + ); + }; + + if (!state.matched) { + return ( +
+ ); + } + + return ( +
+ Provider: lineage feature page +

+ {state.view} is mounted under {mountPath}. +

+
+ + +
+
+ ); +} diff --git a/tests/integration/agent-runtime-mf/cases/usenavigate-blank/provider/src/export-app.tsx b/tests/integration/agent-runtime-mf/cases/usenavigate-blank/provider/src/export-app.tsx new file mode 100644 index 000000000000..5f368cb31dec --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/usenavigate-blank/provider/src/export-app.tsx @@ -0,0 +1,8 @@ +import { createBridgeComponent } from '@module-federation/modern-js-v3/react-v19'; +import App from './App'; + +export const provider = createBridgeComponent({ + rootComponent: App, +}); + +export default provider; diff --git a/tests/integration/agent-runtime-mf/cases/usenavigate-blank/provider/src/modern-app-env.d.ts b/tests/integration/agent-runtime-mf/cases/usenavigate-blank/provider/src/modern-app-env.d.ts new file mode 100644 index 000000000000..11fd97f0e4b9 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/usenavigate-blank/provider/src/modern-app-env.d.ts @@ -0,0 +1,2 @@ +/// + diff --git a/tests/integration/agent-runtime-mf/cases/usenavigate-blank/provider/tsconfig.json b/tests/integration/agent-runtime-mf/cases/usenavigate-blank/provider/tsconfig.json new file mode 100644 index 000000000000..67562ff13113 --- /dev/null +++ b/tests/integration/agent-runtime-mf/cases/usenavigate-blank/provider/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@modern-js/tsconfig/base", + "compilerOptions": { + "declaration": false, + "jsx": "react-jsx" + }, + "include": ["src"] +} + diff --git a/tests/integration/agent-runtime-mf/verify.mjs b/tests/integration/agent-runtime-mf/verify.mjs new file mode 100644 index 000000000000..e423fe9bca48 --- /dev/null +++ b/tests/integration/agent-runtime-mf/verify.mjs @@ -0,0 +1,282 @@ +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const dirname = path.dirname(fileURLToPath(import.meta.url)); + +const cases = [ + ['react-multi-version', 'reactMultiVersionProvider', 4311, 4312], + ['nested-router-tree', 'nestedRouterTreeProvider', 4321, 4322], + ['async-chunk-runtime', 'asyncChunkRuntimeProvider', 4331, 4332], + ['garfish-provider', 'garfishProvider', 4341, 4342], + ['redirect-loader', 'redirectLoaderProvider', 4351, 4352], + ['usenavigate-blank', 'useNavigateBlankProvider', 4361, 4362], +]; + +const expectedProviderExpose = { + 'react-multi-version': "'./RemotePanel'", + 'nested-router-tree': "'./RemotePanel'", + 'garfish-provider': "'./RemotePanel'", + 'redirect-loader': "'./RemotePanel'", + 'usenavigate-blank': "'./export-app'", +}; + +const read = file => readFileSync(file, 'utf8'); + +const assertFile = file => { + if (!existsSync(file)) { + throw new Error(`Missing ${path.relative(dirname, file)}`); + } +}; + +const assertIncludes = (content, text, message) => { + if (!content.includes(text)) { + throw new Error(message); + } +}; + +const assertNotIncludes = (content, text, message) => { + if (content.includes(text)) { + throw new Error(message); + } +}; + +for (const [caseId, remoteName, providerPort, consumerPort] of cases) { + const caseDir = path.join(dirname, 'cases', caseId); + + for (const side of ['provider', 'consumer']) { + const appDir = path.join(caseDir, side); + for (const file of [ + 'package.json', + 'modern.config.ts', + 'tsconfig.json', + 'src/App.tsx', + 'src/modern-app-env.d.ts', + ]) { + assertFile(path.join(appDir, file)); + } + + const modernConfig = read(path.join(appDir, 'modern.config.ts')); + const expectedPort = side === 'provider' ? providerPort : consumerPort; + assertIncludes( + modernConfig, + `port: ${expectedPort}`, + `${caseId}/${side} does not use port ${expectedPort}`, + ); + + if (caseId !== 'async-chunk-runtime' || side !== 'provider') { + assertFile(path.join(appDir, 'module-federation.config.ts')); + assertIncludes( + modernConfig, + 'moduleFederationPlugin()', + `${caseId}/${side} is missing moduleFederationPlugin`, + ); + } + } + + const app = read(path.join(caseDir, 'consumer', 'src', 'App.tsx')); + assertIncludes( + app, + 'Reproduced Issue', + `${caseId}/consumer does not render a visible issue state`, + ); + for (const futureApi of [ + '../../../../shared/agent-runtime', + 'AgentRuntimePanel', + 'useDemoRuntime', + '__AGENT_RUNTIME__', + 'getSnapshot', + 'getEvents', + 'getActions', + 'runAction', + 'patchSnapshot', + ]) { + assertNotIncludes( + app, + futureApi, + `${caseId}/consumer still contains future runtime API: ${futureApi}`, + ); + } + + const consumerTsconfig = read( + path.join(caseDir, 'consumer', 'tsconfig.json'), + ); + assertNotIncludes( + consumerTsconfig, + '../../../shared', + `${caseId}/consumer still includes shared runtime code`, + ); + + if (caseId !== 'async-chunk-runtime') { + const providerMf = read( + path.join(caseDir, 'provider', 'module-federation.config.ts'), + ); + const expectedExpose = expectedProviderExpose[caseId]; + assertIncludes( + providerMf, + `name: '${remoteName}'`, + `${caseId}/provider remote name mismatch`, + ); + assertIncludes( + providerMf, + expectedExpose, + `${caseId}/provider does not expose ${expectedExpose}`, + ); + } + + const consumerMf = read( + path.join(caseDir, 'consumer', 'module-federation.config.ts'), + ); + assertIncludes( + consumerMf, + `${remoteName}@http://localhost:${providerPort}`, + `${caseId}/consumer remote URL mismatch`, + ); + + if (caseId === 'async-chunk-runtime') { + const providerModernConfig = read( + path.join(caseDir, 'provider', 'modern.config.ts'), + ); + const consumerPackage = read( + path.join(caseDir, 'consumer', 'package.json'), + ); + const providerPackage = read( + path.join(caseDir, 'provider', 'package.json'), + ); + assertIncludes( + providerPackage, + '"build": "pnpm run build:remote && pnpm run build:garfish"', + `${caseId}/provider build should produce remote and garfish outputs`, + ); + for (const marker of [ + "uniqueName('ad_rivendell_dev')", + "chunkLoadingGlobal('webpackChunk_ad_rivendell_dev')", + "chunkIds('deterministic')", + "moduleIds('deterministic')", + 'new rspack.container.ModuleFederationPlugin', + 'ASYNC_CHUNK_RUNTIME_BUILD', + ]) { + assertIncludes( + providerModernConfig, + marker, + `${caseId}/provider missing ${marker}`, + ); + } + for (const marker of [ + "import Garfish from 'garfish'", + 'Garfish.registerApp', + 'Garfish.loadApp', + 'Load Provider Remote', + 'Load Garfish Sub App', + 'garfish-rivendell-container', + ]) { + assertIncludes(app, marker, `${caseId}/consumer missing ${marker}`); + } + assertIncludes( + consumerPackage, + '"garfish": "1.19.4"', + `${caseId}/consumer does not install Garfish`, + ); + } + + if (caseId === 'react-multi-version') { + const providerPanel = read( + path.join(caseDir, 'provider', 'src', 'RemotePanel.tsx'), + ); + assertIncludes( + providerPanel, + '@otrade/transaction_adapter', + `${caseId}/provider does not render the bundled dependency`, + ); + assertIncludes( + app, + 'RemoteErrorBoundary', + `${caseId}/consumer does not catch the render error`, + ); + } + + if (caseId === 'nested-router-tree') { + const providerPanel = read( + path.join(caseDir, 'provider', 'src', 'RemotePanel.tsx'), + ); + assertIncludes( + providerPanel, + '', + `${caseId}/provider does not create a nested router`, + ); + assertIncludes( + app, + '', + `${caseId}/consumer does not create the host router`, + ); + } + + if (caseId === 'garfish-provider') { + const providerGarfishEntry = read( + path.join(caseDir, 'provider', 'src', 'garfish', 'CreativeHubEntry.tsx'), + ); + assertIncludes( + providerGarfishEntry, + 'export const provider', + `${caseId}/provider does not expose provider`, + ); + assertNotIncludes( + providerGarfishEntry, + '__GARFISH_EXPORTS__.provider', + `${caseId}/provider should not register provider to Garfish exports`, + ); + assertIncludes( + app, + 'Load Creative Hub', + `${caseId}/consumer does not expose the Garfish load button`, + ); + } + + if (caseId === 'redirect-loader') { + const consumerRootLoader = read( + path.join(caseDir, 'consumer', 'src', 'routes', 'page.loader.ts'), + ); + assertIncludes( + consumerRootLoader, + "redirect('/home')", + `${caseId}/consumer does not define the SSR redirect loader`, + ); + assertIncludes( + app, + 'Run client fallback', + `${caseId}/consumer does not expose the fallback button`, + ); + } + + if (caseId === 'usenavigate-blank') { + const providerExportApp = read( + path.join(caseDir, 'provider', 'src', 'export-app.tsx'), + ); + assertIncludes( + providerExportApp, + 'createBridgeComponent', + `${caseId}/provider does not export a bridge app`, + ); + assertIncludes( + app, + 'Navigate to lineage', + `${caseId}/consumer does not expose the navigation button`, + ); + } +} + +const sharedRuntimePath = path.join(dirname, 'shared', 'agent-runtime.tsx'); +if (existsSync(sharedRuntimePath)) { + throw new Error('shared/agent-runtime.tsx should not be part of demos'); +} + +const readme = read(path.join(dirname, 'README.md')); +for (const futureApi of ['__AGENT_RUNTIME__', 'runAction', 'getSnapshot']) { + assertNotIncludes( + readme, + futureApi, + `README still mentions future runtime API: ${futureApi}`, + ); +} + +console.log(`Verified ${cases.length} Modern.js MF demo cases.`);