feat(plugin): scope virtual instance frontend

This commit is contained in:
jxxghp
2026-08-22 09:40:38 +08:00
parent 9b747c67a9
commit 19e8e06798
19 changed files with 256 additions and 37 deletions
+18 -2
View File
@@ -141,6 +141,8 @@ const props = defineProps({
type: Object,
default: () => {},
},
pluginId: { type: String, default: '' },
sourcePluginId: { type: String, default: '' },
nativeSubscribe: {
type: Function,
default: null,
@@ -189,6 +191,8 @@ const props = defineProps({
type: Object,
default: () => {},
},
pluginId: { type: String, default: '' },
sourcePluginId: { type: String, default: '' },
nativeSubscribe: {
type: Function,
default: null,
@@ -248,6 +252,9 @@ const props = defineProps({
type: Boolean,
default: true,
},
api: { type: Object, default: () => ({}) },
pluginId: { type: String, default: '' },
sourcePluginId: { type: String, default: '' },
nativeSubscribe: {
type: Function,
default: null,
@@ -290,6 +297,7 @@ const props = defineProps({
| `nativeSubscribe` | 打开主应用原生订阅交互 |
| `navKey` | 与侧栏声明的 `nav_key` 一致,同一插件多入口时用于区分 |
| `pluginId` | 当前插件 ID |
| `sourcePluginId` | 虚拟分身共享资源的源插件 ID;普通插件为空 |
```vue
<script setup lang="ts">
@@ -298,6 +306,7 @@ const props = defineProps({
nativeSubscribe: { type: Function, default: null },
navKey: { type: String, default: 'main' },
pluginId: { type: String, default: '' },
sourcePluginId: { type: String, default: '' },
})
const emit = defineEmits(['action'])
</script>
@@ -317,6 +326,8 @@ const emit = defineEmits(['action'])
| 能力 | Page | Config | Dashboard | AppPage | 调用方式 |
| ---------------- | ---- | ------ | --------- | ------- | ---------------------------------------------------------------- |
| 认证 API | ✓ | ✓ | ✓ | ✓ | `api` prop |
| 当前实例 ID | ✓ | ✓ | ✓ | ✓ | `pluginId` prop |
| 共享源码 ID | ✓ | ✓ | ✓ | ✓ | `sourcePluginId` prop;普通插件为空 |
| 原生订阅交互 | ✓ | ✓ | ✓ | ✓ | `nativeSubscribe` prop 或 `inject('moviepilot:nativeSubscribe')` |
| 主应用统一 Toast | ✓ | ✓ | ✓ | ✓ | `inject('moviepilot:toast')` |
| 主应用公共弹窗 | ✓ | ✓ | ✓ | ✓ | `inject('moviepilot:dialog')` |
@@ -324,6 +335,11 @@ const emit = defineEmits(['action'])
`nativeSubscribe`、Toast、公共弹窗和确认弹窗都由主应用宿主提供。插件不应复制主程序订阅弹窗、创建另一套 Toast 容器或自行挂载全局弹窗。插件在旧版主程序或能力不存在的环境中运行时,应保留空值判断和必要的页面内 fallback。
V3 新建插件分身会复用源插件的同一份联邦产物。宿主传入的 `api` 已绑定当前
`pluginId`:即使旧组件仍调用 `plugin/<sourcePluginId>/...`,也会被映射到实例 API。
新组件应直接用 `pluginId` 拼接路径,并始终优先使用 `api` prop;只读取全局
`window.MoviePilotAPI` 会绕过实例作用域,不适合多实例插件。
### 5.6 玻璃光学表面
主应用的 `Page``Config``AppPage` 宿主在玻璃主题下默认采用 `static-material` 光学模式:保留壁纸透射、材质色调和方向反射,但不响应指针流场、局部折射、拖尾或动态焦散。插件列表与 `Dashboard` 继续使用完整动态光学。视觉型插件可以在自己控制的 DOM 区域显式恢复完整动态光学:
@@ -565,8 +581,8 @@ def get_render_mode() -> Tuple[str, str]:
- 需要在插件前端页面调用后端接口时,通过传入的api模块发起调用,后端api接口声明认证类型为:`bear`
```typescript
// 演示使用api模块调用插件接口
recentItems.value = await props.api.get(`plugin/MyPlugin/history`)
// 使用宿主传入的当前实例 ID,普通插件和虚拟分身使用同一份组件代码
recentItems.value = await props.api.get(`plugin/${props.pluginId}/history`)
```
```python
+57
View File
@@ -0,0 +1,57 @@
import { createScopedPluginApi } from '@/api/pluginInstance'
import type { PluginApiClient } from '@/api/client'
import { describe, expect, it, vi } from 'vitest'
/** 创建可调用且带 Axios 风格方法的最小插件客户端。 */
function createClient() {
const client = Object.assign(vi.fn(), {
get: vi.fn(),
post: vi.fn(),
request: vi.fn(),
})
return client as unknown as PluginApiClient
}
describe('createScopedPluginApi', () => {
it('rewrites only the source plugin dynamic API namespace', () => {
const client = createClient()
const scoped = createScopedPluginApi(client, 'DemoPluginwork', 'DemoPlugin')
void scoped.get('plugin/DemoPlugin/items')
void scoped.post('/api/v1/plugin/demoplugin/action?sync=1', { value: 1 })
void scoped.get('system/config')
expect(client.get).toHaveBeenNthCalledWith(1, 'plugin/DemoPluginwork/items')
expect(client.post).toHaveBeenCalledWith(
'/api/v1/plugin/DemoPluginwork/action?sync=1',
{ value: 1 },
)
expect(client.get).toHaveBeenNthCalledWith(2, 'system/config')
})
it('supports callable and request-config forms without mutating input', () => {
const client = createClient()
const scoped = createScopedPluginApi(client, 'DemoPluginhome', 'DemoPlugin')
const config = { url: '/plugin/DemoPlugin/status', method: 'GET' }
void scoped(config)
void scoped.request(config)
expect(client).toHaveBeenCalledWith({
url: '/plugin/DemoPluginhome/status',
method: 'GET',
})
expect(client.request).toHaveBeenCalledWith({
url: '/plugin/DemoPluginhome/status',
method: 'GET',
})
expect(config.url).toBe('/plugin/DemoPlugin/status')
})
it('returns the original client for ordinary plugins', () => {
const client = createClient()
expect(createScopedPluginApi(client, 'DemoPlugin')).toBe(client)
expect(createScopedPluginApi(client, 'DemoPlugin', 'DemoPlugin')).toBe(client)
})
})
+22 -1
View File
@@ -16,6 +16,7 @@ import {
type DataApiClient,
type PluginApiClient,
} from './client'
import { createScopedPluginApi } from './pluginInstance'
/** 带连接探测和反馈策略的 MoviePilot 请求配置。 */
export interface ConnectionAwareRequestConfig extends AxiosRequestConfig {
@@ -68,6 +69,7 @@ const { api, pluginApi } = createApiClients({
},
resolveFallbackMessage: key => i18n.global.t(fallbackMessageKeys[key]),
})
const pluginInstanceApis = new Map<string, PluginApiClient>()
declare global {
interface Window {
@@ -89,10 +91,29 @@ function initializeClient(instance: AxiosInstance | DataApiClient) {
})
}
/** 返回复用同一拦截器链、但把源插件 API 映射到实例命名空间的客户端。 */
function createPluginInstanceApi(instanceId: string, sourcePluginId?: string): PluginApiClient {
if (!sourcePluginId || instanceId === sourcePluginId) return pluginApi
const cacheKey = `${instanceId}\u0000${sourcePluginId}`
let scopedApi = pluginInstanceApis.get(cacheKey)
if (!scopedApi) {
scopedApi = createScopedPluginApi(pluginApi, instanceId, sourcePluginId)
pluginInstanceApis.set(cacheKey, scopedApi)
}
return scopedApi
}
// 插件远程组件接收 endpoint 的最终 payload,内部页面默认使用严格 envelope 解包客户端。
if (typeof window !== 'undefined') window.MoviePilotAPI = pluginApi
export { ApiRequestError, getApiBusinessErrorMessage, isApiBusinessFailure, isApiResponse, pluginApi }
export {
ApiRequestError,
createPluginInstanceApi,
getApiBusinessErrorMessage,
isApiBusinessFailure,
isApiResponse,
pluginApi,
}
export type { ApiFeedbackMode, DataApiClient, PluginApiClient }
export default api
+83
View File
@@ -0,0 +1,83 @@
import type { AxiosRequestConfig } from 'axios'
import type { PluginApiClient } from './client'
const URL_METHODS = new Set([
'delete',
'get',
'head',
'options',
'patch',
'patchForm',
'post',
'postForm',
'put',
'putForm',
])
/** 转义插件 ID,避免其进入正则表达式后改变路径匹配语义。 */
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/** 把源插件动态 API 路径映射到当前虚拟实例的服务端命名空间。 */
function rewritePluginUrl(url: string, instanceId: string, sourcePluginId: string): string {
if (!instanceId || !sourcePluginId || instanceId === sourcePluginId) return url
const sourcePattern = new RegExp(
`(^|/)plugin/${escapeRegExp(sourcePluginId)}(?=/|[?#]|$)`,
'i',
)
return url.replace(sourcePattern, `$1plugin/${instanceId}`)
}
/** 复制请求配置并改写其中的 URL,避免修改远程插件持有的原对象。 */
function rewriteRequestConfig(
config: AxiosRequestConfig,
instanceId: string,
sourcePluginId: string,
): AxiosRequestConfig {
if (typeof config.url !== 'string') return config
return {
...config,
url: rewritePluginUrl(config.url, instanceId, sourcePluginId),
}
}
/**
* 创建实例作用域 API。
*
* 仅改写插件自己的动态 API 前缀,其余系统 API 和 Axios 能力保持原合同,
* 因而无需要求存量联邦插件改造路径拼接方式。
*/
export function createScopedPluginApi(
client: PluginApiClient,
instanceId: string,
sourcePluginId?: string,
): PluginApiClient {
if (!sourcePluginId || instanceId === sourcePluginId) return client
return new Proxy(client, {
apply(target, thisArg, argumentList: unknown[]) {
const [request, ...rest] = argumentList
const rewritten =
typeof request === 'string'
? rewritePluginUrl(request, instanceId, sourcePluginId)
: request && typeof request === 'object'
? rewriteRequestConfig(request as AxiosRequestConfig, instanceId, sourcePluginId)
: request
return Reflect.apply(target, thisArg, [rewritten, ...rest])
},
get(target, property, receiver) {
const value = Reflect.get(target, property, receiver)
if (typeof property !== 'string' || typeof value !== 'function') return value
if (property === 'request') {
return (config: AxiosRequestConfig) =>
value.call(target, rewriteRequestConfig(config, instanceId, sourcePluginId))
}
if (URL_METHODS.has(property)) {
return (url: string, ...args: unknown[]) =>
value.call(target, rewritePluginUrl(url, instanceId, sourcePluginId), ...args)
}
return value.bind(target)
},
}) as PluginApiClient
}
+8
View File
@@ -985,6 +985,12 @@ export interface Plugin {
rating_count?: number
// 当前安装实例评分
user_rating?: number | null
// 共享代码和联邦资源的源插件 ID
source_plugin_id?: string
// 是否为共享源码的虚拟实例
is_instance?: boolean
// 实例实现模式
instance_mode?: 'virtual'
}
export interface PluginRuntimeSummary {
@@ -1078,6 +1084,8 @@ export interface DashboardItem {
elements: RenderProps[]
// 渲染方式
render_mode?: string
// 共享联邦资源的源插件 ID
source_plugin_id?: string
}
// 种子信息
-2
View File
@@ -480,7 +480,6 @@ async function executePluginClone(cloneForm: {
suffix: string
name: string
description: string
version: string
icon: string
}) {
if (!cloneForm.suffix.trim()) {
@@ -497,7 +496,6 @@ async function executePluginClone(cloneForm: {
suffix: cloneForm.suffix.trim(),
name: cloneForm.name.trim(),
description: cloneForm.description.trim(),
version: cloneForm.version.trim(),
icon: cloneForm.icon.trim(),
},
{ feedback: 'silent' },
@@ -261,7 +261,6 @@ describe('PluginCard lifecycle actions', () => {
suffix: string
name: string
description: string
version: string
icon: string
}) => Promise<void>
}
@@ -269,7 +268,6 @@ describe('PluginCard lifecycle actions', () => {
suffix: ' Test ',
name: '演示分身',
description: ' 独立配置 ',
version: ' 1.0.1 ',
icon: ' https://example.com/icon.png ',
})
@@ -277,7 +275,6 @@ describe('PluginCard lifecycle actions', () => {
suffix: 'Test',
name: '演示分身',
description: '独立配置',
version: '1.0.1',
icon: 'https://example.com/icon.png',
})
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件分身 演示分身 创建成功!')
@@ -296,11 +293,10 @@ describe('PluginCard lifecycle actions', () => {
suffix: string
name: string
description: string
version: string
icon: string
}) => Promise<void>
}
await cloneEvents.clone({ suffix: ' ', name: '', description: '', version: '', icon: '' })
await cloneEvents.clone({ suffix: ' ', name: '', description: '', icon: '' })
expect(mocks.toastError).toHaveBeenCalledWith('分身后缀不能为空')
expect(mocks.apiPost).not.toHaveBeenCalled()
@@ -316,11 +312,10 @@ describe('PluginCard lifecycle actions', () => {
suffix: string
name: string
description: string
version: string
icon: string
}) => Promise<void>
}
const form = { suffix: 'Test', name: '测试', description: '', version: '', icon: '' }
const form = { suffix: 'Test', name: '测试', description: '', icon: '' }
await cloneEvents.clone(form)
expect(mocks.toastError).toHaveBeenCalledWith('插件分身创建失败:后缀已存在')
expect(mocks.dialogCloses[0]).not.toHaveBeenCalled()
+1 -14
View File
@@ -42,7 +42,6 @@ const cloneForm = ref({
suffix: '',
name: '',
description: '',
version: '',
icon: '',
})
@@ -52,7 +51,6 @@ function initializeCloneForm() {
suffix: '',
name: t('plugin.cloneDefaultName', { name: props.plugin?.plugin_name }),
description: t('plugin.cloneDefaultDescription', { description: props.plugin?.plugin_desc }),
version: props.plugin?.plugin_version || '1.0',
icon: props.plugin?.plugin_icon || '',
}
}
@@ -121,18 +119,7 @@ onMounted(() => {
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="cloneForm.version"
:label="t('plugin.cloneVersion')"
:placeholder="t('plugin.cloneVersionPlaceholder')"
:hint="t('plugin.cloneVersionHint')"
persistent-hint
prepend-inner-icon="mdi-numeric"
/>
</VCol>
<VCol cols="12" md="6">
<VCol cols="12">
<VTextField
v-model="cloneForm.icon"
:label="t('plugin.cloneIcon')"
+9 -2
View File
@@ -2,7 +2,7 @@
import { useDisplay } from 'vuetify'
import type { Plugin, RenderProps } from '@/api/types'
import { isNullOrEmptyObject } from '@/@core/utils'
import api, { pluginApi } from '@/api'
import api, { createPluginInstanceApi } from '@/api'
import { useToast } from 'vue-toastification'
import FormRender from '../render/FormRender.vue'
import ProgressDialog from '../dialog/ProgressDialog.vue'
@@ -62,6 +62,11 @@ provide('moviepilot:nativeSubscribe', nativeSubscribe)
const pluginSidebarNavStore = usePluginSidebarNavStore()
// 联邦插件可继续使用源插件硬编码路径,宿主会把它映射到当前实例。
const scopedPluginApi = computed(() =>
createPluginInstanceApi(props.plugin?.id || '', props.plugin?.source_plugin_id),
)
// 是否刷新
const isRefreshed = ref(false)
@@ -240,7 +245,9 @@ onBeforeMount(async () => {
<component
:is="dynamicComponent"
:initial-config="pluginConfigForm"
:api="pluginApi"
:api="scopedPluginApi"
:plugin-id="props.plugin?.id"
:source-plugin-id="props.plugin?.source_plugin_id"
:native-subscribe="nativeSubscribe"
@save="handleVueComponentSave"
@layout="handleVueComponentLayout"
+9 -2
View File
@@ -2,7 +2,7 @@
import { useDisplay } from 'vuetify'
import type { Plugin, RenderProps } from '@/api/types'
import PageRender from '@/components/render/PageRender.vue'
import api, { pluginApi } from '@/api'
import api, { createPluginInstanceApi } from '@/api'
import { loadRemoteComponent } from '@/utils/federationLoader'
import { usePWA } from '@/composables/usePWA'
import { useToast } from 'vue-toastification'
@@ -47,6 +47,11 @@ provide('moviepilot:confirm', createConfirm)
const nativeSubscribe = usePluginNativeSubscribe()
provide('moviepilot:nativeSubscribe', nativeSubscribe)
// 数据页沿用源插件组件,同时把其动态 API 限定到当前实例。
const scopedPluginApi = computed(() =>
createPluginInstanceApi(props.plugin?.id || '', props.plugin?.source_plugin_id),
)
// 是否刷新
const isRefreshed = ref(false)
// 只有成功取得合法页面响应时才允许展示空页面状态。
@@ -183,7 +188,9 @@ onMounted(() => {
<VCardText class="pa-0">
<component
:is="dynamicComponent"
:api="pluginApi"
:api="scopedPluginApi"
:plugin-id="props.plugin?.id"
:source-plugin-id="props.plugin?.source_plugin_id"
:native-subscribe="nativeSubscribe"
:show_switch="show_switch"
@action="handleAction"
@@ -23,6 +23,7 @@ const mocks = vi.hoisted(() => {
})
vi.mock('@/api', () => ({
createPluginInstanceApi: () => mocks.api,
pluginApi: mocks.api,
default: mocks.api,
}))
@@ -20,6 +20,7 @@ const mocks = vi.hoisted(() => {
})
vi.mock('@/api', () => ({
createPluginInstanceApi: () => mocks.api,
pluginApi: mocks.api,
default: mocks.api,
}))
+9 -2
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { h, resolveComponent } from 'vue'
import { pluginApi } from '@/api'
import { createPluginInstanceApi } from '@/api'
import { DashboardItem } from '@/api/types'
import DashboardRender from '@/components/render/DashboardRender.vue'
import { isNullOrEmptyObject } from '@/@core/utils'
@@ -129,6 +129,11 @@ let dashboardLoadGeneration = 0
// 插件UI渲染模式 ('vuetify' 或 'vue')
const pluginRenderMode = computed(() => props.config?.render_mode || 'vuetify')
// 仪表盘远程组件共享源码,但所有插件动态 API 保持实例级隔离。
const scopedPluginApi = computed(() =>
createPluginInstanceApi(props.config?.id || '', props.config?.source_plugin_id),
)
// 插件节点身份变化时重建异步组件,使失败后的远程模块可以再次加载。
const pluginDashboardIdentity = computed(
() => `${props.config?.id ?? ''}:${props.config?.key ?? ''}:${pluginRenderMode.value}`,
@@ -257,7 +262,9 @@ onUnmounted(() => {
:is="dynamicPluginComponent"
:config="props.config"
:allow-refresh="props.allowRefresh"
:api="pluginApi"
:api="scopedPluginApi"
:plugin-id="props.config?.id"
:source-plugin-id="props.config?.source_plugin_id"
:native-subscribe="nativeSubscribe"
/>
</div>
@@ -16,6 +16,7 @@ const mocks = vi.hoisted(() => ({
}))
vi.mock('@/api', () => ({
createPluginInstanceApi: () => ({ get: mocks.apiGet }),
default: createDataApiMock({ get: mocks.apiGet }),
pluginApi: { get: mocks.apiGet },
}))
+1
View File
@@ -24,6 +24,7 @@ const mocks = vi.hoisted(() => ({
}))
vi.mock('@/api', () => ({
createPluginInstanceApi: () => mocks.pluginApi,
default: mocks.api,
pluginApi: mocks.pluginApi,
}))
+2
View File
@@ -15,11 +15,13 @@ const mocks = vi.hoisted(() => ({
}))
vi.mock('@/api', () => ({
createPluginInstanceApi: () => mocks.api,
default: mocks.api,
pluginApi: mocks.api,
}))
vi.mock('@/utils/federationLoader', () => ({
getRemoteModuleInfo: vi.fn().mockResolvedValue(null),
loadRemoteAppPageComponent: (...args: unknown[]) => mocks.loadRemoteAppPageComponent(...args),
}))
+12 -2
View File
@@ -4,7 +4,7 @@ import type { Component } from 'vue'
import { useTheme } from 'vuetify'
import { useAuthStore, useUserStore } from '@/stores'
import { authState, userState } from '@/stores/types'
import api, { pluginApi } from '@/api'
import api, { createPluginInstanceApi, pluginApi } from '@/api'
import router from '@/router'
import LoginMfaStep from '@/components/auth/LoginMfaStep.vue'
import OpticalLogoLab from '@/components/misc/OpticalLogoLab.vue'
@@ -181,6 +181,15 @@ function getApiErrorPayload(error: unknown): ApiErrorPayload | undefined {
// 登录认证提供方
const authProviders = ref<LoginAuthProvider[]>([])
const selectedAuthProvider = ref<LoginAuthProvider | null>(null)
// 登录插件也使用实例作用域客户端;系统内置登录请求仍使用全局插件客户端。
const selectedPluginApi = computed(() => {
const pluginId = selectedAuthProvider.value?.plugin_id || ''
return createPluginInstanceApi(
pluginId,
selectedAuthProvider.value?.remote?.source_plugin_id,
)
})
const RemoteAuthView = shallowRef<Component | null>(null)
const pluginAuthDialog = ref(false)
const pluginAuthLoading = ref(false)
@@ -1075,9 +1084,10 @@ onUnmounted(() => {
<component
v-else-if="RemoteAuthView && selectedAuthProvider"
:is="RemoteAuthView"
:api="pluginApi"
:api="selectedPluginApi"
:provider="selectedAuthProvider"
:plugin-id="selectedAuthProvider.plugin_id"
:source-plugin-id="selectedAuthProvider.remote?.source_plugin_id"
@authenticated="handlePluginAuthenticated"
@error="handlePluginAuthError"
@close="closePluginAuth"
+14 -3
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { Component } from 'vue'
import { pluginApi } from '@/api'
import { loadRemoteAppPageComponent } from '@/utils/federationLoader'
import { createPluginInstanceApi } from '@/api'
import { getRemoteModuleInfo, loadRemoteAppPageComponent } from '@/utils/federationLoader'
import { useToast } from 'vue-toastification'
import { usePluginNativeSubscribe } from '@/composables/usePluginNativeSubscribe'
import { useConfirm } from '@/composables/useConfirm'
@@ -13,6 +13,7 @@ const pluginId = computed(() => route.params.pluginId as string)
const navKey = computed(() => (route.params.navKey as string) || 'main')
const RemoteView = shallowRef<Component | null>(null)
const pluginSourceId = ref<string>()
const loadError = ref(false)
let loadGeneration = 0
@@ -37,10 +38,14 @@ watch(
const generation = ++loadGeneration
loadError.value = false
RemoteView.value = null
pluginSourceId.value = undefined
if (!pid) {
return
}
try {
const remoteModule = await getRemoteModuleInfo(pid)
if (generation !== loadGeneration) return
pluginSourceId.value = remoteModule?.source_plugin_id
const remoteView = (await loadRemoteAppPageComponent(pid, nk)) as Component
if (generation !== loadGeneration) return
RemoteView.value = remoteView
@@ -52,6 +57,11 @@ watch(
},
{ immediate: true },
)
// 路由只携带实例 ID,源身份由联邦发现结果补齐。
const scopedPluginApi = computed(() =>
createPluginInstanceApi(pluginId.value, pluginSourceId.value),
)
</script>
<template>
@@ -64,10 +74,11 @@ watch(
v-else
:is="RemoteView"
:key="`${pluginId}-${navKey}`"
:api="pluginApi"
:api="scopedPluginApi"
:native-subscribe="nativeSubscribe"
:nav-key="navKey"
:plugin-id="pluginId"
:source-plugin-id="pluginSourceId"
@action="() => {}"
/>
</div>
+6
View File
@@ -13,6 +13,7 @@ export interface RemoteModule {
id: string
url: string
name?: string
source_plugin_id?: string
}
/**
@@ -29,6 +30,11 @@ async function fetchSingleRemoteModule(id: string): Promise<RemoteModule | null>
}
}
/** 获取远程模块身份信息,供全页宿主构造实例作用域 API。 */
export async function getRemoteModuleInfo(id: string): Promise<RemoteModule | null> {
return fetchSingleRemoteModule(id)
}
/** 发现并注册尚不可用的远程模块,同一 remote 同时只执行一次。 */
async function discoverAndRegisterRemote(id: string): Promise<boolean> {
const activeFlight = remoteRegistrationFlights.get(id)