web-extend-plugin-vue2 0.1.0 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,22 +1,52 @@
1
1
  # web-extend-plugin-vue2
2
2
 
3
- Vue 2.7 宿主侧 **Web 扩展插件**运行时:拉取清单、`hostApi`、菜单与 **`registerSlotComponents` 注册表**,以及 **`ExtensionPoint`**(同一组件覆盖内容区、工具栏等任意扩展点;插件在该点挂载 Vue 组件即可)。
3
+ 面向 **Vue 2.7** 宿主的 **Web 前端扩展插件**运行时(npm 包)。在浏览器中拉取插件清单、加载入口脚本、向插件注入 **`hostApi`**(路由 / 菜单 / 扩展点 / 受控请求桥等),并提供 **`ExtensionPoint`** 组件在布局中挂载插件视图。与后端清单服务、静态资源目录约定配套使用(如 `extend-plugin-framework` 中的 `web-extend-plugin-server`)。
4
4
 
5
- ## 依赖
5
+ ## 安装
6
6
 
7
- - `peerDependencies`: `vue@^2.7`, `vue-router@^3.6`
8
- - `dependencies`: `semver`
7
+ ```bash
8
+ npm add web-extend-plugin-vue2 vue@^2.7 vue-router@^3.6
9
+ ```
10
+
11
+ ## 最小接入
12
+
13
+ ```js
14
+ import Vue from 'vue'
15
+ import {
16
+ bootstrapPlugins,
17
+ createHostApi,
18
+ resolveRuntimeOptions,
19
+ ExtensionPoint
20
+ } from 'web-extend-plugin-vue2'
21
+
22
+ Vue.component('ExtensionPoint', ExtensionPoint) // 布局:<ExtensionPoint point-id="..." />
23
+
24
+ const runtime = resolveRuntimeOptions({
25
+ // 按需覆盖,见 defaultWebExtendPluginRuntime
26
+ })
27
+
28
+ bootstrapPlugins(router, (id, r, kit) => createHostApi(id, r, kit), runtime).catch(console.warn)
29
+ ```
30
+
31
+ 清单请求 URL 为 **`manifestBase` + `manifestListPath`**(默认 `/fp-api` + `/api/frontend-plugins`)。工厂请使用 **`(id, r, kit) => createHostApi(id, r, kit)`**,以便 bridge 白名单等配置生效;若仍写 `(id) => createHostApi(id, router)`,仅清单侧配置会随 `runtime` 变化,bridge 仍为内置默认。
32
+
33
+ ## 配置与默认值
34
+
35
+ - **优先级**:`bootstrapPlugins` 第三参 → `resolveRuntimeOptions({ ... })` 显式字段 → 环境变量 → 包内 **`defaultWebExtendPluginRuntime`**(可从本包导入)。
36
+ - **完整字段列表**:见仓库 **`src/default-runtime-config.js`** 与 **`src/PluginRuntime.js`** 中的 `resolveRuntimeOptions`。
37
+ - **环境变量**:支持 `VITE_*`;同等含义可用 **`PLUGIN_*`**(将 `VITE_` 换成 `PLUGIN_`)。Vite 使用 `PLUGIN_` 时需在 `vite.config` 设置 `envPrefix: ['VITE_', 'PLUGIN_']`;Webpack 用 `DefinePlugin` 注入 `process.env` 即可。
9
38
 
10
- ## 在 Vite 宿主中使用
39
+ ## 卸载
11
40
 
12
41
  ```js
13
- import { bootstrapPlugins, createHostApi, registries } from 'web-extend-plugin-vue2'
42
+ import { disposeWebPlugin } from 'web-extend-plugin-vue2'
14
43
 
15
- bootstrapPlugins(router, (pluginId) => createHostApi(pluginId, router)).catch(console.warn)
44
+ disposeWebPlugin('your.plugin.id')
16
45
  ```
17
46
 
18
- 开发环境下清单前缀、插件 Vite 联调等默认从 `import.meta.env`(`VITE_FRONTEND_PLUGIN_BASE`、`VITE_WEB_PLUGIN_DEV_*`)读取。非 Vite 或需覆盖时可传入第三参数,见源码 `resolveRuntimeOptions` / `WebExtendPluginRuntimeOptions`。
47
+ Vue Router 3 无公开 `removeRoute`,动态路由卸载后可能需整页刷新。
19
48
 
20
- ## 发布到 npm
49
+ ## 仓库与协议
21
50
 
