From fb4a26fb21e3bada6dc8238ab00cbc1176dd9feb Mon Sep 17 00:00:00 2001 From: 2heal1 Date: Thu, 7 May 2026 10:29:24 +0800 Subject: [PATCH 1/4] feat(runtime): add diagnostics milestone 1 --- .changeset/safe-diagnostics-plugin.md | 7 + MF_LOADING_OBSERVABILITY_ROADMAP.md | 1047 +++++++++++++ .../3005-runtime-host/cypress/e2e/app.cy.ts | 136 ++ .../3005-runtime-host/package.json | 3 +- .../3005-runtime-host/src/App.tsx | 8 +- .../3005-runtime-host/src/DiagnosticsDemo.tsx | 330 ++++ .../3005-runtime-host/src/bootstrap.tsx | 15 +- .../src/components/ButtonOldAnt.tsx | 4 +- .../3005-runtime-host/src/diagnostics.ts | 10 + .../3005-runtime-host/src/index.ts | 2 +- .../3005-runtime-host/webpack.config.js | 2 + apps/runtime-demo/README.md | 55 +- packages/diagnostics-plugin/.eslintrc.json | 27 + packages/diagnostics-plugin/README.md | 122 ++ .../__tests__/diagnostics.spec.ts | 774 +++++++++ packages/diagnostics-plugin/package.json | 52 + packages/diagnostics-plugin/src/index.ts | 1383 +++++++++++++++++ packages/diagnostics-plugin/src/node.ts | 304 ++++ packages/diagnostics-plugin/tsconfig.json | 15 + packages/diagnostics-plugin/tsdown.config.ts | 40 + packages/diagnostics-plugin/vitest.config.ts | 19 + packages/runtime-core/__tests__/hooks.spec.ts | 149 ++ .../__tests__/load-remote-diagnostics.spec.ts | 54 + .../__tests__/shared-diagnostics.spec.ts | 166 ++ packages/runtime-core/src/core.ts | 12 + packages/runtime-core/src/index.ts | 6 +- .../src/plugins/snapshot/SnapshotHandler.ts | 28 +- packages/runtime-core/src/remote/index.ts | 99 +- packages/runtime-core/src/shared/index.ts | 496 ++++-- packages/runtime-core/src/type/config.ts | 11 + .../runtime-core/src/utils/hooks/asyncHook.ts | 13 +- .../src/utils/hooks/asyncWaterfallHooks.ts | 27 +- .../runtime-core/src/utils/hooks/syncHook.ts | 5 +- .../src/utils/hooks/syncWaterfallHook.ts | 5 +- packages/runtime-core/src/utils/load.ts | 20 + packages/runtime/src/index.ts | 1 + pnpm-lock.yaml | 9 + 37 files changed, 5254 insertions(+), 202 deletions(-) create mode 100644 .changeset/safe-diagnostics-plugin.md create mode 100644 MF_LOADING_OBSERVABILITY_ROADMAP.md create mode 100644 apps/runtime-demo/3005-runtime-host/src/DiagnosticsDemo.tsx create mode 100644 apps/runtime-demo/3005-runtime-host/src/diagnostics.ts create mode 100644 packages/diagnostics-plugin/.eslintrc.json create mode 100644 packages/diagnostics-plugin/README.md create mode 100644 packages/diagnostics-plugin/__tests__/diagnostics.spec.ts create mode 100644 packages/diagnostics-plugin/package.json create mode 100644 packages/diagnostics-plugin/src/index.ts create mode 100644 packages/diagnostics-plugin/src/node.ts create mode 100644 packages/diagnostics-plugin/tsconfig.json create mode 100644 packages/diagnostics-plugin/tsdown.config.ts create mode 100644 packages/diagnostics-plugin/vitest.config.ts create mode 100644 packages/runtime-core/__tests__/load-remote-diagnostics.spec.ts create mode 100644 packages/runtime-core/__tests__/shared-diagnostics.spec.ts diff --git a/.changeset/safe-diagnostics-plugin.md b/.changeset/safe-diagnostics-plugin.md new file mode 100644 index 00000000000..e04f332d00a --- /dev/null +++ b/.changeset/safe-diagnostics-plugin.md @@ -0,0 +1,7 @@ +--- +"@module-federation/diagnostics-plugin": minor +"@module-federation/runtime-core": minor +"@module-federation/runtime": minor +--- + +Add an opt-in diagnostics plugin, a Node-specific diagnostics plugin export for file reports, remote and shared lifecycle hooks, console trace hints, safe browser/Node report outputs, shared/eager loading diagnostics, final loading outcome summaries for Module Federation loading reports, no-op return handling for observer hooks, and passive remote diagnostics through runtime load hooks. diff --git a/MF_LOADING_OBSERVABILITY_ROADMAP.md b/MF_LOADING_OBSERVABILITY_ROADMAP.md new file mode 100644 index 00000000000..8bb62ea6e22 --- /dev/null +++ b/MF_LOADING_OBSERVABILITY_ROADMAP.md @@ -0,0 +1,1047 @@ +# MF Loading Observability Roadmap + +## 背景 + +Module Federation 的问题通常不是单点错误。一次加载可能同时经过 host 配置、remote 配置、manifest、remoteEntry、共享依赖、运行时插件和实际模块执行。现在的报错信息能给出错误码和部分上下文,但还不足以让人或 AI coding 快速判断问题属于哪一侧。 + +这个方案的终极目标不是把报错文案写长,而是让 MF 加载过程本身变得可观测。报错报告应当从加载过程记录中生成,而不是在抛错点临时拼接一段孤立信息。 + +## 目标 + +- 让每一次 MF 加载都能留下安全、结构化、可读取的加载记录。 +- 让错误报告可以还原失败前后的关键路径。 +- 让 AI coding 能基于同一份事实数据判断下一步应该查哪里。 +- 让构建侧信息和运行时信息可以被关联起来分析。 +- 在默认情况下不引入新的数据泄露风险或调试后门。 + +## 非目标 + +- 不自动上传诊断数据。 +- 不采集业务数据、源码、远端响应内容、请求头、cookie 或 token。 +- 不把 AI 判断作为运行时行为的一部分。 +- 不让可观测逻辑影响 MF 正常加载结果。 + +## 第一原则:安全 + +所有可观测能力都必须遵守以下规则: + +- 默认不开启详细采集。 +- 默认不做公网回传。 +- 浏览器侧默认只保存在当前实例或当前页面内存中。 +- Node 侧只写本地诊断文件,且写入失败不能影响构建或运行。 +- URL 必须脱敏,默认移除 query 和 hash。 +- 禁止记录请求头、cookie、authorization、token、secret、session、完整请求参数。 +- 禁止记录 remote 返回内容、源码、模块源码、用户输入和业务数据。 +- 对外导出的报告必须是脱敏后的结果。 +- 全局暴露能力必须最小化,不能把内部状态无边界挂到根对象上。 +- 生产环境默认低噪声;详细诊断需要用户显式开启。 + +## 现状判断 + +当前已有一些基础: + +- 统一错误码和文档链接。 +- 部分运行时报错能携带 host 配置摘要。 +- Node 侧可以写 `.mf/diagnostics/latest.json`。 +- 部分 remoteEntry 加载错误已经区分了脚本加载失败、脚本执行失败、global 未注册。 + +主要不足: + +- 浏览器侧没有稳定的结构化记录入口。 +- 只记录最近一次错误,不保留加载时间线。 +- 很多错误仍然只是字符串,无法自动判断责任方。 +- 构建信息和运行时错误没有形成稳定关联。 +- shared / eager / 版本匹配类问题缺少完整上下文。 +- AI 需要事实数据,但现在多数信息只存在于错误字符串里。 + +## 总体设计 + +整体分三层。 + +### 1. 加载事件层 + +工程代码负责记录 MF 加载过程中的事实事件。事件只记录白名单字段。 + +典型事件: + +- `loadRemote:start` +- `loadRemote:resolved` +- `loadRemote:success` +- `loadRemote:error` +- `manifest:fetch:start` +- `manifest:fetch:success` +- `manifest:fetch:error` +- `remoteEntry:load:start` +- `remoteEntry:load:success` +- `remoteEntry:load:error` +- `remoteEntry:init:start` +- `remoteEntry:init:success` +- `remoteEntry:init:error` +- `expose:get:start` +- `expose:get:success` +- `expose:get:error` +- `share:resolve:start` +- `share:resolve:success` +- `share:resolve:miss` +- `share:resolve:error` +- `loadRemote:complete` + +事件字段建议: + +- `traceId` +- `timestamp` +- `phase` +- `status` +- `hostName` +- `remoteName` +- `requestId` +- `expose` +- `shareName` +- `shareScope` +- `expectedVersion` +- `resolvedVersion` +- `provider` +- `sanitizedUrl` +- `remoteType` +- `entryGlobalName` +- `duration` +- `errorCode` +- `errorName` +- `errorMessage` +- `ownerHint` +- `startTime` +- `endTime` +- `cached` +- `attempt` +- `fallbackUsed` +- `resultSummary` + +`ownerHint` 只能是工程规则给出的初步分类,例如: + +- `host` +- `remote` +- `network` +- `manifest` +- `shared` +- `runtime-plugin` +- `unknown` + +### 2. 诊断报告层 + +工程代码基于事件生成事实报告。报告不是 AI 生成的,它应当是确定性的、可测试的、可复现的。 + +报告包含: + +- 本次加载的概要 +- 加载时间线 +- 加载结果 +- 总耗时 +- 失败阶段 +- 原始错误 +- 脱敏后的上下文 +- 可能责任方 +- 可执行的检查建议 +- 关联的构建信息摘要 + +这里的“可能责任方”和“检查建议”应由工程规则生成,只能基于已记录事实,不允许猜测未采集的信息。 + +报告不只在失败时生成。成功加载也应当生成可选的 summary,用来回答: + +- remote 是否加载成功。 +- manifest 是否命中缓存。 +- remoteEntry 是否重复使用已有 global。 +- expose 是否获取成功。 +- shared 最终命中了哪个提供方和版本。 +- 是否走了 fallback 或 retry。 +- 每个阶段耗时多少。 +- 是否存在“成功但异常”的信号,例如耗时过长、版本命中不符合预期、使用了 fallback。 + +### 3. AI coding 消费层 + +AI 不负责生成事实报告。AI 负责读取工程报告,并基于报告做解释、归因、修复建议或代码修改。 + +推荐边界: + +- 工程报告回答“发生了什么”。 +- AI 分析回答“这通常意味着什么、下一步怎么修”。 +- AI 输出必须引用报告里的事实,不应凭空补全缺失数据。 + +## 报告是 AI 生成还是工程生成 + +基础报告必须由工程手段生成。 + +原因: + +- 工程生成的报告稳定、可测试、可在没有 AI 的环境里使用。 +- 工程报告可以严格执行脱敏和字段白名单。 +- 工程报告不会编造缺失信息。 +- AI coding 需要的是可靠事实输入,而不是另一个不确定输出。 + +AI 可以在报告之上生成解释版报告,但它应该是第二层消费结果,不应该替代工程报告。 + +建议分成两类: + +- 事实报告:工程生成,默认能力。 +- 分析报告:AI 基于事实报告生成,可选能力。 + +## 只看记录的信息是否足够 + +只看原始事件记录不够。 + +原始记录是必要基础,但它通常太碎。人和 AI 都还需要一个归一化后的摘要,才能快速知道: + +- 哪一步失败 +- 失败前已经完成了哪些步骤 +- 哪些信息缺失 +- 应该优先检查 host、remote、网络、manifest 还是 shared + +因此需要同时保留两种产物: + +- 事件时间线:完整事实,用于深挖。 +- 诊断报告:从时间线聚合出来,用于快速定位。 + +如果只保留报告,不保留事件,后续无法追溯细节。 +如果只保留事件,不生成报告,AI 和人都需要重复做整理工作。 + +## 加载结果可观测方案 + +可观测不是只收集报错。成功加载也需要记录,因为很多问题不会直接抛错,例如加载慢、使用了 fallback、命中了非预期 shared 版本、remoteEntry 被缓存复用、manifest 内容和 host 预期不一致。 + +### 成功事件要记录什么 + +成功事件应当记录: + +- 本次 `traceId`。 +- 请求的 `requestId`。 +- 匹配到的 remote 名称。 +- expose 名称。 +- manifest URL 是否存在。 +- manifest 是否 fetch 成功。 +- manifest 是否来自缓存。 +- remoteEntry URL。 +- remoteEntry 类型。 +- remoteEntry 是否新加载。 +- remoteEntry 是否复用已有 global。 +- remoteEntry init 是否完成。 +- expose factory 是否获取成功。 +- expose module 是否执行完成。 +- shared 命中的 provider。 +- shared 命中的版本。 +- 是否使用 fallback。 +- 是否发生 retry。 +- 每个阶段耗时。 +- 总耗时。 +- 业务组件是否主动声明成功加载。 + +这些信息都必须是结构化字段,不能只写在字符串里。 + +### 成功报告 + +成功报告是加载链路的整理版,不是错误报告。它应包含: + +- `traceId` +- `status: "success"` +- host 摘要 +- remote 摘要 +- expose 摘要 +- shared 命中摘要 +- 阶段耗时 +- 是否缓存命中 +- 是否 retry +- 是否 fallback +- 构建信息关联摘要 + +成功报告默认不一定需要 console 输出。建议由插件配置控制: + +- `level: "error"` 只在失败时生成详细报告。 +- `level: "summary"` 记录成功摘要和失败报告。 +- `level: "verbose"` 记录完整成功和失败时间线。 + +### 成功但需要关注的信号 + +插件可以基于工程规则标记 warning,但不能改变加载结果: + +- remoteEntry 加载耗时超过阈值。 +- manifest 加载耗时超过阈值。 +- 使用了 retry 后才成功。 +- 使用了 fallback。 +- shared 命中 provider 与预期不一致。 +- shared 命中版本低于推荐版本。 +- remote manifest 的 buildVersion 与预期不一致。 +- expose 成功但来自非预期 remote。 + +这些 warning 应进入报告,供 AI 判断是否需要继续排查。 + +### AI 如何使用成功记录 + +AI 看到成功记录后,可以回答: + +- 当前 MF 加载链路是否完整成功。 +- 慢在 manifest、remoteEntry、init、shared 还是 expose。 +- shared 实际用了谁。 +- remote 是否来自预期地址和构建版本。 +- 是否存在 fallback / retry / cache 复用。 + +因此,AI coding 不只在报错时使用诊断文件,也可以在“页面能跑但行为不对”时读取最近一次成功 trace。 + +### 业务组件成功加载事件 + +runtime-core 和 diagnostics plugin 可以自动判断技术层成功,但不能判断业务组件是否真正完成业务加载。因此需要给业务暴露一个固定接口,由业务组件在合适时机主动调用。 + +固定事件名: + +- `component:business-loaded` + +固定语义: + +- 业务组件已经完成自身定义的成功加载。 +- 这个事件由业务代码主动触发。 +- 这个事件不是 React mount,也不是 expose factory 执行成功。 +- 业务可以自定义什么时候调用,但事件名和基础字段必须固定。 + +建议 API: + +```ts +diagnostics.markComponentLoaded({ + traceId, + requestId: 'remote/Button', + componentName: 'Button', +}); +``` + +事件字段: + +- `traceId` +- `requestId` +- `componentName` +- `phase: "component"` +- `eventName: "component:business-loaded"` +- `status: "success"` +- `timestamp` +- `source: "business"` + +可选扩展字段: + +- `reason` +- `duration` +- `metadata` + +安全限制: + +- `metadata` 必须经过用户自定义 sanitize。 +- 不记录 props。 +- 不记录接口响应体。 +- 不记录用户输入。 +- 不记录业务数据明细。 + +AI 看到这个事件后,才能判断: + +- MF 技术加载成功。 +- 组件挂载或渲染链路已完成。 +- 业务组件声明自己已成功加载。 + +如果没有这个事件,AI 只能说“技术加载成功,但业务组件是否成功加载未声明”。 + +## 安全数据策略 + +允许记录: + +- host 名称 +- remote 名称 +- expose 请求名 +- shared 包名 +- shared 版本约束 +- 实际命中的 shared 版本 +- remoteEntry 类型 +- entryGlobalName +- 脱敏后的 URL +- 错误码 +- 错误名称 +- 脱敏后的错误消息 +- 阶段耗时 + +禁止记录: + +- cookie +- request headers +- authorization +- token +- secret +- session +- 完整 query +- hash +- remote 响应体 +- 源码 +- 模块执行结果 +- 用户业务数据 + +URL 脱敏规则: + +- 保留 protocol、host、pathname。 +- 默认删除 query 和 hash。 +- 对常见敏感字段做兜底过滤。 +- 如果 URL 无法解析,按字符串做保守清洗。 + +## 能力形态和输出入口 + +可观测能力应以 runtime plugin 形式提供,形态类似 `@module-federation/retry-plugin`。runtime-core 只提供必要 hook、事件上下文和安全策略,不内置完整收集器,也不默认保存诊断数据。 + +建议新增独立插件: + +- `@module-federation/diagnostics-plugin` + +使用方式: + +```ts +import { createInstance } from '@module-federation/runtime'; +import { DiagnosticsPlugin } from '@module-federation/diagnostics-plugin'; + +const diagnostics = DiagnosticsPlugin({ + level: 'error', + maxEvents: 100, + onReport(report) { + // 用户自行接入日志、监控或 AI coding 工具 + }, +}); + +const mf = createInstance({ + name: 'host', + remotes: [], + plugins: [diagnostics.plugin], +}); + +diagnostics.getLatestReport(); +diagnostics.getEvents(); +``` + +### 输出从哪里拿 + +插件应提供多种可选输出入口,默认只在内存中保存: + +- 插件 controller:`diagnostics.getEvents()`、`diagnostics.getLatestReport()`、`diagnostics.clear()`。 +- 回调:`onEvent(event)`、`onReport(report)`。 +- Node 本地文件:通过 `@module-federation/diagnostics-plugin/node` 的 `DiagnosticsPlugin` 显式开启后写 `.mf/diagnostics/events.jsonl` 和 `.mf/diagnostics/latest.json`。 +- 浏览器只读全局入口:只有显式开启后才暴露,例如 `globalThis.__FEDERATION__.__DIAGNOSTICS__`。 +- thrown error 附带最小报告 id 或摘要,但不把完整报告塞进错误字符串。 + +构建插件注入 runtime plugin 的场景不能拿到外部 controller,因此主要依赖 `onReport`、Node 文件或显式全局入口。 + +### AI 自动感知和读取流程 + +目标是让 AI coding 尽量不依赖用户手动复制完整报错。插件应在安全前提下给 AI 一个稳定的发现信号,再提供可读取的脱敏报告。 + +Node / SSR / build 场景: + +- MF 加载失败时,console 打印固定格式的诊断提示。 +- 提示中包含 `traceId` 和诊断文件路径。 +- 插件写入 `.mf/diagnostics/latest.json`。 +- 插件追加 `.mf/diagnostics/events.jsonl`。 +- AI 从终端输出识别 `traceId`。 +- AI 读取 `latest.json`。 +- 如果 `latest.json` 不够,再按 `traceId` 去 `events.jsonl` 查完整事件时间线。 + +推荐 console 格式: + +```txt +[Module Federation] Diagnostic report generated +traceId: mf-trace-xxx +latest: .mf/diagnostics/latest.json +events: .mf/diagnostics/events.jsonl +``` + +浏览器 dev 场景: + +- MF 加载失败时,console 打印固定格式的诊断提示。 +- AI 可以通过 CDP 或浏览器调试能力监听 console。 +- 如果启用了只读全局入口,AI 可以按 `traceId` 调用 `getReport(traceId)`。 +- 如果没有启用全局入口,console 只提示用户如何导出脱敏报告。 + +推荐 console 格式: + +```txt +[Module Federation] Diagnostic report generated +traceId: mf-trace-xxx +Run: __FEDERATION__.__DIAGNOSTICS__.getReport("mf-trace-xxx") +``` + +浏览器 prod 场景: + +- 默认不暴露全局报告入口。 +- 默认不写浏览器持久存储。 +- console 只输出最小错误码、`traceId` 和安全提示。 +- 完整报告只能通过用户显式开启的导出接口、`onReport` 上传到用户自己的系统,或用户主动导出。 +- AI 不应绕过页面安全边界读取完整报告。 + +prod 推荐 console 格式: + +```txt +[Module Federation] Diagnostic report available +traceId: mf-trace-xxx +Ask the application owner to export the sanitized diagnostic report. +``` + +### 诊断文件分工 + +`.mf/diagnostics/latest.json` 是最近一次诊断报告。它是给人和 AI 快速读取的整理版结构,应包含: + +- `traceId` +- host 摘要 +- remote 摘要 +- 失败阶段 +- 错误码 +- 初步责任方 +- 脱敏后的关键上下文 +- 建议检查项 + +`.mf/diagnostics/events.jsonl` 是完整事件流水。一行一个 JSON,用来保存多次加载、多阶段事件和同一 `traceId` 下的完整时间线。 + +AI 默认读取顺序: + +- 先读 `latest.json`。 +- 如果需要更多细节,再用 `traceId` 过滤 `events.jsonl`。 + +### runtime-core 需要补什么 + +runtime-core 只补底层能力: + +- 增加或复用最小生命周期 hook,例如 `afterLoadRemote`、`afterLoadEntry`、`afterLoadShare`、`errorLoadShare`。 +- 在主流程关键阶段暴露开始、成功、失败这类事实。 +- 给 hook 补齐必要的基础上下文,例如 `requestId`、`remoteInfo`、`shareInfo`。 +- 不在 runtime-core 里计算版本不匹配、missing provider、eager 边界这类诊断原因。 +- 对有返回值语义的 hook 采用“未返回内容即不干预”的规则,让诊断插件可以安全旁听 `onLoad`、`errorLoadRemote` 这类已有 hook。 +- 保证没有 diagnostics plugin 时行为完全不变。 + +runtime-core 不负责: + +- 持久化报告。 +- 对外暴露全局调试对象。 +- 上传诊断数据。 +- 生成 AI 分析结论。 +- 保存完整事件历史。 +- 组装 shared 诊断报告字段。 + +### 插件负责什么 + +diagnostics plugin 负责: + +- 是否启用收集。 +- 事件过滤级别。 +- URL、错误消息和 stack 脱敏。 +- 事件缓存上限。 +- 报告聚合。 +- 从 `beforeRequest`、`onLoad`、`afterLoadRemote`、`errorLoadRemote` 旁听 remote 加载开始、成功、失败和结束,且不返回内容。 +- 从 `loadEntry`、`afterLoadEntry`、snapshot resolve hook 旁听 manifest、remoteEntry 这些阶段。 +- 从 runtime hook 的基础上下文推导 shared / eager 具体原因。 +- 输出到 callback、controller、Node 专用入口文件或显式全局入口。 +- 用户自定义 sanitize 规则。 + +诊断插件可以注册 `errorLoadRemote` 这类已有 hook 做旁听,但必须遵守“不返回内容,不参与恢复”的约束。这样既能复用已有 hook,也不会影响 `@module-federation/retry-plugin` 或用户插件返回兜底结果。`loadEntryError`、`fetch` 这类更贴近重试控制的 hook 暂时不由 diagnostics plugin 接管。 + +`errorLoadShare` 目前只作为 shared 诊断观察 hook,不默认接入 retry-plugin。shared miss、版本不匹配、eager 边界通常不是临时网络错误,默认重试容易掩盖真实配置问题。后续如果要支持 shared retry,应该作为独立可选能力,由用户明确指定可重试条件。 + +## 安全配置和插件配置 + +不建议把所有可观测选项都放进 runtime-core 顶层 `observability`。更合理的边界是: + +- `security.diagnostics`:安全策略,由 runtime-core 识别,作为所有诊断插件必须遵守的上限。 +- `DiagnosticsPlugin(options)`:通用功能配置,由插件控制收集、内存输出、回调和浏览器显式读取。 +- `@module-federation/diagnostics-plugin/node` 的 `DiagnosticsPlugin(options)`:Node 专用入口,额外提供本地文件输出。 + +建议的 `security.diagnostics`: + +```ts +createInstance({ + name: 'host', + security: { + diagnostics: { + enabled: false, + console: true, + browserGlobal: false, + fileOutput: false, + redactUrlQuery: true, + redactUrlHash: true, + maxErrorStackLines: 5, + maxEvents: 100, + redactKeys: ['token', 'secret', 'authorization', 'cookie', 'session'], + }, + }, + plugins: [DiagnosticsPlugin()], +}); +``` + +建议的插件配置: + +```ts +DiagnosticsPlugin({ + level: 'error', + maxEvents: 100, + console: true, + browser: { + enabled: false, + scope: 'host', + }, + onReport(report) {}, +}); +``` + +建议的 Node 插件配置: + +```ts +import { DiagnosticsPlugin } from '@module-federation/diagnostics-plugin/node'; + +DiagnosticsPlugin({ + level: 'error', + maxEvents: 100, + console: true, + directory: '.mf/diagnostics', + onReport(report) {}, +}); +``` + +规则: + +- `security.diagnostics` 是上限,插件不能绕过。 +- 插件可以选择更严格,但不能比 `security.diagnostics` 更宽。 +- 如果 `security.diagnostics.enabled` 为 `false`,插件可以不收集,或只保留最小错误摘要。 +- 如果 `browserGlobal` 为 `false`,插件不能暴露全局读取入口。 +- 如果 `fileOutput` 为 `false`,插件不能写诊断文件。 + +这样既能满足用户选择性添加和定制,也能保证安全边界由核心配置统一约束。 + +## 运行时报错信息收集方案 + +运行时报错信息收集不是简单保存 `Error.message`。它需要把错误发生时的加载阶段、调用上下文和已知配置一起保存下来。 + +### 收集入口 + +第一批应覆盖这些入口: + +- `loadRemote` +- `getRemoteModuleAndOptions` +- `getRemoteEntry` +- `loadEntryScript` +- `loadEntryNode` +- `Module.getEntry` +- `Module.init` +- `Module.get` +- `SharedHandler.loadShare` +- `SharedHandler.loadShareSync` +- `SharedHandler.initializeSharing` +- `SnapshotHandler.getManifestJson` + +这些入口覆盖了 remote 解析、manifest 获取、remoteEntry 加载、remote 初始化、expose 获取和 shared 解析。 + +### 运行时报错事件字段 + +运行时报错事件应当至少包含: + +- `traceId` +- `eventId` +- `timestamp` +- `phase` +- `status: "error"` +- `hostName` +- `hostId` +- `requestId` +- `remoteName` +- `remoteAlias` +- `remoteType` +- `entryGlobalName` +- `sanitizedUrl` +- `expose` +- `shareName` +- `shareScope` +- `requiredVersion` +- `availableVersions` +- `selectedVersion` +- `provider` +- `from` +- `lifecycle` +- `errorCode` +- `errorName` +- `errorMessage` +- `errorStack` +- `ownerHint` +- `retryable` + +`errorStack` 默认只保留当前错误栈的前几行,并且必须经过脱敏。生产环境可以只保留 `errorName` 和 `errorMessage`。 + +### 错误分类规则 + +工程侧应提供确定性的分类规则: + +- `RUNTIME-001`: remoteEntry 已加载但未注册到预期 global,优先指向 remote 产物或 `entryGlobalName` 配置。 +- `RUNTIME-002`: remoteEntry 接口不完整,优先指向 remote 产物。 +- `RUNTIME-003`: manifest 获取或解析失败,优先指向 manifest 地址、网络、manifest 内容结构。 +- `RUNTIME-004`: host 无法匹配 remote,优先指向 host remotes 配置或请求 id。 +- `RUNTIME-005`: 构建运行时同步消费 shared 失败,优先指向 eager / async boundary / shared 配置。 +- `RUNTIME-006`: 纯运行时同步消费 shared 失败,优先指向调用方式或 shared 未就绪。 +- `RUNTIME-008`: remoteEntry 资源加载失败,继续细分为 timeout、network、script execution、unknown。 +- `RUNTIME-011`: manifest 中缺 remoteEntry URL,优先指向 remote manifest 产物。 +- `RUNTIME-012`: shared getter 不可用,优先指向 `shared.import: false` 和 host 未提供对应依赖。 + +### 运行时报告生成 + +当错误发生时,工程代码应做三件事: + +- 记录一条脱敏后的 `error` 事件。 +- 把错误事件和同一 `traceId` 的前序事件聚合成事实报告。 +- 保留现有错误码、错误文案和文档链接,避免破坏用户已有排查路径。 + +报告里不要写“AI 判断”。报告只写工程规则能确认的事实和初步分类。 + +### 存储策略 + +浏览器侧: + +- 默认不启用。 +- 启用后保存在实例内存中。 +- 只有 `exposeGlobal: true` 时才暴露只读入口。 +- 不写 localStorage、sessionStorage、IndexedDB。 + +Node 侧: + +- 默认不启用详细事件。 +- 启用后可以写 `.mf/diagnostics/events.jsonl` 和 `.mf/diagnostics/latest.json`。 +- 文件写入失败不能影响运行。 +- 文件内容必须脱敏。 + +## 构建信息收集方案 + +构建信息的目标不是记录完整构建产物,而是生成一份可以和运行时报错关联的最小摘要。 + +### 收集来源 + +第一批应从这些位置收集: + +- Module Federation 插件原始配置摘要。 +- manifest 输出。 +- stats 输出。 +- normalized remotes。 +- normalized shared。 +- exposes 映射。 +- remoteEntry 文件名和类型。 +- plugin version。 +- bundler name。 +- build version。 + +### 构建信息字段 + +构建摘要建议包含: + +- `buildId` +- `name` +- `bundler` +- `pluginVersion` +- `buildVersion` +- `remoteEntry` +- `remoteEntryType` +- `publicPathMode` +- `remotes` +- `exposes` +- `shared` +- `manifestFile` +- `statsFile` + +`shared` 只保留诊断必要字段: + +- `name` +- `version` +- `requiredVersion` +- `singleton` +- `strictVersion` +- `eager` +- `shareScope` +- `import` + +`remotes` 只保留: + +- `name` +- `alias` +- `entry` +- `type` +- `shareScope` + +`exposes` 只保留 expose key 和脱敏后的请求摘要,不记录源码内容。 + +### 禁止收集的构建信息 + +- 本地绝对路径。 +- 环境变量。 +- 源码内容。 +- loader 完整参数。 +- 插件完整 options 对象。 +- 远端响应内容。 +- 带 token 的 URL。 +- 私有 registry token。 + +如果必须保留路径用于定位,只能保留相对项目根的路径,并需要确认不会泄露用户目录或内部机器信息。 + +### 构建信息产物 + +建议新增一个脱敏构建摘要产物: + +- `.mf/diagnostics/build-info.json` + +它应当可以由构建插件生成,也可以从 manifest / stats 中还原。生成失败不能影响构建。 + +运行时报错报告可以通过以下字段关联构建信息: + +- host `name` +- remote `name` +- `buildVersion` +- `pluginVersion` +- `remoteEntry` +- manifest `id` + +### 构建和运行时关联方式 + +运行时加载 remote 时,优先从 manifest 获取 remote 构建摘要。如果没有 manifest,只记录 remoteEntry 层面的摘要。 + +关联规则: + +- host 侧报告记录 host 构建摘要。 +- remote manifest 可用时,报告记录 remote 构建摘要。 +- shared 报错时,报告同时列出 host 提供摘要和 remote 需求摘要。 +- expose 报错时,报告关联 remote manifest 中的 exposes 摘要。 + +这样 AI coding 可以判断: + +- host 是否配置了这个 remote。 +- remote manifest 是否声明了这个 expose。 +- host 和 remote 的 shared 版本条件是否能匹配。 +- 运行时加载的 remoteEntry 是否来自预期构建。 + +## Roadmap + +### Milestone 1: 前置任务,诊断验证 Demo 和最小闭环 + +状态:已完成。当前 demo、最小插件、运行时 hook、输出入口和验证测试已经形成第一阶段闭环。 + +这一步要先于完整能力建设。它的目标不是做展示页面,而是先建立一个稳定、可重复的验证场景,保证后续每一次加事件、加报告字段、加插件输出,都能在同一套 demo 和测试里验证成功、失败和脱敏结果。 + +实现边界:runtime-core 只负责主流程生命周期 hook,例如 remote 加载成功、失败、manifest / remoteEntry 基础状态和 shared 成功、失败;diagnostics plugin 负责整理事件、推导具体原因和最终加载结论,并输出报告。 + +#### Demo 验证场景 + +- [x] 新增或复用现有 runtime demo,建立一组专用 diagnostics host / remote 场景。 +- [x] demo 支持正常加载 remote 组件,并能触发成功加载事件。 +- [x] demo 支持 remoteEntry / manifest 地址错误场景。 +- [x] demo 支持 remoteEntry globalName 或 expose 不存在场景。 +- [x] demo 支持业务组件主动上报 `component:business-loaded`。 +- [x] demo 支持 shared miss、shared 版本不匹配、eager 配置错误场景。 +- [x] demo 提供稳定的按钮、路由或测试入口,方便 e2e 自动触发。 +- [x] demo 默认不暴露诊断信息,只有显式开启 diagnostics plugin 时才输出。 +- [x] demo 输出必须经过 URL 和错误信息脱敏。 +- [x] demo 作为后续 Phase 的固定验收入口,不为单个实现临时造一次性测试。 + +#### 最小能力闭环 + +- [x] 定义最小 `security.diagnostics` 配置和默认关闭行为。 +- [x] 定义最小事件数据结构。 +- [x] 定义最小报告数据结构。 +- [x] 在 runtime-core 增加最小 lifecycle hook 和必要上下文。 +- [x] 新增最小 `DiagnosticsPlugin`,先只支持内存输出和回调输出。 +- [x] 记录 `loadRemote` 成功和失败。 +- [x] 记录 manifest 获取成功和失败。 +- [x] 记录 remoteEntry 加载成功和失败。 +- [x] 生成最近一次 in-memory report。 +- [x] dev / Node 场景输出固定 console 提示,包含 `traceId`。 +- [x] 确认没有 diagnostics plugin 时现有行为完全不变。 +- [x] 确认 diagnostics 自身异常不会影响 MF 加载。 +- [x] 补充默认关闭、显式开启、URL 脱敏、成功 trace、失败 trace / report 的测试。 + +#### Milestone 1 完成标准 + +- [x] demo 能稳定跑通一次成功加载。 +- [x] demo 能稳定复现至少一种 remoteEntry / manifest 加载失败。 +- [x] 成功和失败都能生成可读取的脱敏 report。 +- [x] AI 或人能从 console 中拿到 `traceId`,再读取对应 report。 +- [x] 默认不启用 diagnostics 时,demo 和现有测试行为不变。 +- [x] 这一步完成后,再进入后续 Phase 0 - Phase 10 的完整能力建设。 + +### Phase 0: 安全边界和数据模型 + +- [ ] 定义 `security.diagnostics` 安全策略和默认值。 +- [ ] 定义 `DiagnosticsPlugin` 插件配置和默认值。 +- [ ] 定义事件字段白名单。 +- [ ] 定义 URL 和错误消息脱敏规则。 +- [ ] 定义事件时间线数据结构。 +- [ ] 定义诊断报告数据结构。 +- [ ] 定义运行时报错事件数据结构。 +- [ ] 定义构建信息摘要数据结构。 +- [ ] 明确浏览器和 Node 的存储边界。 +- [ ] 明确运行时报错和构建信息的字段白名单。 +- [ ] 明确运行时报错和构建信息的禁止字段。 +- [ ] 明确插件配置不能绕过 `security.diagnostics`。 +- [ ] 补充默认不开启、开启后脱敏的测试。 + +### Phase 1: runtime-core 事件 hook 底座 + +- [x] 在 runtime-core 中增加最小 lifecycle hook,供插件旁听主流程状态。 +- [x] 复用 `onLoad`、`errorLoadRemote`,供 diagnostics plugin 观察 remote 加载,不影响 retry / fallback。 +- [x] 给关键 hook 补齐 `phase`、`requestId`、`remoteInfo`、`shareInfo` 等必要上下文。 +- [x] 记录 `loadRemote` 开始、成功、失败。 +- [x] 记录 `loadRemote` complete 事件,用于统一收口成功和失败。 +- [ ] 记录 remote 匹配结果。 +- [x] 记录 manifest 获取开始、成功、失败。 +- [x] 记录 remoteEntry 加载开始、成功、失败。 +- [ ] 记录 remoteEntry init 开始、成功、失败。 +- [ ] 记录 expose 获取开始、成功、失败。 +- [ ] 记录阶段耗时和总耗时。 +- [x] 记录 cache、retry、fallback 的基础结果标记。 +- [x] 确认没有 diagnostics plugin 时行为完全不变。 +- [x] 确认 lifecycle hook 失败不会影响 MF 加载。 +- [x] 确认 diagnostics plugin 旁听 `errorLoadRemote` 时不接管返回路径。 +- [x] 补充 runtime-core 单测。 + +### Phase 2: diagnostics plugin 输出能力 + +- [x] 新建 `@module-federation/diagnostics-plugin`。 +- [x] 提供插件 controller:`getEvents()`、`getLatestReport()`、`clear()`。 +- [x] 提供 `onEvent` 和 `onReport` 回调。 +- [x] 支持内存输出。 +- [x] 通过 Node 专用入口支持显式开启的 Node 本地文件输出。 +- [x] 支持显式开启的浏览器只读全局入口。 +- [x] 确认插件输出受 `security.diagnostics` 约束。 +- [x] 补充插件配置和输出单测。 + +### Phase 3: 加载成功信息收集 + +- [x] 收集加载成功 summary。 +- [ ] 收集 manifest 成功、缓存命中和耗时。 +- [ ] 收集 remoteEntry 成功、新加载或复用已有 global。 +- [ ] 收集 remoteEntry init 成功和耗时。 +- [ ] 收集 expose factory 获取成功和耗时。 +- [ ] 收集 expose module 执行成功和耗时。 +- [ ] 收集 shared 命中的 provider、版本和 shareScope。 +- [ ] 收集 retry / fallback / cache 标记。 +- [x] 定义固定事件 `component:business-loaded`。 +- [x] 提供 `markComponentLoaded` 业务调用接口。 +- [x] 将业务组件成功加载事件关联到同一 `traceId`。 +- [ ] 确认业务 metadata 经过 sanitize 后才能进入报告。 +- [ ] 支持 `level: "summary"` 和 `level: "verbose"` 的成功记录策略。 +- [x] 补充成功链路单测。 + +### Phase 4: 运行时报错信息收集 + +- [ ] 接入统一运行时报错事件记录。 +- [ ] 收集错误码、错误名称、脱敏错误消息。 +- [ ] 收集当前加载阶段和生命周期。 +- [ ] 收集 host、remote、requestId、expose、shared 摘要。 +- [ ] 对错误栈做脱敏和长度限制。 +- [ ] 为 `RUNTIME-001` 增加 remoteEntry global 相关上下文。 +- [ ] 为 `RUNTIME-003` 增加 manifest URL、remote 名称、解析阶段上下文。 +- [ ] 为 `RUNTIME-004` 增加 host remotes 摘要和请求 id。 +- [ ] 为 `RUNTIME-008` 增加 timeout、network、script execution 分类。 +- [ ] 确认错误收集失败不会覆盖原始错误。 +- [ ] 补充运行时报错收集单测。 + +### Phase 5: shared / eager 可观测 + +- [x] 记录 shared 解析开始。 +- [x] 记录 host 当前提供的 shared 摘要。 +- [x] 记录最终命中的 shared 提供方和版本。 +- [x] 记录 shared miss。 +- [x] 记录 eager 相关同步加载失败。 +- [x] 标记 shared 问题的初步责任方。 +- [x] 确认 `errorLoadShare` 只做诊断观察,不默认接入 retry-plugin。 +- [x] 补充 shared、eager、strictVersion、singleton 场景测试。 + +### Phase 6: 构建信息收集 + +- [ ] 从构建插件配置生成脱敏配置摘要。 +- [ ] 从 manifest / stats 生成脱敏构建摘要。 +- [ ] 收集 bundler name、plugin version、build version。 +- [ ] 收集 remoteEntry 文件名、类型和 publicPath 模式。 +- [ ] 收集 remotes 摘要。 +- [ ] 收集 exposes 摘要。 +- [ ] 收集 shared 摘要。 +- [ ] 生成 `.mf/diagnostics/build-info.json`。 +- [ ] 确认构建信息生成失败不影响构建。 +- [ ] 补充构建信息脱敏测试。 + +### Phase 7: 构建信息和运行时关联 + +- [ ] 在 manifest / stats 中确认已有字段是否足够。 +- [ ] 补齐必要的 shared、exposes、remotes、插件版本、构建版本摘要。 +- [ ] 运行时报告关联 remote manifest 信息。 +- [ ] 构建侧错误也输出同一类诊断报告。 +- [ ] shared 报错时同时展示 host 提供摘要和 remote 需求摘要。 +- [ ] expose 报错时关联 remote manifest exposes 摘要。 +- [ ] remoteEntry 报错时关联 remoteEntry 类型、globalName 和构建版本。 +- [ ] 避免把本地绝对路径、源码、环境变量写入可导出报告。 + +### Phase 8: 报告生成 + +- [x] 从 trace 生成成功 summary。 +- [ ] 从 trace 生成事实报告。 +- [ ] 将现有 RUNTIME 错误接入报告。 +- [ ] 将 `RUNTIME-001` 报告关联 remoteEntry globalName。 +- [ ] 将 `RUNTIME-003` 报告关联 manifest URL 和 remote 名称。 +- [ ] 将 `RUNTIME-004` 报告关联 host remotes 列表摘要。 +- [ ] 将 `RUNTIME-005` / `RUNTIME-006` 报告关联 shared 和 eager 信息。 +- [ ] 将 `RUNTIME-008` 报告区分网络、超时、执行失败。 +- [ ] 保留原始错误码和现有文档链接。 + +### Phase 9: AI coding 入口 + +- [ ] 提供稳定的读取入口。 +- [x] Node / SSR / build 场景输出固定格式 console 提示。 +- [x] Node / SSR / build 场景写入 `.mf/diagnostics/latest.json`。 +- [x] Node / SSR / build 场景追加 `.mf/diagnostics/events.jsonl`。 +- [x] 浏览器 dev 场景输出可被 CDP 识别的固定格式 console 提示。 +- [x] 浏览器 dev 场景支持显式开启的只读全局入口。 +- [ ] 浏览器 prod 场景默认只输出最小错误码和 `traceId`。 +- [ ] 浏览器 prod 场景完整报告只允许通过显式导出或用户自有系统获取。 +- [ ] 明确 `latest.json` 和 `events.jsonl` 的格式和读取顺序。 +- [x] 提供最近一次加载报告。 +- [ ] 提供最近 N 次加载报告。 +- [x] 支持按 `traceId` 读取成功或失败报告。 +- [ ] 支持按 remote / expose / shared 查询最近加载记录。 +- [ ] 提供脱敏导出方法。 +- [x] Node 侧通过 Node 专用入口支持本地诊断文件。 +- [x] 浏览器侧支持显式开启的内存读取。 +- [ ] 给 AI 消费格式写示例。 + +### Phase 10: 文档和场景验证 + +- [ ] 文档说明如何开启安全可观测。 +- [ ] 文档说明导出的报告不包含哪些敏感信息。 +- [ ] 增加成功加载场景。 +- [ ] 增加成功但 retry 后恢复场景。 +- [ ] 增加成功但 fallback 生效场景。 +- [ ] 增加成功但 shared 命中非预期 provider 场景。 +- [x] 增加业务主动标记 `component:business-loaded` 场景。 +- [ ] 增加 remote URL 错误场景。 +- [ ] 增加 remoteEntry globalName 错误场景。 +- [ ] 增加 manifest 缺字段场景。 +- [ ] 增加 remoteEntry 自身执行错误场景。 +- [ ] 增加 host 未提供 shared 场景。 +- [ ] 增加 shared 版本不满足场景。 +- [ ] 增加 eager 配错场景。 + +## 第一批建议落地范围 + +第一批就是 Milestone 1,不直接做完整闭环。 + +优先顺序是: + +- 先做 diagnostics demo,确保后续改动都有固定验证入口。 +- 再做最小安全配置、最小事件、最小报告和最小插件输出。 +- 先覆盖 remote / manifest / remoteEntry 的成功和失败。 +- 先保证默认关闭、显式开启、URL 脱敏、记录失败不影响加载。 + +第一批完成后,再接 shared / eager、构建信息关联、浏览器生产环境导出等更完整能力。这样可以先把可验证底座稳定下来,避免一开始把诊断逻辑和所有错误点耦合在一起。 + +## 验收标准 + +- 默认不开启时,现有行为不变。 +- 有固定 demo 可以验证后续诊断能力。 +- 开启后可以读取脱敏后的加载事件。 +- 成功加载时可以读取加载 summary。 +- 失败报告可以指出失败阶段。 +- 报告不包含 query、hash、header、cookie、token、源码、remote 响应体。 +- trace 逻辑异常不会影响 MF 加载。 +- runtime-core 相关测试通过。 +- 构建通过。 diff --git a/apps/runtime-demo/3005-runtime-host/cypress/e2e/app.cy.ts b/apps/runtime-demo/3005-runtime-host/cypress/e2e/app.cy.ts index 4383ea93ec1..0cd95166738 100644 --- a/apps/runtime-demo/3005-runtime-host/cypress/e2e/app.cy.ts +++ b/apps/runtime-demo/3005-runtime-host/cypress/e2e/app.cy.ts @@ -1,5 +1,8 @@ import { getH1, getH3 } from '../support/app.po'; +const getDiagnosticsReader = (win: Cypress.AUTWindow) => + (win as any).__FEDERATION__?.__DIAGNOSTICS__?.runtime_host; + describe('3005-runtime-host/', () => { beforeEach(() => cy.visit('/')); @@ -77,4 +80,137 @@ describe('3005-runtime-host/', () => { }); }); }); + + describe('diagnostics demo fixture', () => { + beforeEach(() => cy.visit('/diagnostics')); + + it('should expose a successful remote loading scenario', () => { + cy.get('[data-testid="diagnostics-load-success"]').click(); + cy.get('[data-testid="diagnostics-load-status"]').contains('success'); + cy.get('[data-testid="diagnostics-remote-result"]') + .find('button.test-remote2') + .contains('Button from antd@4.24.15'); + cy.window().then((win) => { + const reader = getDiagnosticsReader(win); + expect(reader).to.exist; + + const latestReport = reader.getLatestReport(); + expect(latestReport.status).to.equal('success'); + expect(latestReport.summary.runtimeLoaded).to.equal(true); + expect(latestReport.summary.loadCompleted).to.equal(true); + expect(latestReport.summary.componentLoaded).to.equal(false); + expect(latestReport.summary.outcome).to.equal('runtime-loaded'); + }); + cy.get('[data-testid="diagnostics-business-loaded"]').click(); + cy.get('[data-testid="diagnostics-report"]').contains( + 'component:business-loaded', + ); + cy.window().then((win) => { + const reader = getDiagnosticsReader(win); + expect(reader).to.exist; + + const latestReport = reader.getLatestReport(); + expect(latestReport.status).to.equal('success'); + expect(latestReport.summary.componentLoaded).to.equal(true); + expect(latestReport.summary.outcome).to.equal('component-loaded'); + expect(reader.getReport(latestReport.traceId).traceId).to.equal( + latestReport.traceId, + ); + }); + }); + + it('should expose a failed remote loading scenario', () => { + cy.window().then((win) => { + cy.spy(win.console, 'warn').as('diagnosticsWarn'); + }); + cy.get('[data-testid="diagnostics-load-missing-expose"]').click(); + cy.get('[data-testid="diagnostics-load-status"]').contains('error'); + cy.get('[data-testid="diagnostics-report"]').contains( + 'dynamic-remote/__missing_expose__', + ); + cy.get('[data-testid="diagnostics-error-message"]').should( + 'not.contain', + 'token=', + ); + cy.get('@diagnosticsWarn').should( + 'have.been.calledWithMatch', + /Diagnostic report generated[\s\S]*traceId: mf-/, + ); + cy.window().then((win) => { + const reader = getDiagnosticsReader(win); + const latestReport = reader.getLatestReport(); + expect(latestReport.status).to.equal('error'); + expect(latestReport.traceId).to.match(/^mf-/); + expect(latestReport.summary.loadCompleted).to.equal(true); + expect(latestReport.summary.outcome).to.equal('failed'); + expect(reader.getReport(latestReport.traceId).failedPhase).to.exist; + }); + }); + + it('should expose a sanitized manifest failure scenario', () => { + cy.get('[data-testid="diagnostics-load-broken-manifest"]').click(); + cy.get('[data-testid="diagnostics-load-status"]').contains('error'); + cy.get('[data-testid="diagnostics-report"]').contains( + 'diagnostics-broken-remote/Button', + ); + cy.get('[data-testid="diagnostics-report"]') + .should('not.contain', 'demo-secret') + .should('not.contain', 'token=') + .should('not.contain', '#hash'); + cy.get('[data-testid="diagnostics-error-message"]') + .contains('/diagnostics-missing/mf-manifest.json') + .should('not.contain', 'demo-secret') + .should('not.contain', 'token=') + .should('not.contain', '#hash'); + }); + + it('should expose a shared miss diagnostic scenario', () => { + cy.window().then((win) => { + cy.spy(win.console, 'warn').as('diagnosticsWarn'); + }); + cy.get('[data-testid="diagnostics-shared-miss"]').click(); + cy.get('[data-testid="diagnostics-load-status"]').contains('error'); + cy.get('[data-testid="diagnostics-report"]') + .should('contain', 'diagnostics-missing-shared') + .should('contain', 'missing-provider'); + cy.get('@diagnosticsWarn').should( + 'have.been.calledWithMatch', + /Diagnostic report generated[\s\S]*traceId: mf-/, + ); + cy.window().then((win) => { + const latestReport = getDiagnosticsReader(win).getLatestReport(); + expect(latestReport.status).to.equal('error'); + expect(latestReport.shared.reason).to.equal('missing-provider'); + }); + }); + + it('should expose a shared version mismatch diagnostic scenario', () => { + cy.get('[data-testid="diagnostics-shared-version-mismatch"]').click(); + cy.get('[data-testid="diagnostics-load-status"]').contains('error'); + cy.get('[data-testid="diagnostics-report"]') + .should('contain', '"name": "react"') + .should('contain', '^99.0.0') + .should('contain', 'version-mismatch'); + cy.window().then((win) => { + const latestReport = getDiagnosticsReader(win).getLatestReport(); + expect(latestReport.status).to.equal('error'); + expect(latestReport.shared.reason).to.equal('version-mismatch'); + expect(latestReport.shared.availableVersions).to.include('18.3.1'); + }); + }); + + it('should expose an eager config diagnostic scenario', () => { + cy.get('[data-testid="diagnostics-eager-config-error"]').click(); + cy.get('[data-testid="diagnostics-load-status"]').contains('error'); + cy.get('[data-testid="diagnostics-report"]') + .should('contain', 'diagnostics-async-shared') + .should('contain', 'sync-async-boundary') + .should('contain', 'RUNTIME-005'); + cy.window().then((win) => { + const latestReport = getDiagnosticsReader(win).getLatestReport(); + expect(latestReport.status).to.equal('error'); + expect(latestReport.shared.reason).to.equal('sync-async-boundary'); + }); + }); + }); }); diff --git a/apps/runtime-demo/3005-runtime-host/package.json b/apps/runtime-demo/3005-runtime-host/package.json index c4e4f70b641..177b17c26d6 100644 --- a/apps/runtime-demo/3005-runtime-host/package.json +++ b/apps/runtime-demo/3005-runtime-host/package.json @@ -4,6 +4,7 @@ "version": "0.0.0", "devDependencies": { "@module-federation/core": "workspace:*", + "@module-federation/diagnostics-plugin": "workspace:*", "@module-federation/runtime": "workspace:*", "@module-federation/typescript": "workspace:*", "@module-federation/enhanced": "workspace:*", @@ -26,7 +27,7 @@ "serve": "NODE_OPTIONS=--max_old_space_size=4096 pnpm exec webpack-cli serve --config webpack.config.js --mode production --port 3005 --no-hot", "serve:development": "NODE_OPTIONS=--max_old_space_size=4096 pnpm exec webpack-cli serve --config webpack.config.js --mode development --port 3005", "serve:production": "NODE_OPTIONS=--max_old_space_size=4096 pnpm exec webpack-cli serve --config webpack.config.js --mode production --port 3005 --no-hot", - "lint": "ESLINT_USE_FLAT_CONFIG=false pnpm exec eslint --no-error-on-unmatched-pattern --ignore-pattern node_modules **/*.{ts,tsx,js,jsx}", + "lint": "ESLINT_USE_FLAT_CONFIG=false pnpm exec eslint --no-error-on-unmatched-pattern --ignore-pattern node_modules src/**/*.{ts,tsx,js,jsx} cypress/**/*.{ts,tsx,js,jsx} cypress.config.ts webpack.config.js remotes.d.ts @mf-types/**/*.ts", "serve-static": "pnpm exec serve dist -l 3005 --cors", "e2e": "pnpm exec cypress run --project . --e2e --config baseUrl=http://127.0.0.1:3005 --browser chrome", "e2e:development": "pnpm exec cypress open --project . --e2e --config baseUrl=http://127.0.0.1:3005 --browser electron", diff --git a/apps/runtime-demo/3005-runtime-host/src/App.tsx b/apps/runtime-demo/3005-runtime-host/src/App.tsx index 2cf2933f74e..bbf64f1eba8 100644 --- a/apps/runtime-demo/3005-runtime-host/src/App.tsx +++ b/apps/runtime-demo/3005-runtime-host/src/App.tsx @@ -1,6 +1,6 @@ -import React, { lazy } from 'react'; -import { loadRemote } from '@module-federation/runtime'; +import React from 'react'; import { Link, Routes, Route, BrowserRouter } from 'react-router-dom'; +import DiagnosticsDemo from './DiagnosticsDemo'; import Root from './Root'; import Remote1 from './Remote1'; import Remote2 from './Remote2'; @@ -18,11 +18,15 @@ const App = () => (
  • remote2
  • +
  • + diagnostics +
  • } /> } /> } /> + } /> ); diff --git a/apps/runtime-demo/3005-runtime-host/src/DiagnosticsDemo.tsx b/apps/runtime-demo/3005-runtime-host/src/DiagnosticsDemo.tsx new file mode 100644 index 00000000000..d1d957c2edb --- /dev/null +++ b/apps/runtime-demo/3005-runtime-host/src/DiagnosticsDemo.tsx @@ -0,0 +1,330 @@ +import React, { useCallback, useState } from 'react'; +import { + loadRemote, + loadShare, + loadShareSync, + registerRemotes, +} from '@module-federation/runtime'; +import { diagnostics } from './diagnostics'; + +type LoadStatus = 'idle' | 'loading' | 'success' | 'error'; + +type RemoteComponent = React.ComponentType>; + +const successRequest = 'dynamic-remote/ButtonOldAnt'; +const missingExposeRequest = 'dynamic-remote/__missing_expose__'; +const brokenManifestEntry = + 'http://127.0.0.1:3005/diagnostics-missing/mf-manifest.json?token=demo-secret#hash'; +const brokenManifestRequest = 'diagnostics-broken-remote/Button'; +function sanitizeErrorMessage(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + + return message + .replace(/https?:\/\/[^\s'"<>]+/g, (url) => { + try { + const parsedUrl = new URL(url); + return `${parsedUrl.origin}${parsedUrl.pathname}`; + } catch { + return '[redacted-url]'; + } + }) + .replace( + /\b(token|authorization|cookie|secret|password)=([^&\s]+)/gi, + '$1=[redacted]', + ); +} + +function resolveRemoteComponent(remoteModule: unknown): RemoteComponent | null { + if (typeof remoteModule === 'function') { + return remoteModule as RemoteComponent; + } + + if (remoteModule && typeof remoteModule === 'object') { + const candidate = (remoteModule as { default?: unknown }).default; + + if (typeof candidate === 'function') { + return candidate as RemoteComponent; + } + } + + return null; +} + +export default function DiagnosticsDemo() { + const [status, setStatus] = useState('idle'); + const [errorMessage, setErrorMessage] = useState(''); + const [remoteComponent, setRemoteComponent] = + useState(null); + const [reportText, setReportText] = useState('null'); + + const refreshReport = useCallback(() => { + setReportText( + JSON.stringify(diagnostics.getLatestReport() ?? null, null, 2), + ); + }, []); + + const loadSuccessRemote = useCallback(async () => { + setStatus('loading'); + setErrorMessage(''); + setRemoteComponent(null); + + try { + const remoteModule = await loadRemote(successRequest); + const component = resolveRemoteComponent(remoteModule); + + if (!component) { + throw new Error(`Remote module ${successRequest} has no component`); + } + + setRemoteComponent(() => component); + setStatus('success'); + } catch (error) { + const message = sanitizeErrorMessage(error); + setStatus('error'); + setErrorMessage(message); + } finally { + refreshReport(); + } + }, [refreshReport]); + + const loadMissingExpose = useCallback(async () => { + setStatus('loading'); + setErrorMessage(''); + setRemoteComponent(null); + + try { + const remoteModule = await loadRemote(missingExposeRequest); + + if (!remoteModule) { + throw new Error(`Remote module ${missingExposeRequest} returned empty`); + } + + throw new Error( + `Remote module ${missingExposeRequest} unexpectedly loaded`, + ); + } catch (error) { + const message = sanitizeErrorMessage(error); + setStatus('error'); + setErrorMessage(message); + } finally { + refreshReport(); + } + }, [refreshReport]); + + const loadBrokenManifest = useCallback(async () => { + setStatus('loading'); + setErrorMessage(''); + setRemoteComponent(null); + + registerRemotes( + [ + { + name: 'diagnostics_broken_remote', + alias: 'diagnostics-broken-remote', + entry: brokenManifestEntry, + }, + ], + { force: true }, + ); + + try { + await loadRemote(brokenManifestRequest); + throw new Error( + `Remote module ${brokenManifestRequest} unexpectedly loaded`, + ); + } catch (error) { + const message = sanitizeErrorMessage(`${brokenManifestEntry} ${error}`); + setStatus('error'); + setErrorMessage(message); + } finally { + refreshReport(); + } + }, [refreshReport]); + + const loadSharedMiss = useCallback(async () => { + setStatus('loading'); + setErrorMessage(''); + setRemoteComponent(null); + + try { + const result = await loadShare('diagnostics-missing-shared', { + customShareInfo: { + version: '1.0.0', + scope: ['diagnostics-missing-scope'], + shareConfig: { + requiredVersion: '^1.0.0', + singleton: false, + }, + }, + }); + + if (result === false) { + throw new Error( + 'Shared miss: diagnostics-missing-shared was not provided by host', + ); + } + + throw new Error('Shared miss scenario unexpectedly loaded'); + } catch (error) { + const message = sanitizeErrorMessage(error); + setStatus('error'); + setErrorMessage(message); + } finally { + refreshReport(); + } + }, [refreshReport]); + + const loadSharedVersionMismatch = useCallback(async () => { + setStatus('loading'); + setErrorMessage(''); + setRemoteComponent(null); + + try { + const result = await loadShare('react', { + customShareInfo: { + shareConfig: { + requiredVersion: '^99.0.0', + singleton: false, + }, + }, + }); + + if (result === false) { + throw new Error('Shared version mismatch: react needs ^99.0.0'); + } + + throw new Error('Shared version mismatch scenario unexpectedly loaded'); + } catch (error) { + const message = sanitizeErrorMessage(error); + setStatus('error'); + setErrorMessage(message); + } finally { + refreshReport(); + } + }, [refreshReport]); + + const loadEagerConfigError = useCallback(() => { + setStatus('loading'); + setErrorMessage(''); + setRemoteComponent(null); + + try { + loadShareSync('diagnostics-async-shared', { + from: 'build', + customShareInfo: { + version: '1.0.0', + scope: ['default'], + shareConfig: { + requiredVersion: '^1.0.0', + singleton: false, + eager: false, + strictVersion: false, + }, + get: () => + Promise.resolve(() => ({ + value: 'async shared should not be consumed synchronously', + })), + }, + }); + + throw new Error('Eager config scenario unexpectedly loaded'); + } catch (error) { + const message = sanitizeErrorMessage(error); + setStatus('error'); + setErrorMessage(message); + } finally { + refreshReport(); + } + }, [refreshReport]); + + const markBusinessLoaded = useCallback(() => { + diagnostics.markComponentLoaded({ + requestId: successRequest, + componentName: 'ButtonOldAnt', + }); + refreshReport(); + }, [refreshReport]); + + const LoadedRemote = remoteComponent; + + return ( +
    +

    Diagnostics Demo

    + +
    +

    Load Remote

    + + + + +
    + +
    +

    Shared / Eager Scenarios

    + + + +
    + +
    +

    Status

    +

    {status}

    + {errorMessage ? ( +
    {errorMessage}
    + ) : null} + {LoadedRemote ? ( +
    + +
    + ) : null} +
    + +
    +

    Report Fixture

    +
    {reportText}
    +
    +
    + ); +} diff --git a/apps/runtime-demo/3005-runtime-host/src/bootstrap.tsx b/apps/runtime-demo/3005-runtime-host/src/bootstrap.tsx index 3f87d871a13..96f5759d42f 100644 --- a/apps/runtime-demo/3005-runtime-host/src/bootstrap.tsx +++ b/apps/runtime-demo/3005-runtime-host/src/bootstrap.tsx @@ -1,13 +1,20 @@ import React, { StrictMode } from 'react'; -import { - init, - registerGlobalPlugins, -} from '@module-federation/enhanced/runtime'; +import { init } from '@module-federation/enhanced/runtime'; import * as ReactDOM from 'react-dom/client'; import App from './App'; +import { diagnostics } from './diagnostics'; init({ name: 'runtime_host', + security: { + diagnostics: { + enabled: true, + maxEvents: 100, + console: true, + browserGlobal: true, + }, + }, + plugins: [diagnostics.plugin], remotes: [ { name: 'runtime_remote2', diff --git a/apps/runtime-demo/3005-runtime-host/src/components/ButtonOldAnt.tsx b/apps/runtime-demo/3005-runtime-host/src/components/ButtonOldAnt.tsx index c1552882ff5..bad5b1a4610 100644 --- a/apps/runtime-demo/3005-runtime-host/src/components/ButtonOldAnt.tsx +++ b/apps/runtime-demo/3005-runtime-host/src/components/ButtonOldAnt.tsx @@ -1,9 +1,7 @@ import Button from 'antd/lib/button'; -import antdPackage from 'antd/package.json'; +import version from 'antd/lib/version'; import stuff from './stuff.module.css'; -const { version } = antdPackage; - export default function ButtonOldAnt() { return ; } diff --git a/apps/runtime-demo/3005-runtime-host/src/diagnostics.ts b/apps/runtime-demo/3005-runtime-host/src/diagnostics.ts new file mode 100644 index 00000000000..8b6a5bab21d --- /dev/null +++ b/apps/runtime-demo/3005-runtime-host/src/diagnostics.ts @@ -0,0 +1,10 @@ +import { DiagnosticsPlugin } from '@module-federation/diagnostics-plugin'; + +export const diagnostics = DiagnosticsPlugin({ + level: 'verbose', + maxEvents: 100, + browser: { + enabled: true, + scope: 'runtime_host', + }, +}); diff --git a/apps/runtime-demo/3005-runtime-host/src/index.ts b/apps/runtime-demo/3005-runtime-host/src/index.ts index 51ffb285cfc..8cae17419e4 100644 --- a/apps/runtime-demo/3005-runtime-host/src/index.ts +++ b/apps/runtime-demo/3005-runtime-host/src/index.ts @@ -6,4 +6,4 @@ import customPlugin from './runtimePlugin'; registerGlobalPlugins([customPlugin()]); -require('./bootstrap'); +void import('./bootstrap'); diff --git a/apps/runtime-demo/3005-runtime-host/webpack.config.js b/apps/runtime-demo/3005-runtime-host/webpack.config.js index dce5c186906..1b0615bd367 100644 --- a/apps/runtime-demo/3005-runtime-host/webpack.config.js +++ b/apps/runtime-demo/3005-runtime-host/webpack.config.js @@ -1,3 +1,5 @@ +/* eslint-env node */ + const path = require('path'); const reactPath = path.dirname(require.resolve('react/package.json')); const reactDomPath = path.dirname(require.resolve('react-dom/package.json')); diff --git a/apps/runtime-demo/README.md b/apps/runtime-demo/README.md index 81cca8c5617..114d9c67de7 100644 --- a/apps/runtime-demo/README.md +++ b/apps/runtime-demo/README.md @@ -10,8 +10,61 @@ host declare remote2 in webpack.config.js, and use `@module-federation/runtime` # Running Demo -Run `npm run app:runtime:dev` to start host, remote1, remote2 +Run `pnpm run app:runtime:dev` to start host, remote1, remote2. - host: [localhost:3005](http://localhost:3005/) - remote1: [localhost:3006](http://localhost:3006/) - remote2: [localhost:3007](http://localhost:3007/) + +## Diagnostics Demo + +The diagnostics fixture lives in the runtime host. The host explicitly enables +`security.diagnostics` and installs `@module-federation/diagnostics-plugin` for +this demo, so the report is generated by the runtime plugin instead of local UI +state. + +- diagnostics page: + [localhost:3005/diagnostics](http://localhost:3005/diagnostics) + +Start the demo first: + +```bash +pnpm run app:runtime:dev +``` + +Then open the diagnostics page and use these controls: + +- `Load success remote`: loads `dynamic-remote/ButtonOldAnt` from remote2. The + status should become `success`, the remote button should render, and the + report should include a completed `loadRemote` timeline with + `summary.outcome: "runtime-loaded"`. The same report is also available at + `window.__FEDERATION__.__DIAGNOSTICS__.runtime_host.getLatestReport()`. +- `Load missing expose`: loads a missing expose from remote2. The status should + become `error`, and the report should include + `dynamic-remote/__missing_expose__`. The browser console should print a + diagnostic hint with a `traceId`. +- `Load broken manifest`: loads a remote with a broken manifest URL. The status + should become `error`, and the shown error should keep the manifest pathname + while removing query, token, and hash data. +- `Mark business loaded`: calls the diagnostics plugin's component success API, + and the report should include `component:business-loaded` with + `summary.outcome: "component-loaded"`. +- `Shared miss`: triggers a missing shared provider report. The report should + include `diagnostics-missing-shared` and `missing-provider`. +- `Shared version mismatch`: asks for an unsupported React version. The report + should include `react`, `^99.0.0`, the available version, and + `version-mismatch`. +- `Eager config error`: synchronously consumes an async shared dependency. The + report should include `diagnostics-async-shared`, `RUNTIME-005`, and + `sync-async-boundary`. + +Run the automated verification: + +```bash +pnpm run ci:local --only=e2e-runtime +``` + +This command installs the e2e dependencies if needed, builds the packages, +starts the host and remotes, and runs the Cypress checks. The diagnostics +fixture is covered by the `diagnostics demo fixture` tests in the runtime host +e2e suite. diff --git a/packages/diagnostics-plugin/.eslintrc.json b/packages/diagnostics-plugin/.eslintrc.json new file mode 100644 index 00000000000..2c7d3866f19 --- /dev/null +++ b/packages/diagnostics-plugin/.eslintrc.json @@ -0,0 +1,27 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": [ + "!**/*", + "**/vite.config.*.timestamp*", + "**/vitest.config.*.timestamp*" + ], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.json"], + "parser": "jsonc-eslint-parser", + "rules": {} + } + ] +} diff --git a/packages/diagnostics-plugin/README.md b/packages/diagnostics-plugin/README.md new file mode 100644 index 00000000000..4d50b8a8aa4 --- /dev/null +++ b/packages/diagnostics-plugin/README.md @@ -0,0 +1,122 @@ +# @module-federation/diagnostics-plugin + +Runtime diagnostics plugin for Module Federation loading flows. + +This package is currently the minimal diagnostics foundation. It records +sanitized in-memory loading events only when `security.diagnostics.enabled` is +set on the runtime instance. + +```ts +import { createInstance } from '@module-federation/runtime'; +import { DiagnosticsPlugin } from '@module-federation/diagnostics-plugin'; + +const diagnostics = DiagnosticsPlugin({ + level: 'verbose', + browser: { + enabled: true, + scope: 'host', + }, +}); + +createInstance({ + name: 'host', + security: { + diagnostics: { + enabled: true, + maxEvents: 100, + browserGlobal: true, + }, + }, + plugins: [diagnostics.plugin], + remotes: [], +}); + +const report = diagnostics.getLatestReport(); +``` + +The plugin does not upload data or expose a browser global by default. Reports +are kept in memory and URL query/hash data is removed before it is returned. +The runtime only exposes the loading lifecycle hooks needed to know whether the +main flow started, succeeded, or failed. This plugin listens to those hooks, +derives detailed reasons like shared version mismatch or eager boundary issues, +and exposes the final loading state through a small `summary` object: + +- `runtime-loaded`: Module Federation finished loading the remote module. +- `component-loaded`: business code called `markComponentLoaded`. +- `failed`: the load failed and `failedPhase` points to the first specific + failing phase. +- `recovered`: loading hit an error but a fallback/recovery path returned a + result. + +For remote loading, the plugin listens to runtime lifecycle hooks such as +`beforeRequest`, `onLoad`, `afterLoadRemote`, `errorLoadRemote`, `loadEntry`, +`afterLoadEntry`, and snapshot resolve hooks. It does not return a value from +observer hooks, so it does not change fallback or retry results from plugins +such as `@module-federation/retry-plugin`. + +`errorLoadShare` is used only for diagnostics. Shared dependency miss, version +mismatch, and eager boundary errors are not retried by the retry plugin by +default because they are usually configuration or availability problems instead +of transient network failures. + +Business code can mark its own success condition with a fixed event: + +```ts +diagnostics.markComponentLoaded({ + requestId: 'remote/Button', + componentName: 'Button', +}); +``` + +This records `component:business-loaded` on the same trace when possible. + +Optional outputs are gated twice: + +- Plugin options request the output. +- `security.diagnostics` must allow the output. + +When both sides allow browser output, the report can be read from: + +```ts +window.__FEDERATION__.__DIAGNOSTICS__.host.getLatestReport(); +window.__FEDERATION__.__DIAGNOSTICS__.host.getReport('mf-trace-id'); +``` + +Node file output is provided by the Node-specific entry: + +```ts +import { createInstance } from '@module-federation/runtime'; +import { DiagnosticsPlugin } from '@module-federation/diagnostics-plugin/node'; + +const diagnostics = DiagnosticsPlugin({ + level: 'verbose', + directory: '.mf/diagnostics', +}); + +createInstance({ + name: 'host', + security: { + diagnostics: { + enabled: true, + fileOutput: true, + }, + }, + plugins: [diagnostics.plugin], + remotes: [], +}); +``` + +When both sides allow Node file output, the Node entry writes: + +- `.mf/diagnostics/latest.json`: the latest sanitized report. +- `.mf/diagnostics/events.jsonl`: one sanitized event per line. + +On errors, the plugin prints a small console hint with the `traceId` and the +available read path. The console hint is intentionally small and does not carry +the full report. The default browser/runtime entry does not include Node file +output code. + +Shared dependency reports include only diagnostic fields such as package name, +share scope, requested version, available versions, selected provider, and a +reason like `missing-provider`, `version-mismatch`, or `sync-async-boundary`. +They do not include shared factories, module values, source, or business data. diff --git a/packages/diagnostics-plugin/__tests__/diagnostics.spec.ts b/packages/diagnostics-plugin/__tests__/diagnostics.spec.ts new file mode 100644 index 00000000000..d2de09ff5ed --- /dev/null +++ b/packages/diagnostics-plugin/__tests__/diagnostics.spec.ts @@ -0,0 +1,774 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import { DiagnosticsPlugin } from '../src'; +import { DiagnosticsPlugin as DiagnosticsNode } from '../src/node'; + +const enabledOrigin = { + options: { + name: 'host', + security: { + diagnostics: { + enabled: true, + maxEvents: 10, + }, + }, + }, +}; + +const disabledOrigin = { + options: { + name: 'host', + }, +}; + +const createShared = (overrides: Record = {}) => ({ + version: '18.3.1', + scope: ['default'], + from: 'host', + deps: [], + useIn: [], + strategy: 'loaded-first', + shareConfig: { + requiredVersion: '^18.0.0', + singleton: false, + strictVersion: false, + eager: false, + }, + get: () => () => ({ value: 'shared' }), + ...overrides, +}); + +const emitRemoteLoaded = ( + diagnostics: ReturnType, + overrides: Record = {}, +) => + diagnostics.plugin.onLoad?.({ + id: 'remote/Button', + pkgNameOrAlias: 'remote', + expose: './Button', + remote: { + name: 'remote', + entry: 'http://localhost:3001/mf-manifest.json', + }, + options: {}, + origin: enabledOrigin, + exposeModule: {}, + exposeModuleFactory: undefined, + moduleInstance: {}, + ...overrides, + } as any); + +const emitRemoteStart = ( + diagnostics: ReturnType, + overrides: Record = {}, +) => + diagnostics.plugin.beforeRequest?.({ + id: 'remote/Button', + options: {}, + origin: enabledOrigin, + ...overrides, + } as any); + +const emitRemoteComplete = ( + diagnostics: ReturnType, + overrides: Record = {}, +) => + diagnostics.plugin.afterLoadRemote?.({ + id: 'remote/Button', + expose: './Button', + remote: { + name: 'remote', + entry: 'http://localhost:3001/mf-manifest.json', + }, + origin: enabledOrigin, + ...overrides, + } as any); + +const emitRemoteError = ( + diagnostics: ReturnType, + overrides: Record = {}, +) => + diagnostics.plugin.errorLoadRemote?.({ + id: 'remote/Button', + error: new Error('remote load failed'), + from: 'runtime', + lifecycle: 'onLoad', + expose: './Button', + remote: { + name: 'remote', + entry: 'http://localhost:3001/mf-manifest.json', + }, + origin: enabledOrigin, + ...overrides, + } as any); + +const emitManifestError = ( + diagnostics: ReturnType, + overrides: Record = {}, +) => + diagnostics.plugin.errorLoadRemote?.({ + id: 'http://localhost:3001/mf-manifest.json?token=demo-secret#hash', + error: new Error('token=demo-secret manifest failed'), + from: 'runtime', + lifecycle: 'afterResolve', + remote: { + name: 'remote', + entry: 'http://localhost:3001/mf-manifest.json?token=demo-secret#hash', + }, + origin: enabledOrigin, + ...overrides, + } as any); + +const waitForFile = async (filePath: string) => { + const startedAt = Date.now(); + + while (Date.now() - startedAt < 1000) { + if (fs.existsSync(filePath)) { + return; + } + + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + throw new Error(`Timed out waiting for ${filePath}`); +}; + +describe('DiagnosticsPlugin', () => { + it('does not record events unless security diagnostics is enabled', () => { + const diagnostics = DiagnosticsPlugin({ level: 'verbose' }); + + emitRemoteLoaded(diagnostics, { + origin: disabledOrigin, + }); + + expect(diagnostics.getEvents()).toHaveLength(0); + expect(diagnostics.getLatestReport()).toBeUndefined(); + }); + + it('records a successful loadRemote report when enabled', () => { + const diagnostics = DiagnosticsPlugin({ level: 'verbose' }); + + emitRemoteStart(diagnostics); + emitRemoteLoaded(diagnostics); + + const report = diagnostics.getLatestReport(); + expect(report?.status).toBe('success'); + expect(report?.requestId).toBe('remote/Button'); + expect(report?.remote?.name).toBe('remote'); + expect(report?.summary).toMatchObject({ + runtimeLoaded: true, + loadCompleted: false, + componentLoaded: false, + outcome: 'runtime-loaded', + lastPhase: 'loadRemote', + }); + expect(report?.events).toHaveLength(2); + }); + + it('records a complete loadRemote event as the final runtime outcome', () => { + const diagnostics = DiagnosticsPlugin({ level: 'verbose' }); + + emitRemoteStart(diagnostics); + emitRemoteLoaded(diagnostics); + emitRemoteComplete(diagnostics); + + const report = diagnostics.getLatestReport(); + expect(report?.status).toBe('success'); + expect(report?.summary).toMatchObject({ + runtimeLoaded: true, + loadCompleted: true, + componentLoaded: false, + outcome: 'runtime-loaded', + lastPhase: 'loadRemote', + }); + }); + + it('records manifest and remoteEntry lifecycle hooks without diagnostic events', () => { + const diagnostics = DiagnosticsPlugin({ level: 'verbose', console: false }); + + diagnostics.plugin.beforeLoadRemoteSnapshot?.({ + moduleInfo: { + name: 'remote', + entry: 'http://localhost:3001/mf-manifest.json', + }, + origin: enabledOrigin, + } as any); + diagnostics.plugin.afterResolve?.({ + id: 'remote/Button', + expose: './Button', + remoteInfo: { + name: 'remote', + entry: 'http://localhost:3001/mf-manifest.json', + }, + origin: enabledOrigin, + } as any); + diagnostics.plugin.loadEntry?.({ + remoteInfo: { + name: 'remote', + entry: 'http://localhost:3001/remoteEntry.js', + }, + origin: enabledOrigin, + } as any); + diagnostics.plugin.afterLoadEntry?.({ + remoteInfo: { + name: 'remote', + entry: 'http://localhost:3001/remoteEntry.js', + }, + origin: enabledOrigin, + } as any); + + expect(diagnostics.getEvents()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + phase: 'manifest', + status: 'start', + lifecycle: 'beforeLoadRemoteSnapshot', + }), + expect.objectContaining({ + phase: 'manifest', + status: 'success', + lifecycle: 'afterResolve', + }), + expect.objectContaining({ + phase: 'remoteEntry', + status: 'start', + lifecycle: 'loadEntry', + }), + expect.objectContaining({ + phase: 'remoteEntry', + status: 'success', + lifecycle: 'afterLoadEntry', + }), + ]), + ); + }); + + it('treats a recovered complete event as a recovered runtime outcome', () => { + const diagnostics = DiagnosticsPlugin({ level: 'verbose', console: false }); + + emitRemoteStart(diagnostics); + expect(emitRemoteError(diagnostics)).toBeUndefined(); + emitRemoteComplete(diagnostics, { + error: new Error('remote load failed'), + recovered: true, + }); + + const report = diagnostics.getLatestReport(); + expect(report?.summary).toMatchObject({ + recovered: true, + runtimeLoaded: true, + loadCompleted: true, + outcome: 'recovered', + }); + }); + + it('sanitizes urls and sensitive message fields', () => { + const diagnostics = DiagnosticsPlugin({ level: 'verbose', console: false }); + + emitManifestError(diagnostics, { + error: new Error( + 'authorization=demo-secret failed at http://localhost:3001/mf-manifest.json?token=demo-secret#hash', + ), + }); + + const output = JSON.stringify(diagnostics.getLatestReport()); + expect(output).toContain('http://localhost:3001/mf-manifest.json'); + expect(output).not.toContain('demo-secret'); + expect(output).not.toContain('token='); + expect(output).not.toContain('#hash'); + }); + + it('keeps the first specific failed phase when loadRemote closes the trace', () => { + const diagnostics = DiagnosticsPlugin({ level: 'verbose', console: false }); + + emitRemoteStart(diagnostics, { + remote: { + name: 'remote', + }, + }); + emitManifestError(diagnostics, { + id: 'remote/Button', + remote: { + name: 'remote', + }, + error: new Error('manifest failed'), + }); + expect(emitRemoteError(diagnostics)).toBeUndefined(); + emitRemoteComplete(diagnostics, { + remote: { + name: 'remote', + }, + error: new Error('outer loadRemote failed'), + }); + + const report = diagnostics.getLatestReport(); + expect(report?.status).toBe('error'); + expect(report?.failedPhase).toBe('manifest'); + expect(report?.summary).toMatchObject({ + loadCompleted: true, + runtimeLoaded: false, + componentLoaded: false, + outcome: 'failed', + }); + }); + + it('records sanitized shared dependency diagnostics', () => { + const diagnostics = DiagnosticsPlugin({ level: 'verbose', console: false }); + + diagnostics.plugin.errorLoadShare?.({ + pkgName: 'react', + shareInfo: { + version: '18.3.1', + from: 'remote', + scope: ['default'], + strategy: 'loaded-first', + shareConfig: { + requiredVersion: '^99.0.0', + singleton: true, + strictVersion: true, + eager: false, + }, + }, + shared: {}, + shareScopeMap: { + default: { + react: { + '18.3.1': createShared({ + from: 'host?token=demo-secret', + }), + }, + }, + }, + lifecycle: 'loadShare', + origin: enabledOrigin, + error: new Error('token=demo-secret shared failed'), + recovered: true, + } as any); + + const report = diagnostics.getLatestReport(); + const output = JSON.stringify(report); + + expect(report?.status).toBe('error'); + expect(report?.failedPhase).toBe('shared'); + expect(report?.shared).toMatchObject({ + name: 'react', + shareScope: ['default'], + requiredVersion: '^99.0.0', + availableVersions: ['18.3.1'], + reason: 'version-mismatch', + }); + expect(output).not.toContain('demo-secret'); + expect(output).not.toContain('token='); + }); + + it('derives shared success details from loadShare hooks', () => { + const diagnostics = DiagnosticsPlugin({ level: 'verbose', console: false }); + const shared = createShared(); + + diagnostics.plugin.beforeLoadShare?.({ + pkgName: 'react', + shareInfo: shared, + shared: {}, + origin: enabledOrigin, + }); + diagnostics.plugin.afterLoadShare?.({ + pkgName: 'react', + shareInfo: shared, + selectedShared: shared, + shared: {}, + shareScopeMap: { + default: { + react: { + '18.3.1': shared, + }, + }, + }, + lifecycle: 'loadShare', + origin: enabledOrigin, + }); + + const report = diagnostics.getLatestReport(); + + expect(report?.status).toBe('success'); + expect(report?.shared).toMatchObject({ + name: 'react', + selectedVersion: '18.3.1', + provider: 'host', + availableVersions: ['18.3.1'], + }); + }); + + it('derives shared version mismatch details from errorLoadShare', () => { + const diagnostics = DiagnosticsPlugin({ level: 'verbose', console: false }); + const requestedShared = createShared({ + version: '99.0.0', + from: 'remote', + shareConfig: { + requiredVersion: '^99.0.0', + singleton: false, + strictVersion: true, + eager: false, + }, + }); + const hostShared = createShared(); + + diagnostics.plugin.beforeLoadShare?.({ + pkgName: 'react', + shareInfo: requestedShared, + shared: {}, + origin: enabledOrigin, + }); + diagnostics.plugin.errorLoadShare?.({ + pkgName: 'react', + shareInfo: requestedShared, + shared: {}, + shareScopeMap: { + default: { + react: { + '18.3.1': hostShared, + }, + }, + }, + lifecycle: 'loadShare', + origin: enabledOrigin, + recovered: true, + }); + + const report = diagnostics.getLatestReport(); + + expect(report?.status).toBe('error'); + expect(report?.failedPhase).toBe('shared'); + expect(report?.shared).toMatchObject({ + name: 'react', + requiredVersion: '^99.0.0', + availableVersions: ['18.3.1'], + reason: 'version-mismatch', + }); + }); + + it('derives eager boundary details from loadShareSync failures', () => { + const diagnostics = DiagnosticsPlugin({ level: 'verbose', console: false }); + const asyncShared = createShared({ + version: '1.0.0', + from: 'remote', + shareConfig: { + requiredVersion: '^1.0.0', + singleton: false, + strictVersion: false, + eager: false, + }, + get: () => Promise.resolve(() => ({ value: 'async' })), + }); + + diagnostics.plugin.errorLoadShare?.({ + pkgName: 'diagnostics-async-shared', + shareInfo: asyncShared, + shared: {}, + shareScopeMap: {}, + lifecycle: 'loadShareSync', + origin: enabledOrigin, + error: new Error('[ Federation Runtime ]: RUNTIME-005 shared failed'), + }); + + const report = diagnostics.getLatestReport(); + + expect(report?.status).toBe('error'); + expect(report?.shared).toMatchObject({ + name: 'diagnostics-async-shared', + requiredVersion: '^1.0.0', + reason: 'sync-async-boundary', + }); + expect(report?.events.at(-1)?.message).toBe('shared:sync-async-boundary'); + }); + + it('prints a minimal console hint with traceId on error', () => { + const diagnostics = DiagnosticsPlugin({ level: 'verbose' }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + try { + emitManifestError(diagnostics); + + emitManifestError(diagnostics, { + error: new Error('token=demo-secret failed again'), + }); + + expect(warn).toHaveBeenCalledTimes(1); + const output = String(warn.mock.calls[0]?.[0]); + expect(output).toContain( + '[Module Federation] Diagnostic report generated', + ); + expect(output).toContain('traceId: mf-'); + expect(output).toContain('phase: manifest'); + expect(output).toContain('read: diagnostics.getReport'); + expect(output).not.toContain('demo-secret'); + expect(output).not.toContain('token='); + expect(output).not.toContain('#hash'); + } finally { + warn.mockRestore(); + } + }); + + it('exposes a browser reader only when plugin and security both allow it', () => { + const originalFederation = ( + globalThis as { + __FEDERATION__?: { + __DIAGNOSTICS__?: Record; + }; + } + ).__FEDERATION__; + + try { + ( + globalThis as { + __FEDERATION__?: { + __DIAGNOSTICS__?: Record; + }; + } + ).__FEDERATION__ = {}; + + const diagnostics = DiagnosticsPlugin({ + level: 'verbose', + browser: { + enabled: true, + scope: 'host', + }, + }); + + emitRemoteLoaded(diagnostics, { + origin: { + options: { + name: 'host', + security: { + diagnostics: { + enabled: true, + maxEvents: 10, + browserGlobal: true, + }, + }, + }, + }, + }); + + const reader = ( + globalThis as { + __FEDERATION__?: { + __DIAGNOSTICS__?: Record< + string, + { + getLatestReport(): { traceId: string; events: unknown[] }; + getReport(traceId: string): { events: unknown[] } | undefined; + getTraceIds(): string[]; + clear?: unknown; + } + >; + }; + } + ).__FEDERATION__?.__DIAGNOSTICS__?.host; + + expect(reader).toBeDefined(); + expect(reader?.clear).toBeUndefined(); + expect(reader?.getTraceIds()).toHaveLength(1); + + const latestReport = reader?.getLatestReport(); + expect(latestReport?.traceId).toBeDefined(); + latestReport?.events.pop(); + + expect(reader?.getLatestReport().events).toHaveLength(1); + expect( + reader?.getReport(latestReport?.traceId || '')?.events, + ).toHaveLength(1); + } finally { + if (originalFederation) { + ( + globalThis as { + __FEDERATION__?: { + __DIAGNOSTICS__?: Record; + }; + } + ).__FEDERATION__ = originalFederation; + } else { + delete ( + globalThis as { + __FEDERATION__?: { + __DIAGNOSTICS__?: Record; + }; + } + ).__FEDERATION__; + } + } + }); + + it('does not expose the browser reader when security blocks it', () => { + const originalFederation = ( + globalThis as { + __FEDERATION__?: { + __DIAGNOSTICS__?: Record; + }; + } + ).__FEDERATION__; + + try { + ( + globalThis as { + __FEDERATION__?: { + __DIAGNOSTICS__?: Record; + }; + } + ).__FEDERATION__ = {}; + + const diagnostics = DiagnosticsPlugin({ + level: 'verbose', + browser: { + enabled: true, + scope: 'host', + }, + }); + + emitRemoteLoaded(diagnostics); + + expect( + ( + globalThis as { + __FEDERATION__?: { + __DIAGNOSTICS__?: Record; + }; + } + ).__FEDERATION__?.__DIAGNOSTICS__, + ).toBeUndefined(); + } finally { + if (originalFederation) { + ( + globalThis as { + __FEDERATION__?: { + __DIAGNOSTICS__?: Record; + }; + } + ).__FEDERATION__ = originalFederation; + } else { + delete ( + globalThis as { + __FEDERATION__?: { + __DIAGNOSTICS__?: Record; + }; + } + ).__FEDERATION__; + } + } + }); + + it('writes sanitized Node diagnostics files only when security allows it', async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'mf-diagnostics-')); + const latestFile = path.join(directory, 'latest.json'); + const eventsFile = path.join(directory, 'events.jsonl'); + const originalNonWebpackRequire = ( + globalThis as { + __non_webpack_require__?: (id: string) => unknown; + } + ).__non_webpack_require__; + + try { + ( + globalThis as { + __non_webpack_require__?: (id: string) => unknown; + } + ).__non_webpack_require__ = (id: string) => { + if (id === 'node:fs' || id === 'fs') { + return fs; + } + + if (id === 'node:path' || id === 'path') { + return path; + } + + throw new Error(`Unsupported module: ${id}`); + }; + + const diagnostics = DiagnosticsNode({ + level: 'verbose', + console: false, + directory, + }); + + emitManifestError(diagnostics, { + origin: { + options: { + name: 'host', + security: { + diagnostics: { + enabled: true, + maxEvents: 10, + fileOutput: true, + }, + }, + }, + }, + error: new Error('token=demo-secret manifest failed'), + }); + + await waitForFile(latestFile); + await waitForFile(eventsFile); + + const latest = fs.readFileSync(latestFile, 'utf8'); + const eventsOutput = fs.readFileSync(eventsFile, 'utf8'); + + expect(latest).toContain('"status": "error"'); + expect(eventsOutput).toContain('"phase":"manifest"'); + expect(`${latest}\n${eventsOutput}`).not.toContain('demo-secret'); + expect(`${latest}\n${eventsOutput}`).not.toContain('token='); + expect(`${latest}\n${eventsOutput}`).not.toContain('#hash'); + } finally { + if (originalNonWebpackRequire) { + ( + globalThis as { + __non_webpack_require__?: (id: string) => unknown; + } + ).__non_webpack_require__ = originalNonWebpackRequire; + } else { + delete ( + globalThis as { + __non_webpack_require__?: (id: string) => unknown; + } + ).__non_webpack_require__; + } + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + it('lets business code mark component loaded after runtime diagnostics starts', () => { + const diagnostics = DiagnosticsPlugin({ level: 'verbose' }); + + emitRemoteLoaded(diagnostics); + + const componentEvent = diagnostics.markComponentLoaded({ + requestId: 'remote/Button', + componentName: 'RemoteButton', + }); + const report = diagnostics.getLatestReport(); + + expect(componentEvent?.phase).toBe('component'); + expect(componentEvent?.eventName).toBe('component:business-loaded'); + expect(componentEvent?.source).toBe('business'); + expect(componentEvent?.componentName).toBe('RemoteButton'); + expect(report?.summary.componentLoaded).toBe(true); + expect(report?.summary.outcome).toBe('component-loaded'); + expect(JSON.stringify(report)).toContain('component:business-loaded'); + }); + + it('does not let diagnostics callbacks affect loading', () => { + const diagnostics = DiagnosticsPlugin({ + level: 'verbose', + onEvent: vi.fn(() => { + throw new Error('onEvent failed'); + }), + onReport: vi.fn(() => { + throw new Error('onReport failed'); + }), + }); + + expect(() => emitRemoteLoaded(diagnostics)).not.toThrow(); + + expect(diagnostics.getLatestReport()?.status).toBe('success'); + }); +}); diff --git a/packages/diagnostics-plugin/package.json b/packages/diagnostics-plugin/package.json new file mode 100644 index 00000000000..e67b4be73c6 --- /dev/null +++ b/packages/diagnostics-plugin/package.json @@ -0,0 +1,52 @@ +{ + "name": "@module-federation/diagnostics-plugin", + "version": "2.4.0", + "author": "module-federation", + "main": "./dist/index.js", + "module": "./dist/esm/index.js", + "types": "./dist/index.d.ts", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/module-federation/core.git", + "directory": "packages/diagnostics-plugin" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "dist/", + "README.md" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/esm/index.js", + "require": "./dist/index.js" + }, + "./node": { + "types": "./dist/node.d.ts", + "import": "./dist/esm/node.js", + "require": "./dist/node.js" + } + }, + "typesVersions": { + "*": { + ".": [ + "./dist/index.d.ts" + ], + "node": [ + "./dist/node.d.ts" + ] + } + }, + "devDependencies": { + "@module-federation/runtime": "workspace:*" + }, + "scripts": { + "build": "tsdown --config tsdown.config.ts && cp *.md dist", + "test": "vitest run -u -c vitest.config.ts", + "lint": "ESLINT_USE_FLAT_CONFIG=false pnpm exec eslint --ignore-pattern node_modules \"**/*.ts\" \"package.json\"", + "pre-release": "pnpm run test && pnpm run build" + } +} diff --git a/packages/diagnostics-plugin/src/index.ts b/packages/diagnostics-plugin/src/index.ts new file mode 100644 index 00000000000..51f4d4d8f9e --- /dev/null +++ b/packages/diagnostics-plugin/src/index.ts @@ -0,0 +1,1383 @@ +import type { ModuleFederationRuntimePlugin } from '@module-federation/runtime'; + +export type DiagnosticsLevel = 'error' | 'summary' | 'verbose'; +export type DiagnosticsEventStatus = 'start' | 'success' | 'error' | 'complete'; +export type DiagnosticsReportStatus = 'pending' | 'success' | 'error'; +export type DiagnosticsEventSource = 'runtime' | 'business'; +export type DiagnosticsReportOutcome = + | 'pending' + | 'runtime-loaded' + | 'component-loaded' + | 'failed' + | 'recovered'; + +export interface DiagnosticsRemoteInfo { + name: string; + alias?: string; + entry?: string; + entryGlobalName?: string; + type?: string; +} + +export interface DiagnosticsSharedInfo { + name: string; + shareScope?: string[]; + requiredVersion?: string | false; + selectedVersion?: string; + availableVersions?: string[]; + provider?: string; + from?: string; + singleton?: boolean; + strictVersion?: boolean; + eager?: boolean; + strategy?: string; + loaded?: boolean; + loading?: boolean; + reason?: string; +} + +export interface DiagnosticsEvent { + traceId: string; + timestamp: number; + phase: string; + status: DiagnosticsEventStatus; + requestId?: string; + hostName?: string; + remote?: DiagnosticsRemoteInfo; + shared?: DiagnosticsSharedInfo; + expose?: string; + sanitizedUrl?: string; + message?: string; + errorName?: string; + errorMessage?: string; + lifecycle?: string; + eventName?: string; + source?: DiagnosticsEventSource; + recovered?: boolean; + componentName?: string; +} + +export interface DiagnosticsReport { + traceId: string; + status: DiagnosticsReportStatus; + requestId?: string; + hostName?: string; + remote?: DiagnosticsRemoteInfo; + shared?: DiagnosticsSharedInfo; + expose?: string; + startedAt: number; + updatedAt: number; + duration: number; + failedPhase?: string; + events: DiagnosticsEvent[]; + summary: { + eventCount: number; + recovered: boolean; + loadCompleted: boolean; + runtimeLoaded: boolean; + componentLoaded: boolean; + outcome: DiagnosticsReportOutcome; + lastPhase?: string; + }; +} + +export interface DiagnosticsPluginOptions { + enabled?: boolean; + level?: DiagnosticsLevel; + maxEvents?: number; + console?: boolean; + browser?: { + enabled?: boolean; + scope?: string; + }; + onEvent?: ( + event: DiagnosticsEvent, + report: DiagnosticsReport, + context?: DiagnosticsEventContext, + ) => void; + onReport?: ( + report: DiagnosticsReport, + context?: DiagnosticsEventContext, + ) => void; +} + +export interface MarkComponentLoadedOptions { + traceId?: string; + requestId?: string; + componentName?: string; +} + +export interface DiagnosticsController { + plugin: DiagnosticsRuntimePlugin; + getEvents(): DiagnosticsEvent[]; + getTraceIds(): string[]; + getLatestReport(): DiagnosticsReport | undefined; + getReport(traceId: string): DiagnosticsReport | undefined; + clear(): void; + markComponentLoaded( + options?: MarkComponentLoadedOptions, + ): DiagnosticsEvent | undefined; +} + +export interface DiagnosticsRuntimeEventInput { + phase: string; + status: DiagnosticsEventStatus; + requestId?: string; + hostName?: string; + remote?: DiagnosticsRemoteInfo; + shared?: DiagnosticsSharedInfo; + expose?: string; + url?: string; + message?: string; + error?: unknown; + lifecycle?: string; + eventName?: string; + source?: DiagnosticsEventSource; + recovered?: boolean; + timestamp?: number; + traceId?: string; + componentName?: string; +} + +export interface DiagnosticsRuntimeOrigin { + options?: { + name?: string; + security?: { + diagnostics?: { + enabled?: boolean; + maxEvents?: number; + console?: boolean; + browserGlobal?: boolean; + fileOutput?: boolean; + }; + }; + }; +} + +export interface DiagnosticsEventContext { + origin?: DiagnosticsRuntimeOrigin; +} + +interface DiagnosticsRuntimeSharedConfig { + requiredVersion?: string | false; + singleton?: boolean; + strictVersion?: boolean; + eager?: boolean; +} + +interface DiagnosticsRuntimeSharedSource { + version?: string; + scope?: string | string[]; + from?: string; + loaded?: boolean; + loading?: unknown; + strategy?: string; + shareConfig?: DiagnosticsRuntimeSharedConfig; + get?: unknown; +} + +interface DiagnosticsRuntimeRemoteSource { + name?: string; + alias?: string; + entry?: string; + entryGlobalName?: string; + type?: string; +} + +interface DiagnosticsRemoteLoadArgs { + id: string; + expose?: string; + remote?: DiagnosticsRuntimeRemoteSource; + origin: DiagnosticsRuntimeOrigin; +} + +interface DiagnosticsRemoteBeforeRequestArgs { + id: string; + origin: DiagnosticsRuntimeOrigin; +} + +interface DiagnosticsRemoteAfterLoadArgs { + id: string; + expose?: string; + remote?: DiagnosticsRuntimeRemoteSource; + error?: unknown; + recovered?: boolean; + origin: DiagnosticsRuntimeOrigin; +} + +interface DiagnosticsRemoteSnapshotArgs { + moduleInfo?: DiagnosticsRuntimeRemoteSource; + origin: DiagnosticsRuntimeOrigin; +} + +interface DiagnosticsRemoteResolveArgs { + id: string; + expose?: string; + remote?: DiagnosticsRuntimeRemoteSource; + remoteInfo?: DiagnosticsRuntimeRemoteSource; + origin: DiagnosticsRuntimeOrigin; +} + +interface DiagnosticsRemoteErrorArgs { + id: string; + error: unknown; + lifecycle?: string; + remote?: DiagnosticsRuntimeRemoteSource; + expose?: string; + origin: DiagnosticsRuntimeOrigin; +} + +interface DiagnosticsRemoteEntryLoadArgs { + origin: DiagnosticsRuntimeOrigin; + remoteInfo: DiagnosticsRuntimeRemoteSource; +} + +interface DiagnosticsRemoteEntryAfterLoadArgs { + origin: DiagnosticsRuntimeOrigin; + remoteInfo: DiagnosticsRuntimeRemoteSource; + error?: unknown; + recovered?: boolean; +} + +type DiagnosticsRuntimeShareScopeMap = Record< + string, + Record> +>; + +interface DiagnosticsSharedLifecycleArgs { + pkgName: string; + shareInfo?: DiagnosticsRuntimeSharedSource; + selectedShared?: DiagnosticsRuntimeSharedSource; + shared?: Record; + shareScopeMap?: DiagnosticsRuntimeShareScopeMap; + lifecycle?: 'loadShare' | 'loadShareSync'; + origin: DiagnosticsRuntimeOrigin; + error?: unknown; + recovered?: boolean; +} + +export type DiagnosticsRuntimePlugin = ModuleFederationRuntimePlugin; + +export interface DiagnosticsBrowserReader { + getEvents(): DiagnosticsEvent[]; + getTraceIds(): string[]; + getLatestReport(): DiagnosticsReport | undefined; + getReport(traceId: string): DiagnosticsReport | undefined; +} + +interface FederationDiagnosticsGlobal { + __DIAGNOSTICS__?: Record; +} + +const DEFAULT_MAX_EVENTS = 100; +const HARD_MAX_EVENTS = 1000; +const COMPONENT_BUSINESS_LOADED_EVENT = 'component:business-loaded'; +const SENSITIVE_PAIR_PATTERN = + /\b(token|authorization|cookie|secret|password|session|access_token|refresh_token|api_key|apikey|key)\s*[:=]\s*([^&\s'",;<>]+)/gi; +const URL_PATTERN = /https?:\/\/[^\s'"<>]+/g; + +let traceCounter = 0; + +function normalizeMaxEvents(value: number | undefined, fallback: number) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return fallback; + } + + return Math.max(1, Math.min(HARD_MAX_EVENTS, Math.floor(value))); +} + +function sanitizeText(value: unknown, maxLength = 800): string | undefined { + if (value === undefined || value === null) { + return undefined; + } + + const sanitized = String(value) + .replace(URL_PATTERN, (url) => sanitizeUrl(url) || '[redacted-url]') + .replace(SENSITIVE_PAIR_PATTERN, '[redacted]'); + + return sanitized.length > maxLength + ? `${sanitized.slice(0, maxLength)}...` + : sanitized; +} + +function sanitizeRequestId(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + + if (/^https?:\/\//i.test(value)) { + return sanitizeUrl(value); + } + + return sanitizeText(value, 240); +} + +function sanitizeUrl(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + + try { + const base = + typeof window !== 'undefined' && window.location + ? window.location.origin + : 'http://localhost'; + const parsedUrl = new URL(value, base); + const sanitized = `${parsedUrl.origin}${parsedUrl.pathname}`; + + return /^https?:\/\//i.test(value) ? sanitized : parsedUrl.pathname; + } catch { + const [withoutHash] = value.split('#'); + const [withoutQuery] = withoutHash.split('?'); + return sanitizeText(withoutQuery, 240); + } +} + +function sanitizeRemote( + remote: DiagnosticsRemoteInfo | undefined, +): DiagnosticsRemoteInfo | undefined { + if (!remote || !remote.name) { + return undefined; + } + + return { + name: remote.name, + alias: sanitizeText(remote.alias, 120), + entry: sanitizeUrl(remote.entry), + entryGlobalName: sanitizeText(remote.entryGlobalName, 120), + type: sanitizeText(remote.type, 80), + }; +} + +function createRemoteInfo( + remote: DiagnosticsRuntimeRemoteSource | undefined, +): DiagnosticsRemoteInfo | undefined { + if (!remote?.name) { + return undefined; + } + + return { + name: remote.name, + alias: remote.alias, + entry: remote.entry, + entryGlobalName: remote.entryGlobalName, + type: remote.type, + }; +} + +function isManifestUrl(value: string | undefined): boolean { + const sanitized = sanitizeUrl(value); + + return Boolean(sanitized && /manifest.*\.json$/i.test(sanitized)); +} + +function normalizeSharedScope(value: string | string[] | undefined): string[] { + if (!value) { + return []; + } + + return (Array.isArray(value) ? value : [value]) + .map((scope) => sanitizeText(scope, 120)) + .filter((scope): scope is string => Boolean(scope)); +} + +function getSharedScopes( + shareInfo: DiagnosticsRuntimeSharedSource | undefined, +): string[] { + return normalizeSharedScope(shareInfo?.scope).length + ? normalizeSharedScope(shareInfo?.scope) + : ['default']; +} + +function getAvailableSharedVersions(args: DiagnosticsSharedLifecycleArgs) { + const versions = new Set(); + const shareScopeMap = args.shareScopeMap || {}; + + getSharedScopes(args.shareInfo).forEach((scope) => { + Object.keys(shareScopeMap[scope]?.[args.pkgName] || {}).forEach( + (version) => { + versions.add(version); + }, + ); + }); + + return Array.from(versions); +} + +function getSharedMissReason(args: DiagnosticsSharedLifecycleArgs) { + if (!args.shareInfo) { + return 'missing-config'; + } + + return getAvailableSharedVersions(args).length + ? 'version-mismatch' + : 'missing-provider'; +} + +function getSharedErrorReason(args: DiagnosticsSharedLifecycleArgs) { + if (args.recovered) { + return getSharedMissReason(args); + } + + const errorInfo = getErrorInfo(args.error); + const errorMessage = errorInfo.errorMessage || ''; + + if (!args.shareInfo || /Cannot find shared/i.test(errorMessage)) { + return 'missing-config'; + } + + if ( + args.lifecycle === 'loadShareSync' && + typeof args.shareInfo.get === 'function' && + /RUNTIME-00[56]/.test(errorMessage) + ) { + return 'sync-async-boundary'; + } + + if ( + args.lifecycle === 'loadShareSync' && + !args.shareInfo.get && + /RUNTIME-006/.test(errorMessage) + ) { + return getSharedMissReason(args); + } + + if (args.error) { + return 'load-error'; + } + + return undefined; +} + +function createSharedInfo( + args: DiagnosticsSharedLifecycleArgs, + reason?: string, +): DiagnosticsSharedInfo { + const shareConfig = args.shareInfo?.shareConfig; + + return { + name: args.pkgName, + shareScope: getSharedScopes(args.shareInfo), + requiredVersion: shareConfig?.requiredVersion, + selectedVersion: args.selectedShared?.version, + availableVersions: getAvailableSharedVersions(args), + provider: args.selectedShared?.from, + from: args.shareInfo?.from, + singleton: shareConfig?.singleton, + strictVersion: shareConfig?.strictVersion, + eager: shareConfig?.eager, + strategy: args.shareInfo?.strategy, + loaded: args.selectedShared?.loaded, + loading: Boolean(args.selectedShared?.loading) || undefined, + reason, + }; +} + +function sanitizeShared( + shared: DiagnosticsSharedInfo | undefined, +): DiagnosticsSharedInfo | undefined { + if (!shared || !shared.name) { + return undefined; + } + + return { + name: sanitizeText(shared.name, 160) || 'unknown', + shareScope: normalizeSharedScope(shared.shareScope), + requiredVersion: + shared.requiredVersion === false + ? false + : sanitizeText(shared.requiredVersion, 120), + selectedVersion: sanitizeText(shared.selectedVersion, 120), + availableVersions: (shared.availableVersions || []) + .map((version) => sanitizeText(version, 120)) + .filter((version): version is string => Boolean(version)) + .slice(0, 20), + provider: sanitizeText(shared.provider, 160), + from: sanitizeText(shared.from, 160), + singleton: shared.singleton, + strictVersion: shared.strictVersion, + eager: shared.eager, + strategy: sanitizeText(shared.strategy, 80), + loaded: shared.loaded, + loading: shared.loading, + reason: sanitizeText(shared.reason, 120), + }; +} + +function normalizeEventSource( + value: DiagnosticsEventSource | undefined, +): DiagnosticsEventSource | undefined { + return value === 'runtime' || value === 'business' ? value : undefined; +} + +function getErrorInfo(error: unknown): { + errorName?: string; + errorMessage?: string; +} { + if (!error) { + return {}; + } + + if (error instanceof Error) { + return { + errorName: sanitizeText(error.name, 120), + errorMessage: sanitizeText(error.message), + }; + } + + return { + errorMessage: sanitizeText(error), + }; +} + +function copyEvent(event: DiagnosticsEvent): DiagnosticsEvent { + return { + ...event, + remote: event.remote ? { ...event.remote } : undefined, + shared: event.shared + ? { + ...event.shared, + shareScope: event.shared.shareScope + ? [...event.shared.shareScope] + : undefined, + availableVersions: event.shared.availableVersions + ? [...event.shared.availableVersions] + : undefined, + } + : undefined, + }; +} + +function copyReport(report: DiagnosticsReport): DiagnosticsReport { + return { + ...report, + remote: report.remote ? { ...report.remote } : undefined, + shared: report.shared + ? { + ...report.shared, + shareScope: report.shared.shareScope + ? [...report.shared.shareScope] + : undefined, + availableVersions: report.shared.availableVersions + ? [...report.shared.availableVersions] + : undefined, + } + : undefined, + events: report.events.map(copyEvent), + summary: { ...report.summary }, + }; +} + +function getFederationGlobal(): FederationDiagnosticsGlobal | undefined { + return ( + globalThis as { + __FEDERATION__?: FederationDiagnosticsGlobal; + } + ).__FEDERATION__; +} + +function normalizeScope(value: unknown) { + const sanitized = sanitizeText(value, 120); + const normalized = sanitized?.replace(/[^\w:@.-]+/g, '-'); + + return normalized || 'default'; +} + +function shouldRecordEvent( + level: DiagnosticsLevel, + event: DiagnosticsRuntimeEventInput, +) { + if (level === 'verbose') { + return true; + } + + if (level === 'summary') { + return event.status !== 'start'; + } + + return event.status === 'error' || Boolean(event.error); +} + +function createTraceId(event: DiagnosticsRuntimeEventInput) { + traceCounter += 1; + const owner = event.remote?.name || event.phase || 'runtime'; + const normalizedOwner = owner.replace(/[^a-z0-9]+/gi, '-').slice(0, 80); + + return `mf-${normalizedOwner}-${Date.now().toString(36)}-${traceCounter.toString( + 36, + )}`; +} + +export function DiagnosticsPlugin( + options: DiagnosticsPluginOptions = {}, +): DiagnosticsController { + const level = options.level || 'summary'; + const configuredMaxEvents = normalizeMaxEvents( + options.maxEvents, + DEFAULT_MAX_EVENTS, + ); + const events: DiagnosticsEvent[] = []; + const reports = new Map(); + const traceByRequest = new Map(); + const traceByRemote = new Map(); + const consoleReportedTraceIds = new Set(); + let latestTraceId: string | undefined; + let runtimeDiagnosticsEnabled = false; + let effectiveMaxEvents = configuredMaxEvents; + let browserGlobalScope: string | undefined; + let lastRuntimeOrigin: DiagnosticsRuntimeOrigin | undefined; + + const isEnabledForOrigin = (origin: DiagnosticsRuntimeOrigin) => { + if (options.enabled === false) { + return false; + } + + const securityDiagnostics = origin.options?.security?.diagnostics; + if (securityDiagnostics?.enabled !== true) { + return false; + } + + const securityMaxEvents = normalizeMaxEvents( + securityDiagnostics.maxEvents, + configuredMaxEvents, + ); + effectiveMaxEvents = Math.min(configuredMaxEvents, securityMaxEvents); + runtimeDiagnosticsEnabled = true; + return true; + }; + + const resolveTraceId = (event: DiagnosticsRuntimeEventInput) => { + const sanitizedRequestId = sanitizeRequestId(event.requestId); + + if (event.traceId && reports.has(event.traceId)) { + return event.traceId; + } + + if (event.status === 'start' && event.phase === 'loadRemote') { + const traceId = event.traceId || createTraceId(event); + if (sanitizedRequestId) { + traceByRequest.set(sanitizedRequestId, traceId); + } + if (event.remote?.name) { + traceByRemote.set(event.remote.name, traceId); + } + return traceId; + } + + if (sanitizedRequestId) { + const traceId = traceByRequest.get(sanitizedRequestId); + if (traceId) { + return traceId; + } + } + + if (event.remote?.name) { + const traceId = traceByRemote.get(event.remote.name); + if (traceId) { + return traceId; + } + } + + return event.traceId || createTraceId(event); + }; + + const normalizeEvent = ( + event: DiagnosticsRuntimeEventInput, + traceId: string, + origin?: DiagnosticsRuntimeOrigin, + ): DiagnosticsEvent => { + const errorInfo = getErrorInfo(event.error); + const sanitizedRemote = sanitizeRemote(event.remote); + const sanitizedShared = sanitizeShared(event.shared); + const hostName = + sanitizeText(event.hostName, 120) || + sanitizeText(origin?.options?.name, 120); + const message = sanitizeText(event.message) || errorInfo.errorMessage; + + return { + traceId, + timestamp: event.timestamp || Date.now(), + phase: sanitizeText(event.phase, 120) || 'runtime', + status: event.status, + requestId: sanitizeRequestId(event.requestId), + hostName, + remote: sanitizedRemote, + shared: sanitizedShared, + expose: sanitizeText(event.expose, 240), + sanitizedUrl: sanitizeUrl(event.url || event.remote?.entry), + message, + errorName: errorInfo.errorName, + errorMessage: errorInfo.errorMessage, + lifecycle: sanitizeText(event.lifecycle, 120), + eventName: sanitizeText(event.eventName, 160), + source: normalizeEventSource(event.source), + recovered: event.recovered === true || undefined, + componentName: sanitizeText(event.componentName, 160), + }; + }; + + const updateTraceMaps = (event: DiagnosticsEvent) => { + if (event.requestId) { + traceByRequest.set(event.requestId, event.traceId); + } + + if (event.remote?.name) { + traceByRemote.set(event.remote.name, event.traceId); + } + }; + + const trimEvents = (report: DiagnosticsReport) => { + while (events.length > effectiveMaxEvents) { + events.shift(); + } + + while (report.events.length > effectiveMaxEvents) { + report.events.shift(); + } + }; + + const getEventOutcome = (event: DiagnosticsEvent) => { + if (event.status === 'success') { + return 'success'; + } + + if (event.status === 'error') { + return 'error'; + } + + if (event.status === 'complete') { + if (event.recovered) { + return 'recovered'; + } + + if (event.errorName || event.errorMessage) { + return 'error'; + } + } + + return undefined; + }; + + const isLoadRemoteCompleteEvent = (event: DiagnosticsEvent) => + event.phase === 'loadRemote' && event.status === 'complete'; + + const isRuntimeLoadedEvent = (event: DiagnosticsEvent) => + event.phase === 'loadRemote' && + (event.status === 'success' || + (event.status === 'complete' && event.recovered)); + + const isComponentLoadedEvent = (event: DiagnosticsEvent) => + event.status === 'success' && + (event.eventName === COMPONENT_BUSINESS_LOADED_EVENT || + (event.phase === 'component' && + event.message === COMPONENT_BUSINESS_LOADED_EVENT)); + + const shouldReplaceFailedPhase = ( + report: DiagnosticsReport, + event: DiagnosticsEvent, + ) => { + if (isLoadRemoteCompleteEvent(event) && report.failedPhase) { + return false; + } + + if (!report.failedPhase) { + return true; + } + + return report.failedPhase === 'loadRemote' && event.phase !== 'loadRemote'; + }; + + const createReportSummary = ( + report: DiagnosticsReport, + ): DiagnosticsReport['summary'] => { + const loadCompleted = report.events.some(isLoadRemoteCompleteEvent); + const runtimeLoaded = report.events.some(isRuntimeLoadedEvent); + const recovered = report.events.some((item) => item.recovered); + const componentLoaded = report.events.some(isComponentLoadedEvent); + const lastEvent = report.events[report.events.length - 1]; + let outcome: DiagnosticsReportOutcome = 'pending'; + + if (componentLoaded) { + outcome = 'component-loaded'; + } else if (recovered && runtimeLoaded) { + outcome = 'recovered'; + } else if (report.status === 'error') { + outcome = 'failed'; + } else if (runtimeLoaded) { + outcome = 'runtime-loaded'; + } + + return { + eventCount: report.events.length, + recovered, + loadCompleted, + runtimeLoaded, + componentLoaded, + outcome, + lastPhase: lastEvent?.phase, + }; + }; + + const updateReport = (event: DiagnosticsEvent) => { + let report = reports.get(event.traceId); + + if (!report) { + report = { + traceId: event.traceId, + status: event.status === 'error' ? 'error' : 'pending', + requestId: event.requestId, + hostName: event.hostName, + remote: event.remote ? { ...event.remote } : undefined, + shared: event.shared ? copyEvent(event).shared : undefined, + expose: event.expose, + startedAt: event.timestamp, + updatedAt: event.timestamp, + duration: 0, + failedPhase: event.status === 'error' ? event.phase : undefined, + events: [], + summary: { + eventCount: 0, + recovered: false, + loadCompleted: false, + runtimeLoaded: false, + componentLoaded: false, + outcome: 'pending', + lastPhase: undefined, + }, + }; + reports.set(event.traceId, report); + } + + if (event.requestId) { + report.requestId = event.requestId; + } + if (event.hostName) { + report.hostName = event.hostName; + } + if (event.remote) { + report.remote = { ...event.remote }; + } + if (event.shared) { + report.shared = copyEvent(event).shared; + } + if (event.expose) { + report.expose = event.expose; + } + + report.events.push(event); + report.updatedAt = event.timestamp; + report.duration = Math.max(0, report.updatedAt - report.startedAt); + + const eventOutcome = getEventOutcome(event); + + if (eventOutcome === 'error') { + report.status = 'error'; + if (shouldReplaceFailedPhase(report, event)) { + report.failedPhase = event.phase; + } + } else if ( + (eventOutcome === 'success' || eventOutcome === 'recovered') && + report.status !== 'error' + ) { + report.status = 'success'; + } + + report.summary = createReportSummary(report); + + latestTraceId = event.traceId; + trimEvents(report); + return report; + }; + + const notifyEvent = ( + event: DiagnosticsEvent, + report: DiagnosticsReport, + origin?: DiagnosticsRuntimeOrigin, + ) => { + try { + options.onEvent?.(copyEvent(event), copyReport(report), { origin }); + } catch { + // Diagnostics callbacks must not affect Module Federation loading. + } + }; + + const notifyReport = ( + report: DiagnosticsReport, + origin?: DiagnosticsRuntimeOrigin, + ) => { + if (report.events[report.events.length - 1]?.status === 'start') { + return; + } + + try { + options.onReport?.(copyReport(report), { origin }); + } catch { + // Diagnostics callbacks must not affect Module Federation loading. + } + }; + + const getEventsSnapshot = () => events.map(copyEvent); + + const getTraceIdsSnapshot = () => Array.from(reports.keys()); + + const getLatestReportSnapshot = () => { + if (!latestTraceId) { + return undefined; + } + + const report = reports.get(latestTraceId); + return report ? copyReport(report) : undefined; + }; + + const getReportSnapshot = (traceId: string) => { + const report = reports.get(traceId); + return report ? copyReport(report) : undefined; + }; + + const createBrowserReader = (): DiagnosticsBrowserReader => ({ + getEvents: getEventsSnapshot, + getTraceIds: getTraceIdsSnapshot, + getLatestReport: getLatestReportSnapshot, + getReport: getReportSnapshot, + }); + + const shouldExposeBrowserGlobal = (origin?: DiagnosticsRuntimeOrigin) => + options.browser?.enabled === true && + origin?.options?.security?.diagnostics?.browserGlobal === true; + + const ensureBrowserGlobal = (origin?: DiagnosticsRuntimeOrigin) => { + if (!shouldExposeBrowserGlobal(origin)) { + return; + } + + const federationGlobal = getFederationGlobal(); + if (!federationGlobal) { + return; + } + + const scope = normalizeScope( + options.browser?.scope || origin?.options?.name || 'default', + ); + const reader = createBrowserReader(); + + federationGlobal.__DIAGNOSTICS__ = federationGlobal.__DIAGNOSTICS__ || {}; + browserGlobalScope = scope; + + try { + Object.defineProperty(federationGlobal.__DIAGNOSTICS__, scope, { + value: reader, + configurable: true, + enumerable: true, + }); + } catch { + federationGlobal.__DIAGNOSTICS__[scope] = reader; + } + }; + + const shouldUseConsole = (origin?: DiagnosticsRuntimeOrigin) => + options.console !== false && + origin?.options?.security?.diagnostics?.console !== false; + + const getBrowserReadCommand = (traceId: string) => { + if (!browserGlobalScope) { + return undefined; + } + + return `window.__FEDERATION__.__DIAGNOSTICS__[${JSON.stringify( + browserGlobalScope, + )}].getReport(${JSON.stringify(traceId)})`; + }; + + const emitConsoleHint = ( + event: DiagnosticsEvent, + report: DiagnosticsReport, + origin?: DiagnosticsRuntimeOrigin, + ) => { + if ( + getEventOutcome(event) !== 'error' || + !shouldUseConsole(origin) || + consoleReportedTraceIds.has(report.traceId) + ) { + return; + } + + consoleReportedTraceIds.add(report.traceId); + + const lines = [ + '[Module Federation] Diagnostic report generated', + `traceId: ${report.traceId}`, + `phase: ${report.failedPhase || event.phase}`, + ]; + + if (report.requestId) { + lines.push(`requestId: ${report.requestId}`); + } + if (report.shared?.name) { + lines.push(`shared: ${report.shared.name}`); + } + + const browserReadCommand = getBrowserReadCommand(report.traceId); + if (browserReadCommand) { + lines.push(`read: ${browserReadCommand}`); + } else { + lines.push( + `read: diagnostics.getReport(${JSON.stringify(report.traceId)})`, + ); + } + + try { + console.warn(lines.join('\n')); + } catch { + // Console output is best-effort diagnostics only. + } + }; + + const prepareOutputChannels = (origin: DiagnosticsRuntimeOrigin) => { + browserGlobalScope = undefined; + ensureBrowserGlobal(origin); + }; + + const prepareRuntimeOrigin = (origin: DiagnosticsRuntimeOrigin) => { + if (!isEnabledForOrigin(origin)) { + return false; + } + + lastRuntimeOrigin = origin; + prepareOutputChannels(origin); + return true; + }; + + const recordEvent = ( + input: DiagnosticsRuntimeEventInput, + origin?: DiagnosticsRuntimeOrigin, + ) => { + const traceId = resolveTraceId(input); + + if (!shouldRecordEvent(level, input)) { + return undefined; + } + + const event = normalizeEvent(input, traceId, origin); + updateTraceMaps(event); + events.push(event); + const report = updateReport(event); + emitConsoleHint(event, report, origin); + notifyEvent(event, report, origin); + notifyReport(report, origin); + return event; + }; + + const plugin: DiagnosticsRuntimePlugin = { + name: 'diagnostics-plugin', + beforeRequest(args) { + const requestArgs = args as DiagnosticsRemoteBeforeRequestArgs; + if (!prepareRuntimeOrigin(requestArgs.origin)) { + return; + } + + recordEvent( + { + phase: 'loadRemote', + status: 'start', + requestId: requestArgs.id, + lifecycle: 'beforeRequest', + message: 'remote:load-start', + }, + requestArgs.origin, + ); + }, + beforeLoadRemoteSnapshot(args) { + const snapshotArgs = args as DiagnosticsRemoteSnapshotArgs; + if (!prepareRuntimeOrigin(snapshotArgs.origin)) { + return; + } + + const remote = createRemoteInfo(snapshotArgs.moduleInfo); + if (!isManifestUrl(remote?.entry)) { + return; + } + + recordEvent( + { + phase: 'manifest', + status: 'start', + requestId: remote?.entry, + remote, + url: remote?.entry, + lifecycle: 'beforeLoadRemoteSnapshot', + message: 'manifest:load-start', + }, + snapshotArgs.origin, + ); + }, + afterResolve(args) { + const resolveArgs = args as DiagnosticsRemoteResolveArgs; + if (!prepareRuntimeOrigin(resolveArgs.origin)) { + return; + } + + const remote = createRemoteInfo( + resolveArgs.remoteInfo || resolveArgs.remote, + ); + if (!isManifestUrl(remote?.entry)) { + return; + } + + recordEvent( + { + phase: 'manifest', + status: 'success', + requestId: remote?.entry, + expose: resolveArgs.expose, + remote, + url: remote?.entry, + lifecycle: 'afterResolve', + message: 'manifest:resolved', + }, + resolveArgs.origin, + ); + }, + onLoad(args) { + const loadArgs = args as DiagnosticsRemoteLoadArgs; + if (!prepareRuntimeOrigin(loadArgs.origin)) { + return; + } + + recordEvent( + { + phase: 'loadRemote', + status: 'success', + requestId: loadArgs.id, + lifecycle: 'onLoad', + expose: loadArgs.expose, + remote: createRemoteInfo(loadArgs.remote), + message: 'remote:loaded', + }, + loadArgs.origin, + ); + }, + errorLoadRemote(args) { + const errorArgs = args as DiagnosticsRemoteErrorArgs; + if ( + !prepareRuntimeOrigin(errorArgs.origin) || + (errorArgs.lifecycle !== 'onLoad' && + errorArgs.lifecycle !== 'beforeRequest' && + errorArgs.lifecycle !== 'afterResolve') + ) { + return undefined; + } + + const isManifestError = errorArgs.lifecycle === 'afterResolve'; + recordEvent( + { + phase: isManifestError ? 'manifest' : 'loadRemote', + status: 'error', + requestId: errorArgs.id, + lifecycle: errorArgs.lifecycle, + expose: errorArgs.expose, + remote: createRemoteInfo(errorArgs.remote), + url: isManifestError ? errorArgs.id : undefined, + message: isManifestError + ? 'manifest:failed' + : errorArgs.lifecycle + ? `remote:${errorArgs.lifecycle}:failed` + : 'remote:failed', + error: errorArgs.error, + }, + errorArgs.origin, + ); + + return undefined; + }, + afterLoadRemote(args) { + const loadArgs = args as DiagnosticsRemoteAfterLoadArgs; + if (!prepareRuntimeOrigin(loadArgs.origin)) { + return; + } + + recordEvent( + { + phase: 'loadRemote', + status: 'complete', + requestId: loadArgs.id, + lifecycle: 'afterLoadRemote', + expose: loadArgs.expose, + remote: createRemoteInfo(loadArgs.remote), + message: loadArgs.recovered + ? 'remote:load-recovered' + : loadArgs.error + ? 'remote:load-failed' + : 'remote:load-complete', + error: loadArgs.error, + recovered: loadArgs.recovered, + }, + loadArgs.origin, + ); + }, + loadEntry(args) { + const entryArgs = args as DiagnosticsRemoteEntryLoadArgs; + if (!prepareRuntimeOrigin(entryArgs.origin)) { + return; + } + + const remote = createRemoteInfo(entryArgs.remoteInfo); + recordEvent( + { + phase: 'remoteEntry', + status: 'start', + requestId: remote?.name, + remote, + url: remote?.entry, + lifecycle: 'loadEntry', + message: 'remoteEntry:load-start', + }, + entryArgs.origin, + ); + }, + afterLoadEntry(args) { + const entryArgs = args as DiagnosticsRemoteEntryAfterLoadArgs; + if (!prepareRuntimeOrigin(entryArgs.origin)) { + return; + } + + const remote = createRemoteInfo(entryArgs.remoteInfo); + recordEvent( + { + phase: 'remoteEntry', + status: entryArgs.error ? 'error' : 'success', + requestId: remote?.name, + remote, + url: remote?.entry, + lifecycle: 'afterLoadEntry', + message: entryArgs.error + ? 'remoteEntry:load-failed' + : entryArgs.recovered + ? 'remoteEntry:load-recovered' + : 'remoteEntry:loaded', + error: entryArgs.error, + recovered: entryArgs.recovered, + }, + entryArgs.origin, + ); + }, + beforeLoadShare(args) { + if (!prepareRuntimeOrigin(args.origin)) { + return args; + } + + recordEvent( + { + phase: 'shared', + status: 'start', + requestId: `shared:${args.pkgName}`, + lifecycle: 'loadShare', + shared: createSharedInfo(args), + message: 'shared:load-start', + }, + args.origin, + ); + + return args; + }, + afterLoadShare(args) { + if (!prepareRuntimeOrigin(args.origin)) { + return; + } + + recordEvent( + { + phase: 'shared', + status: 'success', + requestId: `shared:${args.pkgName}`, + lifecycle: args.lifecycle, + shared: createSharedInfo(args), + message: + args.lifecycle === 'loadShareSync' + ? 'shared:resolved-sync' + : 'shared:resolved', + }, + args.origin, + ); + }, + errorLoadShare(args) { + if (!prepareRuntimeOrigin(args.origin)) { + return; + } + + const reason = getSharedErrorReason(args); + + recordEvent( + { + phase: 'shared', + status: 'error', + requestId: `shared:${args.pkgName}`, + lifecycle: args.lifecycle, + shared: createSharedInfo(args, reason), + message: reason ? `shared:${reason}` : undefined, + error: args.error, + recovered: args.recovered, + }, + args.origin, + ); + }, + } as DiagnosticsRuntimePlugin; + + return { + plugin, + getEvents() { + return getEventsSnapshot(); + }, + getTraceIds() { + return getTraceIdsSnapshot(); + }, + getLatestReport() { + return getLatestReportSnapshot(); + }, + getReport(traceId: string) { + return getReportSnapshot(traceId); + }, + clear() { + events.length = 0; + reports.clear(); + traceByRequest.clear(); + traceByRemote.clear(); + consoleReportedTraceIds.clear(); + latestTraceId = undefined; + runtimeDiagnosticsEnabled = false; + effectiveMaxEvents = configuredMaxEvents; + browserGlobalScope = undefined; + lastRuntimeOrigin = undefined; + }, + markComponentLoaded(markOptions: MarkComponentLoadedOptions = {}) { + if (options.enabled === false || !runtimeDiagnosticsEnabled) { + return undefined; + } + + const traceId = + markOptions.traceId || + (markOptions.requestId + ? traceByRequest.get(sanitizeRequestId(markOptions.requestId) || '') + : undefined) || + latestTraceId || + createTraceId({ + phase: 'component', + status: 'success', + requestId: markOptions.requestId, + }); + + return recordEvent( + { + traceId, + phase: 'component', + status: 'success', + requestId: markOptions.requestId, + componentName: markOptions.componentName, + eventName: COMPONENT_BUSINESS_LOADED_EVENT, + message: COMPONENT_BUSINESS_LOADED_EVENT, + source: 'business', + }, + lastRuntimeOrigin, + ); + }, + }; +} diff --git a/packages/diagnostics-plugin/src/node.ts b/packages/diagnostics-plugin/src/node.ts new file mode 100644 index 00000000000..19f1da55e5a --- /dev/null +++ b/packages/diagnostics-plugin/src/node.ts @@ -0,0 +1,304 @@ +import { + DiagnosticsPlugin as createBaseDiagnosticsPlugin, + type DiagnosticsController, + type DiagnosticsEvent, + type DiagnosticsEventContext, + type DiagnosticsPluginOptions, + type DiagnosticsReport, + type DiagnosticsRuntimeOrigin, +} from './index'; + +export interface DiagnosticsNodeOptions extends Omit< + DiagnosticsPluginOptions, + 'browser' +> { + directory?: string; + latestFile?: string; + eventsFile?: string; +} + +interface NodeOutputModules { + fs: { + mkdirSync(path: string, options?: { recursive?: boolean }): void; + writeFileSync(path: string, data: string, encoding?: string): void; + appendFileSync(path: string, data: string, encoding?: string): void; + }; + path: { + isAbsolute(path: string): boolean; + join(...paths: string[]): string; + resolve(...paths: string[]): string; + }; +} + +const DEFAULT_NODE_DIRECTORY = '.mf/diagnostics'; +const DEFAULT_LATEST_FILE = 'latest.json'; +const DEFAULT_EVENTS_FILE = 'events.jsonl'; + +let nodeOutputModulesPromise: + | Promise + | undefined; + +declare const __non_webpack_require__: ((id: string) => unknown) | undefined; + +function getNodeProcess(): + | { versions?: { node?: string }; cwd?: () => string } + | undefined { + return ( + globalThis as { + process?: { versions?: { node?: string }; cwd?: () => string }; + } + ).process; +} + +function isNodeEnvironment() { + return Boolean(getNodeProcess()?.versions?.node); +} + +export function getNativeNodeRequire(): ((id: string) => unknown) | undefined { + if (typeof __non_webpack_require__ === 'function') { + return __non_webpack_require__; + } + + const globalRequire = ( + globalThis as { + __non_webpack_require__?: (id: string) => unknown; + } + ).__non_webpack_require__; + if (typeof globalRequire === 'function') { + return globalRequire; + } + + try { + return Function( + 'return typeof require === "function" ? require : undefined', + )() as ((id: string) => unknown) | undefined; + } catch { + return undefined; + } +} + +function getRuntimeImport(): + | ((specifier: string) => Promise>) + | undefined { + try { + return Function('specifier', 'return import(specifier)') as ( + specifier: string, + ) => Promise>; + } catch { + return undefined; + } +} + +function unwrapDefaultExport(moduleValue: T & { default?: T }) { + return moduleValue.default || moduleValue; +} + +async function getNodeOutputModules(): Promise { + if (!isNodeEnvironment()) { + return undefined; + } + + if (nodeOutputModulesPromise) { + return nodeOutputModulesPromise; + } + + nodeOutputModulesPromise = (async () => { + const nativeRequire = getNativeNodeRequire(); + if (nativeRequire) { + try { + const fs = nativeRequire('node:fs'); + const path = nativeRequire('node:path'); + return { + fs, + path, + } as NodeOutputModules; + } catch { + // Fall through to dynamic import for ESM-only Node environments. + } + } + + const runtimeImport = getRuntimeImport(); + if (!runtimeImport) { + return undefined; + } + + try { + const [fsModule, pathModule] = await Promise.all([ + runtimeImport('node:fs'), + runtimeImport('node:path'), + ]); + + return { + fs: unwrapDefaultExport(fsModule), + path: unwrapDefaultExport(pathModule), + } as NodeOutputModules; + } catch { + return undefined; + } + })(); + + return nodeOutputModulesPromise; +} + +function getNodeOutputConfig(options: DiagnosticsNodeOptions) { + return { + directory: options.directory || DEFAULT_NODE_DIRECTORY, + latestFile: options.latestFile || DEFAULT_LATEST_FILE, + eventsFile: options.eventsFile || DEFAULT_EVENTS_FILE, + }; +} + +function shouldUseNodeOutput( + options: DiagnosticsNodeOptions, + origin?: DiagnosticsRuntimeOrigin, +) { + return ( + options.enabled !== false && + origin?.options?.security?.diagnostics?.fileOutput === true && + isNodeEnvironment() + ); +} + +function shouldUseConsole( + options: DiagnosticsNodeOptions, + origin?: DiagnosticsRuntimeOrigin, +) { + return ( + options.console !== false && + origin?.options?.security?.diagnostics?.console !== false + ); +} + +function getNodeLatestPathForConsole(options: DiagnosticsNodeOptions) { + const config = getNodeOutputConfig(options); + return `${config.directory}/${config.latestFile}`; +} + +function getNodeEventsPathForConsole(options: DiagnosticsNodeOptions) { + const config = getNodeOutputConfig(options); + return `${config.directory}/${config.eventsFile}`; +} + +function isErrorEvent(event: DiagnosticsEvent) { + return ( + event.status === 'error' || + (event.status === 'complete' && + Boolean(event.errorName || event.errorMessage)) + ); +} + +async function writeNodeOutput( + options: DiagnosticsNodeOptions, + event: DiagnosticsEvent, + report: DiagnosticsReport, +) { + const modules = await getNodeOutputModules(); + if (!modules) { + return; + } + + const config = getNodeOutputConfig(options); + const cwd = getNodeProcess()?.cwd?.() || '.'; + const directory = modules.path.isAbsolute(config.directory) + ? config.directory + : modules.path.resolve(cwd, config.directory); + const latestFile = modules.path.join(directory, config.latestFile); + const eventsFile = modules.path.join(directory, config.eventsFile); + + modules.fs.mkdirSync(directory, { recursive: true }); + modules.fs.writeFileSync( + latestFile, + `${JSON.stringify(report, null, 2)}\n`, + 'utf8', + ); + modules.fs.appendFileSync(eventsFile, `${JSON.stringify(event)}\n`, 'utf8'); +} + +function emitNodeConsoleHint( + options: DiagnosticsNodeOptions, + event: DiagnosticsEvent, + report: DiagnosticsReport, + context: DiagnosticsEventContext | undefined, + reportedTraceIds: Set, +) { + if ( + !isErrorEvent(event) || + !shouldUseConsole(options, context?.origin) || + reportedTraceIds.has(report.traceId) + ) { + return; + } + + reportedTraceIds.add(report.traceId); + + const lines = [ + '[Module Federation] Diagnostic report generated', + `traceId: ${report.traceId}`, + `phase: ${report.failedPhase || event.phase}`, + ]; + + if (report.requestId) { + lines.push(`requestId: ${report.requestId}`); + } + if (report.shared?.name) { + lines.push(`shared: ${report.shared.name}`); + } + + if (shouldUseNodeOutput(options, context?.origin)) { + lines.push(`latest: ${getNodeLatestPathForConsole(options)}`); + lines.push(`events: ${getNodeEventsPathForConsole(options)}`); + } else { + lines.push( + `read: diagnostics.getReport(${JSON.stringify(report.traceId)})`, + ); + } + + try { + console.warn(lines.join('\n')); + } catch { + // Console output is best-effort diagnostics only. + } +} + +export function DiagnosticsPlugin( + options: DiagnosticsNodeOptions = {}, +): DiagnosticsController { + let nodeWriteQueue: Promise = Promise.resolve(); + const consoleReportedTraceIds = new Set(); + const diagnostics = createBaseDiagnosticsPlugin({ + ...options, + console: false, + browser: undefined, + onEvent(event, report, context) { + if (shouldUseNodeOutput(options, context?.origin)) { + nodeWriteQueue = nodeWriteQueue + .catch(() => undefined) + .then(() => writeNodeOutput(options, event, report)) + .catch(() => undefined); + } + + emitNodeConsoleHint( + options, + event, + report, + context, + consoleReportedTraceIds, + ); + options.onEvent?.(event, report, context); + }, + onReport(report, context) { + options.onReport?.(report, context); + }, + }); + + diagnostics.plugin.name = 'diagnostics-node-plugin'; + + const clear = diagnostics.clear; + diagnostics.clear = () => { + clear(); + nodeWriteQueue = Promise.resolve(); + consoleReportedTraceIds.clear(); + }; + + return diagnostics; +} diff --git a/packages/diagnostics-plugin/tsconfig.json b/packages/diagnostics-plugin/tsconfig.json new file mode 100644 index 00000000000..90b8d931e39 --- /dev/null +++ b/packages/diagnostics-plugin/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/packages/diagnostics-plugin/tsdown.config.ts b/packages/diagnostics-plugin/tsdown.config.ts new file mode 100644 index 00000000000..ed6aed2906a --- /dev/null +++ b/packages/diagnostics-plugin/tsdown.config.ts @@ -0,0 +1,40 @@ +import { defineConfig } from 'tsdown'; + +const entry = { + index: 'src/index.ts', + node: 'src/node.ts', +}; + +const baseConfig = { + cwd: import.meta.dirname, + tsconfig: 'tsconfig.json', + clean: true, + entry, + external: ['@module-federation/runtime'], +}; + +export default defineConfig([ + { + ...baseConfig, + name: 'diagnostics-plugin-cjs', + outDir: 'dist', + format: ['cjs'], + dts: { + resolver: 'tsc', + }, + outExtensions: () => ({ + js: '.js', + dts: '.d.ts', + }), + }, + { + ...baseConfig, + name: 'diagnostics-plugin-esm', + outDir: 'dist/esm', + format: ['esm'], + dts: false, + outExtensions: () => ({ + js: '.js', + }), + }, +]); diff --git a/packages/diagnostics-plugin/vitest.config.ts b/packages/diagnostics-plugin/vitest.config.ts new file mode 100644 index 00000000000..9d82a458863 --- /dev/null +++ b/packages/diagnostics-plugin/vitest.config.ts @@ -0,0 +1,19 @@ +import path from 'path'; +import { defineConfig } from 'vitest/config'; +import tsconfigPaths from 'vite-tsconfig-paths'; + +export default defineConfig({ + define: { + __DEV__: true, + __TEST__: true, + __BROWSER__: false, + __VERSION__: '"unknown"', + }, + plugins: [tsconfigPaths()], + test: { + environment: 'node', + include: [path.resolve(__dirname, '__tests__/*.spec.ts')], + globals: true, + testTimeout: 10000, + }, +}); diff --git a/packages/runtime-core/__tests__/hooks.spec.ts b/packages/runtime-core/__tests__/hooks.spec.ts index 57c8b75229c..4fbce5ab82f 100644 --- a/packages/runtime-core/__tests__/hooks.spec.ts +++ b/packages/runtime-core/__tests__/hooks.spec.ts @@ -3,6 +3,12 @@ import { ModuleFederation } from '../src/core'; import { ModuleFederationRuntimePlugin } from '../src/type/plugin'; import { mockStaticServer, removeScriptTags } from './mock/utils'; import { addGlobalSnapshot } from '../src/global'; +import { + AsyncHook, + AsyncWaterfallHook, + SyncHook, + SyncWaterfallHook, +} from '../src/utils/hooks'; // eslint-disable-next-line max-lines-per-function describe('hooks', () => { @@ -447,4 +453,147 @@ describe('hooks', () => { assert(loadEntryTestRes); expect(loadEntryTestRes).toBe('./testtest'); }); + + it('sync hooks preserve previous returned value when later listeners return nothing', () => { + const hook = new SyncHook<[string], string | void>('sync-noop'); + const calls: Array = []; + + hook.on((value) => { + calls.push(`first:${value}`); + return 'first-result'; + }); + hook.on((value) => { + calls.push(`second:${value}`); + }); + + expect(hook.emit('payload')).toBe('first-result'); + expect(calls).toEqual(['first:payload', 'second:payload']); + }); + + it('sync hooks use the latest explicit returned value', () => { + const hook = new SyncHook<[string], string | void>('sync-override'); + + hook.on(() => 'first-result'); + hook.on(() => 'second-result'); + + expect(hook.emit('payload')).toBe('second-result'); + }); + + it('async hooks preserve previous returned value when later listeners return nothing', async () => { + const hook = new AsyncHook<[string], string | void | false>('async-noop'); + const calls: Array = []; + + hook.on(async (value) => { + calls.push(`first:${value}`); + return 'first-result'; + }); + hook.on((value) => { + calls.push(`second:${value}`); + }); + + await expect(hook.emit('payload')).resolves.toBe('first-result'); + expect(calls).toEqual(['first:payload', 'second:payload']); + }); + + it('async hooks use the latest explicit returned value', async () => { + const hook = new AsyncHook<[string], string | void | false>( + 'async-override', + ); + + hook.on(async () => 'first-result'); + hook.on(() => 'second-result'); + + await expect(hook.emit('payload')).resolves.toBe('second-result'); + }); + + it('async hooks still abort when a listener returns false', async () => { + const hook = new AsyncHook<[string], string | void | false>('async-abort'); + const calls: Array = []; + + hook.on(() => { + calls.push('first'); + return 'first-result'; + }); + hook.on(() => { + calls.push('second'); + return false; + }); + hook.on(() => { + calls.push('third'); + return 'third-result'; + }); + + await expect(hook.emit('payload')).resolves.toBe(false); + expect(calls).toEqual(['first', 'second']); + }); + + it('sync waterfall hooks keep the current payload when observers return nothing', () => { + const hook = new SyncWaterfallHook<{ id: string; changed?: boolean }>( + 'sync-waterfall-noop', + ); + + hook.on((args) => ({ + ...args, + changed: true, + })); + hook.on(() => undefined); + + expect(hook.emit({ id: 'remote/Button' })).toEqual({ + id: 'remote/Button', + changed: true, + }); + }); + + it('async waterfall hooks keep the current payload when observers return nothing', async () => { + const hook = new AsyncWaterfallHook<{ id: string; changed?: boolean }>( + 'async-waterfall-noop', + ); + + hook.on(async (args) => ({ + ...args, + changed: true, + })); + hook.on(() => undefined); + + await expect(hook.emit({ id: 'remote/Button' })).resolves.toEqual({ + id: 'remote/Button', + changed: true, + }); + }); + + it('observer plugins do not clear errorLoadRemote fallback results', async () => { + let observedLifecycle: string | undefined; + const fallbackPlugin: ModuleFederationRuntimePlugin = { + name: 'fallback-plugin', + errorLoadRemote() { + return { + default: () => 'fallback component', + }; + }, + }; + const observerPlugin: ModuleFederationRuntimePlugin = { + name: 'observer-plugin', + errorLoadRemote(args) { + observedLifecycle = args.lifecycle; + }, + }; + const GM = new ModuleFederation({ + name: '@hooks/error-load-remote-fallback', + remotes: [], + plugins: [fallbackPlugin, observerPlugin], + }); + + const result = (await GM.remoteHandler.hooks.lifecycle.errorLoadRemote.emit( + { + id: '@demo/fallback/component', + error: new Error('load failed'), + from: 'runtime', + lifecycle: 'onLoad', + origin: GM, + }, + )) as { default: () => string }; + + expect(result.default()).toBe('fallback component'); + expect(observedLifecycle).toBe('onLoad'); + }); }); diff --git a/packages/runtime-core/__tests__/load-remote-diagnostics.spec.ts b/packages/runtime-core/__tests__/load-remote-diagnostics.spec.ts new file mode 100644 index 00000000000..5cba8a25931 --- /dev/null +++ b/packages/runtime-core/__tests__/load-remote-diagnostics.spec.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { ModuleFederation } from '../src/core'; +import { resetFederationGlobalInfo } from '../src/global'; +import type { ModuleFederationRuntimePlugin } from '../src/type'; +import { mockStaticServer, removeScriptTags } from './mock/utils'; + +const createDiagnosticsRecorder = ( + events: Array>, +): ModuleFederationRuntimePlugin => ({ + name: 'load-remote-diagnostics-test-plugin', + afterLoadRemote(args) { + events.push(args); + }, +}); + +describe('loadRemote diagnostics', () => { + mockStaticServer({ + baseDir: __dirname, + filterKeywords: [], + basename: 'http://localhost:1111/', + }); + + beforeEach(() => { + resetFederationGlobalInfo(); + removeScriptTags(); + }); + + it('emits an afterLoadRemote hook after a successful remote load', async () => { + const events: Array> = []; + const mf = new ModuleFederation({ + name: 'load-remote-diagnostics-host', + remotes: [ + { + name: '@demo/main', + entry: + 'http://localhost:1111/resources/main/federation-manifest.json', + }, + ], + plugins: [createDiagnosticsRecorder(events)], + }); + + const say = await mf.loadRemote<() => string>('@demo/main/say'); + + expect(say?.()).toBe('hello world'); + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: '@demo/main/say', + expose: './say', + }), + ]), + ); + }); +}); diff --git a/packages/runtime-core/__tests__/shared-diagnostics.spec.ts b/packages/runtime-core/__tests__/shared-diagnostics.spec.ts new file mode 100644 index 00000000000..c6a6088086c --- /dev/null +++ b/packages/runtime-core/__tests__/shared-diagnostics.spec.ts @@ -0,0 +1,166 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { RUNTIME_005 } from '@module-federation/error-codes'; +import { ModuleFederation } from '../src/core'; +import { resetFederationGlobalInfo } from '../src/global'; +import type { ModuleFederationRuntimePlugin } from '../src/type'; + +type SharedLifecycleEvent = + | { type: 'before'; pkgName: string } + | { + type: 'after'; + pkgName: string; + lifecycle: string; + selectedVersion?: string; + provider?: string; + } + | { + type: 'error'; + pkgName: string; + lifecycle: string; + recovered?: boolean; + availableVersions: string[]; + error?: unknown; + }; + +const createSharedLifecyclePlugin = ( + events: SharedLifecycleEvent[], +): ModuleFederationRuntimePlugin => ({ + name: 'shared-lifecycle-test-plugin', + beforeLoadShare(args) { + events.push({ + type: 'before', + pkgName: args.pkgName, + }); + return args; + }, + afterLoadShare(args) { + events.push({ + type: 'after', + pkgName: args.pkgName, + lifecycle: args.lifecycle, + selectedVersion: args.selectedShared?.version, + provider: args.selectedShared?.from, + }); + }, + errorLoadShare(args) { + events.push({ + type: 'error', + pkgName: args.pkgName, + lifecycle: args.lifecycle, + recovered: args.recovered, + availableVersions: Object.keys( + args.shareScopeMap.default?.[args.pkgName] || {}, + ), + error: args.error, + }); + }, +}); + +describe('shared lifecycle hooks', () => { + beforeEach(() => { + resetFederationGlobalInfo(); + }); + + it('emits beforeLoadShare and afterLoadShare for loadShare success', async () => { + const events: SharedLifecycleEvent[] = []; + const mf = new ModuleFederation({ + name: 'shared-lifecycle-host', + remotes: [], + plugins: [createSharedLifecyclePlugin(events)], + shared: { + 'diagnostics-shared': { + version: '1.0.0', + lib: () => ({ value: 'shared' }), + }, + }, + }); + + const factory = await mf.loadShare<{ value: string }>('diagnostics-shared'); + + expect(factory?.()).toEqual({ value: 'shared' }); + expect(events).toEqual([ + { + type: 'before', + pkgName: 'diagnostics-shared', + }, + { + type: 'after', + pkgName: 'diagnostics-shared', + lifecycle: 'loadShare', + selectedVersion: '1.0.0', + provider: 'shared-lifecycle-host', + }, + ]); + }); + + it('emits errorLoadShare when custom shared info cannot be matched', async () => { + const events: SharedLifecycleEvent[] = []; + const mf = new ModuleFederation({ + name: 'shared-lifecycle-version-host', + remotes: [], + plugins: [createSharedLifecyclePlugin(events)], + shared: { + react: { + version: '18.3.1', + lib: () => ({ version: '18.3.1' }), + }, + }, + }); + + const result = await mf.loadShare('react', { + customShareInfo: { + shareConfig: { + requiredVersion: '^99.0.0', + singleton: false, + }, + }, + }); + + expect(result).toBe(false); + expect(events.at(-1)).toEqual({ + type: 'error', + pkgName: 'react', + lifecycle: 'loadShare', + recovered: true, + availableVersions: ['18.3.1'], + error: undefined, + }); + }); + + it('emits errorLoadShare for async shared consumed synchronously', () => { + const events: SharedLifecycleEvent[] = []; + const mf = new ModuleFederation({ + name: 'shared-lifecycle-eager-host', + remotes: [], + plugins: [createSharedLifecyclePlugin(events)], + shared: {}, + }); + + expect(() => + mf.loadShareSync('diagnostics-async-shared', { + from: 'build', + customShareInfo: { + version: '1.0.0', + scope: ['default'], + shareConfig: { + requiredVersion: '^1.0.0', + singleton: false, + eager: false, + strictVersion: false, + }, + get: () => Promise.resolve(() => ({ value: 'async' })), + }, + }), + ).toThrow(RUNTIME_005); + + const errorEvent = events.at(-1); + expect(errorEvent).toMatchObject({ + type: 'error', + pkgName: 'diagnostics-async-shared', + lifecycle: 'loadShareSync', + recovered: undefined, + availableVersions: [], + }); + expect(errorEvent?.error).toBeInstanceOf(Error); + }); +}); diff --git a/packages/runtime-core/src/core.ts b/packages/runtime-core/src/core.ts index 80abc579a80..0df0e4e0a66 100644 --- a/packages/runtime-core/src/core.ts +++ b/packages/runtime-core/src/core.ts @@ -159,6 +159,18 @@ export class ModuleFederation { ], Promise | undefined> >(), + afterLoadEntry: new AsyncHook< + [ + { + origin: ModuleFederation; + remoteInfo: RemoteInfo; + remoteEntryExports?: RemoteEntryExports | false | void; + error?: unknown; + recovered?: boolean; + }, + ], + void + >('afterLoadEntry'), getModuleFactory: new AsyncHook< [ { diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index 5efa2c6fb47..da6bb27a4e2 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -15,7 +15,11 @@ export { getGlobalSnapshot, getInfoWithoutType, } from './global'; -export type { UserOptions, ModuleFederationRuntimePlugin } from './type'; +export type { + UserOptions, + ModuleFederationRuntimePlugin, + SecurityOptions, +} from './type'; export { assert, error } from './utils/logger'; export { registerGlobalPlugins } from './global'; export { diff --git a/packages/runtime-core/src/plugins/snapshot/SnapshotHandler.ts b/packages/runtime-core/src/plugins/snapshot/SnapshotHandler.ts index d5a44bd946d..4a0b04e729b 100644 --- a/packages/runtime-core/src/plugins/snapshot/SnapshotHandler.ts +++ b/packages/runtime-core/src/plugins/snapshot/SnapshotHandler.ts @@ -81,6 +81,7 @@ export class SnapshotHandler { { options: Options; moduleInfo: Remote; + origin: ModuleFederation; }, ], void @@ -137,6 +138,7 @@ export class SnapshotHandler { await this.hooks.lifecycle.beforeLoadRemoteSnapshot.emit({ options, moduleInfo, + origin: this.HostInstance, }); let hostSnapshot = getGlobalSnapshotInfoByModuleInfo({ @@ -291,6 +293,7 @@ export class SnapshotHandler { extraOptions: Record, ): Promise { const getManifest = async (): Promise => { + const remoteInfo = getRemoteInfo(moduleInfo); let manifestJson: Manifest | undefined = this.manifestCache.get(manifestUrl); if (manifestJson) { @@ -300,7 +303,7 @@ export class SnapshotHandler { let res = await this.loaderHook.lifecycle.fetch.emit( manifestUrl, {}, - getRemoteInfo(moduleInfo), + remoteInfo, ); if (!res || !(res instanceof Response)) { res = await fetch(manifestUrl, {}); @@ -314,6 +317,7 @@ export class SnapshotHandler { error: err, from: 'runtime', lifecycle: 'afterResolve', + remote: remoteInfo, origin: this.HostInstance, }, )) as Manifest | undefined; @@ -334,9 +338,29 @@ export class SnapshotHandler { } } + const missingRequiredFields = [ + !manifestJson.metaData && 'metaData', + !manifestJson.exposes && 'exposes', + !manifestJson.shared && 'shared', + ].filter(Boolean); + if (missingRequiredFields.length > 0) { + await this.HostInstance.remoteHandler.hooks.lifecycle.errorLoadRemote.emit( + { + id: manifestUrl, + error: new Error( + `"${manifestUrl}" is not a valid federation manifest for remote "${moduleInfo.name}". Missing required fields: ${missingRequiredFields.join(', ')}.`, + ), + from: 'runtime', + lifecycle: 'afterResolve', + remote: remoteInfo, + origin: this.HostInstance, + }, + ); + } + assert( manifestJson.metaData && manifestJson.exposes && manifestJson.shared, - `"${manifestUrl}" is not a valid federation manifest for remote "${moduleInfo.name}". Missing required fields: ${[!manifestJson.metaData && 'metaData', !manifestJson.exposes && 'exposes', !manifestJson.shared && 'shared'].filter(Boolean).join(', ')}.`, + `"${manifestUrl}" is not a valid federation manifest for remote "${moduleInfo.name}". Missing required fields: ${missingRequiredFields.join(', ')}.`, ); this.manifestCache.set(manifestUrl, manifestJson); return manifestJson; diff --git a/packages/runtime-core/src/remote/index.ts b/packages/runtime-core/src/remote/index.ts index f8eca7ea8c3..b981220a16e 100644 --- a/packages/runtime-core/src/remote/index.ts +++ b/packages/runtime-core/src/remote/index.ts @@ -91,6 +91,23 @@ export class RemoteHandler { ], void >('onLoad'), + afterLoadRemote: new AsyncHook< + [ + { + id: string; + expose?: string; + remote?: RemoteInfo; + options?: { + loadFactory?: boolean; + from?: CallFrom; + }; + error?: unknown; + recovered?: boolean; + origin: ModuleFederation; + }, + ], + void + >('afterLoadRemote'), handlePreloadModule: new SyncHook< [ { @@ -116,6 +133,8 @@ export class RemoteHandler { | 'beforeLoadShare' | 'afterResolve' | 'onLoad'; + remote?: RemoteInfo; + expose?: string; origin: ModuleFederation; }, ], @@ -153,12 +172,13 @@ export class RemoteHandler { loadEntry: new AsyncHook< [ { + origin: ModuleFederation; loaderHook: ModuleFederation['loaderHook']; remoteInfo: RemoteInfo; remoteEntryExports?: RemoteEntryExports; }, ], - Promise + Promise | RemoteEntryExports | void >(), }); @@ -199,6 +219,21 @@ export class RemoteHandler { options?: { loadFactory?: boolean; from: CallFrom }, ): Promise { const { host } = this; + const startMatchInfo = matchRemoteWithNameAndExpose( + host.options.remotes, + id, + ); + let completeRequestId = id; + let completeExpose = startMatchInfo?.expose; + let completeRemote = startMatchInfo + ? getRemoteInfo(startMatchInfo.remote) + : undefined; + let afterLoadRemoteArgs: + | Parameters< + RemoteHandler['hooks']['lifecycle']['afterLoadRemote']['emit'] + >[0] + | undefined; + try { const { loadFactory = true } = options || { loadFactory: true, @@ -221,6 +256,9 @@ export class RemoteHandler { id: idRes, remoteSnapshot, } = remoteMatchInfo; + completeRequestId = idRes; + completeExpose = expose; + completeRemote = getRemoteInfo(remote); const moduleOrFactory = (await module.get( idRes, @@ -242,6 +280,14 @@ export class RemoteHandler { }); this.setIdToRemoteMap(id, remoteMatchInfo); + afterLoadRemoteArgs = { + id: completeRequestId, + expose: completeExpose, + remote: completeRemote, + options, + origin: host, + }; + if (typeof moduleWrapper === 'function') { return moduleWrapper as T; } @@ -250,19 +296,56 @@ export class RemoteHandler { } catch (error) { const { from = 'runtime' } = options || { from: 'runtime' }; - const failOver = await this.hooks.lifecycle.errorLoadRemote.emit({ - id, - error, - from, - lifecycle: 'onLoad', - origin: host, - }); + let failOver; + try { + failOver = await this.hooks.lifecycle.errorLoadRemote.emit({ + id, + error, + from, + lifecycle: 'onLoad', + expose: completeExpose, + remote: completeRemote, + origin: host, + }); + } catch (hookError) { + afterLoadRemoteArgs = { + id: completeRequestId, + expose: completeExpose, + remote: completeRemote, + options, + error: hookError, + origin: host, + }; + throw hookError; + } if (!failOver) { + afterLoadRemoteArgs = { + id: completeRequestId, + expose: completeExpose, + remote: completeRemote, + options, + error, + origin: host, + }; throw error; } + afterLoadRemoteArgs = { + id: completeRequestId, + expose: completeExpose, + remote: completeRemote, + options, + error, + origin: host, + recovered: true, + }; + return failOver as T; + } finally { + if (afterLoadRemoteArgs) { + await this.hooks.lifecycle.afterLoadRemote.emit(afterLoadRemoteArgs); + } } } diff --git a/packages/runtime-core/src/shared/index.ts b/packages/runtime-core/src/shared/index.ts index 50b6b30d12a..07ca4d05942 100644 --- a/packages/runtime-core/src/shared/index.ts +++ b/packages/runtime-core/src/shared/index.ts @@ -23,6 +23,7 @@ import { AsyncHook, AsyncWaterfallHook, SyncWaterfallHook, + SyncHook, } from '../utils/hooks'; import { formatShareConfigs, @@ -33,7 +34,13 @@ import { shouldUseTreeShaking, addUseIn, } from '../utils/share'; -import { assert, error, addUniqueItem, optionsToMFContext } from '../utils'; +import { + assert, + error, + addUniqueItem, + optionsToMFContext, + warn, +} from '../utils'; import { DEFAULT_SCOPE } from '../constant'; import { LoadRemoteMatch } from '../remote'; import { createRemoteEntryInitOptions } from '../module'; @@ -56,6 +63,35 @@ export class SharedHandler { }>('beforeLoadShare'), // not used yet loadShare: new AsyncHook<[ModuleFederation, string, ShareInfos]>(), + afterLoadShare: new SyncHook< + [ + { + pkgName: string; + shareInfo?: Partial; + selectedShared?: Partial; + shared: Options['shared']; + shareScopeMap: ShareScopeMap; + lifecycle: 'loadShare' | 'loadShareSync'; + origin: ModuleFederation; + }, + ], + void + >('afterLoadShare'), + errorLoadShare: new SyncHook< + [ + { + pkgName: string; + shareInfo?: Partial; + shared: Options['shared']; + shareScopeMap: ShareScopeMap; + lifecycle: 'loadShare' | 'loadShareSync'; + origin: ModuleFederation; + error?: unknown; + recovered?: boolean; + }, + ], + void + >('errorLoadShare'), resolveShare: new SyncWaterfallHook<{ shareScopeMap: ShareScopeMap; scope: string; @@ -82,6 +118,61 @@ export class SharedHandler { this._setGlobalShareScopeMap(host.options); } + private emitAfterLoadShare({ + lifecycle, + pkgName, + shareInfo, + selectedShared, + }: { + lifecycle: 'loadShare' | 'loadShareSync'; + pkgName: string; + shareInfo?: Partial; + selectedShared?: Partial; + }): void { + try { + this.hooks.lifecycle.afterLoadShare.emit({ + pkgName, + shareInfo, + selectedShared, + shared: this.host.options.shared, + shareScopeMap: this.shareScopeMap, + lifecycle, + origin: this.host, + }); + } catch (error) { + warn(error); + } + } + + private emitErrorLoadShare({ + lifecycle, + pkgName, + shareInfo, + error, + recovered, + }: { + lifecycle: 'loadShare' | 'loadShareSync'; + pkgName: string; + shareInfo?: Partial; + error?: unknown; + recovered?: boolean; + }): void { + try { + this.hooks.lifecycle.errorLoadShare.emit({ + pkgName, + shareInfo, + shared: this.host.options.shared, + shareScopeMap: this.shareScopeMap, + lifecycle, + origin: this.host, + error, + recovered, + }); + } catch (hookError) { + warn(hookError); + } + } + // register shared in shareScopeMap registerShared(globalOptions: Options, userOptions: UserOptions) { const { newShareInfos, allShareInfos } = formatShareConfigs( @@ -138,117 +229,163 @@ export class SharedHandler { extraOptions, shareInfos: host.options.shared, }); + let shareOptionsRes: Shared | undefined = shareOptions; - if (shareOptions?.scope) { - await Promise.all( - shareOptions.scope.map(async (shareScope) => { - await Promise.all( - this.initializeSharing(shareScope, { - strategy: shareOptions.strategy, - }), - ); - return; - }), - ); - } - const loadShareRes = await this.hooks.lifecycle.beforeLoadShare.emit({ - pkgName, - shareInfo: shareOptions, - shared: host.options.shared, - origin: host, - }); - - const { shareInfo: shareOptionsRes } = loadShareRes; + try { + if (shareOptions?.scope) { + await Promise.all( + shareOptions.scope.map(async (shareScope) => { + await Promise.all( + this.initializeSharing(shareScope, { + strategy: shareOptions.strategy, + }), + ); + return; + }), + ); + } + const loadShareRes = await this.hooks.lifecycle.beforeLoadShare.emit({ + pkgName, + shareInfo: shareOptions, + shared: host.options.shared, + origin: host, + }); - // Assert that shareInfoRes exists, if not, throw an error - assert( - shareOptionsRes, - `Cannot find shared "${pkgName}" in host "${host.options.name}". Ensure the shared config for "${pkgName}" is declared in the federation plugin options and the host has been initialized before loading shares.`, - ); + shareOptionsRes = loadShareRes.shareInfo; - const { shared: registeredShared, useTreesShaking } = - getRegisteredShare( - this.shareScopeMap, - pkgName, + // Assert that shareInfoRes exists, if not, throw an error + assert( shareOptionsRes, - this.hooks.lifecycle.resolveShare, - ) || {}; - - if (registeredShared) { - const targetShared = directShare(registeredShared, useTreesShaking); - if (targetShared.lib) { - addUseIn(targetShared, host.options.name); - return targetShared.lib as () => T; - } else if (targetShared.loading && !targetShared.loaded) { - const factory = await targetShared.loading; - targetShared.loaded = true; - if (!targetShared.lib) { - targetShared.lib = factory; + `Cannot find shared "${pkgName}" in host "${host.options.name}". Ensure the shared config for "${pkgName}" is declared in the federation plugin options and the host has been initialized before loading shares.`, + ); + const resolvedShareOptions = shareOptionsRes; + + const { shared: registeredShared, useTreesShaking } = + getRegisteredShare( + this.shareScopeMap, + pkgName, + shareOptionsRes, + this.hooks.lifecycle.resolveShare, + ) || {}; + + if (registeredShared) { + const targetShared = directShare(registeredShared, useTreesShaking); + if (targetShared.lib) { + addUseIn(targetShared, host.options.name); + this.emitAfterLoadShare({ + lifecycle: 'loadShare', + pkgName, + shareInfo: resolvedShareOptions, + selectedShared: registeredShared, + }); + return targetShared.lib as () => T; + } else if (targetShared.loading && !targetShared.loaded) { + const factory = await targetShared.loading; + targetShared.loaded = true; + if (!targetShared.lib) { + targetShared.lib = factory; + } + addUseIn(targetShared, host.options.name); + this.emitAfterLoadShare({ + lifecycle: 'loadShare', + pkgName, + shareInfo: resolvedShareOptions, + selectedShared: registeredShared, + }); + return factory; + } else { + const asyncLoadProcess = async () => { + const factory = await targetShared.get!(); + addUseIn(targetShared, host.options.name); + targetShared.loaded = true; + targetShared.lib = factory; + return factory as () => T; + }; + const loading = asyncLoadProcess(); + this.setShared({ + pkgName, + loaded: false, + shared: registeredShared, + from: host.options.name, + lib: null, + loading, + treeShaking: useTreesShaking + ? (targetShared as TreeShakingArgs) + : undefined, + }); + const factory = await loading; + this.emitAfterLoadShare({ + lifecycle: 'loadShare', + pkgName, + shareInfo: resolvedShareOptions, + selectedShared: registeredShared, + }); + return factory; } - addUseIn(targetShared, host.options.name); - return factory; } else { + if (extraOptions?.customShareInfo) { + this.emitErrorLoadShare({ + lifecycle: 'loadShare', + pkgName, + shareInfo: resolvedShareOptions, + recovered: true, + }); + return false; + } + const _useTreeShaking = shouldUseTreeShaking( + resolvedShareOptions.treeShaking, + ); + const targetShared = directShare(resolvedShareOptions, _useTreeShaking); + const asyncLoadProcess = async () => { const factory = await targetShared.get!(); - addUseIn(targetShared, host.options.name); - targetShared.loaded = true; targetShared.lib = factory; + targetShared.loaded = true; + addUseIn(targetShared, host.options.name); + const { shared: gShared, useTreesShaking: gUseTreeShaking } = + getRegisteredShare( + this.shareScopeMap, + pkgName, + resolvedShareOptions, + this.hooks.lifecycle.resolveShare, + ) || {}; + if (gShared) { + const targetGShared = directShare(gShared, gUseTreeShaking); + targetGShared.lib = factory; + targetGShared.loaded = true; + gShared.from = resolvedShareOptions.from; + } return factory as () => T; }; const loading = asyncLoadProcess(); this.setShared({ pkgName, loaded: false, - shared: registeredShared, + shared: resolvedShareOptions, from: host.options.name, lib: null, loading, - treeShaking: useTreesShaking + treeShaking: _useTreeShaking ? (targetShared as TreeShakingArgs) : undefined, }); - return loading; - } - } else { - if (extraOptions?.customShareInfo) { - return false; + const factory = await loading; + this.emitAfterLoadShare({ + lifecycle: 'loadShare', + pkgName, + shareInfo: resolvedShareOptions, + selectedShared: resolvedShareOptions, + }); + return factory; } - const _useTreeShaking = shouldUseTreeShaking(shareOptionsRes.treeShaking); - const targetShared = directShare(shareOptionsRes, _useTreeShaking); - - const asyncLoadProcess = async () => { - const factory = await targetShared.get!(); - targetShared.lib = factory; - targetShared.loaded = true; - addUseIn(targetShared, host.options.name); - const { shared: gShared, useTreesShaking: gUseTreeShaking } = - getRegisteredShare( - this.shareScopeMap, - pkgName, - shareOptionsRes, - this.hooks.lifecycle.resolveShare, - ) || {}; - if (gShared) { - const targetGShared = directShare(gShared, gUseTreeShaking); - targetGShared.lib = factory; - targetGShared.loaded = true; - gShared.from = shareOptionsRes.from; - } - return factory as () => T; - }; - const loading = asyncLoadProcess(); - this.setShared({ + } catch (shareError) { + this.emitErrorLoadShare({ + lifecycle: 'loadShare', pkgName, - loaded: false, - shared: shareOptionsRes, - from: host.options.name, - lib: null, - loading, - treeShaking: _useTreeShaking - ? (targetShared as TreeShakingArgs) - : undefined, + shareInfo: shareOptionsRes, + error: shareError, }); - return loading; + throw shareError; } } @@ -326,6 +463,7 @@ export class SharedHandler { error, from: 'runtime', lifecycle: 'beforeLoadShare', + remote: module.remoteInfo, origin: host, })) as RemoteEntryExports; if (!remoteEntryExports) { @@ -381,93 +519,129 @@ export class SharedHandler { shareInfos: host.options.shared, }); - if (shareOptions?.scope) { - shareOptions.scope.forEach((shareScope) => { - this.initializeSharing(shareScope, { strategy: shareOptions.strategy }); - }); - } - const { shared: registeredShared, useTreesShaking } = - getRegisteredShare( - this.shareScopeMap, - pkgName, - shareOptions, - this.hooks.lifecycle.resolveShare, - ) || {}; - - if (registeredShared) { - if (typeof registeredShared.lib === 'function') { - addUseIn(registeredShared, host.options.name); - if (!registeredShared.loaded) { - registeredShared.loaded = true; - if (registeredShared.from === host.options.name) { - shareOptions.loaded = true; - } - } - return registeredShared.lib as () => T; + try { + if (shareOptions?.scope) { + shareOptions.scope.forEach((shareScope) => { + this.initializeSharing(shareScope, { + strategy: shareOptions.strategy, + }); + }); } - if (typeof registeredShared.get === 'function') { - const module = registeredShared.get(); - if (!(module instanceof Promise)) { + const { shared: registeredShared } = + getRegisteredShare( + this.shareScopeMap, + pkgName, + shareOptions, + this.hooks.lifecycle.resolveShare, + ) || {}; + + if (registeredShared) { + if (typeof registeredShared.lib === 'function') { addUseIn(registeredShared, host.options.name); - this.setShared({ + if (!registeredShared.loaded) { + registeredShared.loaded = true; + if (registeredShared.from === host.options.name) { + shareOptions.loaded = true; + } + } + this.emitAfterLoadShare({ + lifecycle: 'loadShareSync', pkgName, - loaded: true, - from: host.options.name, - lib: module, - shared: registeredShared, + shareInfo: shareOptions, + selectedShared: registeredShared, }); - return module; + return registeredShared.lib as () => T; + } + if (typeof registeredShared.get === 'function') { + const module = registeredShared.get(); + if (!(module instanceof Promise)) { + addUseIn(registeredShared, host.options.name); + this.setShared({ + pkgName, + loaded: true, + from: host.options.name, + lib: module, + shared: registeredShared, + }); + this.emitAfterLoadShare({ + lifecycle: 'loadShareSync', + pkgName, + shareInfo: shareOptions, + selectedShared: registeredShared, + }); + return module; + } } } - } - if (shareOptions.lib) { - if (!shareOptions.loaded) { - shareOptions.loaded = true; + if (shareOptions.lib) { + if (!shareOptions.loaded) { + shareOptions.loaded = true; + } + this.emitAfterLoadShare({ + lifecycle: 'loadShareSync', + pkgName, + shareInfo: shareOptions, + selectedShared: shareOptions, + }); + return shareOptions.lib as () => T; } - return shareOptions.lib as () => T; - } - if (shareOptions.get) { - const module = shareOptions.get(); - - if (module instanceof Promise) { - const errorCode = - extraOptions?.from === 'build' ? RUNTIME_005 : RUNTIME_006; - error( - errorCode, - runtimeDescMap, - { - hostName: host.options.name, - sharedPkgName: pkgName, - }, - undefined, - optionsToMFContext(host.options), - ); - } + if (shareOptions.get) { + const module = shareOptions.get(); - shareOptions.lib = module; + if (module instanceof Promise) { + const errorCode = + extraOptions?.from === 'build' ? RUNTIME_005 : RUNTIME_006; + error( + errorCode, + runtimeDescMap, + { + hostName: host.options.name, + sharedPkgName: pkgName, + }, + undefined, + optionsToMFContext(host.options), + ); + } + + shareOptions.lib = module; - this.setShared({ + this.setShared({ + pkgName, + loaded: true, + from: host.options.name, + lib: shareOptions.lib, + shared: shareOptions, + }); + this.emitAfterLoadShare({ + lifecycle: 'loadShareSync', + pkgName, + shareInfo: shareOptions, + selectedShared: shareOptions, + }); + return shareOptions.lib as () => T; + } + + error( + RUNTIME_006, + runtimeDescMap, + { + hostName: host.options.name, + sharedPkgName: pkgName, + }, + undefined, + optionsToMFContext(host.options), + ); + } catch (shareError) { + this.emitErrorLoadShare({ + lifecycle: 'loadShareSync', pkgName, - loaded: true, - from: host.options.name, - lib: shareOptions.lib, - shared: shareOptions, + shareInfo: shareOptions, + error: shareError, }); - return shareOptions.lib as () => T; + throw shareError; } - - error( - RUNTIME_006, - runtimeDescMap, - { - hostName: host.options.name, - sharedPkgName: pkgName, - }, - undefined, - optionsToMFContext(host.options), - ); } initShareScopeMap( diff --git a/packages/runtime-core/src/type/config.ts b/packages/runtime-core/src/type/config.ts index 98c6c357bbf..7f4c3a4fe39 100644 --- a/packages/runtime-core/src/type/config.ts +++ b/packages/runtime-core/src/type/config.ts @@ -122,6 +122,16 @@ export type ShareInfos = { [pkgName: string]: Shared[]; }; +export interface SecurityOptions { + diagnostics?: { + enabled?: boolean; + maxEvents?: number; + console?: boolean; + browserGlobal?: boolean; + fileOutput?: boolean; + }; +} + export interface Options { id?: string; name: string; @@ -131,6 +141,7 @@ export interface Options { plugins: Array; inBrowser: boolean; shareStrategy?: ShareStrategy; + security?: SecurityOptions; } export type UserOptions = Omit< diff --git a/packages/runtime-core/src/utils/hooks/asyncHook.ts b/packages/runtime-core/src/utils/hooks/asyncHook.ts index d9692b2d914..aad9fb182f6 100644 --- a/packages/runtime-core/src/utils/hooks/asyncHook.ts +++ b/packages/runtime-core/src/utils/hooks/asyncHook.ts @@ -13,17 +13,24 @@ export class AsyncHook< const ls = Array.from(this.listeners); if (ls.length > 0) { let i = 0; - const call = (prev?: any): any => { + const call = (prev?: unknown): unknown => { if (prev === false) { return false; // Abort process } else if (i < ls.length) { - return Promise.resolve(ls[i++].apply(null, data)).then(call); + return Promise.resolve(ls[i++].apply(null, data)).then((result) => { + if (result === undefined) { + return call(prev); + } + return call(result); + }); } else { return prev; } }; result = call(); } - return Promise.resolve(result); + return Promise.resolve(result) as Promise< + void | false | ExternalEmitReturnType + >; } } diff --git a/packages/runtime-core/src/utils/hooks/asyncWaterfallHooks.ts b/packages/runtime-core/src/utils/hooks/asyncWaterfallHooks.ts index 3660d449444..43098a80c91 100644 --- a/packages/runtime-core/src/utils/hooks/asyncWaterfallHooks.ts +++ b/packages/runtime-core/src/utils/hooks/asyncWaterfallHooks.ts @@ -3,9 +3,9 @@ import { isObject } from '../tool'; import { SyncHook } from './syncHook'; import { checkReturnData } from './syncWaterfallHook'; -type CallbackReturnType = T | Promise; +type CallbackReturnType = T | void | Promise; -export class AsyncWaterfallHook> extends SyncHook< +export class AsyncWaterfallHook extends SyncHook< [T], CallbackReturnType > { @@ -23,26 +23,27 @@ export class AsyncWaterfallHook> extends SyncHook< if (ls.length > 0) { let i = 0; - const processError = (e: any) => { + const processError = (e: unknown): T => { warn(e); this.onerror(e); return data; }; - const call = (prevData: T): any => { - if (checkReturnData(data, prevData)) { + const call = (prevData?: T | Awaited | void): T | Promise => { + if (prevData !== undefined && checkReturnData(data, prevData)) { data = prevData as T; - if (i < ls.length) { - try { - return Promise.resolve(ls[i++](data)).then(call, processError); - } catch (e) { - return processError(e); - } - } - } else { + } else if (prevData !== undefined) { this.onerror( `A plugin returned an incorrect value for the "${this.type}" type.`, ); + return data; + } + if (i < ls.length) { + try { + return Promise.resolve(ls[i++](data)).then(call, processError); + } catch (e) { + return processError(e); + } } return data; }; diff --git a/packages/runtime-core/src/utils/hooks/syncHook.ts b/packages/runtime-core/src/utils/hooks/syncHook.ts index 17f6dadb8c9..dda3ab8f512 100644 --- a/packages/runtime-core/src/utils/hooks/syncHook.ts +++ b/packages/runtime-core/src/utils/hooks/syncHook.ts @@ -32,7 +32,10 @@ export class SyncHook { if (this.listeners.size > 0) { // eslint-disable-next-line prefer-spread this.listeners.forEach((fn) => { - result = fn(...data); + const nextResult = fn(...data); + if (nextResult !== undefined) { + result = nextResult; + } }); } return result; diff --git a/packages/runtime-core/src/utils/hooks/syncWaterfallHook.ts b/packages/runtime-core/src/utils/hooks/syncWaterfallHook.ts index f37be00302b..d5daa8a1105 100644 --- a/packages/runtime-core/src/utils/hooks/syncWaterfallHook.ts +++ b/packages/runtime-core/src/utils/hooks/syncWaterfallHook.ts @@ -20,7 +20,7 @@ export function checkReturnData(originalData: any, returnedData: any): boolean { export class SyncWaterfallHook> extends SyncHook< [T], - T + T | void > { onerror: (errMsg: string | Error | unknown) => void = error; @@ -36,6 +36,9 @@ export class SyncWaterfallHook> extends SyncHook< for (const fn of this.listeners) { try { const tempData = fn(data); + if (tempData === undefined) { + continue; + } if (checkReturnData(data, tempData)) { data = tempData; } else { diff --git a/packages/runtime-core/src/utils/load.ts b/packages/runtime-core/src/utils/load.ts index 22be1e71bd3..4a642c57e6f 100644 --- a/packages/runtime-core/src/utils/load.ts +++ b/packages/runtime-core/src/utils/load.ts @@ -281,6 +281,7 @@ export async function getRemoteEntry(params: { globalLoading[uniqueKey] = loadEntryHook .emit({ + origin, loaderHook, remoteInfo, remoteEntryExports, @@ -304,6 +305,14 @@ export async function getRemoteEntry(params: { }) : loadEntryNode({ remoteInfo, loaderHook }); }) + .then(async (res) => { + await origin.loaderHook.lifecycle.afterLoadEntry.emit({ + origin, + remoteInfo, + remoteEntryExports: res, + }); + return res; + }) .catch(async (err) => { const uniqueKey = getRemoteEntryUniqueKey(remoteInfo); // ScriptExecutionError means the script downloaded fine but its IIFE @@ -333,9 +342,20 @@ export async function getRemoteEntry(params: { }); if (RemoteEntryExports) { + await origin.loaderHook.lifecycle.afterLoadEntry.emit({ + origin, + remoteInfo, + remoteEntryExports: RemoteEntryExports, + recovered: true, + }); return RemoteEntryExports; } } + await origin.loaderHook.lifecycle.afterLoadEntry.emit({ + origin, + remoteInfo, + error: err, + }); throw err; }); } diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 9519a4eca66..f14b4e992d3 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -18,6 +18,7 @@ export { registerGlobalPlugins, type ModuleFederationRuntimePlugin, type Federation, + type SecurityOptions, } from '@module-federation/runtime-core'; export { ModuleFederation }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e13c22a0d9..4b4db01a270 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2437,6 +2437,9 @@ importers: '@module-federation/core': specifier: workspace:* version: link:../../../packages/core + '@module-federation/diagnostics-plugin': + specifier: workspace:* + version: link:../../../packages/diagnostics-plugin '@module-federation/dts-plugin': specifier: workspace:* version: link:../../../packages/dts-plugin @@ -3209,6 +3212,12 @@ importers: specifier: ^0.2.1 version: 0.2.1(@rsbuild/core@1.7.3) + packages/diagnostics-plugin: + devDependencies: + '@module-federation/runtime': + specifier: workspace:* + version: link:../runtime + packages/dts-plugin: dependencies: '@module-federation/error-codes': From 9b21865704eecb958f87d6eb472fd0f864f634b1 Mon Sep 17 00:00:00 2001 From: 2heal1 Date: Sat, 9 May 2026 16:14:01 +0800 Subject: [PATCH 2/4] feat(observability): add mf loading observability --- .changeset/safe-diagnostics-plugin.md | 7 - .changeset/safe-observability-plugin.md | 8 + MF_LOADING_OBSERVABILITY_ROADMAP.md | 1047 ------ .../3005-runtime-host/cypress/e2e/app.cy.ts | 602 +++- .../3005-runtime-host/package.json | 2 +- .../3005-runtime-host/src/App.tsx | 75 +- .../3005-runtime-host/src/DiagnosticsDemo.tsx | 330 -- .../src/ObservabilityDemo.tsx | 1065 ++++++ .../src/ObservabilityShowcase.tsx | 475 +++ .../3005-runtime-host/src/bootstrap.tsx | 12 +- .../3005-runtime-host/src/diagnostics.ts | 10 - .../src/observability-showcase.css | 427 +++ .../3005-runtime-host/src/observability.ts | 10 + .../3005-runtime-host/tsconfig.app.json | 8 +- .../3005-runtime-host/webpack.config.js | 250 +- .../3006-runtime-remote/tsconfig.app.json | 8 +- .../src/components/AnalyticsPanel.tsx | 20 + .../src/components/ProfileCard.tsx | 18 + .../3007-runtime-remote/tsconfig.app.json | 8 +- .../3007-runtime-remote/webpack.config.js | 4 + apps/runtime-demo/AGENTS.md | 80 + apps/runtime-demo/README.md | 116 +- apps/runtime-demo/skills/mf/SKILL.md | 55 + .../skills/mf/reference/bridge-check.md | 29 + .../mf/reference/browser-debug/long-chain.md | 72 + .../mf/reference/browser-debug/setup.md | 148 + .../reference/browser-debug/single-capture.md | 91 + .../skills/mf/reference/config-check.md | 49 + .../skills/mf/reference/context.md | 67 + apps/runtime-demo/skills/mf/reference/docs.md | 66 + .../skills/mf/reference/integrate.md | 422 +++ .../skills/mf/reference/module-info.md | 50 + .../skills/mf/reference/observability.md | 379 ++ apps/runtime-demo/skills/mf/reference/perf.md | 64 + .../skills/mf/reference/runtime-error.md | 165 + .../skills/mf/reference/shared-deps.md | 37 + .../skills/mf/reference/type-check.md | 87 + .../skills/mf/scripts/bridge-check.js | 52 + .../skills/mf/scripts/browser-capture.mjs | 897 +++++ .../skills/mf/scripts/config-exposes-check.js | 178 + .../skills/mf/scripts/module-info.js | 140 + .../skills/mf/scripts/performance-check.js | 61 + .../skills/mf/scripts/shared-config-check.js | 107 + .../skills/mf/scripts/type-check.js | 202 ++ apps/runtime-demo/typings/cssmodule.d.ts | 6 + apps/runtime-demo/typings/image.d.ts | 29 + apps/website-new/docs/en/ai/index.mdx | 29 +- apps/website-new/docs/en/ai/skill.mdx | 28 + .../docs/en/guide/runtime/runtime-hooks.mdx | 255 +- .../docs/en/guide/runtime/runtime-plugins.mdx | 2 + .../docs/en/guide/troubleshooting/runtime.mdx | 59 + .../docs/en/plugin/plugins/_meta.json | 2 +- .../plugin/plugins/observability-plugin.mdx | 263 ++ apps/website-new/docs/zh/ai/index.mdx | 25 +- apps/website-new/docs/zh/ai/skill.mdx | 38 + .../docs/zh/guide/runtime/runtime-hooks.mdx | 255 +- .../docs/zh/guide/runtime/runtime-plugins.mdx | 2 + .../docs/zh/guide/troubleshooting/runtime.mdx | 59 + .../docs/zh/plugin/plugins/_meta.json | 2 +- .../plugin/plugins/observability-plugin.mdx | 392 ++ apps/website-new/module-federation.config.ts | 1 + packages/diagnostics-plugin/README.md | 122 - .../__tests__/diagnostics.spec.ts | 774 ---- packages/diagnostics-plugin/src/index.ts | 1383 ------- packages/error-codes/src/MFContext.ts | 2 +- packages/error-codes/src/desc.ts | 6 + packages/error-codes/src/error-codes.ts | 3 + .../.eslintrc.json | 0 .../AI_TROUBLESHOOTING.md | 498 +++ packages/observability-plugin/README.md | 295 ++ .../__tests__/observability.spec.ts | 2451 +++++++++++++ .../package.json | 15 +- packages/observability-plugin/src/build.ts | 1557 ++++++++ packages/observability-plugin/src/index.ts | 3207 +++++++++++++++++ .../src/node.ts | 124 +- .../tsconfig.json | 0 .../tsdown.config.ts | 7 +- .../vitest.config.ts | 0 .../__tests__/load-remote-diagnostics.spec.ts | 149 +- packages/runtime-core/__tests__/load.spec.ts | 20 +- .../__tests__/resources/load/init-error.js | 11 + packages/runtime-core/src/core.ts | 81 +- packages/runtime-core/src/index.ts | 6 +- packages/runtime-core/src/module/index.ts | 241 +- .../src/plugins/snapshot/SnapshotHandler.ts | 20 +- packages/runtime-core/src/remote/index.ts | 51 +- packages/runtime-core/src/type/config.ts | 11 - packages/runtime/src/index.ts | 1 - pnpm-lock.yaml | 838 ++--- skills/README.md | 1 + skills/mf/SKILL.md | 9 +- skills/mf/reference/context.md | 2 +- skills/mf/reference/observability.md | 379 ++ skills/mf/reference/runtime-error.md | 22 +- skills/mf/reference/type-check.md | 2 +- 95 files changed, 17226 insertions(+), 4509 deletions(-) delete mode 100644 .changeset/safe-diagnostics-plugin.md create mode 100644 .changeset/safe-observability-plugin.md delete mode 100644 MF_LOADING_OBSERVABILITY_ROADMAP.md delete mode 100644 apps/runtime-demo/3005-runtime-host/src/DiagnosticsDemo.tsx create mode 100644 apps/runtime-demo/3005-runtime-host/src/ObservabilityDemo.tsx create mode 100644 apps/runtime-demo/3005-runtime-host/src/ObservabilityShowcase.tsx delete mode 100644 apps/runtime-demo/3005-runtime-host/src/diagnostics.ts create mode 100644 apps/runtime-demo/3005-runtime-host/src/observability-showcase.css create mode 100644 apps/runtime-demo/3005-runtime-host/src/observability.ts create mode 100644 apps/runtime-demo/3007-runtime-remote/src/components/AnalyticsPanel.tsx create mode 100644 apps/runtime-demo/3007-runtime-remote/src/components/ProfileCard.tsx create mode 100644 apps/runtime-demo/AGENTS.md create mode 100644 apps/runtime-demo/skills/mf/SKILL.md create mode 100644 apps/runtime-demo/skills/mf/reference/bridge-check.md create mode 100644 apps/runtime-demo/skills/mf/reference/browser-debug/long-chain.md create mode 100644 apps/runtime-demo/skills/mf/reference/browser-debug/setup.md create mode 100644 apps/runtime-demo/skills/mf/reference/browser-debug/single-capture.md create mode 100644 apps/runtime-demo/skills/mf/reference/config-check.md create mode 100644 apps/runtime-demo/skills/mf/reference/context.md create mode 100644 apps/runtime-demo/skills/mf/reference/docs.md create mode 100644 apps/runtime-demo/skills/mf/reference/integrate.md create mode 100644 apps/runtime-demo/skills/mf/reference/module-info.md create mode 100644 apps/runtime-demo/skills/mf/reference/observability.md create mode 100644 apps/runtime-demo/skills/mf/reference/perf.md create mode 100644 apps/runtime-demo/skills/mf/reference/runtime-error.md create mode 100644 apps/runtime-demo/skills/mf/reference/shared-deps.md create mode 100644 apps/runtime-demo/skills/mf/reference/type-check.md create mode 100644 apps/runtime-demo/skills/mf/scripts/bridge-check.js create mode 100644 apps/runtime-demo/skills/mf/scripts/browser-capture.mjs create mode 100644 apps/runtime-demo/skills/mf/scripts/config-exposes-check.js create mode 100644 apps/runtime-demo/skills/mf/scripts/module-info.js create mode 100644 apps/runtime-demo/skills/mf/scripts/performance-check.js create mode 100644 apps/runtime-demo/skills/mf/scripts/shared-config-check.js create mode 100644 apps/runtime-demo/skills/mf/scripts/type-check.js create mode 100644 apps/runtime-demo/typings/cssmodule.d.ts create mode 100644 apps/runtime-demo/typings/image.d.ts create mode 100644 apps/website-new/docs/en/plugin/plugins/observability-plugin.mdx create mode 100644 apps/website-new/docs/zh/plugin/plugins/observability-plugin.mdx delete mode 100644 packages/diagnostics-plugin/README.md delete mode 100644 packages/diagnostics-plugin/__tests__/diagnostics.spec.ts delete mode 100644 packages/diagnostics-plugin/src/index.ts rename packages/{diagnostics-plugin => observability-plugin}/.eslintrc.json (100%) create mode 100644 packages/observability-plugin/AI_TROUBLESHOOTING.md create mode 100644 packages/observability-plugin/README.md create mode 100644 packages/observability-plugin/__tests__/observability.spec.ts rename packages/{diagnostics-plugin => observability-plugin}/package.json (77%) create mode 100644 packages/observability-plugin/src/build.ts create mode 100644 packages/observability-plugin/src/index.ts rename packages/{diagnostics-plugin => observability-plugin}/src/node.ts (69%) rename packages/{diagnostics-plugin => observability-plugin}/tsconfig.json (100%) rename packages/{diagnostics-plugin => observability-plugin}/tsdown.config.ts (77%) rename packages/{diagnostics-plugin => observability-plugin}/vitest.config.ts (100%) create mode 100644 packages/runtime-core/__tests__/resources/load/init-error.js create mode 100644 skills/mf/reference/observability.md diff --git a/.changeset/safe-diagnostics-plugin.md b/.changeset/safe-diagnostics-plugin.md deleted file mode 100644 index e04f332d00a..00000000000 --- a/.changeset/safe-diagnostics-plugin.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@module-federation/diagnostics-plugin": minor -"@module-federation/runtime-core": minor -"@module-federation/runtime": minor ---- - -Add an opt-in diagnostics plugin, a Node-specific diagnostics plugin export for file reports, remote and shared lifecycle hooks, console trace hints, safe browser/Node report outputs, shared/eager loading diagnostics, final loading outcome summaries for Module Federation loading reports, no-op return handling for observer hooks, and passive remote diagnostics through runtime load hooks. diff --git a/.changeset/safe-observability-plugin.md b/.changeset/safe-observability-plugin.md new file mode 100644 index 00000000000..701b96bb23c --- /dev/null +++ b/.changeset/safe-observability-plugin.md @@ -0,0 +1,8 @@ +--- +"@module-federation/observability-plugin": minor +"@module-federation/error-codes": minor +"@module-federation/runtime-core": minor +"@module-federation/runtime": minor +--- + +Add an opt-in observability plugin, a Node-specific export for file reports, a build-specific export for build summaries and build error reports, remote and shared lifecycle hooks, console trace hints, safe browser/Node report outputs, configurable error stack capture with explicit console raw-stack opt-ins, shared/eager loading evidence, final loading outcome summaries for Module Federation loading reports, deterministic fact reports for runtime and build failures, no-op return handling for observer hooks, detailed remote match/init/expose/factory phase events with phase durations, compact phase summaries, cache/retry/fallback markers, length-limited business component metadata, clipped moduleInfo evidence with preserved deployment locator fields for snapshot-dependent failures, normalized runtime error summaries with error codes, owner hints, retryability, and safe context, dedicated runtime error codes for invalid manifests, missing exposes, and remote container init failures, plus an AI troubleshooting guide for reading and fixing observability reports. diff --git a/MF_LOADING_OBSERVABILITY_ROADMAP.md b/MF_LOADING_OBSERVABILITY_ROADMAP.md deleted file mode 100644 index 8bb62ea6e22..00000000000 --- a/MF_LOADING_OBSERVABILITY_ROADMAP.md +++ /dev/null @@ -1,1047 +0,0 @@ -# MF Loading Observability Roadmap - -## 背景 - -Module Federation 的问题通常不是单点错误。一次加载可能同时经过 host 配置、remote 配置、manifest、remoteEntry、共享依赖、运行时插件和实际模块执行。现在的报错信息能给出错误码和部分上下文,但还不足以让人或 AI coding 快速判断问题属于哪一侧。 - -这个方案的终极目标不是把报错文案写长,而是让 MF 加载过程本身变得可观测。报错报告应当从加载过程记录中生成,而不是在抛错点临时拼接一段孤立信息。 - -## 目标 - -- 让每一次 MF 加载都能留下安全、结构化、可读取的加载记录。 -- 让错误报告可以还原失败前后的关键路径。 -- 让 AI coding 能基于同一份事实数据判断下一步应该查哪里。 -- 让构建侧信息和运行时信息可以被关联起来分析。 -- 在默认情况下不引入新的数据泄露风险或调试后门。 - -## 非目标 - -- 不自动上传诊断数据。 -- 不采集业务数据、源码、远端响应内容、请求头、cookie 或 token。 -- 不把 AI 判断作为运行时行为的一部分。 -- 不让可观测逻辑影响 MF 正常加载结果。 - -## 第一原则:安全 - -所有可观测能力都必须遵守以下规则: - -- 默认不开启详细采集。 -- 默认不做公网回传。 -- 浏览器侧默认只保存在当前实例或当前页面内存中。 -- Node 侧只写本地诊断文件,且写入失败不能影响构建或运行。 -- URL 必须脱敏,默认移除 query 和 hash。 -- 禁止记录请求头、cookie、authorization、token、secret、session、完整请求参数。 -- 禁止记录 remote 返回内容、源码、模块源码、用户输入和业务数据。 -- 对外导出的报告必须是脱敏后的结果。 -- 全局暴露能力必须最小化,不能把内部状态无边界挂到根对象上。 -- 生产环境默认低噪声;详细诊断需要用户显式开启。 - -## 现状判断 - -当前已有一些基础: - -- 统一错误码和文档链接。 -- 部分运行时报错能携带 host 配置摘要。 -- Node 侧可以写 `.mf/diagnostics/latest.json`。 -- 部分 remoteEntry 加载错误已经区分了脚本加载失败、脚本执行失败、global 未注册。 - -主要不足: - -- 浏览器侧没有稳定的结构化记录入口。 -- 只记录最近一次错误,不保留加载时间线。 -- 很多错误仍然只是字符串,无法自动判断责任方。 -- 构建信息和运行时错误没有形成稳定关联。 -- shared / eager / 版本匹配类问题缺少完整上下文。 -- AI 需要事实数据,但现在多数信息只存在于错误字符串里。 - -## 总体设计 - -整体分三层。 - -### 1. 加载事件层 - -工程代码负责记录 MF 加载过程中的事实事件。事件只记录白名单字段。 - -典型事件: - -- `loadRemote:start` -- `loadRemote:resolved` -- `loadRemote:success` -- `loadRemote:error` -- `manifest:fetch:start` -- `manifest:fetch:success` -- `manifest:fetch:error` -- `remoteEntry:load:start` -- `remoteEntry:load:success` -- `remoteEntry:load:error` -- `remoteEntry:init:start` -- `remoteEntry:init:success` -- `remoteEntry:init:error` -- `expose:get:start` -- `expose:get:success` -- `expose:get:error` -- `share:resolve:start` -- `share:resolve:success` -- `share:resolve:miss` -- `share:resolve:error` -- `loadRemote:complete` - -事件字段建议: - -- `traceId` -- `timestamp` -- `phase` -- `status` -- `hostName` -- `remoteName` -- `requestId` -- `expose` -- `shareName` -- `shareScope` -- `expectedVersion` -- `resolvedVersion` -- `provider` -- `sanitizedUrl` -- `remoteType` -- `entryGlobalName` -- `duration` -- `errorCode` -- `errorName` -- `errorMessage` -- `ownerHint` -- `startTime` -- `endTime` -- `cached` -- `attempt` -- `fallbackUsed` -- `resultSummary` - -`ownerHint` 只能是工程规则给出的初步分类,例如: - -- `host` -- `remote` -- `network` -- `manifest` -- `shared` -- `runtime-plugin` -- `unknown` - -### 2. 诊断报告层 - -工程代码基于事件生成事实报告。报告不是 AI 生成的,它应当是确定性的、可测试的、可复现的。 - -报告包含: - -- 本次加载的概要 -- 加载时间线 -- 加载结果 -- 总耗时 -- 失败阶段 -- 原始错误 -- 脱敏后的上下文 -- 可能责任方 -- 可执行的检查建议 -- 关联的构建信息摘要 - -这里的“可能责任方”和“检查建议”应由工程规则生成,只能基于已记录事实,不允许猜测未采集的信息。 - -报告不只在失败时生成。成功加载也应当生成可选的 summary,用来回答: - -- remote 是否加载成功。 -- manifest 是否命中缓存。 -- remoteEntry 是否重复使用已有 global。 -- expose 是否获取成功。 -- shared 最终命中了哪个提供方和版本。 -- 是否走了 fallback 或 retry。 -- 每个阶段耗时多少。 -- 是否存在“成功但异常”的信号,例如耗时过长、版本命中不符合预期、使用了 fallback。 - -### 3. AI coding 消费层 - -AI 不负责生成事实报告。AI 负责读取工程报告,并基于报告做解释、归因、修复建议或代码修改。 - -推荐边界: - -- 工程报告回答“发生了什么”。 -- AI 分析回答“这通常意味着什么、下一步怎么修”。 -- AI 输出必须引用报告里的事实,不应凭空补全缺失数据。 - -## 报告是 AI 生成还是工程生成 - -基础报告必须由工程手段生成。 - -原因: - -- 工程生成的报告稳定、可测试、可在没有 AI 的环境里使用。 -- 工程报告可以严格执行脱敏和字段白名单。 -- 工程报告不会编造缺失信息。 -- AI coding 需要的是可靠事实输入,而不是另一个不确定输出。 - -AI 可以在报告之上生成解释版报告,但它应该是第二层消费结果,不应该替代工程报告。 - -建议分成两类: - -- 事实报告:工程生成,默认能力。 -- 分析报告:AI 基于事实报告生成,可选能力。 - -## 只看记录的信息是否足够 - -只看原始事件记录不够。 - -原始记录是必要基础,但它通常太碎。人和 AI 都还需要一个归一化后的摘要,才能快速知道: - -- 哪一步失败 -- 失败前已经完成了哪些步骤 -- 哪些信息缺失 -- 应该优先检查 host、remote、网络、manifest 还是 shared - -因此需要同时保留两种产物: - -- 事件时间线:完整事实,用于深挖。 -- 诊断报告:从时间线聚合出来,用于快速定位。 - -如果只保留报告,不保留事件,后续无法追溯细节。 -如果只保留事件,不生成报告,AI 和人都需要重复做整理工作。 - -## 加载结果可观测方案 - -可观测不是只收集报错。成功加载也需要记录,因为很多问题不会直接抛错,例如加载慢、使用了 fallback、命中了非预期 shared 版本、remoteEntry 被缓存复用、manifest 内容和 host 预期不一致。 - -### 成功事件要记录什么 - -成功事件应当记录: - -- 本次 `traceId`。 -- 请求的 `requestId`。 -- 匹配到的 remote 名称。 -- expose 名称。 -- manifest URL 是否存在。 -- manifest 是否 fetch 成功。 -- manifest 是否来自缓存。 -- remoteEntry URL。 -- remoteEntry 类型。 -- remoteEntry 是否新加载。 -- remoteEntry 是否复用已有 global。 -- remoteEntry init 是否完成。 -- expose factory 是否获取成功。 -- expose module 是否执行完成。 -- shared 命中的 provider。 -- shared 命中的版本。 -- 是否使用 fallback。 -- 是否发生 retry。 -- 每个阶段耗时。 -- 总耗时。 -- 业务组件是否主动声明成功加载。 - -这些信息都必须是结构化字段,不能只写在字符串里。 - -### 成功报告 - -成功报告是加载链路的整理版,不是错误报告。它应包含: - -- `traceId` -- `status: "success"` -- host 摘要 -- remote 摘要 -- expose 摘要 -- shared 命中摘要 -- 阶段耗时 -- 是否缓存命中 -- 是否 retry -- 是否 fallback -- 构建信息关联摘要 - -成功报告默认不一定需要 console 输出。建议由插件配置控制: - -- `level: "error"` 只在失败时生成详细报告。 -- `level: "summary"` 记录成功摘要和失败报告。 -- `level: "verbose"` 记录完整成功和失败时间线。 - -### 成功但需要关注的信号 - -插件可以基于工程规则标记 warning,但不能改变加载结果: - -- remoteEntry 加载耗时超过阈值。 -- manifest 加载耗时超过阈值。 -- 使用了 retry 后才成功。 -- 使用了 fallback。 -- shared 命中 provider 与预期不一致。 -- shared 命中版本低于推荐版本。 -- remote manifest 的 buildVersion 与预期不一致。 -- expose 成功但来自非预期 remote。 - -这些 warning 应进入报告,供 AI 判断是否需要继续排查。 - -### AI 如何使用成功记录 - -AI 看到成功记录后,可以回答: - -- 当前 MF 加载链路是否完整成功。 -- 慢在 manifest、remoteEntry、init、shared 还是 expose。 -- shared 实际用了谁。 -- remote 是否来自预期地址和构建版本。 -- 是否存在 fallback / retry / cache 复用。 - -因此,AI coding 不只在报错时使用诊断文件,也可以在“页面能跑但行为不对”时读取最近一次成功 trace。 - -### 业务组件成功加载事件 - -runtime-core 和 diagnostics plugin 可以自动判断技术层成功,但不能判断业务组件是否真正完成业务加载。因此需要给业务暴露一个固定接口,由业务组件在合适时机主动调用。 - -固定事件名: - -- `component:business-loaded` - -固定语义: - -- 业务组件已经完成自身定义的成功加载。 -- 这个事件由业务代码主动触发。 -- 这个事件不是 React mount,也不是 expose factory 执行成功。 -- 业务可以自定义什么时候调用,但事件名和基础字段必须固定。 - -建议 API: - -```ts -diagnostics.markComponentLoaded({ - traceId, - requestId: 'remote/Button', - componentName: 'Button', -}); -``` - -事件字段: - -- `traceId` -- `requestId` -- `componentName` -- `phase: "component"` -- `eventName: "component:business-loaded"` -- `status: "success"` -- `timestamp` -- `source: "business"` - -可选扩展字段: - -- `reason` -- `duration` -- `metadata` - -安全限制: - -- `metadata` 必须经过用户自定义 sanitize。 -- 不记录 props。 -- 不记录接口响应体。 -- 不记录用户输入。 -- 不记录业务数据明细。 - -AI 看到这个事件后,才能判断: - -- MF 技术加载成功。 -- 组件挂载或渲染链路已完成。 -- 业务组件声明自己已成功加载。 - -如果没有这个事件,AI 只能说“技术加载成功,但业务组件是否成功加载未声明”。 - -## 安全数据策略 - -允许记录: - -- host 名称 -- remote 名称 -- expose 请求名 -- shared 包名 -- shared 版本约束 -- 实际命中的 shared 版本 -- remoteEntry 类型 -- entryGlobalName -- 脱敏后的 URL -- 错误码 -- 错误名称 -- 脱敏后的错误消息 -- 阶段耗时 - -禁止记录: - -- cookie -- request headers -- authorization -- token -- secret -- session -- 完整 query -- hash -- remote 响应体 -- 源码 -- 模块执行结果 -- 用户业务数据 - -URL 脱敏规则: - -- 保留 protocol、host、pathname。 -- 默认删除 query 和 hash。 -- 对常见敏感字段做兜底过滤。 -- 如果 URL 无法解析,按字符串做保守清洗。 - -## 能力形态和输出入口 - -可观测能力应以 runtime plugin 形式提供,形态类似 `@module-federation/retry-plugin`。runtime-core 只提供必要 hook、事件上下文和安全策略,不内置完整收集器,也不默认保存诊断数据。 - -建议新增独立插件: - -- `@module-federation/diagnostics-plugin` - -使用方式: - -```ts -import { createInstance } from '@module-federation/runtime'; -import { DiagnosticsPlugin } from '@module-federation/diagnostics-plugin'; - -const diagnostics = DiagnosticsPlugin({ - level: 'error', - maxEvents: 100, - onReport(report) { - // 用户自行接入日志、监控或 AI coding 工具 - }, -}); - -const mf = createInstance({ - name: 'host', - remotes: [], - plugins: [diagnostics.plugin], -}); - -diagnostics.getLatestReport(); -diagnostics.getEvents(); -``` - -### 输出从哪里拿 - -插件应提供多种可选输出入口,默认只在内存中保存: - -- 插件 controller:`diagnostics.getEvents()`、`diagnostics.getLatestReport()`、`diagnostics.clear()`。 -- 回调:`onEvent(event)`、`onReport(report)`。 -- Node 本地文件:通过 `@module-federation/diagnostics-plugin/node` 的 `DiagnosticsPlugin` 显式开启后写 `.mf/diagnostics/events.jsonl` 和 `.mf/diagnostics/latest.json`。 -- 浏览器只读全局入口:只有显式开启后才暴露,例如 `globalThis.__FEDERATION__.__DIAGNOSTICS__`。 -- thrown error 附带最小报告 id 或摘要,但不把完整报告塞进错误字符串。 - -构建插件注入 runtime plugin 的场景不能拿到外部 controller,因此主要依赖 `onReport`、Node 文件或显式全局入口。 - -### AI 自动感知和读取流程 - -目标是让 AI coding 尽量不依赖用户手动复制完整报错。插件应在安全前提下给 AI 一个稳定的发现信号,再提供可读取的脱敏报告。 - -Node / SSR / build 场景: - -- MF 加载失败时,console 打印固定格式的诊断提示。 -- 提示中包含 `traceId` 和诊断文件路径。 -- 插件写入 `.mf/diagnostics/latest.json`。 -- 插件追加 `.mf/diagnostics/events.jsonl`。 -- AI 从终端输出识别 `traceId`。 -- AI 读取 `latest.json`。 -- 如果 `latest.json` 不够,再按 `traceId` 去 `events.jsonl` 查完整事件时间线。 - -推荐 console 格式: - -```txt -[Module Federation] Diagnostic report generated -traceId: mf-trace-xxx -latest: .mf/diagnostics/latest.json -events: .mf/diagnostics/events.jsonl -``` - -浏览器 dev 场景: - -- MF 加载失败时,console 打印固定格式的诊断提示。 -- AI 可以通过 CDP 或浏览器调试能力监听 console。 -- 如果启用了只读全局入口,AI 可以按 `traceId` 调用 `getReport(traceId)`。 -- 如果没有启用全局入口,console 只提示用户如何导出脱敏报告。 - -推荐 console 格式: - -```txt -[Module Federation] Diagnostic report generated -traceId: mf-trace-xxx -Run: __FEDERATION__.__DIAGNOSTICS__.getReport("mf-trace-xxx") -``` - -浏览器 prod 场景: - -- 默认不暴露全局报告入口。 -- 默认不写浏览器持久存储。 -- console 只输出最小错误码、`traceId` 和安全提示。 -- 完整报告只能通过用户显式开启的导出接口、`onReport` 上传到用户自己的系统,或用户主动导出。 -- AI 不应绕过页面安全边界读取完整报告。 - -prod 推荐 console 格式: - -```txt -[Module Federation] Diagnostic report available -traceId: mf-trace-xxx -Ask the application owner to export the sanitized diagnostic report. -``` - -### 诊断文件分工 - -`.mf/diagnostics/latest.json` 是最近一次诊断报告。它是给人和 AI 快速读取的整理版结构,应包含: - -- `traceId` -- host 摘要 -- remote 摘要 -- 失败阶段 -- 错误码 -- 初步责任方 -- 脱敏后的关键上下文 -- 建议检查项 - -`.mf/diagnostics/events.jsonl` 是完整事件流水。一行一个 JSON,用来保存多次加载、多阶段事件和同一 `traceId` 下的完整时间线。 - -AI 默认读取顺序: - -- 先读 `latest.json`。 -- 如果需要更多细节,再用 `traceId` 过滤 `events.jsonl`。 - -### runtime-core 需要补什么 - -runtime-core 只补底层能力: - -- 增加或复用最小生命周期 hook,例如 `afterLoadRemote`、`afterLoadEntry`、`afterLoadShare`、`errorLoadShare`。 -- 在主流程关键阶段暴露开始、成功、失败这类事实。 -- 给 hook 补齐必要的基础上下文,例如 `requestId`、`remoteInfo`、`shareInfo`。 -- 不在 runtime-core 里计算版本不匹配、missing provider、eager 边界这类诊断原因。 -- 对有返回值语义的 hook 采用“未返回内容即不干预”的规则,让诊断插件可以安全旁听 `onLoad`、`errorLoadRemote` 这类已有 hook。 -- 保证没有 diagnostics plugin 时行为完全不变。 - -runtime-core 不负责: - -- 持久化报告。 -- 对外暴露全局调试对象。 -- 上传诊断数据。 -- 生成 AI 分析结论。 -- 保存完整事件历史。 -- 组装 shared 诊断报告字段。 - -### 插件负责什么 - -diagnostics plugin 负责: - -- 是否启用收集。 -- 事件过滤级别。 -- URL、错误消息和 stack 脱敏。 -- 事件缓存上限。 -- 报告聚合。 -- 从 `beforeRequest`、`onLoad`、`afterLoadRemote`、`errorLoadRemote` 旁听 remote 加载开始、成功、失败和结束,且不返回内容。 -- 从 `loadEntry`、`afterLoadEntry`、snapshot resolve hook 旁听 manifest、remoteEntry 这些阶段。 -- 从 runtime hook 的基础上下文推导 shared / eager 具体原因。 -- 输出到 callback、controller、Node 专用入口文件或显式全局入口。 -- 用户自定义 sanitize 规则。 - -诊断插件可以注册 `errorLoadRemote` 这类已有 hook 做旁听,但必须遵守“不返回内容,不参与恢复”的约束。这样既能复用已有 hook,也不会影响 `@module-federation/retry-plugin` 或用户插件返回兜底结果。`loadEntryError`、`fetch` 这类更贴近重试控制的 hook 暂时不由 diagnostics plugin 接管。 - -`errorLoadShare` 目前只作为 shared 诊断观察 hook,不默认接入 retry-plugin。shared miss、版本不匹配、eager 边界通常不是临时网络错误,默认重试容易掩盖真实配置问题。后续如果要支持 shared retry,应该作为独立可选能力,由用户明确指定可重试条件。 - -## 安全配置和插件配置 - -不建议把所有可观测选项都放进 runtime-core 顶层 `observability`。更合理的边界是: - -- `security.diagnostics`:安全策略,由 runtime-core 识别,作为所有诊断插件必须遵守的上限。 -- `DiagnosticsPlugin(options)`:通用功能配置,由插件控制收集、内存输出、回调和浏览器显式读取。 -- `@module-federation/diagnostics-plugin/node` 的 `DiagnosticsPlugin(options)`:Node 专用入口,额外提供本地文件输出。 - -建议的 `security.diagnostics`: - -```ts -createInstance({ - name: 'host', - security: { - diagnostics: { - enabled: false, - console: true, - browserGlobal: false, - fileOutput: false, - redactUrlQuery: true, - redactUrlHash: true, - maxErrorStackLines: 5, - maxEvents: 100, - redactKeys: ['token', 'secret', 'authorization', 'cookie', 'session'], - }, - }, - plugins: [DiagnosticsPlugin()], -}); -``` - -建议的插件配置: - -```ts -DiagnosticsPlugin({ - level: 'error', - maxEvents: 100, - console: true, - browser: { - enabled: false, - scope: 'host', - }, - onReport(report) {}, -}); -``` - -建议的 Node 插件配置: - -```ts -import { DiagnosticsPlugin } from '@module-federation/diagnostics-plugin/node'; - -DiagnosticsPlugin({ - level: 'error', - maxEvents: 100, - console: true, - directory: '.mf/diagnostics', - onReport(report) {}, -}); -``` - -规则: - -- `security.diagnostics` 是上限,插件不能绕过。 -- 插件可以选择更严格,但不能比 `security.diagnostics` 更宽。 -- 如果 `security.diagnostics.enabled` 为 `false`,插件可以不收集,或只保留最小错误摘要。 -- 如果 `browserGlobal` 为 `false`,插件不能暴露全局读取入口。 -- 如果 `fileOutput` 为 `false`,插件不能写诊断文件。 - -这样既能满足用户选择性添加和定制,也能保证安全边界由核心配置统一约束。 - -## 运行时报错信息收集方案 - -运行时报错信息收集不是简单保存 `Error.message`。它需要把错误发生时的加载阶段、调用上下文和已知配置一起保存下来。 - -### 收集入口 - -第一批应覆盖这些入口: - -- `loadRemote` -- `getRemoteModuleAndOptions` -- `getRemoteEntry` -- `loadEntryScript` -- `loadEntryNode` -- `Module.getEntry` -- `Module.init` -- `Module.get` -- `SharedHandler.loadShare` -- `SharedHandler.loadShareSync` -- `SharedHandler.initializeSharing` -- `SnapshotHandler.getManifestJson` - -这些入口覆盖了 remote 解析、manifest 获取、remoteEntry 加载、remote 初始化、expose 获取和 shared 解析。 - -### 运行时报错事件字段 - -运行时报错事件应当至少包含: - -- `traceId` -- `eventId` -- `timestamp` -- `phase` -- `status: "error"` -- `hostName` -- `hostId` -- `requestId` -- `remoteName` -- `remoteAlias` -- `remoteType` -- `entryGlobalName` -- `sanitizedUrl` -- `expose` -- `shareName` -- `shareScope` -- `requiredVersion` -- `availableVersions` -- `selectedVersion` -- `provider` -- `from` -- `lifecycle` -- `errorCode` -- `errorName` -- `errorMessage` -- `errorStack` -- `ownerHint` -- `retryable` - -`errorStack` 默认只保留当前错误栈的前几行,并且必须经过脱敏。生产环境可以只保留 `errorName` 和 `errorMessage`。 - -### 错误分类规则 - -工程侧应提供确定性的分类规则: - -- `RUNTIME-001`: remoteEntry 已加载但未注册到预期 global,优先指向 remote 产物或 `entryGlobalName` 配置。 -- `RUNTIME-002`: remoteEntry 接口不完整,优先指向 remote 产物。 -- `RUNTIME-003`: manifest 获取或解析失败,优先指向 manifest 地址、网络、manifest 内容结构。 -- `RUNTIME-004`: host 无法匹配 remote,优先指向 host remotes 配置或请求 id。 -- `RUNTIME-005`: 构建运行时同步消费 shared 失败,优先指向 eager / async boundary / shared 配置。 -- `RUNTIME-006`: 纯运行时同步消费 shared 失败,优先指向调用方式或 shared 未就绪。 -- `RUNTIME-008`: remoteEntry 资源加载失败,继续细分为 timeout、network、script execution、unknown。 -- `RUNTIME-011`: manifest 中缺 remoteEntry URL,优先指向 remote manifest 产物。 -- `RUNTIME-012`: shared getter 不可用,优先指向 `shared.import: false` 和 host 未提供对应依赖。 - -### 运行时报告生成 - -当错误发生时,工程代码应做三件事: - -- 记录一条脱敏后的 `error` 事件。 -- 把错误事件和同一 `traceId` 的前序事件聚合成事实报告。 -- 保留现有错误码、错误文案和文档链接,避免破坏用户已有排查路径。 - -报告里不要写“AI 判断”。报告只写工程规则能确认的事实和初步分类。 - -### 存储策略 - -浏览器侧: - -- 默认不启用。 -- 启用后保存在实例内存中。 -- 只有 `exposeGlobal: true` 时才暴露只读入口。 -- 不写 localStorage、sessionStorage、IndexedDB。 - -Node 侧: - -- 默认不启用详细事件。 -- 启用后可以写 `.mf/diagnostics/events.jsonl` 和 `.mf/diagnostics/latest.json`。 -- 文件写入失败不能影响运行。 -- 文件内容必须脱敏。 - -## 构建信息收集方案 - -构建信息的目标不是记录完整构建产物,而是生成一份可以和运行时报错关联的最小摘要。 - -### 收集来源 - -第一批应从这些位置收集: - -- Module Federation 插件原始配置摘要。 -- manifest 输出。 -- stats 输出。 -- normalized remotes。 -- normalized shared。 -- exposes 映射。 -- remoteEntry 文件名和类型。 -- plugin version。 -- bundler name。 -- build version。 - -### 构建信息字段 - -构建摘要建议包含: - -- `buildId` -- `name` -- `bundler` -- `pluginVersion` -- `buildVersion` -- `remoteEntry` -- `remoteEntryType` -- `publicPathMode` -- `remotes` -- `exposes` -- `shared` -- `manifestFile` -- `statsFile` - -`shared` 只保留诊断必要字段: - -- `name` -- `version` -- `requiredVersion` -- `singleton` -- `strictVersion` -- `eager` -- `shareScope` -- `import` - -`remotes` 只保留: - -- `name` -- `alias` -- `entry` -- `type` -- `shareScope` - -`exposes` 只保留 expose key 和脱敏后的请求摘要,不记录源码内容。 - -### 禁止收集的构建信息 - -- 本地绝对路径。 -- 环境变量。 -- 源码内容。 -- loader 完整参数。 -- 插件完整 options 对象。 -- 远端响应内容。 -- 带 token 的 URL。 -- 私有 registry token。 - -如果必须保留路径用于定位,只能保留相对项目根的路径,并需要确认不会泄露用户目录或内部机器信息。 - -### 构建信息产物 - -建议新增一个脱敏构建摘要产物: - -- `.mf/diagnostics/build-info.json` - -它应当可以由构建插件生成,也可以从 manifest / stats 中还原。生成失败不能影响构建。 - -运行时报错报告可以通过以下字段关联构建信息: - -- host `name` -- remote `name` -- `buildVersion` -- `pluginVersion` -- `remoteEntry` -- manifest `id` - -### 构建和运行时关联方式 - -运行时加载 remote 时,优先从 manifest 获取 remote 构建摘要。如果没有 manifest,只记录 remoteEntry 层面的摘要。 - -关联规则: - -- host 侧报告记录 host 构建摘要。 -- remote manifest 可用时,报告记录 remote 构建摘要。 -- shared 报错时,报告同时列出 host 提供摘要和 remote 需求摘要。 -- expose 报错时,报告关联 remote manifest 中的 exposes 摘要。 - -这样 AI coding 可以判断: - -- host 是否配置了这个 remote。 -- remote manifest 是否声明了这个 expose。 -- host 和 remote 的 shared 版本条件是否能匹配。 -- 运行时加载的 remoteEntry 是否来自预期构建。 - -## Roadmap - -### Milestone 1: 前置任务,诊断验证 Demo 和最小闭环 - -状态:已完成。当前 demo、最小插件、运行时 hook、输出入口和验证测试已经形成第一阶段闭环。 - -这一步要先于完整能力建设。它的目标不是做展示页面,而是先建立一个稳定、可重复的验证场景,保证后续每一次加事件、加报告字段、加插件输出,都能在同一套 demo 和测试里验证成功、失败和脱敏结果。 - -实现边界:runtime-core 只负责主流程生命周期 hook,例如 remote 加载成功、失败、manifest / remoteEntry 基础状态和 shared 成功、失败;diagnostics plugin 负责整理事件、推导具体原因和最终加载结论,并输出报告。 - -#### Demo 验证场景 - -- [x] 新增或复用现有 runtime demo,建立一组专用 diagnostics host / remote 场景。 -- [x] demo 支持正常加载 remote 组件,并能触发成功加载事件。 -- [x] demo 支持 remoteEntry / manifest 地址错误场景。 -- [x] demo 支持 remoteEntry globalName 或 expose 不存在场景。 -- [x] demo 支持业务组件主动上报 `component:business-loaded`。 -- [x] demo 支持 shared miss、shared 版本不匹配、eager 配置错误场景。 -- [x] demo 提供稳定的按钮、路由或测试入口,方便 e2e 自动触发。 -- [x] demo 默认不暴露诊断信息,只有显式开启 diagnostics plugin 时才输出。 -- [x] demo 输出必须经过 URL 和错误信息脱敏。 -- [x] demo 作为后续 Phase 的固定验收入口,不为单个实现临时造一次性测试。 - -#### 最小能力闭环 - -- [x] 定义最小 `security.diagnostics` 配置和默认关闭行为。 -- [x] 定义最小事件数据结构。 -- [x] 定义最小报告数据结构。 -- [x] 在 runtime-core 增加最小 lifecycle hook 和必要上下文。 -- [x] 新增最小 `DiagnosticsPlugin`,先只支持内存输出和回调输出。 -- [x] 记录 `loadRemote` 成功和失败。 -- [x] 记录 manifest 获取成功和失败。 -- [x] 记录 remoteEntry 加载成功和失败。 -- [x] 生成最近一次 in-memory report。 -- [x] dev / Node 场景输出固定 console 提示,包含 `traceId`。 -- [x] 确认没有 diagnostics plugin 时现有行为完全不变。 -- [x] 确认 diagnostics 自身异常不会影响 MF 加载。 -- [x] 补充默认关闭、显式开启、URL 脱敏、成功 trace、失败 trace / report 的测试。 - -#### Milestone 1 完成标准 - -- [x] demo 能稳定跑通一次成功加载。 -- [x] demo 能稳定复现至少一种 remoteEntry / manifest 加载失败。 -- [x] 成功和失败都能生成可读取的脱敏 report。 -- [x] AI 或人能从 console 中拿到 `traceId`,再读取对应 report。 -- [x] 默认不启用 diagnostics 时,demo 和现有测试行为不变。 -- [x] 这一步完成后,再进入后续 Phase 0 - Phase 10 的完整能力建设。 - -### Phase 0: 安全边界和数据模型 - -- [ ] 定义 `security.diagnostics` 安全策略和默认值。 -- [ ] 定义 `DiagnosticsPlugin` 插件配置和默认值。 -- [ ] 定义事件字段白名单。 -- [ ] 定义 URL 和错误消息脱敏规则。 -- [ ] 定义事件时间线数据结构。 -- [ ] 定义诊断报告数据结构。 -- [ ] 定义运行时报错事件数据结构。 -- [ ] 定义构建信息摘要数据结构。 -- [ ] 明确浏览器和 Node 的存储边界。 -- [ ] 明确运行时报错和构建信息的字段白名单。 -- [ ] 明确运行时报错和构建信息的禁止字段。 -- [ ] 明确插件配置不能绕过 `security.diagnostics`。 -- [ ] 补充默认不开启、开启后脱敏的测试。 - -### Phase 1: runtime-core 事件 hook 底座 - -- [x] 在 runtime-core 中增加最小 lifecycle hook,供插件旁听主流程状态。 -- [x] 复用 `onLoad`、`errorLoadRemote`,供 diagnostics plugin 观察 remote 加载,不影响 retry / fallback。 -- [x] 给关键 hook 补齐 `phase`、`requestId`、`remoteInfo`、`shareInfo` 等必要上下文。 -- [x] 记录 `loadRemote` 开始、成功、失败。 -- [x] 记录 `loadRemote` complete 事件,用于统一收口成功和失败。 -- [ ] 记录 remote 匹配结果。 -- [x] 记录 manifest 获取开始、成功、失败。 -- [x] 记录 remoteEntry 加载开始、成功、失败。 -- [ ] 记录 remoteEntry init 开始、成功、失败。 -- [ ] 记录 expose 获取开始、成功、失败。 -- [ ] 记录阶段耗时和总耗时。 -- [x] 记录 cache、retry、fallback 的基础结果标记。 -- [x] 确认没有 diagnostics plugin 时行为完全不变。 -- [x] 确认 lifecycle hook 失败不会影响 MF 加载。 -- [x] 确认 diagnostics plugin 旁听 `errorLoadRemote` 时不接管返回路径。 -- [x] 补充 runtime-core 单测。 - -### Phase 2: diagnostics plugin 输出能力 - -- [x] 新建 `@module-federation/diagnostics-plugin`。 -- [x] 提供插件 controller:`getEvents()`、`getLatestReport()`、`clear()`。 -- [x] 提供 `onEvent` 和 `onReport` 回调。 -- [x] 支持内存输出。 -- [x] 通过 Node 专用入口支持显式开启的 Node 本地文件输出。 -- [x] 支持显式开启的浏览器只读全局入口。 -- [x] 确认插件输出受 `security.diagnostics` 约束。 -- [x] 补充插件配置和输出单测。 - -### Phase 3: 加载成功信息收集 - -- [x] 收集加载成功 summary。 -- [ ] 收集 manifest 成功、缓存命中和耗时。 -- [ ] 收集 remoteEntry 成功、新加载或复用已有 global。 -- [ ] 收集 remoteEntry init 成功和耗时。 -- [ ] 收集 expose factory 获取成功和耗时。 -- [ ] 收集 expose module 执行成功和耗时。 -- [ ] 收集 shared 命中的 provider、版本和 shareScope。 -- [ ] 收集 retry / fallback / cache 标记。 -- [x] 定义固定事件 `component:business-loaded`。 -- [x] 提供 `markComponentLoaded` 业务调用接口。 -- [x] 将业务组件成功加载事件关联到同一 `traceId`。 -- [ ] 确认业务 metadata 经过 sanitize 后才能进入报告。 -- [ ] 支持 `level: "summary"` 和 `level: "verbose"` 的成功记录策略。 -- [x] 补充成功链路单测。 - -### Phase 4: 运行时报错信息收集 - -- [ ] 接入统一运行时报错事件记录。 -- [ ] 收集错误码、错误名称、脱敏错误消息。 -- [ ] 收集当前加载阶段和生命周期。 -- [ ] 收集 host、remote、requestId、expose、shared 摘要。 -- [ ] 对错误栈做脱敏和长度限制。 -- [ ] 为 `RUNTIME-001` 增加 remoteEntry global 相关上下文。 -- [ ] 为 `RUNTIME-003` 增加 manifest URL、remote 名称、解析阶段上下文。 -- [ ] 为 `RUNTIME-004` 增加 host remotes 摘要和请求 id。 -- [ ] 为 `RUNTIME-008` 增加 timeout、network、script execution 分类。 -- [ ] 确认错误收集失败不会覆盖原始错误。 -- [ ] 补充运行时报错收集单测。 - -### Phase 5: shared / eager 可观测 - -- [x] 记录 shared 解析开始。 -- [x] 记录 host 当前提供的 shared 摘要。 -- [x] 记录最终命中的 shared 提供方和版本。 -- [x] 记录 shared miss。 -- [x] 记录 eager 相关同步加载失败。 -- [x] 标记 shared 问题的初步责任方。 -- [x] 确认 `errorLoadShare` 只做诊断观察,不默认接入 retry-plugin。 -- [x] 补充 shared、eager、strictVersion、singleton 场景测试。 - -### Phase 6: 构建信息收集 - -- [ ] 从构建插件配置生成脱敏配置摘要。 -- [ ] 从 manifest / stats 生成脱敏构建摘要。 -- [ ] 收集 bundler name、plugin version、build version。 -- [ ] 收集 remoteEntry 文件名、类型和 publicPath 模式。 -- [ ] 收集 remotes 摘要。 -- [ ] 收集 exposes 摘要。 -- [ ] 收集 shared 摘要。 -- [ ] 生成 `.mf/diagnostics/build-info.json`。 -- [ ] 确认构建信息生成失败不影响构建。 -- [ ] 补充构建信息脱敏测试。 - -### Phase 7: 构建信息和运行时关联 - -- [ ] 在 manifest / stats 中确认已有字段是否足够。 -- [ ] 补齐必要的 shared、exposes、remotes、插件版本、构建版本摘要。 -- [ ] 运行时报告关联 remote manifest 信息。 -- [ ] 构建侧错误也输出同一类诊断报告。 -- [ ] shared 报错时同时展示 host 提供摘要和 remote 需求摘要。 -- [ ] expose 报错时关联 remote manifest exposes 摘要。 -- [ ] remoteEntry 报错时关联 remoteEntry 类型、globalName 和构建版本。 -- [ ] 避免把本地绝对路径、源码、环境变量写入可导出报告。 - -### Phase 8: 报告生成 - -- [x] 从 trace 生成成功 summary。 -- [ ] 从 trace 生成事实报告。 -- [ ] 将现有 RUNTIME 错误接入报告。 -- [ ] 将 `RUNTIME-001` 报告关联 remoteEntry globalName。 -- [ ] 将 `RUNTIME-003` 报告关联 manifest URL 和 remote 名称。 -- [ ] 将 `RUNTIME-004` 报告关联 host remotes 列表摘要。 -- [ ] 将 `RUNTIME-005` / `RUNTIME-006` 报告关联 shared 和 eager 信息。 -- [ ] 将 `RUNTIME-008` 报告区分网络、超时、执行失败。 -- [ ] 保留原始错误码和现有文档链接。 - -### Phase 9: AI coding 入口 - -- [ ] 提供稳定的读取入口。 -- [x] Node / SSR / build 场景输出固定格式 console 提示。 -- [x] Node / SSR / build 场景写入 `.mf/diagnostics/latest.json`。 -- [x] Node / SSR / build 场景追加 `.mf/diagnostics/events.jsonl`。 -- [x] 浏览器 dev 场景输出可被 CDP 识别的固定格式 console 提示。 -- [x] 浏览器 dev 场景支持显式开启的只读全局入口。 -- [ ] 浏览器 prod 场景默认只输出最小错误码和 `traceId`。 -- [ ] 浏览器 prod 场景完整报告只允许通过显式导出或用户自有系统获取。 -- [ ] 明确 `latest.json` 和 `events.jsonl` 的格式和读取顺序。 -- [x] 提供最近一次加载报告。 -- [ ] 提供最近 N 次加载报告。 -- [x] 支持按 `traceId` 读取成功或失败报告。 -- [ ] 支持按 remote / expose / shared 查询最近加载记录。 -- [ ] 提供脱敏导出方法。 -- [x] Node 侧通过 Node 专用入口支持本地诊断文件。 -- [x] 浏览器侧支持显式开启的内存读取。 -- [ ] 给 AI 消费格式写示例。 - -### Phase 10: 文档和场景验证 - -- [ ] 文档说明如何开启安全可观测。 -- [ ] 文档说明导出的报告不包含哪些敏感信息。 -- [ ] 增加成功加载场景。 -- [ ] 增加成功但 retry 后恢复场景。 -- [ ] 增加成功但 fallback 生效场景。 -- [ ] 增加成功但 shared 命中非预期 provider 场景。 -- [x] 增加业务主动标记 `component:business-loaded` 场景。 -- [ ] 增加 remote URL 错误场景。 -- [ ] 增加 remoteEntry globalName 错误场景。 -- [ ] 增加 manifest 缺字段场景。 -- [ ] 增加 remoteEntry 自身执行错误场景。 -- [ ] 增加 host 未提供 shared 场景。 -- [ ] 增加 shared 版本不满足场景。 -- [ ] 增加 eager 配错场景。 - -## 第一批建议落地范围 - -第一批就是 Milestone 1,不直接做完整闭环。 - -优先顺序是: - -- 先做 diagnostics demo,确保后续改动都有固定验证入口。 -- 再做最小安全配置、最小事件、最小报告和最小插件输出。 -- 先覆盖 remote / manifest / remoteEntry 的成功和失败。 -- 先保证默认关闭、显式开启、URL 脱敏、记录失败不影响加载。 - -第一批完成后,再接 shared / eager、构建信息关联、浏览器生产环境导出等更完整能力。这样可以先把可验证底座稳定下来,避免一开始把诊断逻辑和所有错误点耦合在一起。 - -## 验收标准 - -- 默认不开启时,现有行为不变。 -- 有固定 demo 可以验证后续诊断能力。 -- 开启后可以读取脱敏后的加载事件。 -- 成功加载时可以读取加载 summary。 -- 失败报告可以指出失败阶段。 -- 报告不包含 query、hash、header、cookie、token、源码、remote 响应体。 -- trace 逻辑异常不会影响 MF 加载。 -- runtime-core 相关测试通过。 -- 构建通过。 diff --git a/apps/runtime-demo/3005-runtime-host/cypress/e2e/app.cy.ts b/apps/runtime-demo/3005-runtime-host/cypress/e2e/app.cy.ts index 0cd95166738..c77a8b5f8ba 100644 --- a/apps/runtime-demo/3005-runtime-host/cypress/e2e/app.cy.ts +++ b/apps/runtime-demo/3005-runtime-host/cypress/e2e/app.cy.ts @@ -1,7 +1,44 @@ import { getH1, getH3 } from '../support/app.po'; -const getDiagnosticsReader = (win: Cypress.AUTWindow) => - (win as any).__FEDERATION__?.__DIAGNOSTICS__?.runtime_host; +const getObservabilityReader = (win: Cypress.AUTWindow) => + (win as any).__FEDERATION__?.__OBSERVABILITY__?.runtime_host; + +type ObservabilityTestReport = { + traceId: string; + status?: string; + errorCode?: string; + requestId?: string; + summary: { + flags: { + cached?: boolean; + }; + phases: { + remoteEntry?: { + cached?: boolean; + }; + }; + }; + shared?: { + name?: string; + provider?: string; + reason?: string; + availableVersions?: string[]; + }; + events: Array<{ + eventName?: string; + metadata?: Record; + }>; +}; + +const ignoreExpectedObservabilityException = (expectedMessages: string[]) => { + cy.on('uncaught:exception', (error) => { + if (expectedMessages.some((message) => error.message.includes(message))) { + return false; + } + + return undefined; + }); +}; describe('3005-runtime-host/', () => { beforeEach(() => cy.visit('/')); @@ -81,17 +118,51 @@ describe('3005-runtime-host/', () => { }); }); - describe('diagnostics demo fixture', () => { - beforeEach(() => cy.visit('/diagnostics')); + describe('observability demo fixture', () => { + beforeEach(() => { + cy.visit('/observability'); + }); + + it('should emit build observability for the host config', () => { + cy.readFile('.mf/observability/build-info.json').then((buildInfo) => { + expect(buildInfo.source).to.equal('manifest'); + expect(buildInfo.moduleFederation.name).to.equal('runtime_host'); + expect( + buildInfo.moduleFederation.remotes.some( + (remote: { entry?: string; alias?: string }) => + remote.alias === 'remote1' && + remote.entry === 'http://127.0.0.1:3006/mf-manifest.json', + ), + ).to.equal(true); + expect(buildInfo.moduleFederation.exposes).to.deep.include({ + name: 'Button', + }); + expect( + buildInfo.moduleFederation.shared.some( + (shared: { name: string }) => shared.name === 'react', + ), + ).to.equal(true); + const reactShared = buildInfo.moduleFederation.shared.find( + (shared: { name: string }) => shared.name === 'react', + ); + expect(reactShared).to.include({ + name: 'react', + requiredVersion: '^18.2.0', + singleton: true, + }); + expect(JSON.stringify(buildInfo)).not.to.contain('/Users/bytedance'); + expect(JSON.stringify(buildInfo)).not.to.contain('token='); + }); + }); it('should expose a successful remote loading scenario', () => { - cy.get('[data-testid="diagnostics-load-success"]').click(); - cy.get('[data-testid="diagnostics-load-status"]').contains('success'); - cy.get('[data-testid="diagnostics-remote-result"]') + cy.get('[data-testid="observability-load-success"]').click(); + cy.get('[data-testid="observability-load-status"]').contains('success'); + cy.get('[data-testid="observability-remote-result"]') .find('button.test-remote2') .contains('Button from antd@4.24.15'); cy.window().then((win) => { - const reader = getDiagnosticsReader(win); + const reader = getObservabilityReader(win); expect(reader).to.exist; const latestReport = reader.getLatestReport(); @@ -100,19 +171,34 @@ describe('3005-runtime-host/', () => { expect(latestReport.summary.loadCompleted).to.equal(true); expect(latestReport.summary.componentLoaded).to.equal(false); expect(latestReport.summary.outcome).to.equal('runtime-loaded'); + expect(latestReport.summary.phases.loadRemote.status).to.equal( + 'complete', + ); + expect(latestReport.diagnosis.status).to.equal('success'); + expect(latestReport.diagnosis.outcome).to.equal('runtime-loaded'); + expect(latestReport.diagnosis.facts.runtimeLoaded).to.equal(true); + expect(latestReport.summary.phases.remoteEntryInit.duration).to.be.a( + 'number', + ); }); - cy.get('[data-testid="diagnostics-business-loaded"]').click(); - cy.get('[data-testid="diagnostics-report"]').contains( + cy.get('[data-testid="observability-business-loaded"]').click(); + cy.get('[data-testid="observability-report"]').contains( 'component:business-loaded', ); + cy.get('[data-testid="observability-report"]') + .should('contain', '"route": "/observability?token=demo-secret#hash"') + .should('contain', 'token=demo-secret') + .should('contain', '#hash'); cy.window().then((win) => { - const reader = getDiagnosticsReader(win); + const reader = getObservabilityReader(win); expect(reader).to.exist; const latestReport = reader.getLatestReport(); expect(latestReport.status).to.equal('success'); expect(latestReport.summary.componentLoaded).to.equal(true); expect(latestReport.summary.outcome).to.equal('component-loaded'); + expect(latestReport.diagnosis.outcome).to.equal('component-loaded'); + expect(latestReport.diagnosis.facts.componentLoaded).to.equal(true); expect(reader.getReport(latestReport.traceId).traceId).to.equal( latestReport.traceId, ); @@ -121,95 +207,507 @@ describe('3005-runtime-host/', () => { it('should expose a failed remote loading scenario', () => { cy.window().then((win) => { - cy.spy(win.console, 'warn').as('diagnosticsWarn'); + cy.spy(win.console, 'error').as('observabilityError'); }); - cy.get('[data-testid="diagnostics-load-missing-expose"]').click(); - cy.get('[data-testid="diagnostics-load-status"]').contains('error'); - cy.get('[data-testid="diagnostics-report"]').contains( + cy.get('[data-testid="observability-load-missing-expose"]').click(); + cy.get('[data-testid="observability-load-status"]').contains('error'); + cy.get('[data-testid="observability-report"]').contains( 'dynamic-remote/__missing_expose__', ); - cy.get('[data-testid="diagnostics-error-message"]').should( + cy.get('[data-testid="observability-error-message"]').should( 'not.contain', 'token=', ); - cy.get('@diagnosticsWarn').should( + cy.get('@observabilityError').should( 'have.been.calledWithMatch', - /Diagnostic report generated[\s\S]*traceId: mf-/, + /Observability report generated[\s\S]*traceId: mf-/, ); cy.window().then((win) => { - const reader = getDiagnosticsReader(win); + const reader = getObservabilityReader(win); const latestReport = reader.getLatestReport(); expect(latestReport.status).to.equal('error'); expect(latestReport.traceId).to.match(/^mf-/); expect(latestReport.summary.loadCompleted).to.equal(true); expect(latestReport.summary.outcome).to.equal('failed'); - expect(reader.getReport(latestReport.traceId).failedPhase).to.exist; + expect( + latestReport.diagnosis.actions.some( + (action: { id: string }) => action.id === 'check-expose', + ), + ).to.equal(true); + expect(reader.getReport(latestReport.traceId).failedPhase).to.equal( + 'expose', + ); }); }); - it('should expose a sanitized manifest failure scenario', () => { - cy.get('[data-testid="diagnostics-load-broken-manifest"]').click(); - cy.get('[data-testid="diagnostics-load-status"]').contains('error'); - cy.get('[data-testid="diagnostics-report"]').contains( - 'diagnostics-broken-remote/Button', + it('should expose a manifest failure scenario', () => { + cy.get('[data-testid="observability-load-broken-manifest"]').click(); + cy.get('[data-testid="observability-load-status"]').contains('error'); + cy.get('[data-testid="observability-report"]').contains( + 'observability-broken-remote/Button', ); - cy.get('[data-testid="diagnostics-report"]') - .should('not.contain', 'demo-secret') - .should('not.contain', 'token=') - .should('not.contain', '#hash'); - cy.get('[data-testid="diagnostics-error-message"]') - .contains('/diagnostics-missing/mf-manifest.json') + cy.get('[data-testid="observability-report"]') + .should('contain', 'demo-secret') + .should('contain', 'token=') + .should('contain', '#hash') + .should('contain', 'RUNTIME-003') + .should('contain', '"ownerHint": "host"') + .should('contain', '"check-manifest-url"'); + cy.get('[data-testid="observability-error-message"]') + .contains('/observability-missing/mf-manifest.json') .should('not.contain', 'demo-secret') .should('not.contain', 'token=') .should('not.contain', '#hash'); + cy.window().then((win) => { + const latestReport = getObservabilityReader(win).getLatestReport(); + expect(latestReport.diagnosis.facts.url).to.equal( + 'http://127.0.0.1:3005/observability-missing/mf-manifest.json?token=demo-secret#hash', + ); + }); }); - it('should expose a shared miss diagnostic scenario', () => { + it('should expose a remote URL failure scenario', () => { + cy.get('[data-testid="observability-load-remote-url-error"]').click(); + cy.get('[data-testid="observability-load-status"]').contains('error'); + cy.get('[data-testid="observability-report"]') + .should('contain', 'observability-remote-url/Button') + .should( + 'contain', + 'http://127.0.0.1:3999/observability-remote-url/mf-manifest.json', + ) + .should('contain', 'RUNTIME-003') + .should('contain', '"check-manifest-url"'); cy.window().then((win) => { - cy.spy(win.console, 'warn').as('diagnosticsWarn'); + const latestReport = getObservabilityReader(win).getLatestReport(); + expect(latestReport.status).to.equal('error'); + expect(latestReport.failedPhase).to.equal('manifest'); + expect(latestReport.diagnosis.facts.url).to.equal( + 'http://127.0.0.1:3999/observability-remote-url/mf-manifest.json', + ); }); - cy.get('[data-testid="diagnostics-shared-miss"]').click(); - cy.get('[data-testid="diagnostics-load-status"]').contains('error'); - cy.get('[data-testid="diagnostics-report"]') - .should('contain', 'diagnostics-missing-shared') + }); + + it('should expose a retry recovered remote scenario', () => { + cy.get('[data-testid="observability-load-retry-recovered"]').click(); + cy.get('[data-testid="observability-load-status"]').contains('success'); + cy.get('[data-testid="observability-report"]') + .should('contain', 'observability-retry-recovered/Button') + .should('contain', 'remoteEntry:load-recovered') + .should('contain', '"retried": true') + .should('contain', '"recovered": true'); + cy.window().then((win) => { + const latestReport = getObservabilityReader(win).getLatestReport(); + expect(latestReport.status).to.equal('success'); + expect(latestReport.summary.outcome).to.equal('recovered'); + expect(latestReport.summary.flags.retried).to.equal(true); + expect(latestReport.summary.flags.fallback).to.equal(false); + expect(latestReport.summary.phases.remoteEntry.recovered).to.equal( + true, + ); + expect(latestReport.diagnosis.warnings).to.include( + 'Remote entry loading recovered after retry', + ); + }); + }); + + it('should expose a fallback recovered remote scenario', () => { + cy.get('[data-testid="observability-load-fallback-success"]').click(); + cy.get('[data-testid="observability-load-status"]').contains('success'); + cy.get('[data-testid="observability-report"]') + .should('contain', 'dynamic-remote/__observability_fallback__') + .should('contain', 'remote:load-recovered') + .should('contain', '"fallback": true') + .should('contain', '"outcome": "recovered"'); + cy.window().then((win) => { + const latestReport = getObservabilityReader(win).getLatestReport(); + expect(latestReport.status).to.equal('success'); + expect(latestReport.failedPhase).to.equal('expose'); + expect(latestReport.summary.outcome).to.equal('recovered'); + expect(latestReport.summary.flags.fallback).to.equal(true); + expect(latestReport.summary.flags.recovered).to.equal(true); + expect(latestReport.summary.error.failedPhase).to.equal('expose'); + expect(latestReport.diagnosis.warnings).to.include( + 'Remote loading completed through fallback recovery', + ); + expect( + latestReport.diagnosis.actions.some( + (action: { id: string }) => action.id === 'check-expose', + ), + ).to.equal(true); + }); + }); + + it('should expose a manifest missing fields scenario', () => { + ignoreExpectedObservabilityException(['RUNTIME-013']); + cy.get( + '[data-testid="observability-load-missing-fields-manifest"]', + ).click(); + cy.get('[data-testid="observability-load-status"]').contains('error'); + cy.get('[data-testid="observability-report"]') + .should('contain', 'observability-missing-fields/Button') + .should('contain', 'RUNTIME-013') + .should('contain', 'Missing required fields') + .should('contain', 'metaData') + .should('contain', 'exposes') + .should('contain', 'shared') + .should('contain', '"check-manifest-url"'); + cy.window().then((win) => { + const latestReport = getObservabilityReader(win).getLatestReport(); + expect(latestReport.status).to.equal('error'); + expect(latestReport.failedPhase).to.equal('manifest'); + expect(latestReport.diagnosis.facts.url).to.equal( + 'http://127.0.0.1:3005/observability-fixtures/missing-fields/mf-manifest.json', + ); + }); + }); + + it('should expose a remoteEntry globalName mismatch scenario', () => { + ignoreExpectedObservabilityException(['RUNTIME-001']); + cy.get('[data-testid="observability-load-wrong-global"]').click(); + cy.get('[data-testid="observability-load-status"]').contains('error'); + cy.get('[data-testid="observability-report"]') + .should('contain', 'observability-wrong-global/Button') + .should('contain', 'RUNTIME-001') + .should('contain', 'observability_wrong_global_expected') + .should('contain', '"check-remote-global"'); + cy.window().then((win) => { + const latestReport = getObservabilityReader(win).getLatestReport(); + expect(latestReport.status).to.equal('error'); + expect(latestReport.failedPhase).to.equal('remoteEntry'); + expect(latestReport.errorCode).to.equal('RUNTIME-001'); + expect( + latestReport.diagnosis.actions.some( + (action: { id: string }) => action.id === 'check-remote-global', + ), + ).to.equal(true); + }); + }); + + it('should expose a remoteEntry execution error scenario', () => { + ignoreExpectedObservabilityException([ + 'observability remoteEntry execution failed', + 'ScriptExecutionError', + ]); + cy.get( + '[data-testid="observability-load-remote-entry-execution-error"]', + ).click(); + cy.get('[data-testid="observability-load-status"]').contains('error'); + cy.get('[data-testid="observability-report"]') + .should('contain', 'observability-execution-error/Button') + .should('contain', 'RUNTIME-008') + .should('contain', 'ScriptExecutionError') + .should('contain', '"check-remote-entry"'); + cy.window().then((win) => { + const latestReport = getObservabilityReader(win).getLatestReport(); + expect(latestReport.status).to.equal('error'); + expect(latestReport.failedPhase).to.equal('remoteEntry'); + expect(latestReport.errorCode).to.equal('RUNTIME-008'); + expect(latestReport.diagnosis.facts.resourceErrorType).to.equal( + 'script-execution', + ); + }); + }); + + it('should expose a snapshot match observability scenario', () => { + ignoreExpectedObservabilityException(['RUNTIME-007']); + cy.get('[data-testid="observability-load-snapshot-match-error"]').click(); + cy.get('[data-testid="observability-load-status"]').contains('error'); + cy.get('[data-testid="observability-report"]') + .should('contain', 'observability-snapshot-miss/Button') + .should('contain', 'RUNTIME-007') + .should('contain', 'remote-snapshot') + .should('contain', 'observability-unrelated-snapshot') + .should('contain', '"check-module-info"'); + cy.window().then((win) => { + const latestReport = getObservabilityReader(win).getLatestReport(); + expect(latestReport.status).to.equal('error'); + expect(latestReport.errorCode).to.equal('RUNTIME-007'); + expect(latestReport.ownerHint).to.equal('host'); + expect(latestReport.moduleInfo.reason).to.equal('remote-snapshot'); + expect(latestReport.moduleInfo.matchedCount).to.equal(0); + expect(latestReport.moduleInfo.availableNames).to.include( + 'remote:observability-unrelated-snapshot', + ); + expect( + latestReport.diagnosis.actions.some( + (action: { id: string }) => action.id === 'check-module-info', + ), + ).to.equal(true); + }); + }); + + it('should expose a shared miss observability scenario', () => { + cy.window().then((win) => { + cy.spy(win.console, 'error').as('observabilityError'); + }); + cy.get('[data-testid="observability-shared-miss"]').click(); + cy.get('[data-testid="observability-load-status"]').contains('error'); + cy.get('[data-testid="observability-report"]') + .should('contain', 'observability-missing-shared') .should('contain', 'missing-provider'); - cy.get('@diagnosticsWarn').should( + cy.get('@observabilityError').should( 'have.been.calledWithMatch', - /Diagnostic report generated[\s\S]*traceId: mf-/, + /Observability report generated[\s\S]*traceId: mf-/, ); cy.window().then((win) => { - const latestReport = getDiagnosticsReader(win).getLatestReport(); + const latestReport = getObservabilityReader(win).getLatestReport(); expect(latestReport.status).to.equal('error'); expect(latestReport.shared.reason).to.equal('missing-provider'); + expect( + latestReport.diagnosis.actions.some( + (action: { id: string }) => action.id === 'check-shared-provider', + ), + ).to.equal(true); }); }); - it('should expose a shared version mismatch diagnostic scenario', () => { - cy.get('[data-testid="diagnostics-shared-version-mismatch"]').click(); - cy.get('[data-testid="diagnostics-load-status"]').contains('error'); - cy.get('[data-testid="diagnostics-report"]') + it('should expose a shared version mismatch observability scenario', () => { + cy.get('[data-testid="observability-shared-version-mismatch"]').click(); + cy.get('[data-testid="observability-load-status"]').contains('error'); + cy.get('[data-testid="observability-report"]') .should('contain', '"name": "react"') .should('contain', '^99.0.0') .should('contain', 'version-mismatch'); cy.window().then((win) => { - const latestReport = getDiagnosticsReader(win).getLatestReport(); + const latestReport = getObservabilityReader(win).getLatestReport(); expect(latestReport.status).to.equal('error'); expect(latestReport.shared.reason).to.equal('version-mismatch'); expect(latestReport.shared.availableVersions).to.include('18.3.1'); + expect( + latestReport.diagnosis.actions.some( + (action: { id: string }) => action.id === 'check-shared-version', + ), + ).to.equal(true); + }); + }); + + it('should expose a shared unexpected provider observability scenario', () => { + cy.get( + '[data-testid="observability-shared-unexpected-provider"]', + ).click(); + cy.get('[data-testid="observability-load-status"]').contains('success'); + cy.get('[data-testid="observability-report"]') + .should('contain', 'observability-provider-choice') + .should('contain', '"provider": "runtime_remote2"') + .should('contain', '"selectedVersion": "2.0.0"') + .should('contain', 'shared:resolved'); + cy.window().then((win) => { + const latestReport = getObservabilityReader(win).getLatestReport(); + expect(latestReport.status).to.equal('success'); + expect(latestReport.shared.name).to.equal( + 'observability-provider-choice', + ); + expect(latestReport.shared.provider).to.equal('runtime_remote2'); + expect(latestReport.shared.selectedVersion).to.equal('2.0.0'); + expect(latestReport.summary.shared.provider).to.equal( + 'runtime_remote2', + ); + expect(latestReport.diagnosis.facts.provider).to.equal( + 'runtime_remote2', + ); + }); + }); + + it('should expose a multi-consumer loading chain scenario', () => { + cy.get('[data-testid="observability-multi-consumer-chain"]').click(); + cy.get('[data-testid="observability-load-status"]').contains('success'); + cy.get('[data-testid="observability-multi-consumer-results"]') + .should('contain', 'checkout-page loaded') + .should('contain', 'analytics-page loaded') + .should('contain', 'checkout-page-repeat loaded') + .should( + 'contain', + 'observability-checkout-theme from observability_consumer_checkout@1.4.0', + ) + .should( + 'contain', + 'observability-analytics-sdk from observability_consumer_analytics@1.2.0', + ) + .should('contain', 'cached=true'); + cy.get('[data-testid="observability-report"]') + .should('contain', 'multi-consumer-loading-chain') + .should('contain', 'dynamic-remote/ProfileCard') + .should('contain', 'dynamic-remote/AnalyticsPanel') + .should('contain', '"consumer": "checkout-page"') + .should('contain', '"consumer": "analytics-page"') + .should( + 'contain', + '"sharedProvider": "observability_consumer_checkout"', + ) + .should( + 'contain', + '"sharedProvider": "observability_consumer_analytics"', + ) + .should('contain', '"cached": true'); + cy.window().then((win) => { + const reader = getObservabilityReader(win); + const reports = reader.getReports(); + const profileReports = reader.findReports({ + remote: 'runtime_remote2', + expose: 'ProfileCard', + }); + const analyticsReports = reader.findReports({ + remote: 'runtime_remote2', + expose: 'AnalyticsPanel', + }); + const checkoutSharedReports = reader.findReports({ + shared: 'observability-checkout-theme', + }); + const analyticsSharedReports = reader.findReports({ + shared: 'observability-analytics-sdk', + }); + const cachedRemoteReport = ( + profileReports as ObservabilityTestReport[] + ).find((report) => report.summary.flags.cached === true); + const checkoutComponentReport = ( + profileReports as ObservabilityTestReport[] + ).find((report) => + report.events.some( + (event) => + event.eventName === 'component:business-loaded' && + event.metadata?.consumer === 'checkout-page', + ), + ); + + expect(reports.length).to.be.greaterThan(4); + expect(profileReports.length).to.be.greaterThan(1); + expect(analyticsReports.length).to.equal(1); + expect( + (checkoutSharedReports[0] as ObservabilityTestReport).shared + ?.provider, + ).to.equal('observability_consumer_checkout'); + expect( + (analyticsSharedReports[0] as ObservabilityTestReport).shared + ?.provider, + ).to.equal('observability_consumer_analytics'); + expect(cachedRemoteReport?.summary.flags.cached).to.equal(true); + expect( + checkoutComponentReport?.events.some( + (event) => + event.metadata?.sharedTraceId === + (checkoutSharedReports[0] as ObservabilityTestReport).traceId, + ), + ).to.equal(true); }); }); - it('should expose an eager config diagnostic scenario', () => { - cy.get('[data-testid="diagnostics-eager-config-error"]').click(); - cy.get('[data-testid="diagnostics-load-status"]').contains('error'); - cy.get('[data-testid="diagnostics-report"]') - .should('contain', 'diagnostics-async-shared') + it('should expose an eager config observability scenario', () => { + cy.get('[data-testid="observability-eager-config-error"]').click(); + cy.get('[data-testid="observability-load-status"]').contains('error'); + cy.get('[data-testid="observability-report"]') + .should('contain', 'observability-async-shared') .should('contain', 'sync-async-boundary') - .should('contain', 'RUNTIME-005'); + .should('contain', 'RUNTIME-005') + .should('contain', '"ownerHint": "shared"'); cy.window().then((win) => { - const latestReport = getDiagnosticsReader(win).getLatestReport(); + const latestReport = getObservabilityReader(win).getLatestReport(); expect(latestReport.status).to.equal('error'); expect(latestReport.shared.reason).to.equal('sync-async-boundary'); + expect( + latestReport.diagnosis.actions.some( + (action: { id: string }) => action.id === 'check-eager-config', + ), + ).to.equal(true); + }); + }); + + it('should expose a runtime eager config observability scenario', () => { + cy.get( + '[data-testid="observability-runtime-eager-config-error"]', + ).click(); + cy.get('[data-testid="observability-load-status"]').contains('error'); + cy.get('[data-testid="observability-report"]') + .should('contain', 'observability-runtime-async-shared') + .should('contain', 'sync-async-boundary') + .should('contain', 'RUNTIME-006') + .should('contain', '"ownerHint": "shared"'); + cy.window().then((win) => { + const latestReport = getObservabilityReader(win).getLatestReport(); + expect(latestReport.status).to.equal('error'); + expect(latestReport.errorCode).to.equal('RUNTIME-006'); + expect(latestReport.shared.reason).to.equal('sync-async-boundary'); + expect( + latestReport.diagnosis.actions.some( + (action: { id: string }) => action.id === 'check-eager-config', + ), + ).to.equal(true); + }); + }); + }); + + describe('observability showcase fixture', () => { + beforeEach(() => { + cy.visit('/observability-showcase'); + }); + + it('should present a product page route flow for AI observability', () => { + cy.contains('Suggested prompt').should('not.exist'); + cy.contains('AI-ready evidence').should('not.exist'); + cy.contains('Show latest observability report').should('not.exist'); + cy.get('[data-testid="observability-showcase-status"]').contains( + 'success', + ); + cy.get('[data-testid="remote2-profile-card"]').should( + 'contain', + 'ProfileCard', + ); + cy.get('[data-testid="observability-showcase-load"]').click(); + cy.location('pathname').should( + 'equal', + '/observability-showcase/analytics', + ); + cy.get('[data-testid="observability-showcase-status"]').contains( + 'degraded', + ); + cy.get('[data-testid="observability-showcase-fallback"]').should( + 'contain', + 'Limited analytics view', + ); + cy.get('[data-testid="observability-showcase-message"]').should( + 'contain', + 'Some analytics details are temporarily unavailable', + ); + cy.get('[data-testid="observability-showcase-shared"]').should( + 'contain', + 'Detailed analytics are temporarily limited', + ); + + cy.window().then((win) => { + const reports = getObservabilityReader(win).getReports(); + const customerSdkReport = reports.find( + (report: ObservabilityTestReport) => + report.shared?.name === 'observability-customer-sdk', + ); + + expect( + reports.some( + (report: ObservabilityTestReport) => + report.requestId === 'dynamic-remote/ProfileCard', + ), + ).to.equal(true); + expect( + reports.some( + (report: ObservabilityTestReport) => + report.requestId === 'dynamic-remote/AnalyticsPanel', + ), + ).to.equal(true); + expect( + reports.some( + (report: ObservabilityTestReport) => + report.shared?.name === 'react', + ), + ).to.equal(true); + expect( + reports.some( + (report: ObservabilityTestReport) => + report.shared?.name === 'observability-customer-sdk', + ), + ).to.equal(true); + expect(customerSdkReport?.status).to.equal('error'); + expect(customerSdkReport?.shared?.reason).to.equal('version-mismatch'); + expect(customerSdkReport?.shared?.availableVersions).to.include( + '2.1.0', + ); }); }); }); diff --git a/apps/runtime-demo/3005-runtime-host/package.json b/apps/runtime-demo/3005-runtime-host/package.json index 177b17c26d6..16b5a8ba8b9 100644 --- a/apps/runtime-demo/3005-runtime-host/package.json +++ b/apps/runtime-demo/3005-runtime-host/package.json @@ -4,7 +4,7 @@ "version": "0.0.0", "devDependencies": { "@module-federation/core": "workspace:*", - "@module-federation/diagnostics-plugin": "workspace:*", + "@module-federation/observability-plugin": "workspace:*", "@module-federation/runtime": "workspace:*", "@module-federation/typescript": "workspace:*", "@module-federation/enhanced": "workspace:*", diff --git a/apps/runtime-demo/3005-runtime-host/src/App.tsx b/apps/runtime-demo/3005-runtime-host/src/App.tsx index bbf64f1eba8..5b3bcd1bc0e 100644 --- a/apps/runtime-demo/3005-runtime-host/src/App.tsx +++ b/apps/runtime-demo/3005-runtime-host/src/App.tsx @@ -1,33 +1,62 @@ import React from 'react'; -import { Link, Routes, Route, BrowserRouter } from 'react-router-dom'; -import DiagnosticsDemo from './DiagnosticsDemo'; +import { + Link, + Routes, + Route, + BrowserRouter, + useLocation, +} from 'react-router-dom'; +import ObservabilityDemo from './ObservabilityDemo'; +import ObservabilityShowcase from './ObservabilityShowcase'; import Root from './Root'; import Remote1 from './Remote1'; import Remote2 from './Remote2'; +const AppRoutes = () => { + const location = useLocation(); + const isShowcase = location.pathname.startsWith('/observability-showcase'); + + return ( + <> + {!isShowcase ? ( + <> +

    Runtime Demo

    +
      +
    • + Home +
    • +
    • + remote1 +
    • +
    • + remote2 +
    • +
    • + observability +
    • +
    • + observability showcase +
    • +
    + + ) : null} + + } /> + } /> + } /> + } /> + } + /> + + + ); +}; + const App = () => ( -

    Runtime Demo

    -
      -
    • - Home -
    • -
    • - remote1 -
    • -
    • - remote2 -
    • -
    • - diagnostics -
    • -
    - - } /> - } /> - } /> - } /> - +
    ); diff --git a/apps/runtime-demo/3005-runtime-host/src/DiagnosticsDemo.tsx b/apps/runtime-demo/3005-runtime-host/src/DiagnosticsDemo.tsx deleted file mode 100644 index d1d957c2edb..00000000000 --- a/apps/runtime-demo/3005-runtime-host/src/DiagnosticsDemo.tsx +++ /dev/null @@ -1,330 +0,0 @@ -import React, { useCallback, useState } from 'react'; -import { - loadRemote, - loadShare, - loadShareSync, - registerRemotes, -} from '@module-federation/runtime'; -import { diagnostics } from './diagnostics'; - -type LoadStatus = 'idle' | 'loading' | 'success' | 'error'; - -type RemoteComponent = React.ComponentType>; - -const successRequest = 'dynamic-remote/ButtonOldAnt'; -const missingExposeRequest = 'dynamic-remote/__missing_expose__'; -const brokenManifestEntry = - 'http://127.0.0.1:3005/diagnostics-missing/mf-manifest.json?token=demo-secret#hash'; -const brokenManifestRequest = 'diagnostics-broken-remote/Button'; -function sanitizeErrorMessage(error: unknown): string { - const message = error instanceof Error ? error.message : String(error); - - return message - .replace(/https?:\/\/[^\s'"<>]+/g, (url) => { - try { - const parsedUrl = new URL(url); - return `${parsedUrl.origin}${parsedUrl.pathname}`; - } catch { - return '[redacted-url]'; - } - }) - .replace( - /\b(token|authorization|cookie|secret|password)=([^&\s]+)/gi, - '$1=[redacted]', - ); -} - -function resolveRemoteComponent(remoteModule: unknown): RemoteComponent | null { - if (typeof remoteModule === 'function') { - return remoteModule as RemoteComponent; - } - - if (remoteModule && typeof remoteModule === 'object') { - const candidate = (remoteModule as { default?: unknown }).default; - - if (typeof candidate === 'function') { - return candidate as RemoteComponent; - } - } - - return null; -} - -export default function DiagnosticsDemo() { - const [status, setStatus] = useState('idle'); - const [errorMessage, setErrorMessage] = useState(''); - const [remoteComponent, setRemoteComponent] = - useState(null); - const [reportText, setReportText] = useState('null'); - - const refreshReport = useCallback(() => { - setReportText( - JSON.stringify(diagnostics.getLatestReport() ?? null, null, 2), - ); - }, []); - - const loadSuccessRemote = useCallback(async () => { - setStatus('loading'); - setErrorMessage(''); - setRemoteComponent(null); - - try { - const remoteModule = await loadRemote(successRequest); - const component = resolveRemoteComponent(remoteModule); - - if (!component) { - throw new Error(`Remote module ${successRequest} has no component`); - } - - setRemoteComponent(() => component); - setStatus('success'); - } catch (error) { - const message = sanitizeErrorMessage(error); - setStatus('error'); - setErrorMessage(message); - } finally { - refreshReport(); - } - }, [refreshReport]); - - const loadMissingExpose = useCallback(async () => { - setStatus('loading'); - setErrorMessage(''); - setRemoteComponent(null); - - try { - const remoteModule = await loadRemote(missingExposeRequest); - - if (!remoteModule) { - throw new Error(`Remote module ${missingExposeRequest} returned empty`); - } - - throw new Error( - `Remote module ${missingExposeRequest} unexpectedly loaded`, - ); - } catch (error) { - const message = sanitizeErrorMessage(error); - setStatus('error'); - setErrorMessage(message); - } finally { - refreshReport(); - } - }, [refreshReport]); - - const loadBrokenManifest = useCallback(async () => { - setStatus('loading'); - setErrorMessage(''); - setRemoteComponent(null); - - registerRemotes( - [ - { - name: 'diagnostics_broken_remote', - alias: 'diagnostics-broken-remote', - entry: brokenManifestEntry, - }, - ], - { force: true }, - ); - - try { - await loadRemote(brokenManifestRequest); - throw new Error( - `Remote module ${brokenManifestRequest} unexpectedly loaded`, - ); - } catch (error) { - const message = sanitizeErrorMessage(`${brokenManifestEntry} ${error}`); - setStatus('error'); - setErrorMessage(message); - } finally { - refreshReport(); - } - }, [refreshReport]); - - const loadSharedMiss = useCallback(async () => { - setStatus('loading'); - setErrorMessage(''); - setRemoteComponent(null); - - try { - const result = await loadShare('diagnostics-missing-shared', { - customShareInfo: { - version: '1.0.0', - scope: ['diagnostics-missing-scope'], - shareConfig: { - requiredVersion: '^1.0.0', - singleton: false, - }, - }, - }); - - if (result === false) { - throw new Error( - 'Shared miss: diagnostics-missing-shared was not provided by host', - ); - } - - throw new Error('Shared miss scenario unexpectedly loaded'); - } catch (error) { - const message = sanitizeErrorMessage(error); - setStatus('error'); - setErrorMessage(message); - } finally { - refreshReport(); - } - }, [refreshReport]); - - const loadSharedVersionMismatch = useCallback(async () => { - setStatus('loading'); - setErrorMessage(''); - setRemoteComponent(null); - - try { - const result = await loadShare('react', { - customShareInfo: { - shareConfig: { - requiredVersion: '^99.0.0', - singleton: false, - }, - }, - }); - - if (result === false) { - throw new Error('Shared version mismatch: react needs ^99.0.0'); - } - - throw new Error('Shared version mismatch scenario unexpectedly loaded'); - } catch (error) { - const message = sanitizeErrorMessage(error); - setStatus('error'); - setErrorMessage(message); - } finally { - refreshReport(); - } - }, [refreshReport]); - - const loadEagerConfigError = useCallback(() => { - setStatus('loading'); - setErrorMessage(''); - setRemoteComponent(null); - - try { - loadShareSync('diagnostics-async-shared', { - from: 'build', - customShareInfo: { - version: '1.0.0', - scope: ['default'], - shareConfig: { - requiredVersion: '^1.0.0', - singleton: false, - eager: false, - strictVersion: false, - }, - get: () => - Promise.resolve(() => ({ - value: 'async shared should not be consumed synchronously', - })), - }, - }); - - throw new Error('Eager config scenario unexpectedly loaded'); - } catch (error) { - const message = sanitizeErrorMessage(error); - setStatus('error'); - setErrorMessage(message); - } finally { - refreshReport(); - } - }, [refreshReport]); - - const markBusinessLoaded = useCallback(() => { - diagnostics.markComponentLoaded({ - requestId: successRequest, - componentName: 'ButtonOldAnt', - }); - refreshReport(); - }, [refreshReport]); - - const LoadedRemote = remoteComponent; - - return ( -
    -

    Diagnostics Demo

    - -
    -

    Load Remote

    - - - - -
    - -
    -

    Shared / Eager Scenarios

    - - - -
    - -
    -

    Status

    -

    {status}

    - {errorMessage ? ( -
    {errorMessage}
    - ) : null} - {LoadedRemote ? ( -
    - -
    - ) : null} -
    - -
    -

    Report Fixture

    -
    {reportText}
    -
    -
    - ); -} diff --git a/apps/runtime-demo/3005-runtime-host/src/ObservabilityDemo.tsx b/apps/runtime-demo/3005-runtime-host/src/ObservabilityDemo.tsx new file mode 100644 index 00000000000..4e4314221b1 --- /dev/null +++ b/apps/runtime-demo/3005-runtime-host/src/ObservabilityDemo.tsx @@ -0,0 +1,1065 @@ +import React, { useCallback, useState } from 'react'; +import type { ModuleFederationRuntimePlugin } from '@module-federation/runtime'; +import { + createInstance, + getInstance, + loadRemote, + loadShare, + loadShareSync, + registerPlugins, + registerRemotes, +} from '@module-federation/runtime'; +import { observability } from './observability'; + +type LoadStatus = 'idle' | 'loading' | 'success' | 'error'; + +type RemoteComponent = React.ComponentType>; + +const successRequest = 'dynamic-remote/ButtonOldAnt'; +const missingExposeRequest = 'dynamic-remote/__missing_expose__'; +const brokenManifestEntry = + 'http://127.0.0.1:3005/observability-missing/mf-manifest.json?token=demo-secret#hash'; +const brokenManifestRequest = 'observability-broken-remote/Button'; +const remoteUrlErrorEntry = + 'http://127.0.0.1:3999/observability-remote-url/mf-manifest.json'; +const remoteUrlErrorRequest = 'observability-remote-url/Button'; +const retryRecoveryRemoteName = 'observability_retry_recovered_remote'; +const retryRecoveryManifestEntry = + 'http://127.0.0.1:3005/observability-fixtures/retry-recovered/mf-manifest.json'; +const retryRecoveryRequest = 'observability-retry-recovered/Button'; +const fallbackRequest = 'dynamic-remote/__observability_fallback__'; +const missingFieldsManifestEntry = + 'http://127.0.0.1:3005/observability-fixtures/missing-fields/mf-manifest.json'; +const missingFieldsManifestRequest = 'observability-missing-fields/Button'; +const wrongGlobalManifestEntry = + 'http://127.0.0.1:3005/observability-fixtures/wrong-global/mf-manifest.json'; +const wrongGlobalRequest = 'observability-wrong-global/Button'; +const executionErrorManifestEntry = + 'http://127.0.0.1:3005/observability-fixtures/execution-error/mf-manifest.json'; +const executionErrorRequest = 'observability-execution-error/Button'; +const multiProducerRemoteName = 'runtime_remote2'; +const multiProducerAlias = 'dynamic-remote'; +const multiProducerManifestEntry = 'http://127.0.0.1:3007/mf-manifest.json'; +const snapshotMissRequest = 'observability-snapshot-miss/Button'; +const unexpectedProviderSharedName = 'observability-provider-choice'; +const unexpectedProviderSharedScope = 'observability-provider-scope'; + +type RuntimeRemote = Parameters[0][number]; +type RuntimeShareScope = Parameters< + NonNullable>['initShareScopeMap'] +>[1]; +type SharedProviderValue = { + provider: string; + version: string; +}; + +interface MultiConsumerScenario { + consumer: string; + request: string; + expose: string; + componentName: string; + sharedName: string; + sharedScope: string; + requiredVersion: string; + hostVersion: string; + expectedProvider: string; +} + +interface MultiConsumerResult { + consumer: string; + request: string; + expose: string; + sharedName: string; + sharedProvider: string; + sharedVersion: string; + sharedTraceId: string; + remoteTraceId: string; + remoteEntryCached: boolean; + manifestCached: boolean; + summaryCached: boolean; +} + +interface RegisteredRemoteFailureScenario { + remote: RuntimeRemote; + request: string; + errorPrefix?: string; +} + +const multiConsumerScenarios: MultiConsumerScenario[] = [ + { + consumer: 'checkout-page', + request: `${multiProducerAlias}/ProfileCard`, + expose: './ProfileCard', + componentName: 'ProfileCard', + sharedName: 'observability-checkout-theme', + sharedScope: 'observability-checkout-scope', + requiredVersion: '^1.0.0', + hostVersion: '1.4.0', + expectedProvider: 'observability_consumer_checkout', + }, + { + consumer: 'analytics-page', + request: `${multiProducerAlias}/AnalyticsPanel`, + expose: './AnalyticsPanel', + componentName: 'AnalyticsPanel', + sharedName: 'observability-analytics-sdk', + sharedScope: 'observability-analytics-scope', + requiredVersion: '^1.0.0', + hostVersion: '1.2.0', + expectedProvider: 'observability_consumer_analytics', + }, + { + consumer: 'checkout-page-repeat', + request: `${multiProducerAlias}/ProfileCard`, + expose: './ProfileCard', + componentName: 'ProfileCard', + sharedName: 'observability-checkout-theme', + sharedScope: 'observability-checkout-scope', + requiredVersion: '^1.0.0', + hostVersion: '1.4.0', + expectedProvider: 'observability_consumer_checkout', + }, +]; + +function sanitizeErrorMessage(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + + return message + .replace(/https?:\/\/[^\s'"<>]+/g, (url) => { + try { + const parsedUrl = new URL(url); + return `${parsedUrl.origin}${parsedUrl.pathname}`; + } catch { + return '[redacted-url]'; + } + }) + .replace( + /\b(token|authorization|cookie|secret|password)=([^&\s]+)/gi, + '$1=[redacted]', + ); +} + +function resolveRemoteComponent(remoteModule: unknown): RemoteComponent | null { + if (typeof remoteModule === 'function') { + return remoteModule as RemoteComponent; + } + + if (remoteModule && typeof remoteModule === 'object') { + const candidate = (remoteModule as { default?: unknown }).default; + + if (typeof candidate === 'function') { + return candidate as RemoteComponent; + } + } + + return null; +} + +function createUnexpectedProviderShareScope(): RuntimeShareScope { + return { + [unexpectedProviderSharedName]: { + '1.0.0': { + version: '1.0.0', + get: () => () => ({ + provider: 'runtime_host', + version: '1.0.0', + }), + lib: () => ({ + provider: 'runtime_host', + version: '1.0.0', + }), + shareConfig: { + requiredVersion: false, + singleton: false, + eager: false, + strictVersion: false, + }, + scope: [unexpectedProviderSharedScope], + useIn: ['runtime_host'], + from: 'runtime_host', + deps: [], + strategy: 'version-first', + }, + '2.0.0': { + version: '2.0.0', + get: () => () => ({ + provider: 'runtime_remote2', + version: '2.0.0', + }), + lib: () => ({ + provider: 'runtime_remote2', + version: '2.0.0', + }), + shareConfig: { + requiredVersion: false, + singleton: false, + eager: false, + strictVersion: false, + }, + scope: [unexpectedProviderSharedScope], + useIn: ['runtime_remote2'], + from: 'runtime_remote2', + deps: [], + strategy: 'version-first', + }, + }, + }; +} + +function ObservabilityFallbackRemote() { + return null; +} + +const observabilityRetryRecoveryPlugin: ModuleFederationRuntimePlugin = { + name: 'observability-retry-recovery-plugin', + async loadEntryError({ + getRemoteEntry, + origin, + remoteInfo, + remoteEntryExports, + globalLoading, + uniqueKey, + }) { + if (remoteInfo.name !== retryRecoveryRemoteName) { + return undefined; + } + + delete globalLoading[uniqueKey]; + + return getRemoteEntry({ + origin, + remoteInfo, + remoteEntryExports, + getEntryUrl: (url: string) => { + const separator = url.includes('?') ? '&' : '?'; + return `${url}${separator}retryCount=1`; + }, + }); + }, +}; + +const observabilityFallbackPlugin: ModuleFederationRuntimePlugin = { + name: 'observability-fallback-plugin', + async errorLoadRemote({ id, lifecycle }) { + if (id !== fallbackRequest || lifecycle !== 'onLoad') { + return undefined; + } + + return { + default: ObservabilityFallbackRemote, + }; + }, +}; + +export default function ObservabilityDemo() { + const [status, setStatus] = useState('idle'); + const [errorMessage, setErrorMessage] = useState(''); + const [remoteComponent, setRemoteComponent] = + useState(null); + const [multiConsumerResults, setMultiConsumerResults] = useState< + MultiConsumerResult[] + >([]); + const [reportText, setReportText] = useState('null'); + + const refreshReport = useCallback(() => { + setReportText( + JSON.stringify(observability.getLatestReport() ?? null, null, 2), + ); + }, []); + + const loadSuccessRemote = useCallback(async () => { + setStatus('loading'); + setErrorMessage(''); + setRemoteComponent(null); + + try { + const remoteModule = await loadRemote(successRequest); + const component = resolveRemoteComponent(remoteModule); + + if (!component) { + throw new Error(`Remote module ${successRequest} has no component`); + } + + setRemoteComponent(() => component); + setStatus('success'); + } catch (error) { + const message = sanitizeErrorMessage(error); + setStatus('error'); + setErrorMessage(message); + } finally { + refreshReport(); + } + }, [refreshReport]); + + const loadMissingExpose = useCallback(async () => { + setStatus('loading'); + setErrorMessage(''); + setRemoteComponent(null); + + try { + const remoteModule = await loadRemote(missingExposeRequest); + + if (!remoteModule) { + throw new Error(`Remote module ${missingExposeRequest} returned empty`); + } + + throw new Error( + `Remote module ${missingExposeRequest} unexpectedly loaded`, + ); + } catch (error) { + const message = sanitizeErrorMessage(error); + setStatus('error'); + setErrorMessage(message); + } finally { + refreshReport(); + } + }, [refreshReport]); + + const loadRegisteredRemoteFailure = useCallback( + async (scenario: RegisteredRemoteFailureScenario) => { + setStatus('loading'); + setErrorMessage(''); + setRemoteComponent(null); + + registerRemotes([scenario.remote], { force: true }); + + try { + await loadRemote(scenario.request); + throw new Error( + `Remote module ${scenario.request} unexpectedly loaded`, + ); + } catch (error) { + const message = sanitizeErrorMessage( + `${scenario.errorPrefix || ''} ${error}`, + ); + setStatus('error'); + setErrorMessage(message); + } finally { + refreshReport(); + } + }, + [refreshReport], + ); + + const loadBrokenManifest = useCallback(async () => { + await loadRegisteredRemoteFailure({ + remote: { + name: 'observability_broken_remote', + alias: 'observability-broken-remote', + entry: brokenManifestEntry, + }, + request: brokenManifestRequest, + errorPrefix: brokenManifestEntry, + }); + }, [loadRegisteredRemoteFailure]); + + const loadRemoteUrlError = useCallback(async () => { + await loadRegisteredRemoteFailure({ + remote: { + name: 'observability_remote_url_remote', + alias: 'observability-remote-url', + entry: remoteUrlErrorEntry, + }, + request: remoteUrlErrorRequest, + errorPrefix: remoteUrlErrorEntry, + }); + }, [loadRegisteredRemoteFailure]); + + const loadRetryRecoveredRemote = useCallback(async () => { + setStatus('loading'); + setErrorMessage(''); + setRemoteComponent(null); + + registerPlugins([observabilityRetryRecoveryPlugin]); + registerRemotes( + [ + { + name: retryRecoveryRemoteName, + alias: 'observability-retry-recovered', + entry: retryRecoveryManifestEntry, + }, + ], + { force: true }, + ); + + try { + const remoteModule = await loadRemote(retryRecoveryRequest); + const component = resolveRemoteComponent(remoteModule); + + if (!component) { + throw new Error( + `Remote module ${retryRecoveryRequest} has no component`, + ); + } + + setRemoteComponent(() => component); + setStatus('success'); + } catch (error) { + const message = sanitizeErrorMessage(error); + setStatus('error'); + setErrorMessage(message); + } finally { + refreshReport(); + } + }, [refreshReport]); + + const loadFallbackRemote = useCallback(async () => { + setStatus('loading'); + setErrorMessage(''); + setRemoteComponent(null); + + registerPlugins([observabilityFallbackPlugin]); + + try { + const remoteModule = await loadRemote(fallbackRequest); + const component = resolveRemoteComponent(remoteModule); + + if (!component) { + throw new Error(`Remote module ${fallbackRequest} has no component`); + } + + setRemoteComponent(() => component); + setStatus('success'); + } catch (error) { + const message = sanitizeErrorMessage(error); + setStatus('error'); + setErrorMessage(message); + } finally { + refreshReport(); + } + }, [refreshReport]); + + const loadMissingFieldsManifest = useCallback(async () => { + await loadRegisteredRemoteFailure({ + remote: { + name: 'observability_missing_fields_remote', + alias: 'observability-missing-fields', + entry: missingFieldsManifestEntry, + }, + request: missingFieldsManifestRequest, + errorPrefix: missingFieldsManifestEntry, + }); + }, [loadRegisteredRemoteFailure]); + + const loadWrongGlobalName = useCallback(async () => { + await loadRegisteredRemoteFailure({ + remote: { + name: 'observability_wrong_global_remote', + alias: 'observability-wrong-global', + entry: wrongGlobalManifestEntry, + }, + request: wrongGlobalRequest, + errorPrefix: wrongGlobalManifestEntry, + }); + }, [loadRegisteredRemoteFailure]); + + const loadRemoteEntryExecutionError = useCallback(async () => { + await loadRegisteredRemoteFailure({ + remote: { + name: 'observability_execution_error_remote', + alias: 'observability-execution-error', + entry: executionErrorManifestEntry, + }, + request: executionErrorRequest, + errorPrefix: executionErrorManifestEntry, + }); + }, [loadRegisteredRemoteFailure]); + + const loadSnapshotMatchError = useCallback(async () => { + const federation = ( + globalThis as { + __FEDERATION__?: { + moduleInfo?: Record; + }; + } + ).__FEDERATION__; + + if (federation) { + federation.moduleInfo = { + ...federation.moduleInfo, + 'remote:observability-unrelated-snapshot': { + publicPath: + 'http://127.0.0.1:3005/observability-fixtures/unrelated-snapshot/', + getPublicPath: + 'function getPublicPath(){return "http://127.0.0.1:3005/observability-fixtures/unrelated-snapshot/";}', + remoteEntry: + 'http://127.0.0.1:3005/observability-fixtures/unrelated-snapshot/remoteEntry.js', + globalName: 'observability_unrelated_snapshot', + modules: [{ moduleName: 'Button' }], + shared: [{ sharedName: 'react' }], + }, + }; + } + + await loadRegisteredRemoteFailure({ + remote: { + name: 'observability_snapshot_miss_remote', + alias: 'observability-snapshot-miss', + version: '1.0.0', + }, + request: snapshotMissRequest, + errorPrefix: 'observability-snapshot-miss@1.0.0', + }); + }, [loadRegisteredRemoteFailure]); + + const loadSharedMiss = useCallback(async () => { + setStatus('loading'); + setErrorMessage(''); + setRemoteComponent(null); + + try { + const result = await loadShare('observability-missing-shared', { + customShareInfo: { + version: '1.0.0', + scope: ['observability-missing-scope'], + shareConfig: { + requiredVersion: '^1.0.0', + singleton: false, + }, + }, + }); + + if (result === false) { + throw new Error( + 'Shared miss: observability-missing-shared was not provided by host', + ); + } + + throw new Error('Shared miss scenario unexpectedly loaded'); + } catch (error) { + const message = sanitizeErrorMessage(error); + setStatus('error'); + setErrorMessage(message); + } finally { + refreshReport(); + } + }, [refreshReport]); + + const loadSharedVersionMismatch = useCallback(async () => { + setStatus('loading'); + setErrorMessage(''); + setRemoteComponent(null); + + try { + const result = await loadShare('react', { + customShareInfo: { + shareConfig: { + requiredVersion: '^99.0.0', + singleton: false, + }, + }, + }); + + if (result === false) { + throw new Error('Shared version mismatch: react needs ^99.0.0'); + } + + throw new Error('Shared version mismatch scenario unexpectedly loaded'); + } catch (error) { + const message = sanitizeErrorMessage(error); + setStatus('error'); + setErrorMessage(message); + } finally { + refreshReport(); + } + }, [refreshReport]); + + const loadSharedUnexpectedProvider = useCallback(async () => { + setStatus('loading'); + setErrorMessage(''); + setRemoteComponent(null); + + try { + const instance = getInstance(); + + if (!instance) { + throw new Error('Runtime instance is not initialized'); + } + + instance.initShareScopeMap( + unexpectedProviderSharedScope, + createUnexpectedProviderShareScope(), + ); + + const result = await loadShare( + unexpectedProviderSharedName, + { + customShareInfo: { + scope: [unexpectedProviderSharedScope], + shareConfig: { + requiredVersion: '^2.0.0', + singleton: false, + eager: false, + strictVersion: false, + }, + }, + }, + ); + + if (result === false) { + throw new Error( + 'Shared provider choice: observability-provider-choice was not resolved', + ); + } + + const sharedValue = result(); + + if (!sharedValue) { + throw new Error( + 'Shared provider choice: observability-provider-choice returned empty', + ); + } + + setStatus('success'); + } catch (error) { + const message = sanitizeErrorMessage(error); + setStatus('error'); + setErrorMessage(message); + } finally { + refreshReport(); + } + }, [refreshReport]); + + const runMultiConsumerScenario = useCallback( + async ( + runtimeInstance: ReturnType, + scenario: MultiConsumerScenario, + ): Promise => { + const sharedFactory = + await runtimeInstance.loadShare( + scenario.sharedName, + { + customShareInfo: { + scope: [scenario.sharedScope], + shareConfig: { + requiredVersion: scenario.requiredVersion, + singleton: false, + eager: false, + strictVersion: false, + }, + }, + }, + ); + + if (sharedFactory === false) { + throw new Error( + `${scenario.consumer} could not resolve ${scenario.sharedName}`, + ); + } + + const sharedValue = sharedFactory(); + const sharedReport = observability.getLatestReport(); + const sharedTraceId = sharedReport?.traceId || ''; + + if (!sharedValue) { + throw new Error( + `${scenario.consumer} resolved an empty ${scenario.sharedName}`, + ); + } + + const remoteModule = await runtimeInstance.loadRemote(scenario.request); + const component = resolveRemoteComponent(remoteModule); + + if (!component) { + throw new Error(`Remote module ${scenario.request} has no component`); + } + + const remoteReportBeforeComponent = observability.getLatestReport(); + const remoteTraceId = remoteReportBeforeComponent?.traceId || ''; + + observability.markComponentLoaded({ + traceId: remoteTraceId, + requestId: scenario.request, + componentName: scenario.componentName, + metadata: { + consumer: scenario.consumer, + producer: multiProducerRemoteName, + expose: scenario.expose, + sharedName: scenario.sharedName, + sharedProvider: sharedValue.provider, + sharedVersion: sharedValue.version, + sharedTraceId, + }, + }); + + const remoteReport = observability.getReport(remoteTraceId); + + return { + consumer: scenario.consumer, + request: scenario.request, + expose: scenario.expose, + sharedName: scenario.sharedName, + sharedProvider: sharedValue.provider, + sharedVersion: sharedValue.version, + sharedTraceId, + remoteTraceId, + remoteEntryCached: + remoteReport?.summary.phases.remoteEntry?.cached === true, + manifestCached: remoteReport?.summary.phases.manifest?.cached === true, + summaryCached: remoteReport?.summary.flags.cached === true, + }; + }, + [], + ); + + const loadMultiConsumerChain = useCallback(async () => { + setStatus('loading'); + setErrorMessage(''); + setRemoteComponent(null); + setMultiConsumerResults([]); + observability.clear(); + + try { + const checkoutConsumer = createInstance({ + name: 'observability_consumer_checkout', + version: '1.0.0', + plugins: [observability.plugin], + remotes: [ + { + name: multiProducerRemoteName, + alias: multiProducerAlias, + entry: multiProducerManifestEntry, + }, + ], + shared: { + 'observability-checkout-theme': { + version: '1.4.0', + scope: ['observability-checkout-scope'], + lib: () => ({ + provider: 'observability_consumer_checkout', + version: '1.4.0', + }), + shareConfig: { + requiredVersion: '^1.0.0', + singleton: false, + eager: false, + strictVersion: false, + }, + }, + }, + }); + const analyticsConsumer = createInstance({ + name: 'observability_consumer_analytics', + version: '1.0.0', + plugins: [observability.plugin], + remotes: [ + { + name: multiProducerRemoteName, + alias: multiProducerAlias, + entry: multiProducerManifestEntry, + }, + ], + shared: { + 'observability-analytics-sdk': { + version: '1.2.0', + scope: ['observability-analytics-scope'], + lib: () => ({ + provider: 'observability_consumer_analytics', + version: '1.2.0', + }), + shareConfig: { + requiredVersion: '^1.0.0', + singleton: false, + eager: false, + strictVersion: false, + }, + }, + }, + }); + const consumerInstances: Record< + string, + ReturnType + > = { + 'checkout-page': checkoutConsumer, + 'analytics-page': analyticsConsumer, + 'checkout-page-repeat': checkoutConsumer, + }; + + const results: MultiConsumerResult[] = []; + + for (const scenario of multiConsumerScenarios) { + const result = await runMultiConsumerScenario( + consumerInstances[scenario.consumer], + scenario, + ); + + if (result.sharedProvider !== scenario.expectedProvider) { + throw new Error( + `${scenario.consumer} expected ${scenario.expectedProvider} but used ${result.sharedProvider}`, + ); + } + + results.push(result); + } + + setMultiConsumerResults(results); + setStatus('success'); + setReportText( + JSON.stringify( + { + scenario: 'multi-consumer-loading-chain', + results, + reports: observability.getReports({ limit: 12 }), + }, + null, + 2, + ), + ); + } catch (error) { + const message = sanitizeErrorMessage(error); + setStatus('error'); + setErrorMessage(message); + refreshReport(); + } + }, [refreshReport, runMultiConsumerScenario]); + + const loadEagerConfigError = useCallback(() => { + setStatus('loading'); + setErrorMessage(''); + setRemoteComponent(null); + + try { + loadShareSync('observability-async-shared', { + from: 'build', + customShareInfo: { + version: '1.0.0', + scope: ['default'], + shareConfig: { + requiredVersion: '^1.0.0', + singleton: false, + eager: false, + strictVersion: false, + }, + get: () => + Promise.resolve(() => ({ + value: 'async shared should not be consumed synchronously', + })), + }, + }); + + throw new Error('Eager config scenario unexpectedly loaded'); + } catch (error) { + const message = sanitizeErrorMessage(error); + setStatus('error'); + setErrorMessage(message); + } finally { + refreshReport(); + } + }, [refreshReport]); + + const loadRuntimeEagerConfigError = useCallback(() => { + setStatus('loading'); + setErrorMessage(''); + setRemoteComponent(null); + + try { + loadShareSync('observability-runtime-async-shared', { + from: 'runtime', + customShareInfo: { + version: '1.0.0', + scope: ['default'], + shareConfig: { + requiredVersion: '^1.0.0', + singleton: false, + eager: false, + strictVersion: false, + }, + get: () => + Promise.resolve(() => ({ + value: + 'runtime async shared should not be consumed synchronously', + })), + }, + }); + + throw new Error('Runtime eager config scenario unexpectedly loaded'); + } catch (error) { + const message = sanitizeErrorMessage(error); + setStatus('error'); + setErrorMessage(message); + } finally { + refreshReport(); + } + }, [refreshReport]); + const markBusinessLoaded = useCallback(() => { + observability.markComponentLoaded({ + requestId: successRequest, + componentName: 'ButtonOldAnt', + metadata: { + route: '/observability?token=demo-secret#hash', + rendered: true, + }, + }); + refreshReport(); + }, [refreshReport]); + + const LoadedRemote = remoteComponent; + + return ( +
    +

    Observability Demo

    + +
    +

    Load Remote

    + + + + + + + + + + + +
    + +
    +

    Shared / Eager Scenarios

    + + + + + +
    + +
    +

    Multi Consumer Loading Chain

    + + {multiConsumerResults.length ? ( +
      + {multiConsumerResults.map((result) => ( +
    • + {result.consumer} loaded {result.request} with{' '} + {result.sharedName} from {result.sharedProvider}@ + {result.sharedVersion}; remoteTrace={result.remoteTraceId}; + sharedTrace={result.sharedTraceId}; cached= + {String(result.summaryCached)} +
    • + ))} +
    + ) : null} +
    + +
    +

    Status

    +

    {status}

    + {errorMessage ? ( +
    {errorMessage}
    + ) : null} + {LoadedRemote ? ( +
    + +
    + ) : null} +
    + +
    +

    Report Fixture

    +
    {reportText}
    +
    +
    + ); +} diff --git a/apps/runtime-demo/3005-runtime-host/src/ObservabilityShowcase.tsx b/apps/runtime-demo/3005-runtime-host/src/ObservabilityShowcase.tsx new file mode 100644 index 00000000000..034c4cb686b --- /dev/null +++ b/apps/runtime-demo/3005-runtime-host/src/ObservabilityShowcase.tsx @@ -0,0 +1,475 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { createInstance } from '@module-federation/runtime'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { observability } from './observability'; +import './observability-showcase.css'; + +type ShowcaseStatus = 'loading' | 'success' | 'degraded' | 'error'; +type ShowcaseRoute = 'profile' | 'analytics'; +type RemoteComponent = React.ComponentType>; +type CustomerSdk = { + provider: string; + version: string; + feature: string; +}; +type LoadResult = { + Component: RemoteComponent; + traceId: string; + shared: string[]; + status?: ShowcaseStatus; + message?: string; +}; + +const producerName = 'runtime_remote2'; +const producerAlias = 'dynamic-remote'; +const producerManifest = 'http://127.0.0.1:3007/mf-manifest.json'; +const profileRequest = `${producerAlias}/ProfileCard`; +const analyticsRequest = `${producerAlias}/AnalyticsPanel`; +const analyticsConsumerName = 'observability_showcase_analytics_consumer'; + +function getRoute(pathname: string): ShowcaseRoute { + return pathname.endsWith('/analytics') ? 'analytics' : 'profile'; +} + +function resolveRemoteComponent(remoteModule: unknown): RemoteComponent { + if (typeof remoteModule === 'function') { + return remoteModule as RemoteComponent; + } + + if (remoteModule && typeof remoteModule === 'object') { + const candidate = (remoteModule as { default?: unknown }).default; + + if (typeof candidate === 'function') { + return candidate as RemoteComponent; + } + } + + throw new Error('Remote module did not return a React component'); +} + +function createSharedReact() { + return { + version: '18.3.1', + scope: ['default'], + lib: () => React, + shareConfig: { + requiredVersion: '^18.0.0', + singleton: true, + eager: false, + strictVersion: false, + }, + }; +} + +function createConsumer( + name: string, + shared?: Parameters[0]['shared'], +) { + return createInstance({ + name, + version: '1.0.0', + plugins: [observability.plugin], + remotes: [ + { + name: producerName, + alias: producerAlias, + entry: producerManifest, + }, + ], + shared: { + react: createSharedReact(), + ...(shared || {}), + }, + }); +} + +async function loadProfileWidget() { + const consumer = createConsumer('observability_showcase_profile_consumer'); + const remoteModule = await consumer.loadRemote(profileRequest); + const Component = resolveRemoteComponent(remoteModule); + const report = observability.getLatestReport(); + + if (report?.traceId) { + observability.markComponentLoaded({ + traceId: report.traceId, + requestId: profileRequest, + componentName: 'ProfileCard', + metadata: { + route: 'profile', + consumer: 'observability_showcase_profile_consumer', + producer: producerName, + expose: './ProfileCard', + }, + }); + } + + return { + Component, + traceId: report?.traceId || '', + shared: [] as string[], + } satisfies LoadResult; +} + +async function loadAnalyticsWorkspace() { + const consumer = createConsumer(analyticsConsumerName, { + 'observability-customer-sdk': { + version: '2.1.0', + scope: ['default'], + lib: () => ({ + provider: analyticsConsumerName, + version: '2.1.0', + feature: 'customer-insights', + }), + shareConfig: { + requiredVersion: '^2.0.0', + singleton: false, + eager: false, + strictVersion: false, + }, + }, + }); + const remoteModule = await consumer.loadRemote(analyticsRequest); + resolveRemoteComponent(remoteModule); + + const reactFactory = await consumer.loadShare('react', { + customShareInfo: { + version: '18.3.1', + scope: ['default'], + shareConfig: { + requiredVersion: '^18.0.0', + singleton: true, + eager: false, + strictVersion: false, + }, + }, + }); + const sdkFactory = await consumer.loadShare( + 'observability-customer-sdk', + { + customShareInfo: { + version: '2.1.0', + scope: ['default'], + shareConfig: { + requiredVersion: '^3.0.0', + singleton: false, + eager: false, + strictVersion: false, + }, + }, + }, + ); + + if (reactFactory === false) { + throw new Error('React shared dependency was not resolved'); + } + + if (sdkFactory === false) { + throw new Error('Customer SDK shared dependency was not resolved'); + } + + const sdk = sdkFactory(); + const Component = resolveRemoteComponent(remoteModule); + const report = observability.getLatestReport(); + + if (report?.traceId) { + observability.markComponentLoaded({ + traceId: report.traceId, + requestId: analyticsRequest, + componentName: 'AnalyticsPanel', + metadata: { + route: 'analytics', + consumer: analyticsConsumerName, + producer: producerName, + expose: './AnalyticsPanel', + shared: ['react', 'observability-customer-sdk'], + customerSdkProvider: sdk.provider, + customerSdkVersion: sdk.version, + }, + }); + } + + return { + Component, + traceId: report?.traceId || '', + shared: [ + `react from ${analyticsConsumerName}@18.3.1`, + `observability-customer-sdk from ${sdk.provider}@${sdk.version}`, + ], + } satisfies LoadResult; +} + +function AnalyticsFallback() { + return ( +
    +

    Limited analytics view

    +

    + Key account metrics are still available while detailed insights are + temporarily limited. +

    +
    + summary available + details limited + support reference ready +
    +
    + ); +} + +function getLatestTraceId(): string { + return observability.getLatestReport()?.traceId ?? 'pending'; +} + +export default function ObservabilityShowcase() { + const location = useLocation(); + const navigate = useNavigate(); + const route = useMemo(() => getRoute(location.pathname), [location.pathname]); + const [status, setStatus] = useState('loading'); + const [referenceId, setReferenceId] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); + const [remoteComponent, setRemoteComponent] = + useState(null); + const [sharedEvidence, setSharedEvidence] = useState([]); + + useEffect(() => { + let disposed = false; + + setStatus('loading'); + setReferenceId(''); + setErrorMessage(''); + setRemoteComponent(null); + setSharedEvidence([]); + + const load = + route === 'analytics' ? loadAnalyticsWorkspace : loadProfileWidget; + + load() + .then((result) => { + if (disposed) { + return; + } + + setRemoteComponent(() => result.Component); + setSharedEvidence(result.shared); + setReferenceId(result.traceId || getLatestTraceId()); + setErrorMessage(result.message || ''); + setStatus(result.status || 'success'); + }) + .catch((error) => { + if (disposed) { + return; + } + + const message = error instanceof Error ? error.message : String(error); + + if (route === 'analytics') { + setRemoteComponent(() => AnalyticsFallback); + setSharedEvidence([ + 'Core account page is available', + 'Detailed analytics are temporarily limited', + ]); + setErrorMessage( + 'Some analytics details are temporarily unavailable.', + ); + setReferenceId(getLatestTraceId()); + setStatus('degraded'); + return; + } + + setErrorMessage(message); + setReferenceId(getLatestTraceId()); + setStatus('error'); + }); + + return () => { + disposed = true; + }; + }, [route]); + + const openAnalyticsWorkspace = useCallback(() => { + navigate('/observability-showcase/analytics'); + }, [navigate]); + + const isAnalytics = route === 'analytics'; + const RemoteComponent = remoteComponent; + + return ( +
    + + +
    +
    +
    +

    Enterprise account

    +

    Acme Retail Group

    +
    + +
    + +
    +
    + Contract value + $1.42M +
    +
    + Open requests + 18 +
    +
    + Health score + 82 +
    +
    + +
    +
    +
    +
    +

    + {isAnalytics ? 'Route: insights' : 'Route: overview'} +

    +

    {isAnalytics ? 'Account analytics' : 'User profile'}

    +
    + + {status} + +
    + +
    + {RemoteComponent ? ( + + ) : ( + <> +
    AR
    +
    + + {status === 'loading' + ? 'Loading remote component' + : 'Remote component unavailable'} + + + {isAnalytics + ? 'Loading the analytics expose from the producer.' + : 'Loading the profile expose from the producer.'} + +
    + + )} +
    + + {status === 'error' ? ( +
    + Remote widget is temporarily unavailable. + + {errorMessage || 'Share this reference with support:'} + + {referenceId} + + +
    + ) : status === 'degraded' ? ( +
    + Limited analytics view is active. + + {errorMessage} + + {referenceId} + + +
    + ) : ( +
    + {isAnalytics + ? 'This view loads a second expose and resolves React plus the customer SDK as shared dependencies.' + : 'This view is loaded by createInstance when the page opens.'} + {referenceId ? ( + + {referenceId} + + ) : null} +
    + )} + + + + {sharedEvidence.length ? ( +
      + {sharedEvidence.map((item) => ( +
    • {item}
    • + ))} +
    + ) : null} +
    + +
    +

    Recent activity

    +
      +
    • + Profile expose loaded on overview route + +
    • +
    • + Analytics expose waits for route navigation + +
    • +
    • + Shared dependency evidence is kept in reports + +
    • +
    +
    +
    +
    +
    + ); +} diff --git a/apps/runtime-demo/3005-runtime-host/src/bootstrap.tsx b/apps/runtime-demo/3005-runtime-host/src/bootstrap.tsx index 96f5759d42f..6f34b24294e 100644 --- a/apps/runtime-demo/3005-runtime-host/src/bootstrap.tsx +++ b/apps/runtime-demo/3005-runtime-host/src/bootstrap.tsx @@ -2,19 +2,11 @@ import React, { StrictMode } from 'react'; import { init } from '@module-federation/enhanced/runtime'; import * as ReactDOM from 'react-dom/client'; import App from './App'; -import { diagnostics } from './diagnostics'; +import { observability } from './observability'; init({ name: 'runtime_host', - security: { - diagnostics: { - enabled: true, - maxEvents: 100, - console: true, - browserGlobal: true, - }, - }, - plugins: [diagnostics.plugin], + plugins: [observability.plugin], remotes: [ { name: 'runtime_remote2', diff --git a/apps/runtime-demo/3005-runtime-host/src/diagnostics.ts b/apps/runtime-demo/3005-runtime-host/src/diagnostics.ts deleted file mode 100644 index 8b6a5bab21d..00000000000 --- a/apps/runtime-demo/3005-runtime-host/src/diagnostics.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { DiagnosticsPlugin } from '@module-federation/diagnostics-plugin'; - -export const diagnostics = DiagnosticsPlugin({ - level: 'verbose', - maxEvents: 100, - browser: { - enabled: true, - scope: 'runtime_host', - }, -}); diff --git a/apps/runtime-demo/3005-runtime-host/src/observability-showcase.css b/apps/runtime-demo/3005-runtime-host/src/observability-showcase.css new file mode 100644 index 00000000000..fb8682db229 --- /dev/null +++ b/apps/runtime-demo/3005-runtime-host/src/observability-showcase.css @@ -0,0 +1,427 @@ +.customer-portal { + display: grid; + grid-template-columns: 248px minmax(0, 1fr); + min-height: 100vh; + color: #1f2933; + background: #f4f6f8; + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; +} + +.customer-portal *, +.customer-portal *::before, +.customer-portal *::after { + box-sizing: border-box; +} + +.customer-portal__sidebar { + padding: 28px 18px; + border-right: 1px solid #dde4eb; + background: #ffffff; +} + +.customer-portal__brand { + margin-bottom: 28px; + color: #111827; + font-size: 18px; + font-weight: 800; +} + +.customer-portal__nav { + display: grid; + gap: 6px; +} + +.customer-portal__nav-item { + padding: 10px 12px; + border-radius: 6px; + color: #536171; + font-size: 14px; + font-weight: 650; + text-decoration: none; +} + +.customer-portal__nav-item--active { + color: #0f4c81; + background: #e8f2fb; +} + +.customer-portal__content { + padding: 32px; +} + +.customer-portal__header { + display: flex; + gap: 18px; + justify-content: space-between; + align-items: flex-start; + margin: 0 0 24px; +} + +.customer-portal__eyebrow { + margin: 0 0 8px; + color: #667789; + font-size: 12px; + font-weight: 800; + letter-spacing: 0; + text-transform: uppercase; +} + +.customer-portal h1, +.customer-portal h2 { + margin: 0; + color: #111827; + letter-spacing: 0; +} + +.customer-portal h1 { + font-size: 34px; + line-height: 1.15; +} + +.customer-portal h2 { + font-size: 22px; + line-height: 1.25; +} + +.customer-portal__metrics { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; + margin-bottom: 20px; +} + +.customer-portal__metrics div, +.customer-portal__card { + border: 1px solid #dde4eb; + border-radius: 8px; + background: #ffffff; + box-shadow: 0 10px 28px rgba(31, 41, 51, 0.06); +} + +.customer-portal__metrics div { + padding: 18px; +} + +.customer-portal__metrics span { + display: block; + margin-bottom: 8px; + color: #667789; + font-size: 13px; + font-weight: 650; +} + +.customer-portal__metrics strong { + color: #111827; + font-size: 26px; +} + +.customer-portal__workspace { + display: grid; + grid-template-columns: minmax(0, 1fr) 360px; + gap: 20px; +} + +.customer-portal__card { + padding: 22px; +} + +.customer-portal__card--profile { + min-height: 430px; +} + +.customer-portal__card-header { + display: flex; + gap: 16px; + justify-content: space-between; + align-items: flex-start; +} + +.customer-portal__status { + min-width: 82px; + padding: 7px 11px; + border-radius: 999px; + font-size: 12px; + font-weight: 800; + text-align: center; + text-transform: uppercase; +} + +.customer-portal__status--ready { + color: #245275; + background: #e6f2fb; +} + +.customer-portal__status--loading { + color: #7a4f00; + background: #fff3cf; +} + +.customer-portal__status--success { + color: #0f6b45; + background: #ddf8eb; +} + +.customer-portal__status--degraded { + color: #7a4f00; + background: #fff3cf; +} + +.customer-portal__status--error { + color: #9f1d20; + background: #ffe3e3; +} + +.customer-portal__profile-shell { + display: flex; + gap: 16px; + align-items: center; + min-height: 170px; + margin: 28px 0 18px; + padding: 24px; + border: 1px dashed #bac7d3; + border-radius: 8px; + background: #f8fafc; +} + +.customer-portal__avatar { + display: grid; + width: 58px; + height: 58px; + border-radius: 50%; + color: #ffffff; + background: #0f4c81; + font-size: 18px; + font-weight: 800; + place-items: center; + flex: 0 0 auto; +} + +.customer-portal__profile-shell strong { + display: block; + color: #111827; + font-size: 18px; + line-height: 1.35; +} + +.customer-portal__profile-shell span { + display: block; + margin-top: 6px; + color: #667789; + font-size: 14px; + line-height: 1.55; +} + +.customer-portal__remote-widget { + width: 100%; +} + +.customer-portal__remote-widget h3 { + margin: 0 0 8px; + color: #111827; + font-size: 20px; + line-height: 1.25; +} + +.customer-portal__remote-widget p { + margin: 0; + color: #667789; + font-size: 14px; + line-height: 1.55; +} + +.customer-portal__remote-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 16px; +} + +.customer-portal__remote-meta span { + margin: 0; + padding: 6px 9px; + border-radius: 6px; + color: #245275; + background: #e6f2fb; + font-size: 12px; + font-weight: 750; +} + +.customer-portal__hint, +.customer-portal__fallback, +.customer-portal__error { + margin-bottom: 18px; + padding: 14px 16px; + border-radius: 8px; + font-size: 14px; + line-height: 1.6; +} + +.customer-portal__hint { + color: #425466; + background: #f1f5f9; +} + +.customer-portal__error { + color: #7a2222; + background: #fff0f0; +} + +.customer-portal__fallback { + color: #6f4b00; + background: #fff6d9; +} + +.customer-portal__error strong, +.customer-portal__fallback strong, +.customer-portal__fallback span, +.customer-portal__error span { + display: block; +} + +.customer-portal__hint code, +.customer-portal__fallback code, +.customer-portal__error code { + display: inline-block; + margin-left: 6px; + padding: 2px 6px; + border-radius: 4px; + color: #5d1f1f; + background: #ffe1e1; + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', + monospace; + font-size: 13px; + overflow-wrap: anywhere; +} + +.customer-portal__hint code { + color: #245275; + background: #dceefb; +} + +.customer-portal__fallback code { + color: #6f4b00; + background: #ffedaf; +} + +.customer-portal__shared-list { + display: grid; + gap: 8px; + margin: 16px 0 0; + padding: 0; + list-style: none; +} + +.customer-portal__shared-list li { + padding: 10px 12px; + border: 1px solid #d7e5ef; + border-radius: 6px; + color: #245275; + background: #f4f9fd; + font-size: 13px; + font-weight: 700; +} + +.customer-portal__primary-button, +.customer-portal__secondary-button { + min-height: 42px; + border-radius: 6px; + font-size: 14px; + font-weight: 800; + cursor: pointer; +} + +.customer-portal__primary-button { + padding: 0 18px; + border: 0; + color: #ffffff; + background: #0f4c81; +} + +.customer-portal__primary-button:hover { + background: #0b3e69; +} + +.customer-portal__primary-button:disabled { + cursor: wait; + background: #8aa8c1; +} + +.customer-portal__secondary-button { + padding: 0 16px; + border: 1px solid #c8d3de; + color: #334155; + background: #ffffff; +} + +.customer-portal__activity { + display: grid; + gap: 14px; + margin: 8px 0 0; + padding: 0; + list-style: none; +} + +.customer-portal__activity li { + display: flex; + gap: 12px; + justify-content: space-between; + padding-bottom: 14px; + border-bottom: 1px solid #eef2f6; + color: #273547; + font-size: 14px; + line-height: 1.5; +} + +.customer-portal__activity li:last-child { + padding-bottom: 0; + border-bottom: 0; +} + +.customer-portal__activity time { + color: #76879a; + white-space: nowrap; +} + +@media (max-width: 900px) { + .customer-portal { + grid-template-columns: 1fr; + } + + .customer-portal__sidebar { + border-right: 0; + border-bottom: 1px solid #dde4eb; + } + + .customer-portal__nav { + grid-template-columns: repeat(4, max-content); + overflow-x: auto; + } + + .customer-portal__metrics, + .customer-portal__workspace { + grid-template-columns: 1fr; + } +} + +@media (max-width: 560px) { + .customer-portal__content { + padding: 20px; + } + + .customer-portal__header, + .customer-portal__card-header { + display: grid; + } + + .customer-portal h1 { + font-size: 28px; + } +} diff --git a/apps/runtime-demo/3005-runtime-host/src/observability.ts b/apps/runtime-demo/3005-runtime-host/src/observability.ts new file mode 100644 index 00000000000..cff01684308 --- /dev/null +++ b/apps/runtime-demo/3005-runtime-host/src/observability.ts @@ -0,0 +1,10 @@ +import { ObservabilityPlugin } from '@module-federation/observability-plugin'; + +export const observability = ObservabilityPlugin({ + level: 'verbose', + maxEvents: 100, + browser: { + enabled: true, + scope: 'runtime_host', + }, +}); diff --git a/apps/runtime-demo/3005-runtime-host/tsconfig.app.json b/apps/runtime-demo/3005-runtime-host/tsconfig.app.json index 8864b3d724f..af024f1690b 100644 --- a/apps/runtime-demo/3005-runtime-host/tsconfig.app.json +++ b/apps/runtime-demo/3005-runtime-host/tsconfig.app.json @@ -2,16 +2,12 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../../dist/out-tsc", - "types": [ - "node", - "../../../typings/cssmodule.d.ts", - "../../../typings/image.d.ts" - ], + "types": ["node"], "paths": { "*": ["./@mf-types/*"] } }, - "files": ["../../../typings/cssmodule.d.ts", "../../../typings/image.d.ts"], + "files": ["../typings/cssmodule.d.ts", "../typings/image.d.ts"], "exclude": [ "jest.config.ts", "**/*.spec.ts", diff --git a/apps/runtime-demo/3005-runtime-host/webpack.config.js b/apps/runtime-demo/3005-runtime-host/webpack.config.js index 1b0615bd367..5f17e4b512f 100644 --- a/apps/runtime-demo/3005-runtime-host/webpack.config.js +++ b/apps/runtime-demo/3005-runtime-host/webpack.config.js @@ -9,11 +9,55 @@ const cssLoader = require.resolve('css-loader'); const { ModuleFederationPlugin, } = require('@module-federation/enhanced/webpack'); +const { + ObservabilityBuildPlugin, +} = require('@module-federation/observability-plugin/build'); const HtmlWebpackPlugin = require('html-webpack-plugin'); module.exports = (_env, argv = {}) => { const isProduction = argv.mode === 'production'; const sourcePath = path.resolve(__dirname, 'src'); + const moduleFederationOptions = { + name: 'runtime_host', + experiments: { asyncStartup: true }, + remotes: { + remote1: 'runtime_remote1@http://127.0.0.1:3006/mf-manifest.json', + }, + filename: 'remoteEntry.js', + exposes: { + './Button': './src/Button.tsx', + }, + dts: { + tsConfigPath: path.resolve(__dirname, 'tsconfig.app.json'), + }, + shareStrategy: 'loaded-first', + shared: { + lodash: { + singleton: true, + requiredVersion: '^4.0.0', + }, + antd: { + singleton: true, + requiredVersion: '^4.0.0', + }, + react: { + singleton: true, + requiredVersion: '^18.2.0', + }, + 'react/': { + singleton: true, + requiredVersion: '^18.2.0', + }, + 'react-dom': { + singleton: true, + requiredVersion: '^18.2.0', + }, + 'react-dom/': { + singleton: true, + requiredVersion: '^18.2.0', + }, + }, + }; return { mode: isProduction ? 'production' : 'development', @@ -89,46 +133,9 @@ module.exports = (_env, argv = {}) => { ], }, plugins: [ - new ModuleFederationPlugin({ - name: 'runtime_host', - experiments: { asyncStartup: true }, - remotes: { - remote1: 'runtime_remote1@http://127.0.0.1:3006/mf-manifest.json', - }, - filename: 'remoteEntry.js', - exposes: { - './Button': './src/Button.tsx', - }, - dts: { - tsConfigPath: path.resolve(__dirname, 'tsconfig.app.json'), - }, - shareStrategy: 'loaded-first', - shared: { - lodash: { - singleton: true, - requiredVersion: '^4.0.0', - }, - antd: { - singleton: true, - requiredVersion: '^4.0.0', - }, - react: { - singleton: true, - requiredVersion: '^18.2.0', - }, - 'react/': { - singleton: true, - requiredVersion: '^18.2.0', - }, - 'react-dom': { - singleton: true, - requiredVersion: '^18.2.0', - }, - 'react-dom/': { - singleton: true, - requiredVersion: '^18.2.0', - }, - }, + new ModuleFederationPlugin(moduleFederationOptions), + new ObservabilityBuildPlugin({ + moduleFederation: moduleFederationOptions, }), new HtmlWebpackPlugin({ template: path.resolve(__dirname, 'src/index.html'), @@ -146,6 +153,169 @@ module.exports = (_env, argv = {}) => { devMiddleware: { writeToDisk: true, }, + setupMiddlewares: (middlewares, devServer) => { + if (!devServer.app) { + return middlewares; + } + + const sendJson = (response, body) => { + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify(body)); + }; + const sendJs = (response, body) => { + response.setHeader('Content-Type', 'application/javascript'); + response.end(body); + }; + const createManifest = ({ name, globalName, publicPath }) => ({ + id: name, + name, + metaData: { + name, + type: 'app', + buildInfo: { + buildVersion: 'observability-fixture', + buildName: name, + }, + remoteEntry: { + name: 'remoteEntry.js', + path: '', + type: 'global', + }, + types: { + path: '', + name: '', + zip: '', + api: '', + }, + globalName, + pluginVersion: 'observability-fixture', + publicPath, + }, + shared: [], + remotes: [], + exposes: [ + { + id: `${name}:Button`, + name: 'Button', + assets: { + js: { + sync: [], + async: [], + }, + css: { + sync: [], + async: [], + }, + }, + path: './Button', + }, + ], + }); + + devServer.app.get( + '/observability-fixtures/missing-fields/mf-manifest.json', + (_request, response) => { + sendJson(response, { + id: 'observability_missing_fields_remote', + name: 'observability_missing_fields_remote', + }); + }, + ); + devServer.app.get( + '/observability-fixtures/retry-recovered/mf-manifest.json', + (_request, response) => { + sendJson( + response, + createManifest({ + name: 'observability_retry_recovered_remote', + globalName: 'observability_retry_recovered_remote', + publicPath: + 'http://127.0.0.1:3005/observability-fixtures/retry-recovered/', + }), + ); + }, + ); + devServer.app.get( + '/observability-fixtures/retry-recovered/remoteEntry.js', + (request, response) => { + if (request.query.retryCount !== '1') { + response.statusCode = 503; + response.end('observability retry fixture failed before retry'); + return; + } + + sendJs( + response, + [ + 'window.observability_retry_recovered_remote = {', + ' init: function() {},', + ' get: function() {', + ' return Promise.resolve(function() {', + ' return { default: function ObservabilityRetryRecovered() { return null; } };', + ' });', + ' }', + '};', + ].join('\n'), + ); + }, + ); + devServer.app.get( + '/observability-fixtures/wrong-global/mf-manifest.json', + (_request, response) => { + sendJson( + response, + createManifest({ + name: 'observability_wrong_global_remote', + globalName: 'observability_wrong_global_expected', + publicPath: + 'http://127.0.0.1:3005/observability-fixtures/wrong-global/', + }), + ); + }, + ); + devServer.app.get( + '/observability-fixtures/wrong-global/remoteEntry.js', + (_request, response) => { + sendJs( + response, + [ + 'window.observability_wrong_global_actual = {', + ' init: function() {},', + ' get: function() {', + ' return Promise.resolve(function() {', + ' return { default: function ObservabilityWrongGlobal() { return null; } };', + ' });', + ' }', + '};', + ].join('\n'), + ); + }, + ); + devServer.app.get( + '/observability-fixtures/execution-error/mf-manifest.json', + (_request, response) => { + sendJson( + response, + createManifest({ + name: 'observability_execution_error_remote', + globalName: 'observability_execution_error_remote', + publicPath: + 'http://127.0.0.1:3005/observability-fixtures/execution-error/', + }), + ); + }, + ); + devServer.app.get( + '/observability-fixtures/execution-error/remoteEntry.js', + (_request, response) => { + sendJs( + response, + "throw new Error('observability remoteEntry execution failed');", + ); + }, + ); + return middlewares; + }, }, optimization: { runtimeChunk: false, diff --git a/apps/runtime-demo/3006-runtime-remote/tsconfig.app.json b/apps/runtime-demo/3006-runtime-remote/tsconfig.app.json index 9f647569f50..196facc7dcf 100644 --- a/apps/runtime-demo/3006-runtime-remote/tsconfig.app.json +++ b/apps/runtime-demo/3006-runtime-remote/tsconfig.app.json @@ -3,12 +3,8 @@ "compilerOptions": { "composite": true, "declaration": true, - "types": [ - "node", - "../../../typings/cssmodule.d.ts", - "../../../typings/image.d.ts" - ] + "types": ["node"] }, - "files": ["../../../typings/cssmodule.d.ts", "../../../typings/image.d.ts"], + "files": ["../typings/cssmodule.d.ts", "../typings/image.d.ts"], "include": ["src/**/*"] } diff --git a/apps/runtime-demo/3007-runtime-remote/src/components/AnalyticsPanel.tsx b/apps/runtime-demo/3007-runtime-remote/src/components/AnalyticsPanel.tsx new file mode 100644 index 00000000000..55355f372d4 --- /dev/null +++ b/apps/runtime-demo/3007-runtime-remote/src/components/AnalyticsPanel.tsx @@ -0,0 +1,20 @@ +export default function AnalyticsPanel() { + return ( +
    +

    Expansion analytics

    +

    + Analytics panel loaded from runtime_remote2/AnalyticsPanel after route + navigation. +

    +
    + producer: runtime_remote2 + expose: AnalyticsPanel + shared: react + shared: observability-customer-sdk +
    +
    + ); +} diff --git a/apps/runtime-demo/3007-runtime-remote/src/components/ProfileCard.tsx b/apps/runtime-demo/3007-runtime-remote/src/components/ProfileCard.tsx new file mode 100644 index 00000000000..786d89ba900 --- /dev/null +++ b/apps/runtime-demo/3007-runtime-remote/src/components/ProfileCard.tsx @@ -0,0 +1,18 @@ +export default function ProfileCard() { + return ( +
    +

    Jordan Lee

    +

    + Account owner profile loaded from runtime_remote2/ProfileCard when the + page opened. +

    +
    + producer: runtime_remote2 + expose: ProfileCard +
    +
    + ); +} diff --git a/apps/runtime-demo/3007-runtime-remote/tsconfig.app.json b/apps/runtime-demo/3007-runtime-remote/tsconfig.app.json index e02bdff676a..6d7c66b0018 100644 --- a/apps/runtime-demo/3007-runtime-remote/tsconfig.app.json +++ b/apps/runtime-demo/3007-runtime-remote/tsconfig.app.json @@ -2,13 +2,9 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../../dist/out-tsc", - "types": [ - "node", - "../../../typings/cssmodule.d.ts", - "../../../typings/image.d.ts" - ] + "types": ["node"] }, - "files": ["../../../typings/cssmodule.d.ts", "../../../typings/image.d.ts"], + "files": ["../typings/cssmodule.d.ts", "../typings/image.d.ts"], "exclude": [ "jest.config.ts", "**/*.spec.ts", diff --git a/apps/runtime-demo/3007-runtime-remote/webpack.config.js b/apps/runtime-demo/3007-runtime-remote/webpack.config.js index c7aa170ed70..feff0c14c5f 100644 --- a/apps/runtime-demo/3007-runtime-remote/webpack.config.js +++ b/apps/runtime-demo/3007-runtime-remote/webpack.config.js @@ -1,3 +1,5 @@ +/* eslint-env node */ + const path = require('path'); const reactPath = path.dirname(require.resolve('react/package.json')); const reactDomPath = path.dirname(require.resolve('react-dom/package.json')); @@ -92,6 +94,8 @@ module.exports = (_env, argv = {}) => { filename: 'remoteEntry.js', exposes: { './ButtonOldAnt': './src/components/ButtonOldAnt', + './ProfileCard': './src/components/ProfileCard', + './AnalyticsPanel': './src/components/AnalyticsPanel', }, shared: { lodash: { diff --git a/apps/runtime-demo/AGENTS.md b/apps/runtime-demo/AGENTS.md new file mode 100644 index 00000000000..8ce63645c5e --- /dev/null +++ b/apps/runtime-demo/AGENTS.md @@ -0,0 +1,80 @@ +# Runtime Demo Agent Guide + +This directory has a local copy of the MF skill at `./skills/mf/SKILL.md`. +When the user asks about Module Federation loading, shared dependencies, +observability reports, trace IDs, or the runtime demo, read that skill first. +For the showcase flow, use `./skills/mf/reference/observability.md`. + +## Showcase Task + +Use this flow when the user asks you to inspect the shared dependency loading +state in the runtime demo: + +1. Start the demo from the repository root: + +```bash +pnpm run app:runtime:dev +``` + +2. Open: + +```text +http://localhost:3005/observability-showcase +``` + +3. Confirm the page renders the profile widget. This first view is loaded with + `createInstance` from `runtime_remote2/ProfileCard`. + +4. Click `Open Analytics Workspace`. + +5. Confirm the route changes to: + +```text +http://localhost:3005/observability-showcase/analytics +``` + +6. Confirm the page shows a limited analytics view instead of crashing. Do not + treat the page copy as the root cause. This second view first tries to load + `runtime_remote2/AnalyticsPanel` with `createInstance`, then the + observability report shows `observability-customer-sdk` failed because the + route asks for `^3.0.0` but only `2.1.0` is available. + +7. Read the browser observability reports: + +```js +window.__FEDERATION__.__OBSERVABILITY__.runtime_host.getReports({ limit: 20 }); +window.__FEDERATION__.__OBSERVABILITY__.runtime_host.findReports({ + remote: 'runtime_remote2', +}); +window.__FEDERATION__.__OBSERVABILITY__.runtime_host.findReports({ + shared: 'react', +}); +window.__FEDERATION__.__OBSERVABILITY__.runtime_host.findReports({ + shared: 'observability-customer-sdk', +}); +``` + +8. Answer with: + +- which consumer loaded `ProfileCard` +- which consumer loaded `AnalyticsPanel` +- whether `react` was resolved +- whether `observability-customer-sdk` failed +- which version was required and which version was available +- why the page did not crash and what limited view was rendered + +## Suggested User Prompt + +```text +启动项目,访问 http://localhost:3005/observability-showcase, +确认首屏远程组件是否加载成功。 +然后点击 Open Analytics Workspace,看下共享依赖加载情况。 + +请说明: +1. 首屏加载了哪个生产者的哪个 expose +2. 点击后路由加载了哪个生产者的哪个 expose +3. 第二个 expose 使用了哪些 shared +4. 哪个 shared 加载失败了,原因是什么 +5. 页面为什么没有崩溃,显示了什么降级视图 +6. 观测报告里有哪些关键证据 +``` diff --git a/apps/runtime-demo/README.md b/apps/runtime-demo/README.md index 114d9c67de7..aa7335c84c7 100644 --- a/apps/runtime-demo/README.md +++ b/apps/runtime-demo/README.md @@ -16,15 +16,20 @@ Run `pnpm run app:runtime:dev` to start host, remote1, remote2. - remote1: [localhost:3006](http://localhost:3006/) - remote2: [localhost:3007](http://localhost:3007/) -## Diagnostics Demo +## Observability Demo -The diagnostics fixture lives in the runtime host. The host explicitly enables -`security.diagnostics` and installs `@module-federation/diagnostics-plugin` for -this demo, so the report is generated by the runtime plugin instead of local UI -state. +The observability fixture lives in the runtime host. The host explicitly enables +`@module-federation/observability-plugin` for this demo, so the report is +generated by the runtime plugin instead of local UI state. +The host also installs `ObservabilityBuildPlugin`, so each build writes a +build summary to `.mf/observability/build-info.json`. If a host build fails +after the plugin runs, the build-side report is written to +`.mf/observability/build-report.json`. -- diagnostics page: - [localhost:3005/diagnostics](http://localhost:3005/diagnostics) +- observability fixture page: + [localhost:3005/observability](http://localhost:3005/observability) +- observability showcase: + [localhost:3005/observability-showcase](http://localhost:3005/observability-showcase) Start the demo first: @@ -32,31 +37,100 @@ Start the demo first: pnpm run app:runtime:dev ``` -Then open the diagnostics page and use these controls: +Then open the observability fixture page and use these controls: + +- `observability-showcase`: a clean recording page that looks like a normal + product page. Opening the page loads `runtime_remote2/ProfileCard` with + `createInstance`. Click `Open Analytics Workspace` to route to the analytics + view, which loads `runtime_remote2/AnalyticsPanel` with another + `createInstance`, resolves `react`, and then fails to resolve + `observability-customer-sdk` because the route asks for `^3.0.0` while only + `2.1.0` is available. The page renders a limited analytics view instead of + crashing, without exposing the low-level shared error in the UI. Use this page + to record an AI agent discovering which expose was attempted, which shared + dependency failed, why the limited view appeared, and what evidence exists in + the observability report. Use `/observability` for the full fixture matrix. + +- Build observability: after the host compiles, check + `apps/runtime-demo/3005-runtime-host/.mf/observability/build-info.json`. It + should include `runtime_host`, the remote manifest URL, `Button`, and shared + dependencies such as `react`, without local source paths. + A clean build should not leave a stale + `apps/runtime-demo/3005-runtime-host/.mf/observability/build-report.json`. - `Load success remote`: loads `dynamic-remote/ButtonOldAnt` from remote2. The status should become `success`, the remote button should render, and the report should include a completed `loadRemote` timeline with - `summary.outcome: "runtime-loaded"`. The same report is also available at - `window.__FEDERATION__.__DIAGNOSTICS__.runtime_host.getLatestReport()`. + `summary.outcome: "runtime-loaded"` and per-phase success facts under + `summary.phases`. The report should also include + `diagnosis.outcome: "runtime-loaded"`. + The same report is available at + `window.__FEDERATION__.__OBSERVABILITY__.runtime_host.getLatestReport()`. - `Load missing expose`: loads a missing expose from remote2. The status should become `error`, and the report should include - `dynamic-remote/__missing_expose__`. The browser console should print a - diagnostic hint with a `traceId`. + `dynamic-remote/__missing_expose__` with `failedPhase: "expose"`. The + `diagnosis.actions` list should include a concrete expose check. The browser + console should print an observability hint with a `traceId`. - `Load broken manifest`: loads a remote with a broken manifest URL. The status - should become `error`, and the shown error should keep the manifest pathname - while removing query, token, and hash data. -- `Mark business loaded`: calls the diagnostics plugin's component success API, + should become `error`. The report should include the original manifest URL, + `RUNTIME-003`, `ownerHint: "host"`, `diagnosis.facts.url`, and a manifest + check in `diagnosis.actions`. +- `Load remote URL error`: registers a remote whose manifest URL points to an + unavailable server. The report should fail at `manifest` and include + `RUNTIME-003`, the original URL, and a manifest check. +- `Load retry recovered`: serves a remoteEntry that fails on the first script + load and succeeds on retry. The report should be successful with + `summary.outcome: "recovered"`, `summary.flags.retried: true`, and a retry + recovery warning. +- `Load fallback success`: loads a missing expose and lets the demo fallback + plugin return a fallback module. The report should be successful with + `summary.outcome: "recovered"`, keep the original expose failure under + `summary.error`, and set `summary.flags.fallback: true`. +- `Load manifest missing fields`: serves a JSON manifest that misses required + fields. The report should fail at `manifest`, show the missing fields, and + include a manifest check. +- `Load wrong globalName`: serves a manifest and remoteEntry where the + remoteEntry registers a different global name from the manifest. The report + should fail at `remoteEntry`, include `RUNTIME-001`, and include a remote + global check. +- `Load remoteEntry execution error`: serves a remoteEntry that throws while it + is being executed. The report should fail at `remoteEntry`, include + `RUNTIME-008`, classify the resource error as script execution, and include a + remoteEntry check. +- `Load snapshot match error`: registers a version-only remote that cannot be + matched from deployment `moduleInfo`. The report should include + `RUNTIME-007`, `moduleInfo.reason: "remote-snapshot"`, the clipped + `moduleInfo.availableNames`, and a moduleInfo check. +- `Mark business loaded`: calls the observability plugin's component success API, and the report should include `component:business-loaded` with - `summary.outcome: "component-loaded"`. + `summary.outcome: "component-loaded"`. The demo also sends sample metadata + to verify that business-provided metadata stays visible in the report. - `Shared miss`: triggers a missing shared provider report. The report should - include `diagnostics-missing-shared` and `missing-provider`. + include `observability-missing-shared`, `missing-provider`, and shared + provider/version checks in `diagnosis.actions`. - `Shared version mismatch`: asks for an unsupported React version. The report should include `react`, `^99.0.0`, the available version, and `version-mismatch`. +- `Shared unexpected provider`: resolves a shared dependency from a remote-like + provider even though the host provider is present. The report should be + successful and include `observability-provider-choice`, + `provider: "runtime_remote2"`, and `selectedVersion: "2.0.0"`. +- `Load multi-consumer chain`: creates two runtime consumers with + `createInstance`, loads different exposes from the real `runtime_remote2` + remote on port 3007, and gives each consumer its own shared dependency. It + records the remote load report, shared dependency report, business component + success event, and repeated load cache evidence. Use this scenario when + validating whether an agent can answer who loaded which expose, which shared + provider was selected, and whether the producer load reused cached runtime + state. - `Eager config error`: synchronously consumes an async shared dependency. The - report should include `diagnostics-async-shared`, `RUNTIME-005`, and - `sync-async-boundary`. + report should include `observability-async-shared`, `RUNTIME-005`, and + `sync-async-boundary`, with `ownerHint: "shared"` and an eager config check + in `diagnosis.actions`. +- `Runtime eager config error`: synchronously consumes an async shared + dependency from the pure runtime path. The report should include + `observability-runtime-async-shared`, `RUNTIME-006`, and + `sync-async-boundary`, with the same eager config check. Run the automated verification: @@ -65,6 +139,6 @@ pnpm run ci:local --only=e2e-runtime ``` This command installs the e2e dependencies if needed, builds the packages, -starts the host and remotes, and runs the Cypress checks. The diagnostics -fixture is covered by the `diagnostics demo fixture` tests in the runtime host +starts the host and remotes, and runs the Cypress checks. The observability +fixture is covered by the `observability demo fixture` tests in the runtime host e2e suite. diff --git a/apps/runtime-demo/skills/mf/SKILL.md b/apps/runtime-demo/skills/mf/SKILL.md new file mode 100644 index 00000000000..0c4b3415464 --- /dev/null +++ b/apps/runtime-demo/skills/mf/SKILL.md @@ -0,0 +1,55 @@ +--- +name: mf +description: "All-in-one Module Federation skill. Use when the user asks anything about MF — concepts, configuration, runtime API, shared dependencies, type errors, runtime error code troubleshooting, observability, slow builds, Bridge integration, or adding MF to an existing project." +argument-hint: [args...] +allowed-tools: Read Glob Bash(node *) Bash(npx tsc*) Bash(npx mf dts*) Bash(curl *) WebFetch Write Edit AskUserQuestion +--- + +# MF — Module Federation All-in-One Skill + +## Step 1: Identify the sub-skill + +Parse `$ARGUMENTS` and map to a reference file in the `reference/` directory (same directory as this file): + +| Sub-command (case-insensitive) | Aliases | Reference file | +|---|---|---| +| `docs` | `doc`, `help`, `?` | `reference/docs.md` | +| `context` | `ctx`, `info`, `status` | `reference/context.md` | +| `module-info` | `module`, `remote`, `manifest` | `reference/module-info.md` | +| `integrate` | `init`, `setup`, `add` | `reference/integrate.md` | +| `type-check` | `types`, `ts`, `dts` | `reference/type-check.md` | +| `shared-deps` | `shared`, `deps`, `singleton` | `reference/shared-deps.md` | +| `perf` | `performance`, `hmr`, `speed` | `reference/perf.md` | +| `config-check` | `config`, `plugin`, `exposes` | `reference/config-check.md` | +| `bridge-check` | `bridge`, `sub-app` | `reference/bridge-check.md` | +| `runtime-error` | `runtime-code`, `runtime-008`, `runtime-001`, `remote-entry` | `reference/runtime-error.md` | +| `observability` | `observe`, `trace`, `traceId`, `report`, `observability`, `debug-loading`, `telemetry`, `runtime-007`, `moduleInfo`, `snapshot` | `reference/observability.md` | + +**If no explicit sub-command is found**, detect intent from the full input: + +If the input contains an observability report, `traceId`, console `read:` command, +`.mf/observability` file path, or asks how to observe, debug, trace, inspect, or +upload Module Federation loading data, choose `reference/observability.md` even +when the same input also contains a `RUNTIME-xxx` code. + +| Signal in input | Reference file | +|---|---| +| Question about MF concepts, API, configuration options | `reference/docs.md` | +| "integrate", "add MF", "setup", "scaffold", "new project" | `reference/integrate.md` | +| "type error", "TS error", "@mf-types", "dts", "typescript" | `reference/type-check.md` | +| "shared", "singleton", "duplicate", "antd", "transformImport" | `reference/shared-deps.md` | +| "slow", "HMR", "performance", "build speed", "ts-go" | `reference/perf.md` | +| "plugin", "asyncStartup", "exposes key", "config" | `reference/config-check.md` | +| "bridge", "sub-app", "export-app", "createRemoteAppComponent" | `reference/bridge-check.md` | +| "RUNTIME-001", "RUNTIME-008", "runtime error code", "remote entry load failed", "ScriptNetworkError", "ScriptExecutionError", "container missing", "window[remoteEntryKey]" | `reference/runtime-error.md` | +| "Observability report generated", "console.error", "traceId", "read:", "diagnosis", "ownerHint", "summary.phases", ".mf/observability", "build-report.json", "latest.json", "RUNTIME-007", "moduleInfo", "remote snapshot", "global snapshot", "snapshot match", "observability", "observe MF", "debug MF loading", "trace loading", "loading report", "telemetry", "onReport", "onEvent", "production report", "upload observability" | `reference/observability.md` | +| "manifest", "remoteEntry URL", "module info", "publicPath" | `reference/module-info.md` | +| "context", "what is configured", "MF role", "bundler" | `reference/context.md` | + +If still ambiguous, show the user the sub-command table above and ask them to pick. + +## Step 2: Load and execute the reference + +Read the matched file from the `reference/` directory (same directory as this SKILL.md). + +Execute all instructions in that file, passing the remaining arguments (everything after the sub-command token, or the full `$ARGUMENTS` if intent-detected) as `ARGS`. diff --git a/apps/runtime-demo/skills/mf/reference/bridge-check.md b/apps/runtime-demo/skills/mf/reference/bridge-check.md new file mode 100644 index 00000000000..915e683f86f --- /dev/null +++ b/apps/runtime-demo/skills/mf/reference/bridge-check.md @@ -0,0 +1,29 @@ +# Sub-skill: bridge-check + +Check Module Federation Bridge usage: verify that producers correctly export `export-app`, and that consumers use the recommended Bridge API. + +## Step 1: Collect MFContext + +Read and follow the instructions in `./context.md`, passing ARGS as the project root. + +## Step 2: Run bridge check script + +Serialize MFContext to JSON and pass it to the check script: + +```bash +node scripts/bridge-check.js --context '' +``` + +Process each item in the output `results` and `context.mfConfig`: + +**BRIDGE-USAGE · info — No export-app export found** +- No key matching the `export-app` pattern found in `exposes` +- If this project is a sub-app that should follow the Bridge spec, guide the user to: + 1. Add `"./export-app": "./src/export-app.tsx"` to `exposes` + 2. The exported module must return an object conforming to the Bridge spec (containing `render` and `destroy` methods) + +**BRIDGE-USAGE · info — Consumer API recommendation** +- Advise consumers to use official Bridge APIs such as `createRemoteAppComponent` +- Avoid directly concatenating remote URLs or manually calling `loadRemote` + +If `context.mfRole` is `host` (no exposes), skip the producer-side check and only provide consumer-side recommendations. diff --git a/apps/runtime-demo/skills/mf/reference/browser-debug/long-chain.md b/apps/runtime-demo/skills/mf/reference/browser-debug/long-chain.md new file mode 100644 index 00000000000..c6a78cae92e --- /dev/null +++ b/apps/runtime-demo/skills/mf/reference/browser-debug/long-chain.md @@ -0,0 +1,72 @@ +# Long-Chain Capture + +Keep a tab alive across multiple steps — navigate, click through interactions, then capture. + +## Usage + +```bash +# Step 1 — open tab, keep it alive +TAB=$(node ../scripts/browser-capture.mjs "https://example.com" --keep-tab | jq -r .tabId) + +# Step 2 — click through the interaction chain (faster: domcontentloaded/none) +node ../scripts/browser-capture.mjs --tab-id "$TAB" --click "Profile" --action-wait domcontentloaded +node ../scripts/browser-capture.mjs --tab-id "$TAB" --click "Favorites" --action-wait none + +# Step 3 — final action, capture variables, close tab +node ../scripts/browser-capture.mjs --tab-id "$TAB" --click "Add" --vars __FEDERATION__ --action-wait networkidle --close +``` + +## Flags + +| Flag | Description | +|---|---| +| `--keep-tab` | Don't close tab after capture; outputs `tabId` in result | +| `--tab-id ` | Attach to existing tab instead of navigating | +| `--click ""` | Click an element; matching prefers CSS/interactive elements first | +| `--fill "placeholder::text"` | Type into an input/textarea located by placeholder | +| `--select "placeholder::value"` | Choose an option in a select located by placeholder | +| `--action-wait ` | Wait strategy after click/fill/select (`auto` is default; use `networkidle` on the final step when strict consistency is needed) | +| `--no-entries` | Exclude entries logs to speed up capture and reduce output size | +| `--dump-dom` | Output page DOM structure (for identifying selectors) | +| `--close` | Close the tab after this step | + +## Click matching + +Applied in order: +1. If query starts with `#`, `.`, `[`, or contains `>` → CSS selector +2. Strong interactive elements (`button`, `a`, role/button/tab/menuitem/option, submit/button inputs) +3. Weak interactive elements (`div`/`span`/`li`) only when they look clickable (`cursor:pointer`, `onclick`, or focusable tabindex) +4. Text match priority inside each layer: **exact** → **prefix** → **contains** + +## Fill (input/textarea) + +Locates the field by `placeholder` attribute, injects text using native value setter — compatible with React and Vue controlled inputs. + +```bash +node ../scripts/browser-capture.mjs --tab-id "$TAB" --fill "Enter keyword::Module Federation" +``` + +## Select (dropdown) + +Locates by `placeholder` attribute or default option text, then: +- **Native `, otherwise click the custom dropdown trigger + const r1 = await session.send('Runtime.evaluate', { + expression: `(function(ph, val) { + // native