From 2042726ddf4301293cef670e3485711e4f8a9d7a Mon Sep 17 00:00:00 2001 From: zhn Date: Wed, 12 Aug 2026 18:40:13 +0800 Subject: [PATCH 1/7] feat(runtime): add AI remote proxy plugin --- .changeset/fresh-spiders-proxy.md | 6 + .../docs/en/plugin/plugins/_meta.json | 2 +- .../module-federation-ai-proxy-remotes.mdx | 120 +++++++ .../docs/zh/plugin/plugins/_meta.json | 2 +- .../module-federation-ai-proxy-remotes.mdx | 120 +++++++ .../module-federation-ai-proxy-remotes/.swcrc | 28 ++ .../LICENSE | 21 ++ .../README.md | 89 +++++ .../__tests__/index.spec.ts | 272 ++++++++++++++++ .../jest.config.cjs | 22 ++ .../package.json | 87 +++++ .../src/console/components/proxy-console.ts | 74 +++++ .../src/console/components/proxy-rule-row.ts | 68 ++++ .../src/console/index.ts | 12 + .../src/console/mount.ts | 123 +++++++ .../src/console/storage.ts | 148 +++++++++ .../src/console/styles.ts | 33 ++ .../src/console/types.ts | 24 ++ .../src/core.ts | 305 ++++++++++++++++++ .../src/index.ts | 20 ++ .../tsconfig.json | 28 ++ .../tsconfig.lib.json | 10 + .../tsconfig.spec.json | 15 + .../tsdown.config.ts | 29 ++ pnpm-lock.yaml | 6 + 25 files changed, 1662 insertions(+), 2 deletions(-) create mode 100644 .changeset/fresh-spiders-proxy.md create mode 100644 apps/website-new/docs/en/plugin/plugins/module-federation-ai-proxy-remotes.mdx create mode 100644 apps/website-new/docs/zh/plugin/plugins/module-federation-ai-proxy-remotes.mdx create mode 100644 packages/runtime-plugins/module-federation-ai-proxy-remotes/.swcrc create mode 100644 packages/runtime-plugins/module-federation-ai-proxy-remotes/LICENSE create mode 100644 packages/runtime-plugins/module-federation-ai-proxy-remotes/README.md create mode 100644 packages/runtime-plugins/module-federation-ai-proxy-remotes/__tests__/index.spec.ts create mode 100644 packages/runtime-plugins/module-federation-ai-proxy-remotes/jest.config.cjs create mode 100644 packages/runtime-plugins/module-federation-ai-proxy-remotes/package.json create mode 100644 packages/runtime-plugins/module-federation-ai-proxy-remotes/src/console/components/proxy-console.ts create mode 100644 packages/runtime-plugins/module-federation-ai-proxy-remotes/src/console/components/proxy-rule-row.ts create mode 100644 packages/runtime-plugins/module-federation-ai-proxy-remotes/src/console/index.ts create mode 100644 packages/runtime-plugins/module-federation-ai-proxy-remotes/src/console/mount.ts create mode 100644 packages/runtime-plugins/module-federation-ai-proxy-remotes/src/console/storage.ts create mode 100644 packages/runtime-plugins/module-federation-ai-proxy-remotes/src/console/styles.ts create mode 100644 packages/runtime-plugins/module-federation-ai-proxy-remotes/src/console/types.ts create mode 100644 packages/runtime-plugins/module-federation-ai-proxy-remotes/src/core.ts create mode 100644 packages/runtime-plugins/module-federation-ai-proxy-remotes/src/index.ts create mode 100644 packages/runtime-plugins/module-federation-ai-proxy-remotes/tsconfig.json create mode 100644 packages/runtime-plugins/module-federation-ai-proxy-remotes/tsconfig.lib.json create mode 100644 packages/runtime-plugins/module-federation-ai-proxy-remotes/tsconfig.spec.json create mode 100644 packages/runtime-plugins/module-federation-ai-proxy-remotes/tsdown.config.ts diff --git a/.changeset/fresh-spiders-proxy.md b/.changeset/fresh-spiders-proxy.md new file mode 100644 index 00000000000..d3f1a236631 --- /dev/null +++ b/.changeset/fresh-spiders-proxy.md @@ -0,0 +1,6 @@ +--- +"module-federation-ai-proxy-remotes": patch +--- + +Add an AI-oriented Runtime Plugin for overriding remotes through a lightweight +proxy console or URL configuration, with rules scoped to session storage. diff --git a/apps/website-new/docs/en/plugin/plugins/_meta.json b/apps/website-new/docs/en/plugin/plugins/_meta.json index 0316025bb9c..65e1f2f5a36 100644 --- a/apps/website-new/docs/en/plugin/plugins/_meta.json +++ b/apps/website-new/docs/en/plugin/plugins/_meta.json @@ -1 +1 @@ -["index", "retry-plugin", "observability-plugin", "building-custom-retry-plugin"] +["index", "retry-plugin", "module-federation-ai-proxy-remotes", "observability-plugin", "building-custom-retry-plugin"] diff --git a/apps/website-new/docs/en/plugin/plugins/module-federation-ai-proxy-remotes.mdx b/apps/website-new/docs/en/plugin/plugins/module-federation-ai-proxy-remotes.mdx new file mode 100644 index 00000000000..e620c0f073f --- /dev/null +++ b/apps/website-new/docs/en/plugin/plugins/module-federation-ai-proxy-remotes.mdx @@ -0,0 +1,120 @@ +--- +title: AI Proxy Remotes Plugin +description: Let AI proxy Module Federation Remotes in any environment without depending on a Chrome extension environment. +--- + +# AI Proxy Remotes Plugin + +Use `module-federation-ai-proxy-remotes` to let AI debug Module Federation remotes in any environment by switching them to local manifests from a URL or a floating browser console, without depending on a Chrome extension environment. + +## Use Cases + +- Ask an AI coding agent to start a remote locally and open the host with the correct proxy rule. +- Debug a host against one or more locally running remotes. +- Debug H5 environments where installing or using a Chrome extension is inconvenient. Proxy rules work through the URL or in-page floating console without desktop Chrome DevTools. +- Save and switch proxy rules from the page without opening DevTools. + +## Install + +```bash +pnpm add -D module-federation-ai-proxy-remotes +``` + +## Add the Runtime Plugin + +Install and configure this plugin in the **host** application. + +Add the package name to `runtimePlugins` in the Module Federation plugin configuration: + +```ts title="module-federation.config.ts" +export default { + name: 'host', + runtimePlugins: ['module-federation-ai-proxy-remotes'], +}; +``` + +## Enable Debugging + +Add `__mf_devtools` to the host URL: + +```text +https://host.example.com/checkout?__mf_devtools +``` + +The page refreshes and removes the parameter from the address bar. Debugging remains enabled for the current tab session. Use **Disable debug** in the floating console to turn it off; saved rules are retained until the tab session ends. + +## Debug a Remote with AI + +Give your AI coding agent a request such as: + +```text +Start the checkout remote locally, find its mf-manifest.json URL, and open +https://host.example.com/checkout with checkout proxied to that local manifest. +``` + +### Complete Debugging URL Example + +Given: + +- Host page: `https://host.example.com/checkout`; +- Remote name: `checkout`; and +- local Manifest: `http://localhost:3001/mf-manifest.json`. + +The AI agent can build the complete debugging URL with: + +```js +const config = { + overrides: { + checkout: 'http://localhost:3001/mf-manifest.json', + }, +}; + +const url = new URL('https://host.example.com/checkout'); +url.searchParams.set('__mf_devtools', JSON.stringify(config)); +console.log(url.href); +``` + +The output is: + +```text +https://host.example.com/checkout?__mf_devtools=%7B%22overrides%22%3A%7B%22checkout%22%3A%22http%3A%2F%2Flocalhost%3A3001%2Fmf-manifest.json%22%7D%7D +``` + +The AI agent should then open this complete URL in the browser. After the page refreshes, the `checkout` Remote in the Host uses the local Manifest. Remote names and aliases are both supported as `overrides` keys. + +:::tip +`URLSearchParams` handles URL encoding. When manually building the query string, apply `encodeURIComponent` to `JSON.stringify(config)` exactly once. +::: + +## Proxy Domain Security + +By default, the plugin only accepts manifest URLs hosted on `localhost` or `127.0.0.1`. Every override URL must: + +- use the `http:` or `https:` protocol; +- point to a path ending in `.json`; +- not contain a username or password; and +- use a default host or a hostname explicitly listed in `allowedHosts`. + +`allowedHosts` contains exact hostnames without a protocol, port, path, or wildcard. For example, `assets.example.com` allows URLs on that hostname, including URLs with an explicit port, but does not allow `sub.assets.example.com`. + +Add only trusted hosts that are required for debugging: + +```ts +import { aiDebugRuntimePlugin } from 'module-federation-ai-proxy-remotes/core'; + +export default () => + aiDebugRuntimePlugin({ + allowedHosts: ['assets.example.com', 'mf-dev.internal.example.com'], + }); +``` + +The target server must also allow the host application to request the manifest according to its CORS policy. Remove temporary non-local hosts after debugging. + +## Options + +| Option | Type | Default | Usage | +| --- | --- | --- | --- | +| `allowedHosts` | `string[]` | `[]` | Allow additional trusted manifest hosts. | +| `parameterName` | `string` | `__mf_devtools` | Use another activation and configuration parameter. | +| `storageKey` | `string` | `__MF_DEVTOOLS__` | Use another session storage key for saved rules. | +| `console` | `boolean \| AIDebugConsoleOptions` | URL-controlled | Hide or configure the floating console. | diff --git a/apps/website-new/docs/zh/plugin/plugins/_meta.json b/apps/website-new/docs/zh/plugin/plugins/_meta.json index 5d7d71f5ddc..d0e67f6e04a 100644 --- a/apps/website-new/docs/zh/plugin/plugins/_meta.json +++ b/apps/website-new/docs/zh/plugin/plugins/_meta.json @@ -1 +1 @@ -["retry-plugin", "observability-plugin"] +["retry-plugin", "module-federation-ai-proxy-remotes", "observability-plugin"] diff --git a/apps/website-new/docs/zh/plugin/plugins/module-federation-ai-proxy-remotes.mdx b/apps/website-new/docs/zh/plugin/plugins/module-federation-ai-proxy-remotes.mdx new file mode 100644 index 00000000000..ea18a893da4 --- /dev/null +++ b/apps/website-new/docs/zh/plugin/plugins/module-federation-ai-proxy-remotes.mdx @@ -0,0 +1,120 @@ +--- +title: AI 代理远程插件 +description: 让 AI 在任何环境代理 Module Federation Remote,不依赖 Chrome 扩展环境。 +--- + +# AI 代理远程插件 + +使用 `module-federation-ai-proxy-remotes`,AI 可以在任何环境中通过 URL 或页面内的浮动控制台,把 Module Federation Remote 切换到本地 Manifest,不依赖 Chrome 扩展环境。 + +## 使用场景 + +- 让 AI 编程 Agent 在本地启动 Remote,并使用正确的代理规则打开宿主页面。 +- 使用一个或多个本地 Remote 调试宿主应用。 +- 调试不方便安装或使用 Chrome 扩展的 H5 环境。代理规则通过 URL 或页面内浮动控制台生效,不依赖桌面 Chrome DevTools。 +- 不打开 DevTools,直接在页面中保存和切换 Remote 代理规则。 + +## 安装 + +```bash +pnpm add -D module-federation-ai-proxy-remotes +``` + +## 接入 Runtime Plugin + +该插件需要安装并配置在 **Host** 应用中。 + +在 Module Federation 插件配置的 `runtimePlugins` 中填写包名: + +```ts title="module-federation.config.ts" +export default { + name: 'host', + runtimePlugins: ['module-federation-ai-proxy-remotes'], +}; +``` + +## 开启调试 + +在宿主 URL 中加入 `__mf_devtools`: + +```text +https://host.example.com/checkout?__mf_devtools +``` + +页面会刷新并从地址栏移除该参数。调试开关会在当前标签页会话内保留,无需再次添加参数。点击浮动控制台中的 **Disable debug** 可以关闭调试;关闭标签页后,已保存的代理规则会随会话结束而清除。 + +## 使用 AI 调试 Remote + +可以向 AI 编程 Agent 提出类似下面的请求: + +```text +在本地启动 checkout remote,找到它的 mf-manifest.json 地址,然后打开 +https://host.example.com/checkout,并把 checkout 代理到这个本地 Manifest。 +``` + +### 完整调试 URL 示例 + +假设: + +- Host 页面是 `https://host.example.com/checkout`; +- Remote 名称是 `checkout`; +- 本地 Manifest 是 `http://localhost:3001/mf-manifest.json`。 + +AI 可以使用下面的代码拼出完整调试 URL: + +```js +const config = { + overrides: { + checkout: 'http://localhost:3001/mf-manifest.json', + }, +}; + +const url = new URL('https://host.example.com/checkout'); +url.searchParams.set('__mf_devtools', JSON.stringify(config)); +console.log(url.href); +``` + +输出结果是: + +```text +https://host.example.com/checkout?__mf_devtools=%7B%22overrides%22%3A%7B%22checkout%22%3A%22http%3A%2F%2Flocalhost%3A3001%2Fmf-manifest.json%22%7D%7D +``` + +AI 最后需要在浏览器中打开这个完整 URL。页面刷新后,Host 中名为 `checkout` 的 Remote 将使用本地 Manifest。Remote 的 `name` 和 `alias` 均可作为 `overrides` 的键。 + +:::tip +`URLSearchParams` 会自动处理 URL 编码。如果手动拼接查询字符串,只需对 `JSON.stringify(config)` 的结果执行一次 `encodeURIComponent`。 +::: + +## 代理域名安全策略 + +默认只允许代理到 `localhost` 或 `127.0.0.1` 上的 Manifest。每个代理地址必须满足以下条件: + +- 使用 `http:` 或 `https:` 协议; +- URL 路径以 `.json` 结尾; +- URL 不包含用户名或密码; +- 使用默认允许的主机,或使用 `allowedHosts` 中显式配置的主机名。 + +`allowedHosts` 使用精确主机名,不要包含协议、端口、路径或通配符。例如,`assets.example.com` 允许该主机名上的 URL,包括带端口的 URL,但不允许 `sub.assets.example.com`。 + +只添加调试所需且可信的主机: + +```ts +import { aiDebugRuntimePlugin } from 'module-federation-ai-proxy-remotes/core'; + +export default () => + aiDebugRuntimePlugin({ + allowedHosts: ['assets.example.com', 'mf-dev.internal.example.com'], + }); +``` + +目标服务器还需要通过 CORS 策略允许 Host 应用请求 Manifest。调试结束后,应删除临时添加的非本地主机。 + +## 配置项 + +| 配置项 | 类型 | 默认值 | 用途 | +| --- | --- | --- | --- | +| `allowedHosts` | `string[]` | `[]` | 允许额外的可信 Manifest 主机。 | +| `parameterName` | `string` | `__mf_devtools` | 使用其他 URL 参数开启调试并传入配置。 | +| `storageKey` | `string` | `__MF_DEVTOOLS__` | 使用其他 sessionStorage key 保存规则。 | +| `console` | `boolean \| AIDebugConsoleOptions` | 由 URL 控制 | 隐藏或配置浮动控制台。 | diff --git a/packages/runtime-plugins/module-federation-ai-proxy-remotes/.swcrc b/packages/runtime-plugins/module-federation-ai-proxy-remotes/.swcrc new file mode 100644 index 00000000000..9021c458330 --- /dev/null +++ b/packages/runtime-plugins/module-federation-ai-proxy-remotes/.swcrc @@ -0,0 +1,28 @@ +{ + "jsc": { + "target": "es2017", + "parser": { + "syntax": "typescript", + "decorators": true, + "dynamicImport": true + }, + "transform": { + "decoratorMetadata": true, + "legacyDecorator": true + }, + "keepClassNames": true, + "externalHelpers": true, + "loose": true + }, + "module": { + "type": "es6" + }, + "sourceMaps": true, + "inputSourceMap": false, + "exclude": [ + "jest.config.ts", + "./src/jest-setup.ts$", + "./**/jest-setup.ts$", + ".*.js$" + ] +} diff --git a/packages/runtime-plugins/module-federation-ai-proxy-remotes/LICENSE b/packages/runtime-plugins/module-federation-ai-proxy-remotes/LICENSE new file mode 100644 index 00000000000..ccc1f30c455 --- /dev/null +++ b/packages/runtime-plugins/module-federation-ai-proxy-remotes/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019-present Zack Jackson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/runtime-plugins/module-federation-ai-proxy-remotes/README.md b/packages/runtime-plugins/module-federation-ai-proxy-remotes/README.md new file mode 100644 index 00000000000..02786ca1a7f --- /dev/null +++ b/packages/runtime-plugins/module-federation-ai-proxy-remotes/README.md @@ -0,0 +1,89 @@ +# `module-federation-ai-proxy-remotes` + +Use this Runtime Plugin to let AI debug Module Federation remotes in any environment by switching them to local manifests from a URL or a floating browser console, without depending on a Chrome extension environment. + +## Use cases + +- Ask an AI coding agent to start a remote locally and open the host with the correct proxy rule. +- Debug a host against one or more locally running remotes. +- Save and switch proxy rules from the page without opening DevTools. + +## Install + +```bash +pnpm add -D module-federation-ai-proxy-remotes +``` + +## Add the Runtime Plugin + +Install and configure this plugin in the **host** application. + +Add the package name to `runtimePlugins` in the Module Federation plugin configuration: + +```ts title="module-federation.config.ts" +export default { + name: 'host', + runtimePlugins: ['module-federation-ai-proxy-remotes'], +}; +``` + +## Enable debugging + +Add `__mf_devtools` to the host URL: + +```text +https://host.example.com/checkout?__mf_devtools +``` + +The page refreshes and removes the parameter from the address bar. Debugging remains enabled for the current tab session. Use **Disable debug** in the floating console to turn it off; saved rules are retained until the tab session ends. + +## Configure remotes from the URL + +```js +const config = { + overrides: { + checkout: 'http://localhost:3001/mf-manifest.json', + }, +}; + +const url = new URL('https://host.example.com/checkout'); +url.searchParams.set('__mf_devtools', JSON.stringify(config)); +console.log(url.href); +``` + +Open the generated URL to enable debugging and save the rule. Remote names and aliases are both supported. + +`URLSearchParams` handles URL encoding. When manually building the query string, apply `encodeURIComponent` to `JSON.stringify(config)` exactly once. + +## Proxy domain security + +By default, the plugin only accepts manifest URLs hosted on `localhost` or `127.0.0.1`. Every override URL must: + +- use the `http:` or `https:` protocol; +- point to a path ending in `.json`; +- not contain a username or password; and +- use a default host or a hostname explicitly listed in `allowedHosts`. + +`allowedHosts` contains exact hostnames without a protocol, port, path, or wildcard. For example, `assets.example.com` allows URLs on that hostname, including URLs with an explicit port, but does not allow `sub.assets.example.com`. + +Add only trusted hosts that are required for debugging: + +```ts +import { aiDebugRuntimePlugin } from 'module-federation-ai-proxy-remotes/core'; + +export default () => + aiDebugRuntimePlugin({ + allowedHosts: ['assets.example.com', 'mf-dev.internal.example.com'], + }); +``` + +The target server must also allow the host application to request the manifest according to its CORS policy. Remove temporary non-local hosts after debugging. + +## Options + +| Option | Type | Default | Usage | +| --------------- | ---------------------------------- | ----------------- | --------------------------------------------------- | +| `allowedHosts` | `string[]` | `[]` | Allow additional trusted manifest hosts. | +| `parameterName` | `string` | `__mf_devtools` | Use another activation and configuration parameter. | +| `storageKey` | `string` | `__MF_DEVTOOLS__` | Use another session storage key for saved rules. | +| `console` | `boolean \| AIDebugConsoleOptions` | URL-controlled | Hide or configure the floating console. | diff --git a/packages/runtime-plugins/module-federation-ai-proxy-remotes/__tests__/index.spec.ts b/packages/runtime-plugins/module-federation-ai-proxy-remotes/__tests__/index.spec.ts new file mode 100644 index 00000000000..fa6aa4a544a --- /dev/null +++ b/packages/runtime-plugins/module-federation-ai-proxy-remotes/__tests__/index.spec.ts @@ -0,0 +1,272 @@ +import aiDebugRuntimePlugin from '../src'; +import { + AI_DEBUG_ENV_KEY, + AI_DEBUG_CONSOLE_KEY, + AI_DEBUG_SNAPSHOT_KEY, + AI_DEBUG_STORAGE_KEY, + applyAIDebugUrlConfig, + mergeAIDebugConfig, + parseAIDebugUrlConfig, +} from '../src/core'; +import { + AI_DEBUG_CONSOLE_ELEMENT_ID, + mountAIDebugConsole, +} from '../src/console'; + +describe('module-federation-ai-proxy-remotes', () => { + let consoleError: jest.SpyInstance; + + beforeEach(() => { + consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + sessionStorage.clear(); + window.history.replaceState(null, '', '/'); + delete (window as typeof window & { __FEDERATION__?: unknown }) + .__FEDERATION__; + delete (window as typeof window & { __VMOK__?: unknown }).__VMOK__; + document.getElementById(AI_DEBUG_CONSOLE_ELEMENT_ID)?.remove(); + }); + + afterEach(() => { + consoleError.mockRestore(); + }); + + it('merges by default, replaces on demand, and deletes null entries', () => { + const current = { + overrides: { old: 'http://localhost:3001/mf-manifest.json' }, + enableFastRefresh: true, + }; + + expect( + mergeAIDebugConfig(current, { + overrides: { added: 'http://127.0.0.1:3002/mf-manifest.json' }, + }), + ).toEqual({ + overrides: { + old: 'http://localhost:3001/mf-manifest.json', + added: 'http://127.0.0.1:3002/mf-manifest.json', + }, + enableFastRefresh: true, + }); + expect( + mergeAIDebugConfig(current, { + replace: true, + overrides: { + old: null, + added: 'http://localhost:3002/mf-manifest.json', + }, + }), + ).toEqual({ + overrides: { added: 'http://localhost:3002/mf-manifest.json' }, + enableFastRefresh: true, + }); + }); + + it('allows loopback and explicitly whitelisted hosts only', () => { + expect(() => + parseAIDebugUrlConfig( + JSON.stringify({ + overrides: { remote: 'https://assets.example.com/mf-manifest.json' }, + }), + ), + ).toThrow(/localhost or 127\.0\.0\.1/); + + expect( + parseAIDebugUrlConfig( + JSON.stringify({ + overrides: { remote: 'https://assets.example.com/mf-manifest.json' }, + }), + { allowedHosts: ['assets.example.com'] }, + ).overrides.remote, + ).toBe('https://assets.example.com/mf-manifest.json'); + }); + + it('applies encoded URL config, preserves other settings and cleans the URL', () => { + sessionStorage.setItem( + AI_DEBUG_STORAGE_KEY, + JSON.stringify({ + overrides: { old: 'http://localhost:3001/mf-manifest.json' }, + enableFastRefresh: true, + }), + ); + sessionStorage.setItem(AI_DEBUG_SNAPSHOT_KEY, '{}'); + const url = new URL('/host?keep=yes', location.href); + url.searchParams.set( + '__mf_devtools', + JSON.stringify({ + overrides: { remote: 'http://localhost:3002/mf-manifest.json' }, + }), + ); + history.replaceState(null, '', url.href); + + expect(applyAIDebugUrlConfig()).toBe(true); + expect( + JSON.parse(sessionStorage.getItem(AI_DEBUG_STORAGE_KEY) ?? '{}'), + ).toEqual({ + overrides: { + old: 'http://localhost:3001/mf-manifest.json', + remote: 'http://localhost:3002/mf-manifest.json', + }, + enableFastRefresh: true, + }); + expect(sessionStorage.getItem(AI_DEBUG_SNAPSHOT_KEY)).toBeNull(); + expect(sessionStorage.getItem(AI_DEBUG_ENV_KEY)).toBe('true'); + expect(sessionStorage.getItem(AI_DEBUG_CONSOLE_KEY)).toBe('true'); + expect(new URL(location.href).searchParams.has('__mf_devtools')).toBe( + false, + ); + expect(new URL(location.href).searchParams.get('keep')).toBe('yes'); + }); + + it('loads the console asynchronously only after URL activation', async () => { + aiDebugRuntimePlugin(); + await Promise.resolve(); + expect(document.getElementById(AI_DEBUG_CONSOLE_ELEMENT_ID)).toBeNull(); + + const url = new URL(location.href); + url.searchParams.set( + '__mf_devtools', + JSON.stringify({ + overrides: { remote: 'http://localhost:3002/mf-manifest.json' }, + }), + ); + history.replaceState(null, '', url.href); + + aiDebugRuntimePlugin(); + expect(sessionStorage.getItem(AI_DEBUG_CONSOLE_KEY)).toBe('true'); + expect(document.getElementById(AI_DEBUG_CONSOLE_ELEMENT_ID)).toBeNull(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(document.getElementById(AI_DEBUG_CONSOLE_ELEMENT_ID)).not.toBeNull(); + }); + + it('enables the console when the URL parameter has no value', async () => { + history.replaceState(null, '', '/?__mf_devtools'); + + aiDebugRuntimePlugin(); + + expect(sessionStorage.getItem(AI_DEBUG_CONSOLE_KEY)).toBe('true'); + expect(new URL(location.href).searchParams.has('__mf_devtools')).toBe( + false, + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(document.getElementById(AI_DEBUG_CONSOLE_ELEMENT_ID)).not.toBeNull(); + }); + + it('globally overrides a remote during registration and snapshot loading', () => { + sessionStorage.setItem( + AI_DEBUG_STORAGE_KEY, + JSON.stringify({ + overrides: { alias: 'http://localhost:3002/mf-manifest.json' }, + }), + ); + sessionStorage.setItem(AI_DEBUG_CONSOLE_KEY, 'true'); + const plugin = aiDebugRuntimePlugin({ console: false }); + const args = { + remote: { name: 'remote', alias: 'alias', version: '1.0.0' }, + origin: {} as never, + }; + + plugin.beforeRegisterRemote?.(args); + expect(args.remote).toEqual({ + name: 'remote', + alias: 'alias', + entry: 'http://localhost:3002/mf-manifest.json', + }); + + const snapshotArgs = { + moduleInfo: { name: 'remote', alias: 'alias', version: '1.0.0' }, + options: { inBrowser: true } as never, + origin: {} as never, + }; + plugin.beforeLoadRemoteSnapshot?.(snapshotArgs); + expect(snapshotArgs.moduleInfo).toEqual({ + name: 'remote', + alias: 'alias', + entry: 'http://localhost:3002/mf-manifest.json', + }); + }); + + it('does not apply overrides while debugging is disabled', () => { + sessionStorage.setItem( + AI_DEBUG_STORAGE_KEY, + JSON.stringify({ + overrides: { remote: 'http://localhost:3002/mf-manifest.json' }, + }), + ); + const plugin = aiDebugRuntimePlugin({ console: false }); + const args = { + remote: { name: 'remote', entry: 'https://example.com/remoteEntry.js' }, + origin: {} as never, + }; + + plugin.beforeRegisterRemote?.(args); + + expect(args.remote.entry).toBe('https://example.com/remoteEntry.js'); + }); + + it('registers the runtime plugin globally once', () => { + const plugin = aiDebugRuntimePlugin({ console: false }); + expect(plugin.name).toBe('ai-proxy-remotes-runtime-plugin'); + expect(window.__FEDERATION__.__GLOBAL_PLUGIN__).toEqual([plugin]); + expect(aiDebugRuntimePlugin({ console: false })).toBe(plugin); + expect(window.__FEDERATION__.__GLOBAL_PLUGIN__).toHaveLength(1); + }); + + it('mounts a componentized console and saves proxy rules', () => { + (window as typeof window & { __FEDERATION__?: unknown }).__FEDERATION__ = { + moduleInfo: { + host: { + remotesInfo: { + checkout: { + matchedVersion: 'http://localhost:3000/mf-manifest.json', + }, + }, + }, + }, + __INSTANCES__: [], + }; + mountAIDebugConsole( + { defaultOpen: true, reloadOnSave: false }, + { allowedHosts: [] }, + ); + const host = document.getElementById(AI_DEBUG_CONSOLE_ELEMENT_ID); + const root = host?.shadowRoot; + expect(root).toBeDefined(); + + const buttons = Array.from(root!.querySelectorAll('button')); + buttons.find((button) => button.textContent === '+ Add remote')?.click(); + const remote = root!.querySelector('select'); + expect(Array.from(remote!.options).map((option) => option.value)).toEqual([ + '', + 'checkout', + ]); + remote!.value = 'checkout'; + remote!.dispatchEvent(new Event('change')); + const manifest = root!.querySelector('input[type=text]'); + manifest!.value = 'http://localhost:3001/mf-manifest.json'; + manifest!.dispatchEvent(new Event('input')); + Array.from(root!.querySelectorAll('button')) + .find((button) => button.textContent === 'Save') + ?.click(); + + expect( + JSON.parse(sessionStorage.getItem(AI_DEBUG_STORAGE_KEY) ?? '{}'), + ).toEqual({ + overrides: { + checkout: 'http://localhost:3001/mf-manifest.json', + }, + }); + }); + + it('can disable debugging from the console', () => { + sessionStorage.setItem(AI_DEBUG_CONSOLE_KEY, 'true'); + mountAIDebugConsole({ defaultOpen: true, reloadOnSave: false }); + const host = document.getElementById(AI_DEBUG_CONSOLE_ELEMENT_ID); + + Array.from(host!.shadowRoot!.querySelectorAll('button')) + .find((button) => button.textContent === 'Disable debug') + ?.click(); + + expect(sessionStorage.getItem(AI_DEBUG_CONSOLE_KEY)).toBeNull(); + expect(document.getElementById(AI_DEBUG_CONSOLE_ELEMENT_ID)).toBeNull(); + }); +}); diff --git a/packages/runtime-plugins/module-federation-ai-proxy-remotes/jest.config.cjs b/packages/runtime-plugins/module-federation-ai-proxy-remotes/jest.config.cjs new file mode 100644 index 00000000000..bfef7e82739 --- /dev/null +++ b/packages/runtime-plugins/module-federation-ai-proxy-remotes/jest.config.cjs @@ -0,0 +1,22 @@ +/* eslint-disable */ +const { readFileSync } = require('fs'); + +const { exclude: _, ...swcJestConfig } = JSON.parse( + readFileSync(`${__dirname}/.swcrc`, 'utf-8'), +); + +if (swcJestConfig.swcrc === undefined) { + swcJestConfig.swcrc = false; +} + +module.exports = { + displayName: 'ai-proxy-remotes-runtime-plugin', + preset: '../../../jest.preset.js', + transform: { + '^.+\\.[tj]s$': ['@swc/jest', swcJestConfig], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + testEnvironment: 'jsdom', + coverageDirectory: + '../../../coverage/packages/runtime-plugins/module-federation-ai-proxy-remotes', +}; diff --git a/packages/runtime-plugins/module-federation-ai-proxy-remotes/package.json b/packages/runtime-plugins/module-federation-ai-proxy-remotes/package.json new file mode 100644 index 00000000000..e8078b8136e --- /dev/null +++ b/packages/runtime-plugins/module-federation-ai-proxy-remotes/package.json @@ -0,0 +1,87 @@ +{ + "name": "module-federation-ai-proxy-remotes", + "version": "0.1.0", + "type": "module", + "license": "MIT", + "description": "A Module Federation runtime plugin for AI-driven local remote debugging", + "keywords": [ + "Module Federation", + "runtime plugin", + "AI debugging", + "remote override" + ], + "files": [ + "dist/", + "README.md" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/module-federation/core.git", + "directory": "packages/runtime-plugins/module-federation-ai-proxy-remotes" + }, + "author": "Module Federation Team", + "sideEffects": false, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.ts", + "default": "./dist/index.cjs" + } + }, + "./core": { + "import": { + "types": "./dist/core.d.ts", + "default": "./dist/core.js" + }, + "require": { + "types": "./dist/core.d.ts", + "default": "./dist/core.cjs" + } + }, + "./console": { + "import": { + "types": "./dist/console.d.ts", + "default": "./dist/console.js" + }, + "require": { + "types": "./dist/console.d.ts", + "default": "./dist/console.cjs" + } + } + }, + "typesVersions": { + "*": { + ".": [ + "./dist/index.d.ts" + ], + "core": [ + "./dist/core.d.ts" + ], + "console": [ + "./dist/console.d.ts" + ] + } + }, + "peerDependencies": { + "@module-federation/runtime-tools": "workspace:*" + }, + "devDependencies": { + "@module-federation/runtime-tools": "workspace:*" + }, + "scripts": { + "build": "tsdown --config tsdown.config.ts --filter ai-proxy-remotes-runtime-plugin-build", + "test": "NODE_OPTIONS=--experimental-vm-modules pnpm exec jest --config jest.config.cjs", + "lint": "ESLINT_USE_FLAT_CONFIG=false pnpm exec eslint --ignore-pattern node_modules \"**/*.ts\" \"package.json\"", + "pre-release": "pnpm run build" + } +} diff --git a/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/console/components/proxy-console.ts b/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/console/components/proxy-console.ts new file mode 100644 index 00000000000..d676cb1c181 --- /dev/null +++ b/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/console/components/proxy-console.ts @@ -0,0 +1,74 @@ +import { consoleStyles } from '../styles'; +import type { ProxyConsoleActions, ProxyConsoleState } from '../types'; +import { createProxyRuleRow } from './proxy-rule-row'; + +const createButton = ( + label: string, + className: string, + action: () => void, +): HTMLButtonElement => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = className; + button.textContent = label; + button.addEventListener('click', action); + return button; +}; + +export const renderProxyConsole = ( + root: ShadowRoot, + state: ProxyConsoleState, + remoteNames: string[], + actions: ProxyConsoleActions, +): void => { + root.replaceChildren(); + const style = document.createElement('style'); + style.textContent = consoleStyles; + + const trigger = createButton('MF', 'trigger', actions.toggle); + trigger.title = 'Module Federation proxy console'; + + const panel = document.createElement('section'); + panel.className = state.open ? 'panel' : 'panel hidden'; + const header = document.createElement('header'); + const titleWrap = document.createElement('div'); + const title = document.createElement('h2'); + title.textContent = 'Remote proxy overrides'; + const subtitle = document.createElement('div'); + subtitle.className = 'subtitle'; + subtitle.textContent = 'Route host remotes to local manifests'; + titleWrap.append(title, subtitle); + header.append(titleWrap, createButton('Close', 'icon-button', actions.close)); + + const content = document.createElement('div'); + content.className = 'content'; + if (state.rules.length) { + state.rules.forEach((rule) => + content.append(createProxyRuleRow(rule, remoteNames, actions)), + ); + } else { + const empty = document.createElement('div'); + empty.className = 'empty'; + empty.textContent = 'No proxy overrides configured.'; + content.append(empty); + } + const toolbar = document.createElement('div'); + toolbar.className = 'toolbar'; + toolbar.append(createButton('+ Add remote', 'button', actions.addRule)); + content.append(toolbar); + + const footer = document.createElement('footer'); + const message = document.createElement('div'); + message.className = `message ${state.messageType}`; + message.textContent = state.message; + const buttons = document.createElement('div'); + buttons.className = 'toolbar'; + buttons.append( + createButton('Disable debug', 'button danger', actions.disableDebug), + createButton('Clear', 'button danger', actions.clearRules), + createButton('Save', 'button primary', actions.saveRules), + ); + footer.append(message, buttons); + panel.append(header, content, footer); + root.append(style, trigger, panel); +}; diff --git a/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/console/components/proxy-rule-row.ts b/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/console/components/proxy-rule-row.ts new file mode 100644 index 00000000000..db3836c77a3 --- /dev/null +++ b/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/console/components/proxy-rule-row.ts @@ -0,0 +1,68 @@ +import type { ProxyConsoleActions, ProxyRule } from '../types'; + +const createTextInput = ( + value: string, + placeholder: string, + onInput: (value: string) => void, +): HTMLInputElement => { + const input = document.createElement('input'); + input.type = 'text'; + input.value = value; + input.placeholder = placeholder; + input.addEventListener('input', () => onInput(input.value)); + return input; +}; + +export const createProxyRuleRow = ( + rule: ProxyRule, + remoteNames: string[], + actions: ProxyConsoleActions, +): HTMLElement => { + const row = document.createElement('div'); + row.className = 'rule'; + + const enabled = document.createElement('input'); + enabled.type = 'checkbox'; + enabled.checked = rule.enabled; + enabled.title = 'Enable override'; + enabled.addEventListener('change', () => + actions.updateRule(rule.id, { enabled: enabled.checked }), + ); + + const name = document.createElement('select'); + name.title = 'Remote name'; + const placeholder = document.createElement('option'); + placeholder.value = ''; + placeholder.textContent = 'Select remote'; + placeholder.disabled = true; + name.append(placeholder); + const options = + rule.name && !remoteNames.includes(rule.name) + ? [rule.name, ...remoteNames] + : remoteNames; + options.forEach((remoteName) => { + const option = document.createElement('option'); + option.value = remoteName; + option.textContent = remoteName; + name.append(option); + }); + name.value = rule.name; + name.addEventListener('change', () => + actions.updateRule(rule.id, { name: name.value }), + ); + const manifest = createTextInput( + rule.manifestUrl, + 'http://localhost:3001/mf-manifest.json', + (value) => actions.updateRule(rule.id, { manifestUrl: value }), + ); + + const remove = document.createElement('button'); + remove.type = 'button'; + remove.className = 'remove'; + remove.title = 'Remove rule'; + remove.textContent = '×'; + remove.addEventListener('click', () => actions.removeRule(rule.id)); + + row.append(enabled, name, manifest, remove); + return row; +}; diff --git a/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/console/index.ts b/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/console/index.ts new file mode 100644 index 00000000000..984824f4767 --- /dev/null +++ b/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/console/index.ts @@ -0,0 +1,12 @@ +export { AI_DEBUG_CONSOLE_ELEMENT_ID, mountAIDebugConsole } from './mount'; +export { + createEmptyRule, + readProxyRules, + validateProxyRules, + writeProxyRules, +} from './storage'; +export type { + ProxyConsoleActions, + ProxyConsoleState, + ProxyRule, +} from './types'; diff --git a/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/console/mount.ts b/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/console/mount.ts new file mode 100644 index 00000000000..027daef342f --- /dev/null +++ b/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/console/mount.ts @@ -0,0 +1,123 @@ +import type { + AIDebugConsoleOptions, + AIDebugRuntimePluginOptions, +} from '../core'; +import { AI_DEBUG_CONSOLE_KEY } from '../core'; +import { + createEmptyRule, + readAvailableRemoteNames, + readProxyRules, + validateProxyRules, + writeProxyRules, +} from './storage'; +import { renderProxyConsole } from './components/proxy-console'; +import type { ProxyConsoleActions, ProxyConsoleState } from './types'; + +export const AI_DEBUG_CONSOLE_ELEMENT_ID = 'mf-ai-debug-console'; + +const mount = ( + consoleOptions: AIDebugConsoleOptions, + pluginOptions: AIDebugRuntimePluginOptions, +): HTMLElement | undefined => { + if (!document.body || document.getElementById(AI_DEBUG_CONSOLE_ELEMENT_ID)) { + return undefined; + } + const host = document.createElement('div'); + host.id = AI_DEBUG_CONSOLE_ELEMENT_ID; + host.style.position = 'relative'; + host.style.zIndex = String(consoleOptions.zIndex ?? 2147483647); + const root = host.attachShadow({ mode: 'open' }); + const state: ProxyConsoleState = { + open: consoleOptions.defaultOpen === true, + rules: readProxyRules( + sessionStorage, + pluginOptions.storageKey ?? undefined, + ), + message: '', + messageType: 'idle', + }; + + const render = () => + renderProxyConsole(root, state, readAvailableRemoteNames(), actions); + const actions: ProxyConsoleActions = { + addRule() { + state.rules.push(createEmptyRule()); + render(); + }, + clearRules() { + state.rules = []; + writeProxyRules(sessionStorage, state.rules, pluginOptions); + state.message = 'Overrides cleared.'; + state.messageType = 'success'; + render(); + if (consoleOptions.reloadOnSave !== false) { + location.reload(); + } + }, + close() { + state.open = false; + render(); + }, + disableDebug() { + sessionStorage.removeItem(AI_DEBUG_CONSOLE_KEY); + host.remove(); + if (consoleOptions.reloadOnSave !== false) { + location.reload(); + } + }, + removeRule(id) { + state.rules = state.rules.filter((rule) => rule.id !== id); + render(); + }, + saveRules() { + const error = validateProxyRules(state.rules, pluginOptions.allowedHosts); + if (error) { + state.message = error; + state.messageType = 'error'; + render(); + return; + } + writeProxyRules(sessionStorage, state.rules, pluginOptions); + state.message = 'Overrides saved.'; + state.messageType = 'success'; + render(); + if (consoleOptions.reloadOnSave !== false) { + location.reload(); + } + }, + toggle() { + state.open = !state.open; + render(); + }, + updateRule(id, patch) { + const rule = state.rules.find((item) => item.id === id); + if (rule) { + Object.assign(rule, patch); + state.message = ''; + state.messageType = 'idle'; + } + }, + }; + + document.body.append(host); + render(); + return host; +}; + +export const mountAIDebugConsole = ( + consoleOptions: AIDebugConsoleOptions = {}, + pluginOptions: AIDebugRuntimePluginOptions = {}, +): void => { + if (typeof document !== 'object' || typeof sessionStorage !== 'object') { + return; + } + if (document.readyState === 'loading') { + document.addEventListener( + 'DOMContentLoaded', + () => mount(consoleOptions, pluginOptions), + { once: true }, + ); + } else { + mount(consoleOptions, pluginOptions); + } +}; diff --git a/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/console/storage.ts b/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/console/storage.ts new file mode 100644 index 00000000000..1ab97c9e3fd --- /dev/null +++ b/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/console/storage.ts @@ -0,0 +1,148 @@ +import { + AI_DEBUG_ENV_KEY, + AI_DEBUG_SNAPSHOT_KEY, + AI_DEBUG_STORAGE_KEY, + isAllowedManifestUrl, + type AIDebugRuntimePluginOptions, + type AIDebugStoredConfig, +} from '../core'; + +import type { ProxyRule } from './types'; + +const createRuleId = (): string => + typeof crypto === 'object' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `mf-${Date.now()}-${Math.random().toString(16).slice(2)}`; + +const isObject = (value: unknown): value is Record => + Boolean(value) && typeof value === 'object' && !Array.isArray(value); + +type FederationRemoteSource = { + __FEDERATION__?: { + moduleInfo?: Record; + __INSTANCES__?: Array<{ + options?: { remotes?: Array<{ name?: unknown; alias?: unknown }> }; + }>; + }; + __VMOK__?: FederationRemoteSource['__FEDERATION__']; +}; + +export const readAvailableRemoteNames = ( + target: FederationRemoteSource = globalThis, +): string[] => { + const federation = target.__FEDERATION__ ?? target.__VMOK__; + const names = new Set(); + + for (const module of Object.values(federation?.moduleInfo ?? {})) { + if (!isObject(module) || !isObject(module.remotesInfo)) { + continue; + } + Object.keys(module.remotesInfo).forEach((name) => names.add(name)); + } + + for (const instance of federation?.__INSTANCES__ ?? []) { + for (const remote of instance.options?.remotes ?? []) { + if (typeof remote.name === 'string' && remote.name) { + names.add(remote.name); + } + if (typeof remote.alias === 'string' && remote.alias) { + names.add(remote.alias); + } + } + } + + return [...names].sort((left, right) => left.localeCompare(right)); +}; + +export const readProxyRules = ( + storage: Storage, + storageKey = AI_DEBUG_STORAGE_KEY, +): ProxyRule[] => { + try { + const parsed: unknown = JSON.parse(storage.getItem(storageKey) ?? '{}'); + if (!isObject(parsed)) { + return []; + } + const source = isObject(parsed.overrides) ? parsed.overrides : parsed; + return Object.entries(source) + .filter((entry): entry is [string, string] => + Boolean(entry[0] && typeof entry[1] === 'string' && entry[1]), + ) + .map(([name, manifestUrl]) => ({ + id: createRuleId(), + name, + manifestUrl, + enabled: true, + })); + } catch { + return []; + } +}; + +export const validateProxyRules = ( + rules: ProxyRule[], + allowedHosts: string[] = [], +): string | undefined => { + const names = new Set(); + for (const rule of rules) { + if (!rule.enabled) { + continue; + } + const name = rule.name.trim(); + if (!name) { + return 'Remote name is required.'; + } + if (names.has(name)) { + return `Remote "${name}" is duplicated.`; + } + names.add(name); + if (!isAllowedManifestUrl(rule.manifestUrl.trim(), allowedHosts)) { + return `"${name}" must use an allowed HTTP(S) JSON manifest URL.`; + } + } + return undefined; +}; + +export const writeProxyRules = ( + storage: Storage, + rules: ProxyRule[], + options: AIDebugRuntimePluginOptions = {}, +): void => { + const storageKey = options.storageKey ?? AI_DEBUG_STORAGE_KEY; + let current: AIDebugStoredConfig = {}; + try { + const parsed: unknown = JSON.parse(storage.getItem(storageKey) ?? '{}'); + if (isObject(parsed)) { + current = parsed; + } + } catch { + // Invalid previous values are replaced while unrelated valid settings stay. + } + + const overrides = Object.fromEntries( + rules + .filter((rule) => rule.enabled) + .map((rule) => [rule.name.trim(), rule.manifestUrl.trim()]), + ); + const next = { ...current }; + if (Object.keys(overrides).length) { + next.overrides = overrides; + } else { + delete next.overrides; + } + + if (Object.keys(next).length) { + storage.setItem(storageKey, JSON.stringify(next)); + } else { + storage.removeItem(storageKey); + } + storage.removeItem(AI_DEBUG_SNAPSHOT_KEY); + storage.setItem(AI_DEBUG_ENV_KEY, 'true'); +}; + +export const createEmptyRule = (): ProxyRule => ({ + id: createRuleId(), + name: '', + manifestUrl: '', + enabled: true, +}); diff --git a/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/console/styles.ts b/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/console/styles.ts new file mode 100644 index 00000000000..68ccdfd5b3d --- /dev/null +++ b/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/console/styles.ts @@ -0,0 +1,33 @@ +export const consoleStyles = ` + :host { all: initial; color-scheme: light dark; } + * { box-sizing: border-box; } + button, input, select { font: inherit; } + .trigger { position: fixed; right: 20px; bottom: 20px; width: 48px; height: 48px; border: 0; border-radius: 14px; color: #fff; background: #6547f5; box-shadow: 0 10px 30px rgba(30, 20, 80, .28); cursor: pointer; font: 700 13px/1 system-ui, sans-serif; } + .panel { position: fixed; right: 20px; bottom: 80px; width: min(560px, calc(100vw - 32px)); max-height: min(680px, calc(100vh - 110px)); display: flex; flex-direction: column; overflow: hidden; border: 1px solid rgba(127,127,140,.25); border-radius: 16px; background: #fff; color: #17171c; box-shadow: 0 24px 70px rgba(20, 18, 40, .28); font: 13px/1.45 system-ui, -apple-system, sans-serif; } + .hidden { display: none; } + header { display: flex; align-items: center; justify-content: space-between; padding: 16px 18px; border-bottom: 1px solid #ececf2; } + h2 { margin: 0; font-size: 16px; } + .subtitle { margin-top: 2px; color: #71717d; font-size: 12px; } + .icon-button { border: 0; padding: 6px 8px; background: transparent; color: inherit; cursor: pointer; } + .content { overflow: auto; padding: 14px 18px; } + .empty { padding: 28px 12px; border: 1px dashed #d6d4df; border-radius: 10px; color: #777583; text-align: center; } + .rule { display: grid; grid-template-columns: 24px minmax(100px,.7fr) minmax(190px,1.5fr) 28px; gap: 8px; align-items: center; margin-bottom: 9px; } + .rule input[type="text"], .rule select { min-width: 0; width: 100%; height: 36px; padding: 0 10px; border: 1px solid #d9d7e2; border-radius: 8px; background: #fff; color: #17171c; outline: none; } + .rule input[type="text"]:focus, .rule select:focus { border-color: #6547f5; box-shadow: 0 0 0 2px rgba(101,71,245,.12); } + .remove { border: 0; background: transparent; color: #a33; cursor: pointer; font-size: 18px; } + .toolbar, footer { display: flex; align-items: center; gap: 8px; } + .toolbar { margin-top: 12px; } + footer { justify-content: space-between; padding: 12px 18px 16px; border-top: 1px solid #ececf2; } + .message { min-height: 18px; color: #71717d; } + .message.error { color: #c0392b; } + .message.success { color: #218a55; } + .button { height: 34px; padding: 0 13px; border: 1px solid #d9d7e2; border-radius: 8px; background: #fff; color: #292832; cursor: pointer; } + .button.primary { border-color: #6547f5; background: #6547f5; color: #fff; } + .button.danger { color: #aa3030; } + @media (prefers-color-scheme: dark) { + .panel { border-color: #3a3945; background: #202027; color: #f2f1f6; } + header, footer { border-color: #35343f; } + .rule input[type="text"], .rule select, .button { border-color: #44434f; background: #292931; color: #f2f1f6; } + .empty { border-color: #494754; } + } +`; diff --git a/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/console/types.ts b/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/console/types.ts new file mode 100644 index 00000000000..b4bdf046558 --- /dev/null +++ b/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/console/types.ts @@ -0,0 +1,24 @@ +export type ProxyRule = { + id: string; + name: string; + manifestUrl: string; + enabled: boolean; +}; + +export type ProxyConsoleState = { + open: boolean; + rules: ProxyRule[]; + message: string; + messageType: 'idle' | 'success' | 'error'; +}; + +export type ProxyConsoleActions = { + addRule(): void; + clearRules(): void; + close(): void; + disableDebug(): void; + removeRule(id: string): void; + saveRules(): void; + toggle(): void; + updateRule(id: string, patch: Partial>): void; +}; diff --git a/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/core.ts b/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/core.ts new file mode 100644 index 00000000000..66db299794b --- /dev/null +++ b/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/core.ts @@ -0,0 +1,305 @@ +import type { ModuleFederationRuntimePlugin } from '@module-federation/runtime-tools/runtime-core'; + +export const AI_DEBUG_URL_PARAM = '__mf_devtools'; +export const AI_DEBUG_STORAGE_KEY = '__MF_DEVTOOLS__'; +export const AI_DEBUG_SNAPSHOT_KEY = '__MF_DEVTOOLS_MODULE_INFO__'; +export const AI_DEBUG_ENV_KEY = 'MF_ENV'; +export const AI_DEBUG_CONSOLE_KEY = '__MF_AI_DEBUG_CONSOLE__'; + +const PLUGIN_NAME = 'ai-proxy-remotes-runtime-plugin'; +const LOOPBACK_HOSTS = ['localhost', '127.0.0.1']; + +export type AIDebugUrlConfig = { + overrides: Record; + replace?: boolean; +}; + +export type AIDebugStoredConfig = { + overrides?: Record; + [key: string]: unknown; +}; + +export type AIDebugRuntimePluginOptions = { + /** Additional production hosts allowed as override targets. */ + allowedHosts?: string[]; + /** Defaults to `__mf_devtools`. */ + parameterName?: string; + /** Defaults to `__MF_DEVTOOLS__`. */ + storageKey?: string; + /** Set to false to disable a console enabled through the URL parameter. */ + console?: boolean | AIDebugConsoleOptions; +}; + +export type AIDebugConsoleOptions = { + /** Open the proxy panel immediately after mounting. */ + defaultOpen?: boolean; + /** Reload the host page after saving or clearing rules. Defaults to true. */ + reloadOnSave?: boolean; + /** Override the floating console stacking order. */ + zIndex?: number; +}; + +type AIDebugGlobal = typeof globalThis & { + location?: { href: string; reload?(): void }; + history?: { replaceState(data: unknown, unused: string, url?: string): void }; + sessionStorage?: Storage; + window?: { sessionStorage?: Storage }; + __FEDERATION__?: { + __GLOBAL_PLUGIN__?: ModuleFederationRuntimePlugin[]; + }; + __VMOK__?: AIDebugGlobal['__FEDERATION__']; +}; + +const isObject = (value: unknown): value is Record => + Boolean(value) && typeof value === 'object' && !Array.isArray(value); + +const getGlobal = (): AIDebugGlobal => globalThis as AIDebugGlobal; + +const getStorage = (target = getGlobal()): Storage | undefined => + target.sessionStorage ?? target.window?.sessionStorage; + +const getGlobalPlugins = (): ModuleFederationRuntimePlugin[] => { + const target = getGlobal(); + target.__FEDERATION__ ??= target.__VMOK__ ?? {}; + target.__VMOK__ ??= target.__FEDERATION__; + target.__FEDERATION__.__GLOBAL_PLUGIN__ ??= []; + return target.__FEDERATION__.__GLOBAL_PLUGIN__; +}; + +export const isAllowedManifestUrl = ( + value: string, + allowedHosts: string[] = [], +): boolean => { + try { + const url = new URL(value); + const hosts = new Set([...LOOPBACK_HOSTS, ...allowedHosts]); + return ( + (url.protocol === 'http:' || url.protocol === 'https:') && + hosts.has(url.hostname) && + url.pathname.endsWith('.json') && + !url.username && + !url.password + ); + } catch { + return false; + } +}; + +export const parseAIDebugUrlConfig = ( + value: string, + options: Pick = {}, +): AIDebugUrlConfig => { + const parsed: unknown = JSON.parse(value); + if (!isObject(parsed) || !isObject(parsed.overrides)) { + throw new Error('The overrides field must be an object.'); + } + if (parsed.replace !== undefined && typeof parsed.replace !== 'boolean') { + throw new Error('The replace field must be a boolean.'); + } + + const overrides: Record = {}; + for (const [name, manifestUrl] of Object.entries(parsed.overrides)) { + if (!name.trim()) { + throw new Error('A remote name cannot be empty.'); + } + if (manifestUrl === null) { + overrides[name] = null; + continue; + } + if ( + typeof manifestUrl !== 'string' || + !isAllowedManifestUrl(manifestUrl, options.allowedHosts) + ) { + throw new Error( + `The override for "${name}" must be a localhost or 127.0.0.1 JSON URL, or target an explicitly allowed host.`, + ); + } + overrides[name] = manifestUrl; + } + + return { + overrides, + ...(parsed.replace === true ? { replace: true } : {}), + }; +}; + +const readStoredConfig = ( + storage: Storage, + storageKey = AI_DEBUG_STORAGE_KEY, +): AIDebugStoredConfig => { + try { + const value = storage.getItem(storageKey); + const parsed: unknown = value ? JSON.parse(value) : {}; + return isObject(parsed) ? parsed : {}; + } catch { + return {}; + } +}; + +export const mergeAIDebugConfig = ( + current: AIDebugStoredConfig, + config: AIDebugUrlConfig, +): AIDebugStoredConfig => { + const overrides: Record = config.replace + ? {} + : { ...(current.overrides ?? {}) }; + + for (const [name, manifestUrl] of Object.entries(config.overrides)) { + if (manifestUrl === null) { + delete overrides[name]; + } else { + overrides[name] = manifestUrl; + } + } + + const next = { ...current }; + if (Object.keys(overrides).length) { + next.overrides = overrides; + } else { + delete next.overrides; + } + return next; +}; + +export const applyAIDebugUrlConfig = ( + options: AIDebugRuntimePluginOptions = {}, +): boolean => { + const target = getGlobal(); + const storage = getStorage(target); + if (!target.location || !storage) { + return false; + } + + const parameterName = options.parameterName ?? AI_DEBUG_URL_PARAM; + const storageKey = options.storageKey ?? AI_DEBUG_STORAGE_KEY; + const url = new URL(target.location.href); + if (!url.searchParams.has(parameterName)) { + return false; + } + + storage.setItem(AI_DEBUG_CONSOLE_KEY, 'true'); + const value = url.searchParams.get(parameterName); + if (!value) { + if (target.history) { + url.searchParams.delete(parameterName); + target.history.replaceState(null, '', url.href); + target.location.reload?.(); + } + return false; + } + + try { + const config = parseAIDebugUrlConfig(value, options); + const next = mergeAIDebugConfig( + readStoredConfig(storage, storageKey), + config, + ); + + if (Object.keys(next).length) { + storage.setItem(storageKey, JSON.stringify(next)); + } else { + storage.removeItem(storageKey); + } + storage.removeItem(AI_DEBUG_SNAPSHOT_KEY); + storage.setItem(AI_DEBUG_ENV_KEY, 'true'); + if (target.history) { + url.searchParams.delete(parameterName); + target.history.replaceState(null, '', url.href); + target.location.reload?.(); + } + return true; + } catch (error) { + target.console?.error( + '[Module Federation AI Debug] Invalid URL proxy config.', + error, + ); + return false; + } +}; + +export const isAIDebugConsoleEnabled = ( + options: AIDebugRuntimePluginOptions = {}, +): boolean => + options.console !== false && + getStorage()?.getItem(AI_DEBUG_CONSOLE_KEY) === 'true'; + +const isAIDebugEnabled = (): boolean => + getStorage()?.getItem(AI_DEBUG_CONSOLE_KEY) === 'true'; + +const readOverrides = (storageKey: string): Record => { + const storage = getStorage(); + if (!storage) { + return {}; + } + const config = readStoredConfig(storage, storageKey); + const source = isObject(config.overrides) ? config.overrides : config; + return Object.fromEntries( + Object.entries(source).filter( + (entry): entry is [string, string] => + typeof entry[1] === 'string' && Boolean(entry[1]), + ), + ); +}; + +type AIDebugRemote = { + name: string; + alias?: string; + entry?: string; + version?: string; +}; + +const applyRemoteOverride = ( + remote: AIDebugRemote, + storageKey: string, +): void => { + if (!isAIDebugEnabled()) { + return; + } + const overrides = readOverrides(storageKey); + const override = overrides[remote.name] ?? overrides[remote.alias ?? '']; + if (!override) { + return; + } + + if (/^(https?:)?\/\//.test(override)) { + Reflect.deleteProperty(remote, 'version'); + remote.entry = override; + } else { + Reflect.deleteProperty(remote, 'entry'); + remote.version = override; + } +}; + +const createGlobalPlugin = ( + options: AIDebugRuntimePluginOptions = {}, +): ModuleFederationRuntimePlugin => { + const storageKey = options.storageKey ?? AI_DEBUG_STORAGE_KEY; + + return { + name: PLUGIN_NAME, + beforeRegisterRemote(args) { + applyRemoteOverride(args.remote, storageKey); + return args; + }, + beforeLoadRemoteSnapshot(args) { + if (args.options.inBrowser === false) { + return; + } + applyRemoteOverride(args.moduleInfo, storageKey); + }, + }; +}; + +export function aiDebugRuntimePlugin( + options: AIDebugRuntimePluginOptions = {}, +): ModuleFederationRuntimePlugin { + applyAIDebugUrlConfig(options); + const plugins = getGlobalPlugins(); + const existing = plugins.find((plugin) => plugin.name === PLUGIN_NAME); + if (existing) { + return existing; + } + const plugin = createGlobalPlugin(options); + plugins.push(plugin); + return plugin; +} diff --git a/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/index.ts b/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/index.ts new file mode 100644 index 00000000000..e844b65a200 --- /dev/null +++ b/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/index.ts @@ -0,0 +1,20 @@ +import { + aiDebugRuntimePlugin, + isAIDebugConsoleEnabled, + type AIDebugRuntimePluginOptions, +} from './core'; + +export default function aiDebugPlugin( + options: AIDebugRuntimePluginOptions = {}, +) { + const plugin = aiDebugRuntimePlugin(options); + if (isAIDebugConsoleEnabled(options)) { + void import('./console/mount').then(({ mountAIDebugConsole }) => + mountAIDebugConsole( + typeof options.console === 'object' ? options.console : {}, + options, + ), + ); + } + return plugin; +} diff --git a/packages/runtime-plugins/module-federation-ai-proxy-remotes/tsconfig.json b/packages/runtime-plugins/module-federation-ai-proxy-remotes/tsconfig.json new file mode 100644 index 00000000000..f27bedf7fc3 --- /dev/null +++ b/packages/runtime-plugins/module-federation-ai-proxy-remotes/tsconfig.json @@ -0,0 +1,28 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "skipLibCheck": true, + "skipDefaultLibCheck": true, + "declaration": true + }, + "files": [], + "include": [], + "exclude": ["node_modules"], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/runtime-plugins/module-federation-ai-proxy-remotes/tsconfig.lib.json b/packages/runtime-plugins/module-federation-ai-proxy-remotes/tsconfig.lib.json new file mode 100644 index 00000000000..33eca2c2cdf --- /dev/null +++ b/packages/runtime-plugins/module-federation-ai-proxy-remotes/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/packages/runtime-plugins/module-federation-ai-proxy-remotes/tsconfig.spec.json b/packages/runtime-plugins/module-federation-ai-proxy-remotes/tsconfig.spec.json new file mode 100644 index 00000000000..24f47e6001f --- /dev/null +++ b/packages/runtime-plugins/module-federation-ai-proxy-remotes/tsconfig.spec.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts", + "__tests__/**/*.ts" + ] +} diff --git a/packages/runtime-plugins/module-federation-ai-proxy-remotes/tsdown.config.ts b/packages/runtime-plugins/module-federation-ai-proxy-remotes/tsdown.config.ts new file mode 100644 index 00000000000..0c5a7537c10 --- /dev/null +++ b/packages/runtime-plugins/module-federation-ai-proxy-remotes/tsdown.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from 'tsdown'; +import { + createDualFormatConfig, + packageDirFromMetaUrl, +} from '../../../tools/scripts/tsdown/config-helpers.mjs'; + +const packageDir = packageDirFromMetaUrl(import.meta.url); + +export default defineConfig([ + { + ...createDualFormatConfig({ + name: 'ai-proxy-remotes-runtime-plugin-build', + packageDir, + entry: { + index: 'src/index.ts', + core: 'src/core.ts', + console: 'src/console/index.ts', + }, + external: ['@module-federation/*'], + dts: { + resolver: 'tsc', + }, + copyLicense: true, + unbundle: true, + }), + sourcemap: false, + minify: true, + }, +]); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0183592fba..5fb29971d8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4219,6 +4219,12 @@ importers: specifier: workspace:* version: link:../sdk + packages/runtime-plugins/module-federation-ai-proxy-remotes: + devDependencies: + '@module-federation/runtime-tools': + specifier: workspace:* + version: link:../../runtime-tools + packages/runtime-plugins/inject-external-runtime-core-plugin: devDependencies: '@module-federation/runtime-tools': From 4a97dafd75b837d13943a1e1e5d8a43bbe6f22aa Mon Sep 17 00:00:00 2001 From: zhn Date: Wed, 12 Aug 2026 20:20:46 +0800 Subject: [PATCH 2/7] fix(runtime-plugins): update AI remote proxy plugin --- .changeset/fresh-spiders-proxy.md | 6 ------ .../module-federation-ai-proxy-remotes.mdx | 12 ++++++++---- .../module-federation-ai-proxy-remotes.mdx | 12 ++++++++---- .../module-federation-ai-proxy-remotes/README.md | 12 ++++++++---- .../__tests__/index.spec.ts | 10 ++++++++++ .../package.json | 2 +- .../src/core.ts | 16 ++++++++-------- 7 files changed, 43 insertions(+), 27 deletions(-) delete mode 100644 .changeset/fresh-spiders-proxy.md diff --git a/.changeset/fresh-spiders-proxy.md b/.changeset/fresh-spiders-proxy.md deleted file mode 100644 index d3f1a236631..00000000000 --- a/.changeset/fresh-spiders-proxy.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"module-federation-ai-proxy-remotes": patch ---- - -Add an AI-oriented Runtime Plugin for overriding remotes through a lightweight -proxy console or URL configuration, with rules scoped to session storage. diff --git a/apps/website-new/docs/en/plugin/plugins/module-federation-ai-proxy-remotes.mdx b/apps/website-new/docs/en/plugin/plugins/module-federation-ai-proxy-remotes.mdx index e620c0f073f..65c8a754380 100644 --- a/apps/website-new/docs/en/plugin/plugins/module-federation-ai-proxy-remotes.mdx +++ b/apps/website-new/docs/en/plugin/plugins/module-federation-ai-proxy-remotes.mdx @@ -69,9 +69,13 @@ const config = { }, }; -const url = new URL('https://host.example.com/checkout'); -url.searchParams.set('__mf_devtools', JSON.stringify(config)); -console.log(url.href); +const createDebugUrl = (hostUrl, debugConfig) => { + const url = new URL(hostUrl); + url.searchParams.set('__mf_devtools', JSON.stringify(debugConfig)); + return url.href; +}; + +console.log(createDebugUrl('https://host.example.com/checkout', config)); ``` The output is: @@ -83,7 +87,7 @@ https://host.example.com/checkout?__mf_devtools=%7B%22overrides%22%3A%7B%22check The AI agent should then open this complete URL in the browser. After the page refreshes, the `checkout` Remote in the Host uses the local Manifest. Remote names and aliases are both supported as `overrides` keys. :::tip -`URLSearchParams` handles URL encoding. When manually building the query string, apply `encodeURIComponent` to `JSON.stringify(config)` exactly once. +Pass the raw `JSON.stringify` result directly to `searchParams.set`. Do not call `encodeURIComponent`; `URLSearchParams` performs the URL encoding. A correct parameter value starts with `%7B`. `%257B` means the JSON was encoded twice and is invalid. ::: ## Proxy Domain Security diff --git a/apps/website-new/docs/zh/plugin/plugins/module-federation-ai-proxy-remotes.mdx b/apps/website-new/docs/zh/plugin/plugins/module-federation-ai-proxy-remotes.mdx index ea18a893da4..506ca141deb 100644 --- a/apps/website-new/docs/zh/plugin/plugins/module-federation-ai-proxy-remotes.mdx +++ b/apps/website-new/docs/zh/plugin/plugins/module-federation-ai-proxy-remotes.mdx @@ -69,9 +69,13 @@ const config = { }, }; -const url = new URL('https://host.example.com/checkout'); -url.searchParams.set('__mf_devtools', JSON.stringify(config)); -console.log(url.href); +const createDebugUrl = (hostUrl, debugConfig) => { + const url = new URL(hostUrl); + url.searchParams.set('__mf_devtools', JSON.stringify(debugConfig)); + return url.href; +}; + +console.log(createDebugUrl('https://host.example.com/checkout', config)); ``` 输出结果是: @@ -83,7 +87,7 @@ https://host.example.com/checkout?__mf_devtools=%7B%22overrides%22%3A%7B%22check AI 最后需要在浏览器中打开这个完整 URL。页面刷新后,Host 中名为 `checkout` 的 Remote 将使用本地 Manifest。Remote 的 `name` 和 `alias` 均可作为 `overrides` 的键。 :::tip -`URLSearchParams` 会自动处理 URL 编码。如果手动拼接查询字符串,只需对 `JSON.stringify(config)` 的结果执行一次 `encodeURIComponent`。 +将 `JSON.stringify` 的原始结果直接传给 `searchParams.set`,不要调用 `encodeURIComponent`,URL 编码由 `URLSearchParams` 完成。正确的参数值以 `%7B` 开头;出现 `%257B` 表示 JSON 被编码了两次,该 URL 无效。 ::: ## 代理域名安全策略 diff --git a/packages/runtime-plugins/module-federation-ai-proxy-remotes/README.md b/packages/runtime-plugins/module-federation-ai-proxy-remotes/README.md index 02786ca1a7f..8f425ef7f4b 100644 --- a/packages/runtime-plugins/module-federation-ai-proxy-remotes/README.md +++ b/packages/runtime-plugins/module-federation-ai-proxy-remotes/README.md @@ -46,14 +46,18 @@ const config = { }, }; -const url = new URL('https://host.example.com/checkout'); -url.searchParams.set('__mf_devtools', JSON.stringify(config)); -console.log(url.href); +const createDebugUrl = (hostUrl, debugConfig) => { + const url = new URL(hostUrl); + url.searchParams.set('__mf_devtools', JSON.stringify(debugConfig)); + return url.href; +}; + +console.log(createDebugUrl('https://host.example.com/checkout', config)); ``` Open the generated URL to enable debugging and save the rule. Remote names and aliases are both supported. -`URLSearchParams` handles URL encoding. When manually building the query string, apply `encodeURIComponent` to `JSON.stringify(config)` exactly once. +Pass the raw `JSON.stringify` result directly to `searchParams.set`. Do not call `encodeURIComponent`; `URLSearchParams` performs the URL encoding. A correct encoded value starts with `%7B`, not `%257B`. ## Proxy domain security diff --git a/packages/runtime-plugins/module-federation-ai-proxy-remotes/__tests__/index.spec.ts b/packages/runtime-plugins/module-federation-ai-proxy-remotes/__tests__/index.spec.ts index fa6aa4a544a..5c42eca98e8 100644 --- a/packages/runtime-plugins/module-federation-ai-proxy-remotes/__tests__/index.spec.ts +++ b/packages/runtime-plugins/module-federation-ai-proxy-remotes/__tests__/index.spec.ts @@ -151,6 +151,16 @@ describe('module-federation-ai-proxy-remotes', () => { expect(document.getElementById(AI_DEBUG_CONSOLE_ELEMENT_ID)).not.toBeNull(); }); + it('removes the URL parameter when its config is invalid', () => { + history.replaceState(null, '', '/host?keep=yes&__mf_devtools=invalid'); + + expect(applyAIDebugUrlConfig()).toBe(false); + + const url = new URL(location.href); + expect(url.searchParams.has('__mf_devtools')).toBe(false); + expect(url.searchParams.get('keep')).toBe('yes'); + }); + it('globally overrides a remote during registration and snapshot loading', () => { sessionStorage.setItem( AI_DEBUG_STORAGE_KEY, diff --git a/packages/runtime-plugins/module-federation-ai-proxy-remotes/package.json b/packages/runtime-plugins/module-federation-ai-proxy-remotes/package.json index e8078b8136e..e3a8e186e4c 100644 --- a/packages/runtime-plugins/module-federation-ai-proxy-remotes/package.json +++ b/packages/runtime-plugins/module-federation-ai-proxy-remotes/package.json @@ -1,6 +1,6 @@ { "name": "module-federation-ai-proxy-remotes", - "version": "0.1.0", + "version": "0.1.1", "type": "module", "license": "MIT", "description": "A Module Federation runtime plugin for AI-driven local remote debugging", diff --git a/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/core.ts b/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/core.ts index 66db299794b..5ff72046cf5 100644 --- a/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/core.ts +++ b/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/core.ts @@ -179,12 +179,15 @@ export const applyAIDebugUrlConfig = ( storage.setItem(AI_DEBUG_CONSOLE_KEY, 'true'); const value = url.searchParams.get(parameterName); - if (!value) { + url.searchParams.delete(parameterName); + const finishConsumption = () => { if (target.history) { - url.searchParams.delete(parameterName); target.history.replaceState(null, '', url.href); - target.location.reload?.(); + target.location?.reload?.(); } + }; + if (!value) { + finishConsumption(); return false; } @@ -202,17 +205,14 @@ export const applyAIDebugUrlConfig = ( } storage.removeItem(AI_DEBUG_SNAPSHOT_KEY); storage.setItem(AI_DEBUG_ENV_KEY, 'true'); - if (target.history) { - url.searchParams.delete(parameterName); - target.history.replaceState(null, '', url.href); - target.location.reload?.(); - } + finishConsumption(); return true; } catch (error) { target.console?.error( '[Module Federation AI Debug] Invalid URL proxy config.', error, ); + finishConsumption(); return false; } }; From 4e50746b1f493047688ea643fbd9fb560f8a7896 Mon Sep 17 00:00:00 2001 From: zhn Date: Wed, 12 Aug 2026 20:32:36 +0800 Subject: [PATCH 3/7] docs(runtime-plugins): simplify AI proxy URL example --- .../en/plugin/plugins/module-federation-ai-proxy-remotes.mdx | 4 ---- .../zh/plugin/plugins/module-federation-ai-proxy-remotes.mdx | 4 ---- .../module-federation-ai-proxy-remotes/README.md | 2 -- 3 files changed, 10 deletions(-) diff --git a/apps/website-new/docs/en/plugin/plugins/module-federation-ai-proxy-remotes.mdx b/apps/website-new/docs/en/plugin/plugins/module-federation-ai-proxy-remotes.mdx index 65c8a754380..02c6191bbf9 100644 --- a/apps/website-new/docs/en/plugin/plugins/module-federation-ai-proxy-remotes.mdx +++ b/apps/website-new/docs/en/plugin/plugins/module-federation-ai-proxy-remotes.mdx @@ -86,10 +86,6 @@ https://host.example.com/checkout?__mf_devtools=%7B%22overrides%22%3A%7B%22check The AI agent should then open this complete URL in the browser. After the page refreshes, the `checkout` Remote in the Host uses the local Manifest. Remote names and aliases are both supported as `overrides` keys. -:::tip -Pass the raw `JSON.stringify` result directly to `searchParams.set`. Do not call `encodeURIComponent`; `URLSearchParams` performs the URL encoding. A correct parameter value starts with `%7B`. `%257B` means the JSON was encoded twice and is invalid. -::: - ## Proxy Domain Security By default, the plugin only accepts manifest URLs hosted on `localhost` or `127.0.0.1`. Every override URL must: diff --git a/apps/website-new/docs/zh/plugin/plugins/module-federation-ai-proxy-remotes.mdx b/apps/website-new/docs/zh/plugin/plugins/module-federation-ai-proxy-remotes.mdx index 506ca141deb..2b8a5b479e8 100644 --- a/apps/website-new/docs/zh/plugin/plugins/module-federation-ai-proxy-remotes.mdx +++ b/apps/website-new/docs/zh/plugin/plugins/module-federation-ai-proxy-remotes.mdx @@ -86,10 +86,6 @@ https://host.example.com/checkout?__mf_devtools=%7B%22overrides%22%3A%7B%22check AI 最后需要在浏览器中打开这个完整 URL。页面刷新后,Host 中名为 `checkout` 的 Remote 将使用本地 Manifest。Remote 的 `name` 和 `alias` 均可作为 `overrides` 的键。 -:::tip -将 `JSON.stringify` 的原始结果直接传给 `searchParams.set`,不要调用 `encodeURIComponent`,URL 编码由 `URLSearchParams` 完成。正确的参数值以 `%7B` 开头;出现 `%257B` 表示 JSON 被编码了两次,该 URL 无效。 -::: - ## 代理域名安全策略 默认只允许代理到 `localhost` 或 `127.0.0.1` 上的 Manifest。每个代理地址必须满足以下条件: diff --git a/packages/runtime-plugins/module-federation-ai-proxy-remotes/README.md b/packages/runtime-plugins/module-federation-ai-proxy-remotes/README.md index 8f425ef7f4b..5a49c14820b 100644 --- a/packages/runtime-plugins/module-federation-ai-proxy-remotes/README.md +++ b/packages/runtime-plugins/module-federation-ai-proxy-remotes/README.md @@ -57,8 +57,6 @@ console.log(createDebugUrl('https://host.example.com/checkout', config)); Open the generated URL to enable debugging and save the rule. Remote names and aliases are both supported. -Pass the raw `JSON.stringify` result directly to `searchParams.set`. Do not call `encodeURIComponent`; `URLSearchParams` performs the URL encoding. A correct encoded value starts with `%7B`, not `%257B`. - ## Proxy domain security By default, the plugin only accepts manifest URLs hosted on `localhost` or `127.0.0.1`. Every override URL must: From 2aab6183ec9017a3f7ff1a569b908b0e7141c327 Mon Sep 17 00:00:00 2001 From: zhn Date: Thu, 13 Aug 2026 10:42:34 +0800 Subject: [PATCH 4/7] docs(runtime-plugins): fix AI proxy URL encoding --- .../module-federation-ai-proxy-remotes.mdx | 27 ++++++++++--------- .../module-federation-ai-proxy-remotes.mdx | 27 ++++++++++--------- .../README.md | 26 +++++++++--------- 3 files changed, 41 insertions(+), 39 deletions(-) diff --git a/apps/website-new/docs/en/plugin/plugins/module-federation-ai-proxy-remotes.mdx b/apps/website-new/docs/en/plugin/plugins/module-federation-ai-proxy-remotes.mdx index 02c6191bbf9..9ca78f146b8 100644 --- a/apps/website-new/docs/en/plugin/plugins/module-federation-ai-proxy-remotes.mdx +++ b/apps/website-new/docs/en/plugin/plugins/module-federation-ai-proxy-remotes.mdx @@ -63,19 +63,20 @@ Given: The AI agent can build the complete debugging URL with: ```js -const config = { - overrides: { - checkout: 'http://localhost:3001/mf-manifest.json', - }, -}; - -const createDebugUrl = (hostUrl, debugConfig) => { - const url = new URL(hostUrl); - url.searchParams.set('__mf_devtools', JSON.stringify(debugConfig)); - return url.href; -}; - -console.log(createDebugUrl('https://host.example.com/checkout', config)); +const remoteName = 'checkout'; +const localManifestUrl = 'http://localhost:3001/mf-manifest.json'; +const debugConfig = encodeURIComponent( + JSON.stringify({ + overrides: { + [remoteName]: localManifestUrl, + }, + }), +); + +const debugUrl = + `https://host.example.com/checkout?__mf_devtools=${debugConfig}`; + +console.log(debugUrl); ``` The output is: diff --git a/apps/website-new/docs/zh/plugin/plugins/module-federation-ai-proxy-remotes.mdx b/apps/website-new/docs/zh/plugin/plugins/module-federation-ai-proxy-remotes.mdx index 2b8a5b479e8..8e63fc97b08 100644 --- a/apps/website-new/docs/zh/plugin/plugins/module-federation-ai-proxy-remotes.mdx +++ b/apps/website-new/docs/zh/plugin/plugins/module-federation-ai-proxy-remotes.mdx @@ -63,19 +63,20 @@ https://host.example.com/checkout,并把 checkout 代理到这个本地 Manife AI 可以使用下面的代码拼出完整调试 URL: ```js -const config = { - overrides: { - checkout: 'http://localhost:3001/mf-manifest.json', - }, -}; - -const createDebugUrl = (hostUrl, debugConfig) => { - const url = new URL(hostUrl); - url.searchParams.set('__mf_devtools', JSON.stringify(debugConfig)); - return url.href; -}; - -console.log(createDebugUrl('https://host.example.com/checkout', config)); +const remoteName = 'checkout'; +const localManifestUrl = 'http://localhost:3001/mf-manifest.json'; +const debugConfig = encodeURIComponent( + JSON.stringify({ + overrides: { + [remoteName]: localManifestUrl, + }, + }), +); + +const debugUrl = + `https://host.example.com/checkout?__mf_devtools=${debugConfig}`; + +console.log(debugUrl); ``` 输出结果是: diff --git a/packages/runtime-plugins/module-federation-ai-proxy-remotes/README.md b/packages/runtime-plugins/module-federation-ai-proxy-remotes/README.md index 5a49c14820b..6aaea5e3741 100644 --- a/packages/runtime-plugins/module-federation-ai-proxy-remotes/README.md +++ b/packages/runtime-plugins/module-federation-ai-proxy-remotes/README.md @@ -40,19 +40,19 @@ The page refreshes and removes the parameter from the address bar. Debugging rem ## Configure remotes from the URL ```js -const config = { - overrides: { - checkout: 'http://localhost:3001/mf-manifest.json', - }, -}; - -const createDebugUrl = (hostUrl, debugConfig) => { - const url = new URL(hostUrl); - url.searchParams.set('__mf_devtools', JSON.stringify(debugConfig)); - return url.href; -}; - -console.log(createDebugUrl('https://host.example.com/checkout', config)); +const remoteName = 'checkout'; +const localManifestUrl = 'http://localhost:3001/mf-manifest.json'; +const debugConfig = encodeURIComponent( + JSON.stringify({ + overrides: { + [remoteName]: localManifestUrl, + }, + }), +); + +const debugUrl = `https://host.example.com/checkout?__mf_devtools=${debugConfig}`; + +console.log(debugUrl); ``` Open the generated URL to enable debugging and save the rule. Remote names and aliases are both supported. From d7bf86fdc0fe42afee0268fdb376ed3628607fa1 Mon Sep 17 00:00:00 2001 From: zhangHongEn Date: Thu, 13 Aug 2026 15:49:39 +0800 Subject: [PATCH 5/7] fix(runtime-plugins): remove AI proxy runtime-tools dependency --- .../package.json | 8 +------ .../src/core.ts | 23 ++++++++++++++----- pnpm-lock.yaml | 6 +---- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/packages/runtime-plugins/module-federation-ai-proxy-remotes/package.json b/packages/runtime-plugins/module-federation-ai-proxy-remotes/package.json index e3a8e186e4c..92452320415 100644 --- a/packages/runtime-plugins/module-federation-ai-proxy-remotes/package.json +++ b/packages/runtime-plugins/module-federation-ai-proxy-remotes/package.json @@ -1,6 +1,6 @@ { "name": "module-federation-ai-proxy-remotes", - "version": "0.1.1", + "version": "0.1.3", "type": "module", "license": "MIT", "description": "A Module Federation runtime plugin for AI-driven local remote debugging", @@ -72,12 +72,6 @@ ] } }, - "peerDependencies": { - "@module-federation/runtime-tools": "workspace:*" - }, - "devDependencies": { - "@module-federation/runtime-tools": "workspace:*" - }, "scripts": { "build": "tsdown --config tsdown.config.ts --filter ai-proxy-remotes-runtime-plugin-build", "test": "NODE_OPTIONS=--experimental-vm-modules pnpm exec jest --config jest.config.cjs", diff --git a/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/core.ts b/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/core.ts index 5ff72046cf5..b3f4c9cf031 100644 --- a/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/core.ts +++ b/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/core.ts @@ -1,5 +1,3 @@ -import type { ModuleFederationRuntimePlugin } from '@module-federation/runtime-tools/runtime-core'; - export const AI_DEBUG_URL_PARAM = '__mf_devtools'; export const AI_DEBUG_STORAGE_KEY = '__MF_DEVTOOLS__'; export const AI_DEBUG_SNAPSHOT_KEY = '__MF_DEVTOOLS_MODULE_INFO__'; @@ -45,7 +43,7 @@ type AIDebugGlobal = typeof globalThis & { sessionStorage?: Storage; window?: { sessionStorage?: Storage }; __FEDERATION__?: { - __GLOBAL_PLUGIN__?: ModuleFederationRuntimePlugin[]; + __GLOBAL_PLUGIN__?: AIDebugRuntimePlugin[]; }; __VMOK__?: AIDebugGlobal['__FEDERATION__']; }; @@ -58,7 +56,7 @@ const getGlobal = (): AIDebugGlobal => globalThis as AIDebugGlobal; const getStorage = (target = getGlobal()): Storage | undefined => target.sessionStorage ?? target.window?.sessionStorage; -const getGlobalPlugins = (): ModuleFederationRuntimePlugin[] => { +const getGlobalPlugins = (): AIDebugRuntimePlugin[] => { const target = getGlobal(); target.__FEDERATION__ ??= target.__VMOK__ ?? {}; target.__VMOK__ ??= target.__FEDERATION__; @@ -248,6 +246,19 @@ type AIDebugRemote = { version?: string; }; +type AIDebugRuntimePlugin = { + name: string; + beforeRegisterRemote(args: { remote: AIDebugRemote; origin: unknown }): { + remote: AIDebugRemote; + origin: unknown; + }; + beforeLoadRemoteSnapshot(args: { + options: { inBrowser?: boolean }; + moduleInfo: AIDebugRemote; + origin: unknown; + }): void; +}; + const applyRemoteOverride = ( remote: AIDebugRemote, storageKey: string, @@ -272,7 +283,7 @@ const applyRemoteOverride = ( const createGlobalPlugin = ( options: AIDebugRuntimePluginOptions = {}, -): ModuleFederationRuntimePlugin => { +): AIDebugRuntimePlugin => { const storageKey = options.storageKey ?? AI_DEBUG_STORAGE_KEY; return { @@ -292,7 +303,7 @@ const createGlobalPlugin = ( export function aiDebugRuntimePlugin( options: AIDebugRuntimePluginOptions = {}, -): ModuleFederationRuntimePlugin { +): AIDebugRuntimePlugin { applyAIDebugUrlConfig(options); const plugins = getGlobalPlugins(); const existing = plugins.find((plugin) => plugin.name === PLUGIN_NAME); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5fb29971d8b..b4fc04fe000 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4219,11 +4219,7 @@ importers: specifier: workspace:* version: link:../sdk - packages/runtime-plugins/module-federation-ai-proxy-remotes: - devDependencies: - '@module-federation/runtime-tools': - specifier: workspace:* - version: link:../../runtime-tools + packages/runtime-plugins/module-federation-ai-proxy-remotes: {} packages/runtime-plugins/inject-external-runtime-core-plugin: devDependencies: From aac379da38bc2e4f1ecc5b03dfa86069d05eb834 Mon Sep 17 00:00:00 2001 From: zhn Date: Thu, 13 Aug 2026 18:15:14 +0800 Subject: [PATCH 6/7] fix(runtime-plugins): harden AI proxy debug URLs --- .../module-federation-ai-proxy-remotes.mdx | 36 +++++++---- .../module-federation-ai-proxy-remotes.mdx | 36 +++++++---- .../README.md | 27 +++++++-- .../__tests__/index.spec.ts | 59 +++++++++++++++++-- .../package.json | 2 +- .../src/core.ts | 29 ++++++++- .../src/index.ts | 24 ++++++++ 7 files changed, 176 insertions(+), 37 deletions(-) diff --git a/apps/website-new/docs/en/plugin/plugins/module-federation-ai-proxy-remotes.mdx b/apps/website-new/docs/en/plugin/plugins/module-federation-ai-proxy-remotes.mdx index 9ca78f146b8..4f82f9e0522 100644 --- a/apps/website-new/docs/en/plugin/plugins/module-federation-ai-proxy-remotes.mdx +++ b/apps/website-new/docs/en/plugin/plugins/module-federation-ai-proxy-remotes.mdx @@ -38,7 +38,7 @@ export default { Add `__mf_devtools` to the host URL: ```text -https://host.example.com/checkout?__mf_devtools +https://host.example.com/remote?__mf_devtools ``` The page refreshes and removes the parameter from the address bar. Debugging remains enabled for the current tab session. Use **Disable debug** in the floating console to turn it off; saved rules are retained until the tab session ends. @@ -48,24 +48,27 @@ The page refreshes and removes the parameter from the address bar. Debugging rem Give your AI coding agent a request such as: ```text -Start the checkout remote locally, find its mf-manifest.json URL, and open -https://host.example.com/checkout with checkout proxied to that local manifest. +Start the remote remote locally, find its mf-manifest.json URL, and open +https://host.example.com/remote with remote proxied to that local manifest. ``` ### Complete Debugging URL Example Given: -- Host page: `https://host.example.com/checkout`; -- Remote name: `checkout`; and +- Host page: `https://host.example.com/remote`; +- Remote name: `remote`; and - local Manifest: `http://localhost:3001/mf-manifest.json`. -The AI agent can build the complete debugging URL with: +The AI agent can build the complete debugging URL directly: ```js -const remoteName = 'checkout'; +const remoteName = 'remote'; const localManifestUrl = 'http://localhost:3001/mf-manifest.json'; -const debugConfig = encodeURIComponent( +const hostUrl = new URL('https://host.example.com/remote'); + +hostUrl.searchParams.set( + '__mf_devtools', JSON.stringify({ overrides: { [remoteName]: localManifestUrl, @@ -73,8 +76,17 @@ const debugConfig = encodeURIComponent( }), ); -const debugUrl = - `https://host.example.com/checkout?__mf_devtools=${debugConfig}`; +console.log(hostUrl.href); +``` + +Or use the package helper: + +```js +import { generateAIDebugUrl } from 'module-federation-ai-proxy-remotes'; + +const debugUrl = generateAIDebugUrl('https://host.example.com/remote', { + remote: 'http://localhost:3001/mf-manifest.json', +}); console.log(debugUrl); ``` @@ -82,10 +94,10 @@ console.log(debugUrl); The output is: ```text -https://host.example.com/checkout?__mf_devtools=%7B%22overrides%22%3A%7B%22checkout%22%3A%22http%3A%2F%2Flocalhost%3A3001%2Fmf-manifest.json%22%7D%7D +https://host.example.com/remote?__mf_devtools=%7B%22overrides%22%3A%7B%22remote%22%3A%22http%3A%2F%2Flocalhost%3A3001%2Fmf-manifest.json%22%7D%7D ``` -The AI agent should then open this complete URL in the browser. After the page refreshes, the `checkout` Remote in the Host uses the local Manifest. Remote names and aliases are both supported as `overrides` keys. +The AI agent should then open this complete URL in the browser. After the page refreshes, the `remote` Remote in the Host uses the local Manifest. Remote names and aliases are both supported as `overrides` keys. ## Proxy Domain Security diff --git a/apps/website-new/docs/zh/plugin/plugins/module-federation-ai-proxy-remotes.mdx b/apps/website-new/docs/zh/plugin/plugins/module-federation-ai-proxy-remotes.mdx index 8e63fc97b08..294a5651a9c 100644 --- a/apps/website-new/docs/zh/plugin/plugins/module-federation-ai-proxy-remotes.mdx +++ b/apps/website-new/docs/zh/plugin/plugins/module-federation-ai-proxy-remotes.mdx @@ -38,7 +38,7 @@ export default { 在宿主 URL 中加入 `__mf_devtools`: ```text -https://host.example.com/checkout?__mf_devtools +https://host.example.com/remote?__mf_devtools ``` 页面会刷新并从地址栏移除该参数。调试开关会在当前标签页会话内保留,无需再次添加参数。点击浮动控制台中的 **Disable debug** 可以关闭调试;关闭标签页后,已保存的代理规则会随会话结束而清除。 @@ -48,24 +48,27 @@ https://host.example.com/checkout?__mf_devtools 可以向 AI 编程 Agent 提出类似下面的请求: ```text -在本地启动 checkout remote,找到它的 mf-manifest.json 地址,然后打开 -https://host.example.com/checkout,并把 checkout 代理到这个本地 Manifest。 +在本地启动 remote remote,找到它的 mf-manifest.json 地址,然后打开 +https://host.example.com/remote,并把 remote 代理到这个本地 Manifest。 ``` ### 完整调试 URL 示例 假设: -- Host 页面是 `https://host.example.com/checkout`; -- Remote 名称是 `checkout`; +- Host 页面是 `https://host.example.com/remote`; +- Remote 名称是 `remote`; - 本地 Manifest 是 `http://localhost:3001/mf-manifest.json`。 -AI 可以使用下面的代码拼出完整调试 URL: +AI 可以直接使用下面的代码拼出完整调试 URL: ```js -const remoteName = 'checkout'; +const remoteName = 'remote'; const localManifestUrl = 'http://localhost:3001/mf-manifest.json'; -const debugConfig = encodeURIComponent( +const hostUrl = new URL('https://host.example.com/remote'); + +hostUrl.searchParams.set( + '__mf_devtools', JSON.stringify({ overrides: { [remoteName]: localManifestUrl, @@ -73,8 +76,17 @@ const debugConfig = encodeURIComponent( }), ); -const debugUrl = - `https://host.example.com/checkout?__mf_devtools=${debugConfig}`; +console.log(hostUrl.href); +``` + +也可以使用包提供的辅助函数: + +```js +import { generateAIDebugUrl } from 'module-federation-ai-proxy-remotes'; + +const debugUrl = generateAIDebugUrl('https://host.example.com/remote', { + remote: 'http://localhost:3001/mf-manifest.json', +}); console.log(debugUrl); ``` @@ -82,10 +94,10 @@ console.log(debugUrl); 输出结果是: ```text -https://host.example.com/checkout?__mf_devtools=%7B%22overrides%22%3A%7B%22checkout%22%3A%22http%3A%2F%2Flocalhost%3A3001%2Fmf-manifest.json%22%7D%7D +https://host.example.com/remote?__mf_devtools=%7B%22overrides%22%3A%7B%22remote%22%3A%22http%3A%2F%2Flocalhost%3A3001%2Fmf-manifest.json%22%7D%7D ``` -AI 最后需要在浏览器中打开这个完整 URL。页面刷新后,Host 中名为 `checkout` 的 Remote 将使用本地 Manifest。Remote 的 `name` 和 `alias` 均可作为 `overrides` 的键。 +AI 最后需要在浏览器中打开这个完整 URL。页面刷新后,Host 中名为 `remote` 的 Remote 将使用本地 Manifest。Remote 的 `name` 和 `alias` 均可作为 `overrides` 的键。 ## 代理域名安全策略 diff --git a/packages/runtime-plugins/module-federation-ai-proxy-remotes/README.md b/packages/runtime-plugins/module-federation-ai-proxy-remotes/README.md index 6aaea5e3741..7718c53da54 100644 --- a/packages/runtime-plugins/module-federation-ai-proxy-remotes/README.md +++ b/packages/runtime-plugins/module-federation-ai-proxy-remotes/README.md @@ -32,17 +32,22 @@ export default { Add `__mf_devtools` to the host URL: ```text -https://host.example.com/checkout?__mf_devtools +https://host.example.com/remote?__mf_devtools ``` The page refreshes and removes the parameter from the address bar. Debugging remains enabled for the current tab session. Use **Disable debug** in the floating console to turn it off; saved rules are retained until the tab session ends. ## Configure remotes from the URL +Generate the URL directly: + ```js -const remoteName = 'checkout'; +const remoteName = 'remote'; const localManifestUrl = 'http://localhost:3001/mf-manifest.json'; -const debugConfig = encodeURIComponent( +const hostUrl = new URL('https://host.example.com/remote'); + +hostUrl.searchParams.set( + '__mf_devtools', JSON.stringify({ overrides: { [remoteName]: localManifestUrl, @@ -50,13 +55,23 @@ const debugConfig = encodeURIComponent( }), ); -const debugUrl = `https://host.example.com/checkout?__mf_devtools=${debugConfig}`; - -console.log(debugUrl); +console.log(hostUrl.href); ``` Open the generated URL to enable debugging and save the rule. Remote names and aliases are both supported. +Or use the package helper: + +```js +import { generateAIDebugUrl } from 'module-federation-ai-proxy-remotes'; + +const debugUrl = generateAIDebugUrl('https://host.example.com/remote', { + remote: 'http://localhost:3001/mf-manifest.json', +}); + +console.log(debugUrl); +``` + ## Proxy domain security By default, the plugin only accepts manifest URLs hosted on `localhost` or `127.0.0.1`. Every override URL must: diff --git a/packages/runtime-plugins/module-federation-ai-proxy-remotes/__tests__/index.spec.ts b/packages/runtime-plugins/module-federation-ai-proxy-remotes/__tests__/index.spec.ts index 5c42eca98e8..c810fc2e0e4 100644 --- a/packages/runtime-plugins/module-federation-ai-proxy-remotes/__tests__/index.spec.ts +++ b/packages/runtime-plugins/module-federation-ai-proxy-remotes/__tests__/index.spec.ts @@ -1,4 +1,4 @@ -import aiDebugRuntimePlugin from '../src'; +import aiDebugRuntimePlugin, { generateAIDebugUrl } from '../src'; import { AI_DEBUG_ENV_KEY, AI_DEBUG_CONSOLE_KEY, @@ -30,6 +30,38 @@ describe('module-federation-ai-proxy-remotes', () => { consoleError.mockRestore(); }); + it('generates a debug URL without changing the host URL', () => { + const hostUrl = new URL('https://host.example.com/remote?keep=yes'); + + const debugUrl = generateAIDebugUrl(hostUrl, { + remote: 'http://localhost:3001/mf-manifest.json', + }); + + expect(hostUrl.searchParams.has('__mf_devtools')).toBe(false); + const generated = new URL(debugUrl); + expect(generated.searchParams.get('keep')).toBe('yes'); + expect( + JSON.parse(generated.searchParams.get('__mf_devtools') ?? ''), + ).toEqual({ + overrides: { remote: 'http://localhost:3001/mf-manifest.json' }, + }); + }); + + it('generates a replacement config with a custom parameter name', () => { + const debugUrl = new URL( + generateAIDebugUrl( + 'https://host.example.com/remote', + { remote: null }, + { parameterName: 'debug', replace: true }, + ), + ); + + expect(JSON.parse(debugUrl.searchParams.get('debug') ?? '')).toEqual({ + overrides: { remote: null }, + replace: true, + }); + }); + it('merges by default, replaces on demand, and deletes null entries', () => { const current = { overrides: { old: 'http://localhost:3001/mf-manifest.json' }, @@ -80,6 +112,23 @@ describe('module-federation-ai-proxy-remotes', () => { ).toBe('https://assets.example.com/mf-manifest.json'); }); + it.each([ + ['plain', (value: string) => value], + ['URL encoded', (value: string) => encodeURIComponent(value)], + [ + 'URL encoded twice', + (value: string) => encodeURIComponent(encodeURIComponent(value)), + ], + ])('parses %s JSON config', (_label, encode) => { + const config = JSON.stringify({ + overrides: { remote: 'http://localhost:3002/mf-manifest.json' }, + }); + + expect(parseAIDebugUrlConfig(encode(config))).toEqual({ + overrides: { remote: 'http://localhost:3002/mf-manifest.json' }, + }); + }); + it('applies encoded URL config, preserves other settings and cleans the URL', () => { sessionStorage.setItem( AI_DEBUG_STORAGE_KEY, @@ -226,7 +275,7 @@ describe('module-federation-ai-proxy-remotes', () => { moduleInfo: { host: { remotesInfo: { - checkout: { + remote: { matchedVersion: 'http://localhost:3000/mf-manifest.json', }, }, @@ -247,9 +296,9 @@ describe('module-federation-ai-proxy-remotes', () => { const remote = root!.querySelector('select'); expect(Array.from(remote!.options).map((option) => option.value)).toEqual([ '', - 'checkout', + 'remote', ]); - remote!.value = 'checkout'; + remote!.value = 'remote'; remote!.dispatchEvent(new Event('change')); const manifest = root!.querySelector('input[type=text]'); manifest!.value = 'http://localhost:3001/mf-manifest.json'; @@ -262,7 +311,7 @@ describe('module-federation-ai-proxy-remotes', () => { JSON.parse(sessionStorage.getItem(AI_DEBUG_STORAGE_KEY) ?? '{}'), ).toEqual({ overrides: { - checkout: 'http://localhost:3001/mf-manifest.json', + remote: 'http://localhost:3001/mf-manifest.json', }, }); }); diff --git a/packages/runtime-plugins/module-federation-ai-proxy-remotes/package.json b/packages/runtime-plugins/module-federation-ai-proxy-remotes/package.json index 92452320415..fc2417f0aa1 100644 --- a/packages/runtime-plugins/module-federation-ai-proxy-remotes/package.json +++ b/packages/runtime-plugins/module-federation-ai-proxy-remotes/package.json @@ -1,6 +1,6 @@ { "name": "module-federation-ai-proxy-remotes", - "version": "0.1.3", + "version": "0.1.6", "type": "module", "license": "MIT", "description": "A Module Federation runtime plugin for AI-driven local remote debugging", diff --git a/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/core.ts b/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/core.ts index b3f4c9cf031..0f5ff06b1f8 100644 --- a/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/core.ts +++ b/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/core.ts @@ -64,6 +64,33 @@ const getGlobalPlugins = (): AIDebugRuntimePlugin[] => { return target.__FEDERATION__.__GLOBAL_PLUGIN__; }; +const parsePossiblyEncodedJson = (value: string): unknown => { + let candidate = value; + let parseError: unknown; + + // URLSearchParams already decodes once. Some dev servers encode the complete + // open URL again, so retry a bounded number of times for those extra layers. + for (let decodeCount = 0; decodeCount <= 2; decodeCount += 1) { + try { + return JSON.parse(candidate); + } catch (error) { + parseError = error; + } + + try { + const decoded = decodeURIComponent(candidate); + if (decoded === candidate) { + break; + } + candidate = decoded; + } catch { + break; + } + } + + throw parseError; +}; + export const isAllowedManifestUrl = ( value: string, allowedHosts: string[] = [], @@ -87,7 +114,7 @@ export const parseAIDebugUrlConfig = ( value: string, options: Pick = {}, ): AIDebugUrlConfig => { - const parsed: unknown = JSON.parse(value); + const parsed = parsePossiblyEncodedJson(value); if (!isObject(parsed) || !isObject(parsed.overrides)) { throw new Error('The overrides field must be an object.'); } diff --git a/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/index.ts b/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/index.ts index e844b65a200..10aae5ea6df 100644 --- a/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/index.ts +++ b/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/index.ts @@ -4,6 +4,30 @@ import { type AIDebugRuntimePluginOptions, } from './core'; +export type AIDebugUrlOverrides = Record; + +export type GenerateAIDebugUrlOptions = { + /** Defaults to `__mf_devtools`. */ + parameterName?: string; + replace?: boolean; +}; + +export const generateAIDebugUrl = ( + hostUrl: string | URL, + overrides: AIDebugUrlOverrides, + options: GenerateAIDebugUrlOptions = {}, +): string => { + const url = new URL(hostUrl.toString()); + url.searchParams.set( + options.parameterName ?? '__mf_devtools', + JSON.stringify({ + overrides, + ...(options.replace === true ? { replace: true } : {}), + }), + ); + return url.href; +}; + export default function aiDebugPlugin( options: AIDebugRuntimePluginOptions = {}, ) { From d81bdd52d4c9bb22c6e75e8202297e4808bc7a69 Mon Sep 17 00:00:00 2001 From: zhn Date: Thu, 13 Aug 2026 18:47:34 +0800 Subject: [PATCH 7/7] fix(runtime-plugins): initialize AI proxy plugin once --- .../__tests__/index.spec.ts | 51 +++++++++++++++---- .../package.json | 2 +- .../src/core.ts | 36 ++++++++++--- .../src/index.ts | 4 +- 4 files changed, 74 insertions(+), 19 deletions(-) diff --git a/packages/runtime-plugins/module-federation-ai-proxy-remotes/__tests__/index.spec.ts b/packages/runtime-plugins/module-federation-ai-proxy-remotes/__tests__/index.spec.ts index c810fc2e0e4..b9289afd89d 100644 --- a/packages/runtime-plugins/module-federation-ai-proxy-remotes/__tests__/index.spec.ts +++ b/packages/runtime-plugins/module-federation-ai-proxy-remotes/__tests__/index.spec.ts @@ -23,6 +23,9 @@ describe('module-federation-ai-proxy-remotes', () => { delete (window as typeof window & { __FEDERATION__?: unknown }) .__FEDERATION__; delete (window as typeof window & { __VMOK__?: unknown }).__VMOK__; + delete ( + window as typeof window & { __MF_AI_DEBUG_RUNTIME_PLUGIN__?: unknown } + ).__MF_AI_DEBUG_RUNTIME_PLUGIN__; document.getElementById(AI_DEBUG_CONSOLE_ELEMENT_ID)?.remove(); }); @@ -167,10 +170,6 @@ describe('module-federation-ai-proxy-remotes', () => { }); it('loads the console asynchronously only after URL activation', async () => { - aiDebugRuntimePlugin(); - await Promise.resolve(); - expect(document.getElementById(AI_DEBUG_CONSOLE_ELEMENT_ID)).toBeNull(); - const url = new URL(location.href); url.searchParams.set( '__mf_devtools', @@ -218,7 +217,11 @@ describe('module-federation-ai-proxy-remotes', () => { }), ); sessionStorage.setItem(AI_DEBUG_CONSOLE_KEY, 'true'); - const plugin = aiDebugRuntimePlugin({ console: false }); + aiDebugRuntimePlugin({ console: false }); + const plugin = window.__FEDERATION__.__GLOBAL_PLUGIN__.find( + ({ name }) => name === 'ai-proxy-remotes-runtime-plugin', + )!; + expect(plugin).toBeDefined(); const args = { remote: { name: 'remote', alias: 'alias', version: '1.0.0' }, origin: {} as never, @@ -251,7 +254,11 @@ describe('module-federation-ai-proxy-remotes', () => { overrides: { remote: 'http://localhost:3002/mf-manifest.json' }, }), ); - const plugin = aiDebugRuntimePlugin({ console: false }); + aiDebugRuntimePlugin({ console: false }); + const plugin = window.__FEDERATION__.__GLOBAL_PLUGIN__.find( + ({ name }) => name === 'ai-proxy-remotes-runtime-plugin', + )!; + expect(plugin).toBeDefined(); const args = { remote: { name: 'remote', entry: 'https://example.com/remoteEntry.js' }, origin: {} as never, @@ -263,10 +270,34 @@ describe('module-federation-ai-proxy-remotes', () => { }); it('registers the runtime plugin globally once', () => { - const plugin = aiDebugRuntimePlugin({ console: false }); - expect(plugin.name).toBe('ai-proxy-remotes-runtime-plugin'); - expect(window.__FEDERATION__.__GLOBAL_PLUGIN__).toEqual([plugin]); - expect(aiDebugRuntimePlugin({ console: false })).toBe(plugin); + const entryPlugin = aiDebugRuntimePlugin({ console: false }); + const globalPlugin = window.__FEDERATION__.__GLOBAL_PLUGIN__.find( + ({ name }) => name === 'ai-proxy-remotes-runtime-plugin', + ); + expect(entryPlugin).toEqual({ + name: 'ai-proxy-remotes-runtime-plugin-entry', + }); + expect(globalPlugin).toBeDefined(); + expect(globalPlugin?.beforeRegisterRemote).toBeDefined(); + expect(globalPlugin?.beforeLoadRemoteSnapshot).toBeDefined(); + expect(aiDebugRuntimePlugin({ console: false })).toBe(entryPlugin); + expect(window.__FEDERATION__.__GLOBAL_PLUGIN__).toHaveLength(1); + }); + + it('initializes only once and does not consume URL config again', () => { + const entryPlugin = aiDebugRuntimePlugin({ console: false }); + const url = new URL(location.href); + url.searchParams.set( + '__mf_devtools', + JSON.stringify({ + overrides: { remote: 'http://localhost:3002/mf-manifest.json' }, + }), + ); + history.replaceState(null, '', url.href); + + expect(aiDebugRuntimePlugin({ console: false })).toBe(entryPlugin); + expect(new URL(location.href).searchParams.has('__mf_devtools')).toBe(true); + expect(sessionStorage.getItem(AI_DEBUG_STORAGE_KEY)).toBeNull(); expect(window.__FEDERATION__.__GLOBAL_PLUGIN__).toHaveLength(1); }); diff --git a/packages/runtime-plugins/module-federation-ai-proxy-remotes/package.json b/packages/runtime-plugins/module-federation-ai-proxy-remotes/package.json index fc2417f0aa1..fdfca755b19 100644 --- a/packages/runtime-plugins/module-federation-ai-proxy-remotes/package.json +++ b/packages/runtime-plugins/module-federation-ai-proxy-remotes/package.json @@ -1,6 +1,6 @@ { "name": "module-federation-ai-proxy-remotes", - "version": "0.1.6", + "version": "0.1.7", "type": "module", "license": "MIT", "description": "A Module Federation runtime plugin for AI-driven local remote debugging", diff --git a/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/core.ts b/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/core.ts index 0f5ff06b1f8..f84725ac896 100644 --- a/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/core.ts +++ b/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/core.ts @@ -5,6 +5,7 @@ export const AI_DEBUG_ENV_KEY = 'MF_ENV'; export const AI_DEBUG_CONSOLE_KEY = '__MF_AI_DEBUG_CONSOLE__'; const PLUGIN_NAME = 'ai-proxy-remotes-runtime-plugin'; +const EMPTY_PLUGIN_NAME = 'ai-proxy-remotes-runtime-plugin-entry'; const LOOPBACK_HOSTS = ['localhost', '127.0.0.1']; export type AIDebugUrlConfig = { @@ -46,6 +47,7 @@ type AIDebugGlobal = typeof globalThis & { __GLOBAL_PLUGIN__?: AIDebugRuntimePlugin[]; }; __VMOK__?: AIDebugGlobal['__FEDERATION__']; + __MF_AI_DEBUG_RUNTIME_PLUGIN__?: AIDebugRuntimePlugin; }; const isObject = (value: unknown): value is Record => @@ -64,6 +66,20 @@ const getGlobalPlugins = (): AIDebugRuntimePlugin[] => { return target.__FEDERATION__.__GLOBAL_PLUGIN__; }; +const getExistingGlobalPlugin = (): AIDebugRuntimePlugin | undefined => { + const target = getGlobal(); + const existing = + target.__MF_AI_DEBUG_RUNTIME_PLUGIN__ ?? + getGlobalPlugins().find((plugin) => plugin.name === PLUGIN_NAME); + if (existing) { + target.__MF_AI_DEBUG_RUNTIME_PLUGIN__ = existing; + } + return existing; +}; + +export const isAIDebugRuntimePluginInitialized = (): boolean => + Boolean(getExistingGlobalPlugin()); + const parsePossiblyEncodedJson = (value: string): unknown => { let candidate = value; let parseError: unknown; @@ -275,17 +291,21 @@ type AIDebugRemote = { type AIDebugRuntimePlugin = { name: string; - beforeRegisterRemote(args: { remote: AIDebugRemote; origin: unknown }): { + beforeRegisterRemote?(args: { remote: AIDebugRemote; origin: unknown }): { remote: AIDebugRemote; origin: unknown; }; - beforeLoadRemoteSnapshot(args: { + beforeLoadRemoteSnapshot?(args: { options: { inBrowser?: boolean }; moduleInfo: AIDebugRemote; origin: unknown; }): void; }; +const EMPTY_RUNTIME_PLUGIN: AIDebugRuntimePlugin = { + name: EMPTY_PLUGIN_NAME, +}; + const applyRemoteOverride = ( remote: AIDebugRemote, storageKey: string, @@ -331,13 +351,15 @@ const createGlobalPlugin = ( export function aiDebugRuntimePlugin( options: AIDebugRuntimePluginOptions = {}, ): AIDebugRuntimePlugin { - applyAIDebugUrlConfig(options); - const plugins = getGlobalPlugins(); - const existing = plugins.find((plugin) => plugin.name === PLUGIN_NAME); + const existing = getExistingGlobalPlugin(); if (existing) { - return existing; + return EMPTY_RUNTIME_PLUGIN; } + + applyAIDebugUrlConfig(options); + const plugins = getGlobalPlugins(); const plugin = createGlobalPlugin(options); plugins.push(plugin); - return plugin; + getGlobal().__MF_AI_DEBUG_RUNTIME_PLUGIN__ = plugin; + return EMPTY_RUNTIME_PLUGIN; } diff --git a/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/index.ts b/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/index.ts index 10aae5ea6df..06499d0c4bf 100644 --- a/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/index.ts +++ b/packages/runtime-plugins/module-federation-ai-proxy-remotes/src/index.ts @@ -1,5 +1,6 @@ import { aiDebugRuntimePlugin, + isAIDebugRuntimePluginInitialized, isAIDebugConsoleEnabled, type AIDebugRuntimePluginOptions, } from './core'; @@ -31,8 +32,9 @@ export const generateAIDebugUrl = ( export default function aiDebugPlugin( options: AIDebugRuntimePluginOptions = {}, ) { + const shouldInitialize = !isAIDebugRuntimePluginInitialized(); const plugin = aiDebugRuntimePlugin(options); - if (isAIDebugConsoleEnabled(options)) { + if (shouldInitialize && isAIDebugConsoleEnabled(options)) { void import('./console/mount').then(({ mountAIDebugConsole }) => mountAIDebugConsole( typeof options.console === 'object' ? options.console : {},