22
- 在包目录执行 `npm publish`(需有权限)。本仓库可用 `file:../web-extend-plugin-vue2` 联调示例宿主。
51
+ - 源码:[extend-plugin-framework / web-extend-plugin-vue2](https://github.com/xtemplus/extend-plugin-framework/tree/master/web-extend-plugin-vue2)
52
+ - 许可证:Apache-2.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "web-extend-plugin-vue2",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
4
4
  "description": "Vue 2.7 host runtime for web-extend-plugin: manifest bootstrap, hostApi, registries, ExtensionPoint",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -1,66 +1,288 @@
1
- import * as semver from 'semver'
1
+ /**
2
+ * 宿主侧插件引导:拉取清单、dev 映射、加载入口脚本、调用 activator。
3
+ * 路径与白名单等默认值见 `defaultWebExtendPluginRuntime`,可通过 `resolveRuntimeOptions` / 环境变量覆盖。
4
+ *
5
+ * **Webpack 宿主**:无 `import.meta.env` 时,用 `DefinePlugin` 注入 `process.env.VITE_*` 或 **`PLUGIN_*`**(等价键)或传入第三参。
6
+ *
7
+ * @module PluginRuntime
8
+ */
9
+ import { coerce, satisfies } from 'semver'
2
10
  import { HOST_PLUGIN_API_VERSION } from './constants.js'
11
+ import { defaultWebExtendPluginRuntime } from './default-runtime-config.js'
12
+
13
+ const DEF = defaultWebExtendPluginRuntime
3
14
 
4
15
  /**
5
16
  * @typedef {object} WebExtendPluginRuntimeOptions
6
- * @property {string} [manifestBase] 清单服务 URL 前缀,默认 `/fp-api` 或 `VITE_FRONTEND_PLUGIN_BASE`
7
- * @property {boolean} [isDev] 是否开发模式;默认取 Vite `import.meta.env.DEV`
8
- * @property {string} [webPluginDevOrigin] 插件 Vite dev origin,如 `http://localhost:5188`
9
- * @property {string} [webPluginDevIds] 逗号分隔插件 id,用于隐式 dev 入口
10
- * @property {string} [webPluginDevMapJson] JSON 字符串,id → 入口 URL,等价 `VITE_WEB_PLUGIN_DEV_MAP`
17
+ * @property {string} [manifestBase] 清单服务 URL 前缀
18
+ * @property {string} [manifestListPath] 清单接口路径(以 `/` 开头),拼在 manifestBase 后
19
+ * @property {RequestCredentials} [manifestFetchCredentials] 清单 fetch credentials
20
+ * @property {boolean} [isDev] 开发模式
21
+ * @property {string} [webPluginDevOrigin] 插件 dev origin
22
+ * @property {string} [webPluginDevIds] 逗号分隔 id,隐式 dev 映射
23
+ * @property {string} [webPluginDevMapJson] 显式 dev 映射 JSON
24
+ * @property {string} [webPluginDevEntryPath] 隐式 dev 入口路径(相对插件 dev origin)
25
+ * @property {string} [devPingPath] dev 存活探测路径
26
+ * @property {string} [devReloadSsePath] dev 热更新 SSE 路径
27
+ * @property {number} [devPingTimeoutMs] 探测超时
28
+ * @property {string[]} [defaultImplicitDevPluginIds] 无 `webPluginDevIds`/env 时用于隐式 dev 的 id;包内默认 `[]`
29
+ * @property {string[]} [allowedScriptHosts] 允许加载脚本的主机名
30
+ * @property {string[]} [bridgeAllowedPathPrefixes] bridge.request 白名单前缀
31
+ * @property {boolean} [bootstrapSummary] bootstrap 结束是否打印摘要
11
32
  */
12
33
 
13
- /** 与清单里 entryUrl 的 hostname 一致;含 IPv6 本机,避免部分环境解析 localhost → ::1 后被宿主拒绝 */
14
- const DEFAULT_ALLOWED_HOSTS = new Set(['localhost', '127.0.0.1', '::1'])
15
-
16
- function viteEnv(key, fallback = '') {
34
+ /**
35
+ * Vite 注入的 `import.meta.env` 读取字符串配置。
36
+ * @param {string} key
37
+ * @returns {string|undefined}
38
+ */
39
+ function readImportMetaEnv(key) {
17
40
  try {
18
- if (typeof import.meta !== 'undefined' && import.meta.env && import.meta.env[key] !== undefined) {
41
+ if (typeof import.meta !== 'undefined' && import.meta.env) {
19
42
  const v = import.meta.env[key]
20
- if (v !== '' && v !== undefined) return v
43
+ if (v !== undefined && v !== '') {
44
+ return String(v)
45
+ }
46
+ }
47
+ } catch (_) {}
48
+ return undefined
49
+ }
50
+
51
+ /**
52
+ * 从 Webpack `DefinePlugin` 等注入的 `process.env` 读取。
53
+ * @param {string} key
54
+ * @returns {string|undefined}
55
+ */
56
+ function readProcessEnv(key) {
57
+ try {
58
+ if (typeof process !== 'undefined' && process.env && key in process.env) {
59
+ const v = process.env[key]
60
+ if (v !== undefined && v !== '') {
61
+ return String(v)
62
+ }
21
63
  }
22
- } catch (_) {
23
- /* non-Vite bundler */
64
+ } catch (_) {}
65
+ return undefined
66
+ }
67
+
68
+ /**
69
+ * `VITE_*` 的并列命名:同值可读 `PLUGIN_*`(`VITE_WEB_PLUGIN_X` → `PLUGIN_WEB_PLUGIN_X`)。
70
+ * Vite 需在 `defineConfig({ envPrefix: ['VITE_', 'PLUGIN_'] })` 中暴露 `PLUGIN_`;Webpack 用 DefinePlugin 注入即可。
71
+ * @param {string} viteStyleKey 以 `VITE_` 开头的键名
72
+ * @returns {string|null}
73
+ */
74
+ function viteKeyToPluginAlternate(viteStyleKey) {
75
+ if (typeof viteStyleKey !== 'string' || !viteStyleKey.startsWith('VITE_')) {
76
+ return null
24
77
  }
25
- return fallback
78
+ return `PLUGIN_${viteStyleKey.slice(5)}`
26
79
  }
27
80
 
28
- function viteIsDev() {
81
+ /**
82
+ * 先读 `VITE_*`,再读对应的 `PLUGIN_*`,再 `process.env`,最后 `fallback`。
83
+ * @param {string} key 仍以 `VITE_*` 为逻辑名(与文档一致)
84
+ * @param {string} [fallback='']
85
+ */
86
+ function resolveBundledEnv(key, fallback = '') {
87
+ const alt = viteKeyToPluginAlternate(key)
88
+ const fromMeta =
89
+ readImportMetaEnv(key) ?? (alt ? readImportMetaEnv(alt) : undefined)
90
+ const fromProcess =
91
+ readProcessEnv(key) ?? (alt ? readProcessEnv(alt) : undefined)
92
+ return fromMeta ?? fromProcess ?? fallback
93
+ }
94
+
95
+ /**
96
+ * @returns {boolean}
97
+ */
98
+ function resolveBundledIsDev() {
29
99
  try {
30
- return !!import.meta.env.DEV
31
- } catch (_) {
32
- return false
100
+ if (typeof import.meta !== 'undefined' && import.meta.env && import.meta.env.DEV === true) {
101
+ return true
102
+ }
103
+ } catch (_) {}
104
+ try {
105
+ if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'development') {
106
+ return true
107
+ }
108
+ } catch (_) {}
109
+ return false
110
+ }
111
+
112
+ /**
113
+ * @param {string} p
114
+ */
115
+ function ensureLeadingPath(p) {
116
+ const t = String(p || '').trim()
117
+ if (!t) {
118
+ return '/'
119
+ }
120
+ return t.startsWith('/') ? t : `/${t}`
121
+ }
122
+
123
+ /**
124
+ * 解析 `include` | `omit` | `same-origin`,非法时回退默认值。
125
+ * @param {string|undefined} userVal
126
+ * @param {string} envKey
127
+ * @param {RequestCredentials} fallback
128
+ */
129
+ function resolveManifestCredentials(userVal, envKey, fallback) {
130
+ if (userVal !== undefined && userVal !== '') {
131
+ const s = String(userVal)
132
+ if (s === 'include' || s === 'omit' || s === 'same-origin') {
133
+ return s
134
+ }
135
+ }
136
+ const e = resolveBundledEnv(envKey, '')
137
+ if (e === 'include' || e === 'omit' || e === 'same-origin') {
138
+ return e
139
+ }
140
+ return fallback
141
+ }
142
+
143
+ /**
144
+ * @param {number|undefined} userVal
145
+ * @param {string} envKey
146
+ * @param {number} fallback
147
+ */
148
+ function resolvePositiveInt(userVal, envKey, fallback) {
149
+ if (typeof userVal === 'number' && Number.isFinite(userVal) && userVal > 0) {
150
+ return Math.floor(userVal)
151
+ }
152
+ const raw = resolveBundledEnv(envKey, '')
153
+ const n = raw ? parseInt(raw, 10) : NaN
154
+ if (Number.isFinite(n) && n > 0) {
155
+ return n
33
156
  }
157
+ return fallback
34
158
  }
35
159
 
36
160
  /**
37
- * 合并宿主传入项与 Vite 环境变量(便于 npm 包在 Vite 宿主中零配置;亦可在非 Vite 场景显式传入)。
161
+ * 合并用户、环境变量与 `defaultWebExtendPluginRuntime`,得到完整运行时选项(宿主可只传需要覆盖的字段)。
38
162
  * @param {WebExtendPluginRuntimeOptions} [user]
163
+ * @returns {object}
39
164
  */
40
165
  export function resolveRuntimeOptions(user = {}) {
166
+ const manifestBaseRaw =
167
+ user.manifestBase !== undefined && user.manifestBase !== ''
168
+ ? String(user.manifestBase)
169
+ : resolveBundledEnv('VITE_FRONTEND_PLUGIN_BASE', DEF.manifestBase) || DEF.manifestBase
170
+
171
+ const manifestListPath = ensureLeadingPath(
172
+ user.manifestListPath !== undefined && user.manifestListPath !== ''
173
+ ? user.manifestListPath
174
+ : resolveBundledEnv('VITE_WEB_PLUGIN_MANIFEST_PATH', DEF.manifestListPath)
175
+ )
176
+
177
+ const defaultImplicitDevPluginIds =
178
+ Array.isArray(user.defaultImplicitDevPluginIds)
179
+ ? user.defaultImplicitDevPluginIds.map(String).filter(Boolean)
180
+ : (() => {
181
+ const e = resolveBundledEnv('VITE_WEB_PLUGIN_IMPLICIT_DEV_IDS', '')
182
+ if (e) {
183
+ return e
184
+ .split(',')
185
+ .map((s) => s.trim())
186
+ .filter(Boolean)
187
+ }
188
+ return [...DEF.defaultImplicitDevPluginIds]
189
+ })()
190
+
191
+ const allowedScriptHosts =
192
+ Array.isArray(user.allowedScriptHosts) && user.allowedScriptHosts.length > 0
193
+ ? user.allowedScriptHosts.map((h) => normalizeHost(String(h))).filter(Boolean)
194
+ : (() => {
195
+ const e = resolveBundledEnv('VITE_WEB_PLUGIN_ALLOWED_SCRIPT_HOSTS', '')
196
+ if (e) {
197
+ return e
198
+ .split(',')
199
+ .map((s) => normalizeHost(s.trim()))
200
+ .filter(Boolean)
201
+ }
202
+ return [...DEF.allowedScriptHosts]
203
+ })()
204
+
205
+ const bridgeAllowedPathPrefixes =
206
+ Array.isArray(user.bridgeAllowedPathPrefixes) && user.bridgeAllowedPathPrefixes.length > 0
207
+ ? user.bridgeAllowedPathPrefixes.map((p) => ensureLeadingPath(p)).filter(Boolean)
208
+ : (() => {
209
+ const e = resolveBundledEnv('VITE_WEB_PLUGIN_BRIDGE_PREFIXES', '')
210
+ if (e) {
211
+ return e
212
+ .split(',')
213
+ .map((s) => ensureLeadingPath(s.trim()))
214
+ .filter(Boolean)
215
+ }
216
+ return [...DEF.bridgeAllowedPathPrefixes]
217
+ })()
218
+
41
219
  return {
42
- manifestBase:
43
- user.manifestBase !== undefined && user.manifestBase !== ''
44
- ? user.manifestBase
45
- : viteEnv('VITE_FRONTEND_PLUGIN_BASE', '/fp-api') || '/fp-api',
46
- isDev: user.isDev !== undefined ? user.isDev : viteIsDev(),
220
+ manifestBase: manifestBaseRaw.replace(/\/$/, '') || DEF.manifestBase.replace(/\/$/, ''),
221
+ manifestListPath,
222
+ manifestFetchCredentials: resolveManifestCredentials(
223
+ user.manifestFetchCredentials,
224
+ 'VITE_WEB_PLUGIN_MANIFEST_CREDENTIALS',
225
+ DEF.manifestFetchCredentials
226
+ ),
227
+ isDev: user.isDev !== undefined ? user.isDev : resolveBundledIsDev(),
47
228
  webPluginDevOrigin:
48
- user.webPluginDevOrigin !== undefined
49
- ? user.webPluginDevOrigin
50
- : viteEnv('VITE_WEB_PLUGIN_DEV_ORIGIN', ''),
229
+ user.webPluginDevOrigin !== undefined ? user.webPluginDevOrigin : resolveBundledEnv('VITE_WEB_PLUGIN_DEV_ORIGIN', ''),
51
230
  webPluginDevIds:
52
- user.webPluginDevIds !== undefined
53
- ? user.webPluginDevIds
54
- : viteEnv('VITE_WEB_PLUGIN_DEV_IDS', ''),
231
+ user.webPluginDevIds !== undefined ? user.webPluginDevIds : resolveBundledEnv('VITE_WEB_PLUGIN_DEV_IDS', ''),
55
232
  webPluginDevMapJson:
56
233
  user.webPluginDevMapJson !== undefined
57
234
  ? user.webPluginDevMapJson
58
- : viteEnv('VITE_WEB_PLUGIN_DEV_MAP', '')
235
+ : resolveBundledEnv('VITE_WEB_PLUGIN_DEV_MAP', ''),
236
+ webPluginDevEntryPath: ensureLeadingPath(
237
+ user.webPluginDevEntryPath !== undefined && user.webPluginDevEntryPath !== ''
238
+ ? user.webPluginDevEntryPath
239
+ : resolveBundledEnv('VITE_WEB_PLUGIN_DEV_ENTRY', DEF.webPluginDevEntryPath)
240
+ ),
241
+ devPingPath: ensureLeadingPath(
242
+ user.devPingPath !== undefined && user.devPingPath !== ''
243
+ ? user.devPingPath
244
+ : resolveBundledEnv('VITE_WEB_PLUGIN_DEV_PING_PATH', DEF.devPingPath)
245
+ ),
246
+ devReloadSsePath: ensureLeadingPath(
247
+ user.devReloadSsePath !== undefined && user.devReloadSsePath !== ''
248
+ ? user.devReloadSsePath
249
+ : resolveBundledEnv('VITE_WEB_PLUGIN_DEV_SSE_PATH', DEF.devReloadSsePath)
250
+ ),
251
+ devPingTimeoutMs: resolvePositiveInt(user.devPingTimeoutMs, 'VITE_WEB_PLUGIN_DEV_PING_TIMEOUT_MS', DEF.devPingTimeoutMs),
252
+ defaultImplicitDevPluginIds,
253
+ allowedScriptHosts,
254
+ bridgeAllowedPathPrefixes,
255
+ bootstrapSummary: user.bootstrapSummary
256
+ }
257
+ }
258
+
259
+ /**
260
+ * @param {ReturnType<typeof resolveRuntimeOptions>} opts
261
+ */
262
+ function shouldShowBootstrapSummary(opts) {
263
+ if (opts.bootstrapSummary === true) {
264
+ return true
265
+ }
266
+ if (opts.bootstrapSummary === false) {
267
+ return false
268
+ }
269
+ const env = resolveBundledEnv('VITE_PLUGINS_BOOTSTRAP_SUMMARY', '')
270
+ if (env === '0' || env === 'false') {
271
+ return false
272
+ }
273
+ if (env === '1' || env === 'true') {
274
+ return true
59
275
  }
276
+ return resolveBundledIsDev()
60
277
  }
61
278
 
279
+ /**
280
+ * @param {string} hostname
281
+ */
62
282
  function normalizeHost(hostname) {
63
- if (!hostname) return ''
283
+ if (!hostname) {
284
+ return ''
285
+ }
64
286
  const h = hostname.toLowerCase()
65
287
  if (h.startsWith('[') && h.endsWith(']')) {
66
288
  return h.slice(1, -1)
@@ -68,16 +290,41 @@ function normalizeHost(hostname) {
68
290
  return h
69
291
  }
70
292
 
71
- function isScriptHostAllowed(url) {
293
+ /**
294
+ * @param {string[]} hostnames
295
+ * @returns {Set<string>}
296
+ */
297
+ function buildAllowedScriptHostsSet(hostnames) {
298
+ const s = new Set()
299
+ for (const h of hostnames) {
300
+ const n = normalizeHost(h)
301
+ if (n) {
302
+ s.add(n)
303
+ }
304
+ }
305
+ return s
306
+ }
307
+
308
+ /**
309
+ * @param {string} url
310
+ * @param {Set<string>} hostSet
311
+ */
312
+ function isScriptHostAllowed(url, hostSet) {
313
+ if (typeof window === 'undefined') {
314
+ return false
315
+ }
72
316
  try {
73
317
  const u = new URL(url, window.location.origin)
74
318
  const h = normalizeHost(u.hostname)
75
- return DEFAULT_ALLOWED_HOSTS.has(h)
319
+ return hostSet.has(h)
76
320
  } catch {
77
321
  return false
78
322
  }
79
323
  }
80
324
 
325
+ /**
326
+ * @param {ReturnType<typeof resolveRuntimeOptions>} opts
327
+ */
81
328
  function parseWebPluginDevMapExplicit(opts) {
82
329
  if (!opts.isDev) {
83
330
  return null
@@ -90,29 +337,48 @@ function parseWebPluginDevMapExplicit(opts) {
90
337
  const map = JSON.parse(String(raw))
91
338
  return map && typeof map === 'object' ? map : null
92
339
  } catch {
93
- console.warn('[plugins] VITE_WEB_PLUGIN_DEV_MAP is not valid JSON')
340
+ console.warn('[plugins] webPluginDevMapJson / VITE_WEB_PLUGIN_DEV_MAP is not valid JSON')
94
341
  return null
95
342
  }
96
343
  }
97
344
 
98
- async function buildImplicitWebPluginDevMap(opts) {
345
+ /**
346
+ * @param {ReturnType<typeof resolveRuntimeOptions>} opts
347
+ * @param {Set<string>} hostSet
348
+ */
349
+ async function buildImplicitWebPluginDevMap(opts, hostSet) {
99
350
  if (!opts.isDev) {
100
351
  return {}
101
352
  }
102
- const origin = opts.webPluginDevOrigin === undefined || opts.webPluginDevOrigin === null
103
- ? ''
104
- : String(opts.webPluginDevOrigin).trim()
353
+ const origin =
354
+ opts.webPluginDevOrigin === undefined || opts.webPluginDevOrigin === null
355
+ ? ''
356
+ : String(opts.webPluginDevOrigin).trim()
105
357
  if (!origin) {
106
358
  return {}
107
359
  }
108
- if (!isScriptHostAllowed(`${origin}/`)) {
360
+ if (!isScriptHostAllowed(`${origin}/`, hostSet)) {
361
+ return {}
362
+ }
363
+
364
+ const idsRaw = opts.webPluginDevIds
365
+ const ids =
366
+ idsRaw !== undefined && idsRaw !== null && String(idsRaw).trim() !== ''
367
+ ? String(idsRaw)
368
+ .split(',')
369
+ .map((s) => s.trim())
370
+ .filter(Boolean)
371
+ : [...opts.defaultImplicitDevPluginIds]
372
+
373
+ if (ids.length === 0) {
109
374
  return {}
110
375
  }
376
+
111
377
  const base = origin.replace(/\/$/, '')
112
- const pingUrl = `${base}/__web_plugin_dev_ping`
378
+ const pingUrl = `${base}${opts.devPingPath}`
113
379
  try {
114
380
  const ctrl = new AbortController()
115
- const timer = setTimeout(() => ctrl.abort(), 500)
381
+ const timer = setTimeout(() => ctrl.abort(), opts.devPingTimeoutMs)
116
382
  const r = await fetch(pingUrl, {
117
383
  mode: 'cors',
118
384
  cache: 'no-store',
@@ -130,58 +396,88 @@ async function buildImplicitWebPluginDevMap(opts) {
130
396
  return {}
131
397
  }
132
398
 
133
- const idsRaw = opts.webPluginDevIds
134
- const ids =
135
- idsRaw !== undefined && idsRaw !== null && String(idsRaw).trim() !== ''
136
- ? String(idsRaw)
137
- .split(',')
138
- .map((s) => s.trim())
139
- .filter(Boolean)
140
- : ['com.example.frontend.demo']
141
-
399
+ const pathPart = opts.webPluginDevEntryPath
142
400
  const map = {}
143
401
  for (const id of ids) {
144
- map[id] = `${base}/src/plugin-entry.js`
402
+ map[id] = `${base}${pathPart}`
403
+ }
404
+ if (ids.length) {
405
+ console.info(
406
+ '[plugins] 已检测到插件 dev 服务(',
407
+ base,
408
+ '),下列 id 将加载隐式 dev 入口(',
409
+ pathPart,
410
+ ')而非清单 dist:',
411
+ ids.join(', ')
412
+ )
145
413
  }
146
- console.info(
147
- '[plugins] 已检测到插件 Vite(',
148
- base,
149
- '),下列 id 将加载源码入口而非清单 dist:',
150
- ids.join(', ')
151
- )
152
414
  return map
153
415
  }
154
416
 
417
+ /**
418
+ * @param {Record<string, string>} implicit
419
+ * @param {Record<string, string>|null} explicit
420
+ */
155
421
  function mergeDevMaps(implicit, explicit) {
156
422
  const i = implicit && typeof implicit === 'object' ? implicit : {}
157
423
  const e = explicit && typeof explicit === 'object' ? explicit : {}
158
424
  return { ...i, ...e }
159
425
  }
160
426
 
161
- /** @type {Set<string>} */
162
- const pluginDevSseStarted = typeof window !== 'undefined' ? new Set() : new Set()
427
+ /** @type {Map<string, EventSource>} */
428
+ const pluginDevEventSources = new Map()
163
429
 
164
- function isDevOriginAllowedForSse(origin) {
430
+ let pluginDevBeforeUnloadRegistered = false
431
+
432
+ function closeAllPluginDevEventSources() {
433
+ for (const es of pluginDevEventSources.values()) {
434
+ try {
435
+ es.close()
436
+ } catch (_) {}
437
+ }
438
+ pluginDevEventSources.clear()
439
+ }
440
+
441
+ function ensurePluginDevBeforeUnload() {
442
+ if (pluginDevBeforeUnloadRegistered || typeof window === 'undefined') {
443
+ return
444
+ }
445
+ pluginDevBeforeUnloadRegistered = true
446
+ window.addEventListener('beforeunload', closeAllPluginDevEventSources)
447
+ }
448
+
449
+ /**
450
+ * @param {string} origin
451
+ * @param {Set<string>} hostSet
452
+ */
453
+ function isDevOriginAllowedForSse(origin, hostSet) {
165
454
  try {
166
455
  const u = new URL(origin)
167
- return DEFAULT_ALLOWED_HOSTS.has(normalizeHost(u.hostname))
456
+ return hostSet.has(normalizeHost(u.hostname))
168
457
  } catch {
169
458
  return false
170
459
  }
171
460
  }
172
461
 
173
- function startPluginDevReloadSse(origin, isDev) {
174
- if (!isDev || pluginDevSseStarted.has(origin)) {
462
+ /**
463
+ * @param {string} origin
464
+ * @param {boolean} isDev
465
+ * @param {Set<string>} hostSet
466
+ * @param {string} ssePath
467
+ */
468
+ function startPluginDevReloadSse(origin, isDev, hostSet, ssePath) {
469
+ if (!isDev || pluginDevEventSources.has(origin)) {
175
470
  return
176
471
  }
177
- if (!isDevOriginAllowedForSse(origin)) {
472
+ if (!isDevOriginAllowedForSse(origin, hostSet)) {
178
473
  return
179
474
  }
180
- pluginDevSseStarted.add(origin)
475
+ ensurePluginDevBeforeUnload()
181
476
  const base = origin.replace(/\/$/, '')
182
- const url = `${base}/__web_plugin_reload_stream`
477
+ const url = `${base}${ssePath}`
183
478
  try {
184
479
  const es = new EventSource(url)
480
+ pluginDevEventSources.set(origin, es)
185
481
  es.addEventListener('reload', () => {
186
482
  window.location.reload()
187
483
  })
@@ -193,8 +489,14 @@ function startPluginDevReloadSse(origin, isDev) {
193
489
  }
194
490
  }
195
491
 
196
- function startPluginDevSseForMap(devMap, isDev) {
197
- if (!isDev || !devMap) {
492
+ /**
493
+ * @param {Record<string, string>|null|undefined} devMap
494
+ * @param {boolean} isDev
495
+ * @param {Set<string>} hostSet
496
+ * @param {string} ssePath
497
+ */
498
+ function startPluginDevSseForMap(devMap, isDev, hostSet, ssePath) {
499
+ if (!isDev || !devMap || typeof window === 'undefined') {
198
500
  return
199
501
  }
200
502
  const origins = new Set()
@@ -213,40 +515,84 @@ function startPluginDevSseForMap(devMap, isDev) {
213
515
  }
214
516
  }
215
517
  for (const o of origins) {
216
- startPluginDevReloadSse(o, isDev)
518
+ startPluginDevReloadSse(o, isDev, hostSet, ssePath)
217
519
  }
218
520
  }
219
521
 
522
+ const loadScriptMemo = new Map()
523
+
220
524
  function loadScript(src) {
221
- return new Promise((resolve, reject) => {
525
+ if (typeof document === 'undefined') {
526
+ return Promise.reject(new Error('loadScript: no document'))
527
+ }
528
+ if (loadScriptMemo.has(src)) {
529
+ return loadScriptMemo.get(src)
530
+ }
531
+ const p = new Promise((resolve, reject) => {
532
+ const scripts = document.getElementsByTagName('script')
533
+ for (let i = 0; i < scripts.length; i++) {
534
+ const el = scripts[i]
535
+ if (el.src === src) {
536
+ if (el.getAttribute('data-wep-loaded') === 'true') {
537
+ resolve()
538
+ return
539
+ }
540
+ el.addEventListener(
541
+ 'load',
542
+ () => {
543
+ el.setAttribute('data-wep-loaded', 'true')
544
+ resolve()
545
+ },
546
+ { once: true }
547
+ )
548
+ el.addEventListener('error', () => reject(new Error('loadScript failed: ' + src)), { once: true })
549
+ return
550
+ }
551
+ }
222
552
  const s = document.createElement('script')
223
553
  s.async = true
224
554
  s.src = src
225
- s.onload = () => resolve()
555
+ s.onload = () => {
556
+ s.setAttribute('data-wep-loaded', 'true')
557
+ resolve()
558
+ }
226
559
  s.onerror = () => reject(new Error('loadScript failed: ' + src))
227
560
  document.head.appendChild(s)
228
561
  })
562
+ loadScriptMemo.set(src, p)
563
+ p.catch(() => loadScriptMemo.delete(src))
564
+ return p
229
565
  }
230
566
 
231
- async function loadPluginEntry(p, entryUrl, devMap) {
567
+ /**
568
+ * @param {{ id: string }} p
569
+ * @param {string} [entryUrl]
570
+ * @param {Record<string, string>|null|undefined} devMap
571
+ * @param {Set<string>} hostSet
572
+ */
573
+ async function loadPluginEntry(p, entryUrl, devMap, hostSet) {
232
574
  const devEntry = devMap && typeof devMap[p.id] === 'string' ? devMap[p.id].trim() : ''
233
575
  if (devEntry) {
234
- if (!isScriptHostAllowed(devEntry)) {
576
+ if (!isScriptHostAllowed(devEntry, hostSet)) {
235
577
  console.warn('[plugins] dev entry URL not allowed', p.id, devEntry)
236
578
  return
237
579
  }
238
580
  try {
239
- await import(/* @vite-ignore */ devEntry)
581
+ await import(
582
+ /* webpackIgnore: true */
583
+ /* @vite-ignore */
584
+ devEntry
585
+ )
240
586
  } catch (e) {
241
587
  console.warn('[plugins] dev module import failed, try manifest entryUrl', p.id, e)
242
- if (entryUrl && isScriptHostAllowed(entryUrl)) {
588
+ if (entryUrl && isScriptHostAllowed(entryUrl, hostSet)) {
243
589
  await loadScript(entryUrl)
244
590
  }
245
591
  return
246
592
  }
247
593
  return
248
594
  }
249
- if (!entryUrl || !isScriptHostAllowed(entryUrl)) {
595
+ if (!entryUrl || !isScriptHostAllowed(entryUrl, hostSet)) {
250
596
  console.warn('[plugins] skip (entryUrl not allowed)', p.id, entryUrl)
251
597
  return
252
598
  }
@@ -255,18 +601,25 @@ async function loadPluginEntry(p, entryUrl, devMap) {
255
601
 
256
602
  /**
257
603
  * @param {import('vue-router').default} router
258
- * @param {(pluginId: string) => object} createHostApi
259
- * @param {WebExtendPluginRuntimeOptions} [runtimeOptions] 可选;不传则从 Vite `import.meta.env` 读取(若存在)
604
+ * @param {(pluginId: string, router: import('vue-router').default, hostKit?: { bridgeAllowedPathPrefixes: string[] }) => object} createHostApiFactory
605
+ * 始终传入三个参数;单参工厂 `(id) => createHostApi(id, router)` 仍可用,后两个实参被忽略。
606
+ * @param {WebExtendPluginRuntimeOptions} [runtimeOptions]
260
607
  */
261
- export async function bootstrapPlugins(router, createHostApi, runtimeOptions) {
608
+ export async function bootstrapPlugins(router, createHostApiFactory, runtimeOptions) {
609
+ if (typeof window === 'undefined') {
610
+ console.warn('[plugins] bootstrapPlugins skipped: requires browser (window)')
611
+ return
612
+ }
262
613
  const opts = resolveRuntimeOptions(runtimeOptions || {})
263
614
  const base = String(opts.manifestBase).replace(/\/$/, '')
615
+ const manifestUrl = `${base}${opts.manifestListPath}`
616
+ const hostSet = buildAllowedScriptHostsSet(opts.allowedScriptHosts)
264
617
  const explicit = parseWebPluginDevMapExplicit(opts)
265
618
 
266
619
  const [manifestResult, implicit] = await Promise.all([
267
620
  (async () => {
268
621
  try {
269
- const res = await fetch(`${base}/api/frontend-plugins`, { credentials: 'include' })
622
+ const res = await fetch(manifestUrl, { credentials: opts.manifestFetchCredentials })
270
623
  if (!res.ok) {
271
624
  return { ok: false, status: res.status, data: null }
272
625
  }
@@ -276,32 +629,40 @@ export async function bootstrapPlugins(router, createHostApi, runtimeOptions) {
276
629
  return { ok: false, error: e, data: null }
277
630
  }
278
631
  })(),
279
- buildImplicitWebPluginDevMap(opts)
632
+ buildImplicitWebPluginDevMap(opts, hostSet)
280
633
  ])
281
634
 
282
635
  const devMap = mergeDevMaps(implicit, explicit)
283
- startPluginDevSseForMap(devMap, opts.isDev)
636
+ startPluginDevSseForMap(devMap, opts.isDev, hostSet, opts.devReloadSsePath)
637
+
638
+ const hostKit = { bridgeAllowedPathPrefixes: opts.bridgeAllowedPathPrefixes }
284
639
 
285
640
  if (!manifestResult.ok) {
286
641
  if (manifestResult.error) {
287
642
  console.warn('[plugins] fetch manifest failed', manifestResult.error)
288
643
  } else {
289
- console.warn('[plugins] manifest HTTP', manifestResult.status)
644
+ console.warn('[plugins] manifest HTTP', manifestResult.status, manifestUrl)
645
+ }
646
+ if (shouldShowBootstrapSummary(opts)) {
647
+ console.info('[plugins] bootstrap_summary', { ok: false, reason: 'manifest_fetch' })
290
648
  }
291
649
  return
292
650
  }
293
651
  /** @type {{ hostPluginApiVersion?: string, plugins?: object[] }} */
294
652
  const data = manifestResult.data
295
653
  if (!data) {
654
+ if (shouldShowBootstrapSummary(opts)) {
655
+ console.info('[plugins] bootstrap_summary', { ok: false, reason: 'manifest_empty_body' })
656
+ }
296
657
  return
297
658
  }
298
659
 
299
660
  const apiVer = data.hostPluginApiVersion
300
661
  if (apiVer) {
301
- const coerced = semver.coerce(apiVer)
662
+ const coerced = coerce(apiVer)
302
663
  const maj = coerced ? coerced.major : 0
303
664
  const range = `^${maj}.0.0`
304
- if (!semver.satisfies(HOST_PLUGIN_API_VERSION, range, { includePrerelease: true })) {
665
+ if (!satisfies(HOST_PLUGIN_API_VERSION, range, { includePrerelease: true })) {
305
666
  console.warn(
306
667
  '[plugins] host API version mismatch: host implements',
307
668
  HOST_PLUGIN_API_VERSION,
@@ -316,32 +677,53 @@ export async function bootstrapPlugins(router, createHostApi, runtimeOptions) {
316
677
  const plugins = data.plugins || []
317
678
  if (plugins.length === 0) {
318
679
  console.info(
319
- '[plugins] 清单为空。请检查:① web-extend-plugin-server 是否已启动;② 其 JVM 工作目录下 web-plugins-dir 是否指向含 manifest 的目录(通常应在 web-extend-plugin-server 目录执行 mvn spring-boot:run,且存在 ../web-plugins/子目录);③ 浏览器访问清单 URL 是否有 plugins 条目。'
680
+ '[plugins] 清单为空。请检查:① web-extend-plugin-server 是否已启动;② frontend-plugin.web-plugins-dir 是否指向含各插件子目录及 manifest.json 的路径;③ 浏览器直接访问',
681
+ manifestUrl,
682
+ '是否返回 plugins 条目。'
320
683
  )
321
684
  }
685
+
686
+ const summary = {
687
+ manifestCount: plugins.length,
688
+ activated: 0,
689
+ skipEngines: 0,
690
+ skipLoad: 0,
691
+ skipNoActivator: 0,
692
+ activateFail: 0
693
+ }
694
+
322
695
  for (const p of plugins) {
323
696
  const range = p.engines && p.engines.host
324
- if (range && !semver.satisfies(HOST_PLUGIN_API_VERSION, range, { includePrerelease: true })) {
697
+ if (range && !satisfies(HOST_PLUGIN_API_VERSION, range, { includePrerelease: true })) {
325
698
  console.warn('[plugins] skip (engines.host)', p.id, range)
699
+ summary.skipEngines++
326
700
  continue
327
701
  }
328
702
  const entryUrl = p.entryUrl
329
703
  try {
330
- await loadPluginEntry(p, entryUrl, devMap)
704
+ await loadPluginEntry(p, entryUrl, devMap, hostSet)
331
705
  } catch (e) {
332
706
  console.warn('[plugins] script load failed', p.id, e)
707
+ summary.skipLoad++
333
708
  continue
334
709
  }
335
710
  const activator = window.__PLUGIN_ACTIVATORS__[p.id]
336
711
  if (typeof activator !== 'function') {
337
712
  console.warn('[plugins] no activator for', p.id)
713
+ summary.skipNoActivator++
338
714
  continue
339
715
  }
340
- const hostApi = createHostApi(p.id)
716
+ const hostApi = createHostApiFactory(p.id, router, hostKit)
341
717
  try {
342
718
  activator(hostApi)
719
+ summary.activated++
343
720
  } catch (e) {
344
721
  console.error('[plugins] activate failed', p.id, e)
722
+ summary.activateFail++
345
723
  }
346
724
  }
725
+
726
+ if (shouldShowBootstrapSummary(opts)) {
727
+ console.info('[plugins] bootstrap_summary', { ok: true, ...summary })
728
+ }
347
729
  }
package/src/bridge.js CHANGED
@@ -1,15 +1,43 @@
1
- const ALLOWED_PREFIXES = ['/api/']
1
+ /**
2
+ * 插件通过宿主访问后端的受控通道:仅允许配置的前缀路径,默认 `/api/`;强制默认 `same-origin` 携带 Cookie。
3
+ * 前缀列表由 `createRequestBridge({ allowedPathPrefixes })` 传入,与 `defaultWebExtendPluginRuntime.bridgeAllowedPathPrefixes` 对齐。
4
+ *
5
+ * @module bridge
6
+ */
7
+ import { defaultWebExtendPluginRuntime } from './default-runtime-config.js'
8
+
9
+ /**
10
+ * @param {string} p
11
+ */
12
+ function ensureLeadingSlash(p) {
13
+ const t = String(p || '').trim()
14
+ if (!t) {
15
+ return '/'
16
+ }
17
+ return t.startsWith('/') ? t : `/${t}`
18
+ }
2
19
 
3
20
  /**
4
- * @returns {{ request: (path: string, init?: RequestInit) => Promise<Response> }}
21
+ * @param {{ allowedPathPrefixes?: string[] }} [config]
5
22
  */
6
- export function createRequestBridge() {
23
+ export function createRequestBridge(config = {}) {
24
+ const raw =
25
+ Array.isArray(config.allowedPathPrefixes) && config.allowedPathPrefixes.length > 0
26
+ ? config.allowedPathPrefixes
27
+ : defaultWebExtendPluginRuntime.bridgeAllowedPathPrefixes
28
+ const allowedPathPrefixes = raw.map((p) => ensureLeadingSlash(p))
29
+
7
30
  return {
31
+ /**
32
+ * 发起受控 `fetch`。
33
+ * @param {string} path 必须以 `/` 开头,且匹配某一 `allowedPathPrefixes` 前缀
34
+ * @param {RequestInit} [init] 会与默认 `{ credentials: 'same-origin' }` 合并(后者可被覆盖)
35
+ */
8
36
  async request(path, init = {}) {
9
37
  if (typeof path !== 'string' || !path.startsWith('/')) {
10
38
  throw new Error('[bridge] path must be a string starting with /')
11
39
  }
12
- const allowed = ALLOWED_PREFIXES.some((p) => path.startsWith(p))
40
+ const allowed = allowedPathPrefixes.some((p) => path.startsWith(p))
13
41
  if (!allowed) {
14
42
  throw new Error('[bridge] path not allowed: ' + path)
15
43
  }
@@ -26,4 +26,13 @@ describe('createRequestBridge', () => {
26
26
  await expect(bridge.request('/fp-api/x')).rejects.toThrow(/not allowed/)
27
27
  await expect(bridge.request('http://evil.com/api/x')).rejects.toThrow()
28
28
  })
29
+
30
+ it('honors custom allowedPathPrefixes', async () => {
31
+ const bridge = createRequestBridge({ allowedPathPrefixes: ['/fp-api/'] })
32
+ await bridge.request('/fp-api/frontend-plugins')
33
+ expect(globalThis.fetch).toHaveBeenCalledWith(
34
+ '/fp-api/frontend-plugins',
35
+ expect.objectContaining({ credentials: 'same-origin' })
36
+ )
37
+ })
29
38
  })
@@ -11,6 +11,10 @@
11
11
  </template>
12
12
 
13
13
  <script>
14
+ /**
15
+ * 在宿主布局中声明扩展点;插件通过 `hostApi.registerSlotComponents(pointId, ...)` 注入组件。
16
+ * 使用 `Vue.observable` 注册表驱动重渲染;子树错误由边界组件捕获,避免拖垮整页。
17
+ */
14
18
  import { registries } from '../registries.js'
15
19
 
16
20
  const SlotErrorBoundary = {
@@ -46,6 +50,7 @@ export default {
46
50
  },
47
51
  computed: {
48
52
  items() {
53
+ void registries.slotRevision
49
54
  return registries.slots[this.pointId] || []
50
55
  },
51
56
  forwardProps() {
package/src/constants.js CHANGED
@@ -1,2 +1,5 @@
1
- /** Must match web-extend-plugin-server FrontendPluginsResponse.hostPluginApiVersion */
1
+ /**
2
+ * 与 `web-extend-plugin-server` 返回的 `FrontendPluginsResponse.hostPluginApiVersion` 保持一致,用于契约校验。
3
+ * @type {string}
4
+ */
2
5
  export const HOST_PLUGIN_API_VERSION = '1.0.0'
@@ -1,16 +1,68 @@
1
+ /**
2
+ * 构造供插件 activator 调用的宿主 API(路由、菜单、扩展点、资源注入等)。
3
+ * 与打包工具无关;Webpack 宿主需已配置 `vue-loader` 以编译本包内的 `.vue` 依赖。
4
+ *
5
+ * @module createHostApi
6
+ */
1
7
  import Vue from 'vue'
2
8
  import { HOST_PLUGIN_API_VERSION } from './constants.js'
3
9
  import { createRequestBridge } from './bridge.js'
10
+ import { defaultWebExtendPluginRuntime } from './default-runtime-config.js'
4
11
  import { registries } from './registries.js'
12
+ import { registerPluginTeardown } from './teardown-registry.js'
13
+
14
+ /** 扩展点列表项 key 递增,避免用 Date.now() 导致列表重排时误卸载组件 */
15
+ let slotItemKeySeq = 0
16
+ /** 无 name 的动态路由合成名递增,避免多次 registerRoutes 重名 */
17
+ let routeSynthSeq = 0
18
+
19
+ /**
20
+ * @typedef {object} RegisterSlotEntry
21
+ * @property {import('vue').Component} component
22
+ * @property {number} [priority]
23
+ */
5
24
 
6
25
  /**
7
- * @param {string} pluginId
8
- * @param {import('vue-router').default} router
26
+ * @typedef {object} HostApi
27
+ * @property {string} hostPluginApiVersion 宿主实现的协议版本
28
+ * @property {(routes: import('vue-router').RouteConfig[]) => void} registerRoutes 注册路由;无 `name` 时自动生成稳定合成名;优先 `router.addRoute`
29
+ * @property {(items: object[]) => void} registerMenuItems 注册菜单并按 `order` 排序
30
+ * @property {(pointId: string, components: RegisterSlotEntry[]) => void} registerSlotComponents 向扩展点挂载组件
31
+ * @property {(urls?: string[]) => void} registerStylesheetUrls 注入 `link[rel=stylesheet]`,带 `data-plugin-asset`
32
+ * @property {(urls?: string[]) => void} registerScriptUrls 顺序注入外链脚本
33
+ * @property {() => void} registerSanitizedHtmlSnippet MVP 未实现,调用即抛错
34
+ * @property {() => ReturnType<typeof createRequestBridge>} getBridge 受控 `fetch` 代理
35
+ * @property {(pluginId: string, fn: () => void) => void} onTeardown 注册卸载回调;由 `disposeWebPlugin(pluginId)` 触发
9
36
  */
10
- export function createHostApi(pluginId, router) {
11
- const bridge = createRequestBridge()
12
- const teardowns = []
13
37
 
38
+ /**
39
+ * @typedef {object} HostKitOptions
40
+ * @property {string[]} [bridgeAllowedPathPrefixes] 覆盖 `getBridge().request` 允许的 URL 前缀;默认见 `defaultWebExtendPluginRuntime.bridgeAllowedPathPrefixes`
41
+ */
42
+
43
+ /**
44
+ * 创建单个插件在宿主侧的 API 句柄,传入插件 `activator(hostApi)`。
45
+ *
46
+ * `bootstrapPlugins` 始终以 `(pluginId, router, hostKitOptions)` 调用工厂;请使用 `(id, r, kit) => createHostApi(id, r, kit)` 传入 `bridgeAllowedPathPrefixes`。
47
+ * 单参工厂 `(id) => createHostApi(id, router)` 仍可用(忽略后两个实参),此时 bridge 仅用包内默认前缀。
48
+ *
49
+ * @param {string} pluginId 与 manifest.id 一致
50
+ * @param {import('vue-router').default} router 宿主 Vue Router 实例(vue-router@3)
51
+ * @param {HostKitOptions} [hostKitOptions]
52
+ * @returns {HostApi}
53
+ */
54
+ export function createHostApi(pluginId, router, hostKitOptions = {}) {
55
+ const bridgePrefixes =
56
+ Array.isArray(hostKitOptions.bridgeAllowedPathPrefixes) &&
57
+ hostKitOptions.bridgeAllowedPathPrefixes.length > 0
58
+ ? hostKitOptions.bridgeAllowedPathPrefixes
59
+ : defaultWebExtendPluginRuntime.bridgeAllowedPathPrefixes
60
+ const bridge = createRequestBridge({ allowedPathPrefixes: bridgePrefixes })
61
+
62
+ /**
63
+ * 注入样式表;`disposeWebPlugin` 会按 `data-plugin-asset` 移除对应节点。
64
+ * @param {string} href
65
+ */
14
66
  function injectStylesheet(href) {
15
67
  const link = document.createElement('link')
16
68
  link.rel = 'stylesheet'
@@ -19,6 +71,11 @@ export function createHostApi(pluginId, router) {
19
71
  document.head.appendChild(link)
20
72
  }
21
73
 
74
+ /**
75
+ * 注入外链脚本(用于插件额外资源,非清单主入口)。
76
+ * @param {string} src
77
+ * @returns {Promise<void>}
78
+ */
22
79
  function injectScript(src) {
23
80
  return new Promise((resolve, reject) => {
24
81
  const s = document.createElement('script')
@@ -34,14 +91,29 @@ export function createHostApi(pluginId, router) {
34
91
  return {
35
92
  hostPluginApiVersion: HOST_PLUGIN_API_VERSION,
36
93
 
94
+ /**
95
+ * 动态注册路由。Vue Router 3.5+ 推荐 `addRoute`;若不存在则回退已弃用的 `addRoutes`。
96
+ * @param {import('vue-router').RouteConfig[]} routes
97
+ */
37
98
  registerRoutes(routes) {
38
99
  const wrapped = routes.map((r) => ({
39
100
  ...r,
101
+ name: r.name || `__wep_${pluginId}_${routeSynthSeq++}`,
40
102
  meta: { ...(r.meta || {}), pluginId }
41
103
  }))
42
- router.addRoutes(wrapped)
104
+ if (typeof router.addRoute === 'function') {
105
+ for (const r of wrapped) {
106
+ router.addRoute(r)
107
+ }
108
+ } else {
109
+ router.addRoutes(wrapped)
110
+ }
43
111
  },
44
112
 
113
+ /**
114
+ * 写入全局菜单注册表(响应式);按 `order` 升序排列。
115
+ * @param {object[]} items
116
+ */
45
117
  registerMenuItems(items) {
46
118
  for (const item of items) {
47
119
  registries.menus.push({ ...item, pluginId })
@@ -49,8 +121,15 @@ export function createHostApi(pluginId, router) {
49
121
  registries.menus.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
50
122
  },
51
123
 
124
+ /**
125
+ * 向指定扩展点 id 注册 Vue 组件;`ExtensionPoint` 按 `priority` 降序渲染。
126
+ * @param {string} pointId
127
+ * @param {RegisterSlotEntry[]} components
128
+ */
52
129
  registerSlotComponents(pointId, components) {
53
- if (!pointId) return
130
+ if (!pointId) {
131
+ return
132
+ }
54
133
  if (!registries.slots[pointId]) {
55
134
  Vue.set(registries.slots, pointId, [])
56
135
  }
@@ -60,18 +139,28 @@ export function createHostApi(pluginId, router) {
60
139
  pluginId,
61
140
  component: c.component,
62
141
  priority: c.priority ?? 0,
63
- key: `${pluginId}-${pointId}-${list.length}-${Date.now()}`
142
+ key: `${pluginId}-${pointId}-${++slotItemKeySeq}`
64
143
  })
65
144
  }
66
145
  list.sort((a, b) => b.priority - a.priority)
146
+ registries.slotRevision++
67
147
  },
68
148
 
149
+ /**
150
+ * @param {string[]|undefined} urls
151
+ */
69
152
  registerStylesheetUrls(urls) {
70
153
  for (const u of urls || []) {
71
- if (typeof u === 'string' && u) injectStylesheet(u)
154
+ if (typeof u === 'string' && u) {
155
+ injectStylesheet(u)
156
+ }
72
157
  }
73
158
  },
74
159
 
160
+ /**
161
+ * 串行加载多个脚本,失败仅告警不中断宿主。
162
+ * @param {string[]|undefined} urls
163
+ */
75
164
  registerScriptUrls(urls) {
76
165
  const chain = (urls || []).filter((u) => typeof u === 'string' && u).reduce(
77
166
  (p, u) => p.then(() => injectScript(u)),
@@ -86,8 +175,15 @@ export function createHostApi(pluginId, router) {
86
175
 
87
176
  getBridge: () => bridge,
88
177
 
89
- onTeardown(_pid, fn) {
90
- if (typeof fn === 'function') teardowns.push(fn)
178
+ /**
179
+ * 插件卸载前清理逻辑;第一个参数为预留与协议对齐,实际以创建 API 时的 `pluginId` 为准。
180
+ * @param {string} _pluginId 预留,与 manifest.id 一致时可传入
181
+ * @param {() => void} fn
182
+ */
183
+ onTeardown(_pluginId, fn) {
184
+ if (typeof fn === 'function') {
185
+ registerPluginTeardown(pluginId, fn)
186
+ }
91
187
  }
92
188
  }
93
189
  }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * 宿主可覆盖的默认运行时配置(路径、白名单、超时等)。
3
+ * 使用方式:`resolveRuntimeOptions({ ...defaultWebExtendPluginRuntime, manifestListPath: '/api/my-plugins' })`
4
+ * 或只传需要改动的字段:`resolveRuntimeOptions({ manifestListPath: '/api/my-plugins' })`。
5
+ *
6
+ * @module default-runtime-config
7
+ */
8
+
9
+ /**
10
+ * @typedef {typeof defaultWebExtendPluginRuntime} WebExtendPluginDefaultRuntime
11
+ */
12
+
13
+ export const defaultWebExtendPluginRuntime = {
14
+ /** 清单 HTTP 服务前缀(与后端 context-path 对齐),不含尾部 `/` */
15
+ manifestBase: '/fp-api',
16
+
17
+ /**
18
+ * 拉取插件清单的 **路径段**(以 `/` 开头),拼在 `manifestBase` 之后。
19
+ * 完整 URL:`{manifestBase}{manifestListPath}` → 默认 `/fp-api/api/frontend-plugins`
20
+ */
21
+ manifestListPath: '/api/frontend-plugins',
22
+
23
+ /** 清单 `fetch` 的 `credentials`,需 Cookie 会话时用 `include` */
24
+ manifestFetchCredentials: 'include',
25
+
26
+ /** 插件 dev 服务存活探测路径(拼在 `webPluginDevOrigin` 后) */
27
+ devPingPath: '/__web_plugin_dev_ping',
28
+
29
+ /** 插件 dev 热更新 SSE 路径(拼在插件 dev 的 origin 后) */
30
+ devReloadSsePath: '/__web_plugin_reload_stream',
31
+
32
+ /** 隐式 dev 映射里,每个 id 对应的入口路径(相对插件 dev origin) */
33
+ webPluginDevEntryPath: '/src/plugin-entry.js',
34
+
35
+ /** `fetch(devPingUrl)` 超时毫秒数 */
36
+ devPingTimeoutMs: 500,
37
+
38
+ /**
39
+ * **隐式 dev 映射**(可选):开发模式下若 `webPluginDevOrigin` 上 ping 成功,运行时会为若干插件 id 自动生成
40
+ * `{ id → origin + webPluginDevEntryPath }`,从而用 dev 服务入口替代清单里的 dist,便于热更新联调。
41
+ *
42
+ * 插件 id 来源优先级:`webPluginDevIds`(或 `VITE_WEB_PLUGIN_DEV_IDS`)→ 本字段。**默认 `[]`**,
43
+ * 避免把仓库示例 id 写进通用宿主;联调时在 `.env` 里设 `VITE_WEB_PLUGIN_DEV_IDS=com.xxx,com.yyy`,
44
+ * 或 `resolveRuntimeOptions({ defaultImplicitDevPluginIds: ['com.xxx'] })` 即可。
45
+ */
46
+ defaultImplicitDevPluginIds: [],
47
+
48
+ /**
49
+ * 允许通过 `<script>` / 动态 `import()` 加载的插件脚本所在主机名(小写),防误配公网 URL。
50
+ */
51
+ allowedScriptHosts: ['localhost', '127.0.0.1', '::1'],
52
+
53
+ /**
54
+ * `hostApi.getBridge().request(path)` 允许的 path 前缀;须以 `/` 开头。
55
+ * 需与后端实际 API 前缀一致。
56
+ */
57
+ bridgeAllowedPathPrefixes: ['/api/']
58
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * 单插件卸载:与 `bootstrapPlugins` / `createHostApi` 对称,清理注册表与 DOM 副作用。
3
+ *
4
+ * @module dispose-plugin
5
+ */
6
+ import Vue from 'vue'
7
+ import { registries } from './registries.js'
8
+ import { runPluginTeardowns } from './teardown-registry.js'
9
+
10
+ /**
11
+ * 卸载指定 id 的插件:依次执行 teardown、移除菜单与扩展点条目、删除 activator、移除带 `data-plugin-asset` 的节点。
12
+ *
13
+ * **路由**:Vue Router 3 无公开 `removeRoute`,此处不改动 matcher;动态路由需整页刷新或自行维护路由表。
14
+ *
15
+ * @param {string} pluginId 与 manifest.id 一致
16
+ */
17
+ export function disposeWebPlugin(pluginId) {
18
+ if (!pluginId || typeof pluginId !== 'string') {
19
+ return
20
+ }
21
+
22
+ runPluginTeardowns(pluginId)
23
+
24
+ for (let i = registries.menus.length - 1; i >= 0; i--) {
25
+ if (registries.menus[i].pluginId === pluginId) {
26
+ registries.menus.splice(i, 1)
27
+ }
28
+ }
29
+
30
+ const slots = registries.slots
31
+ for (const pointId of Object.keys(slots)) {
32
+ const list = slots[pointId]
33
+ if (!Array.isArray(list)) {
34
+ continue
35
+ }
36
+ const next = list.filter((x) => x.pluginId !== pluginId)
37
+ if (next.length === 0) {
38
+ Vue.delete(slots, pointId)
39
+ } else if (next.length !== list.length) {
40
+ Vue.set(slots, pointId, next)
41
+ }
42
+ }
43
+ registries.slotRevision++
44
+
45
+ if (typeof window !== 'undefined' && window.__PLUGIN_ACTIVATORS__) {
46
+ delete window.__PLUGIN_ACTIVATORS__[pluginId]
47
+ }
48
+
49
+ if (typeof document !== 'undefined') {
50
+ document.querySelectorAll('[data-plugin-asset]').forEach((el) => {
51
+ if (el.getAttribute('data-plugin-asset') === pluginId) {
52
+ el.remove()
53
+ }
54
+ })
55
+ }
56
+ }
package/src/index.js CHANGED
@@ -1,5 +1,11 @@
1
+ /**
2
+ * Vue 2.7 宿主 Web 扩展插件运行时公共入口。
3
+ * @see README 中 Webpack 宿主需配置 DefinePlugin 或显式 `resolveRuntimeOptions`
4
+ */
5
+ export { defaultWebExtendPluginRuntime } from './default-runtime-config.js'
1
6
  export { bootstrapPlugins, resolveRuntimeOptions } from './PluginRuntime.js'
2
7
  export { createHostApi } from './createHostApi.js'
8
+ export { disposeWebPlugin } from './dispose-plugin.js'
3
9
  export { registries } from './registries.js'
4
10
  export { createRequestBridge } from './bridge.js'
5
11
  export { HOST_PLUGIN_API_VERSION } from './constants.js'
package/src/registries.js CHANGED
@@ -1,10 +1,23 @@
1
+ /**
2
+ * 宿主全局响应式注册表:菜单与扩展点槽位,供布局与 `ExtensionPoint` 订阅。
3
+ *
4
+ * @module registries
5
+ */
1
6
  import Vue from 'vue'
2
7
 
3
8
  /**
4
- * Reactive registries for menus, extension slots, and button toolbars.
9
+ * @type {{
10
+ * menus: object[],
11
+ * slots: Record<string, Array<{ pluginId: string, component: import('vue').Component, priority: number, key: string }>>
12
+ * }}
5
13
  */
6
14
  export const registries = Vue.observable({
7
15
  menus: [],
8
- /** pointId -> Array<{ pluginId, component, priority, key }>(工具栏等扩展点与内容区共用同一模型) */
9
- slots: {}
16
+ /** 扩展点 id 已注册组件列表(内容区 / 工具栏等共用模型) */
17
+ slots: {},
18
+ /**
19
+ * 每次变更 slots 时递增,供 ExtensionPoint 计算属性显式依赖。
20
+ * Vue 2 对「先访问不存在的 slots[key]、后 Vue.set 补 key」的依赖收集不可靠,会导致扩展点不刷新。
21
+ */
22
+ slotRevision: 0
10
23
  })
@@ -0,0 +1,44 @@
1
+ /**
2
+ * 按 `pluginId` 收集 `onTeardown` 回调,供 `disposeWebPlugin` 统一执行。
3
+ *
4
+ * @module teardown-registry
5
+ */
6
+
7
+ /** @type {Map<string, Function[]>} */
8
+ const byPlugin = new Map()
9
+
10
+ /**
11
+ * 登记插件卸载时要执行的同步回调(建议只做解绑、清定时器等轻量逻辑)。
12
+ * @param {string} pluginId
13
+ * @param {() => void} fn
14
+ */
15
+ export function registerPluginTeardown(pluginId, fn) {
16
+ if (typeof fn !== 'function') {
17
+ return
18
+ }
19
+ let arr = byPlugin.get(pluginId)
20
+ if (!arr) {
21
+ arr = []
22
+ byPlugin.set(pluginId, arr)
23
+ }
24
+ arr.push(fn)
25
+ }
26
+
27
+ /**
28
+ * 执行并清空该插件已登记的全部 teardown;调用后 Map 中不再保留该 id。
29
+ * @param {string} pluginId
30
+ */
31
+ export function runPluginTeardowns(pluginId) {
32
+ const arr = byPlugin.get(pluginId)
33
+ if (!arr) {
34
+ return
35
+ }
36
+ byPlugin.delete(pluginId)
37
+ for (const fn of arr) {
38
+ try {
39
+ fn()
40
+ } catch (e) {
41
+ console.warn('[plugins] teardown failed', pluginId, e)
42
+ }
43
+ }
44
+ }