web-extend-plugin-vue2 0.1.2 → 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/package.json +1 -1
- package/src/bridge.test.js +38 -38
- package/src/components/ExtensionPoint.vue +1 -0
- package/src/createHostApi.js +1 -0
- package/src/default-runtime-config.js +58 -58
- package/src/dispose-plugin.js +56 -55
- package/src/registries.js +6 -1
- package/src/teardown-registry.js +44 -44
package/package.json
CHANGED
package/src/bridge.test.js
CHANGED
|
@@ -1,38 +1,38 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
-
import { createRequestBridge } from './bridge.js'
|
|
3
|
-
|
|
4
|
-
describe('createRequestBridge', () => {
|
|
5
|
-
const originalFetch = globalThis.fetch
|
|
6
|
-
|
|
7
|
-
beforeEach(() => {
|
|
8
|
-
globalThis.fetch = vi.fn(() => Promise.resolve(new Response(null, { status: 200 })))
|
|
9
|
-
})
|
|
10
|
-
|
|
11
|
-
afterEach(() => {
|
|
12
|
-
globalThis.fetch = originalFetch
|
|
13
|
-
})
|
|
14
|
-
|
|
15
|
-
it('allows paths under /api/', async () => {
|
|
16
|
-
const bridge = createRequestBridge()
|
|
17
|
-
await bridge.request('/api/channels')
|
|
18
|
-
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
19
|
-
'/api/channels',
|
|
20
|
-
expect.objectContaining({ credentials: 'same-origin' })
|
|
21
|
-
)
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
it('rejects paths outside allowlist', async () => {
|
|
25
|
-
const bridge = createRequestBridge()
|
|
26
|
-
await expect(bridge.request('/fp-api/x')).rejects.toThrow(/not allowed/)
|
|
27
|
-
await expect(bridge.request('http://evil.com/api/x')).rejects.toThrow()
|
|
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
|
-
})
|
|
38
|
-
})
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { createRequestBridge } from './bridge.js'
|
|
3
|
+
|
|
4
|
+
describe('createRequestBridge', () => {
|
|
5
|
+
const originalFetch = globalThis.fetch
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
globalThis.fetch = vi.fn(() => Promise.resolve(new Response(null, { status: 200 })))
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
globalThis.fetch = originalFetch
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('allows paths under /api/', async () => {
|
|
16
|
+
const bridge = createRequestBridge()
|
|
17
|
+
await bridge.request('/api/channels')
|
|
18
|
+
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
19
|
+
'/api/channels',
|
|
20
|
+
expect.objectContaining({ credentials: 'same-origin' })
|
|
21
|
+
)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('rejects paths outside allowlist', async () => {
|
|
25
|
+
const bridge = createRequestBridge()
|
|
26
|
+
await expect(bridge.request('/fp-api/x')).rejects.toThrow(/not allowed/)
|
|
27
|
+
await expect(bridge.request('http://evil.com/api/x')).rejects.toThrow()
|
|
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
|
+
})
|
|
38
|
+
})
|
package/src/createHostApi.js
CHANGED
|
@@ -1,58 +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
|
-
}
|
|
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
|
+
}
|
package/src/dispose-plugin.js
CHANGED
|
@@ -1,55 +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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
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/registries.js
CHANGED
|
@@ -14,5 +14,10 @@ import Vue from 'vue'
|
|
|
14
14
|
export const registries = Vue.observable({
|
|
15
15
|
menus: [],
|
|
16
16
|
/** 扩展点 id → 已注册组件列表(内容区 / 工具栏等共用模型) */
|
|
17
|
-
slots: {}
|
|
17
|
+
slots: {},
|
|
18
|
+
/**
|
|
19
|
+
* 每次变更 slots 时递增,供 ExtensionPoint 计算属性显式依赖。
|
|
20
|
+
* Vue 2 对「先访问不存在的 slots[key]、后 Vue.set 补 key」的依赖收集不可靠,会导致扩展点不刷新。
|
|
21
|
+
*/
|
|
22
|
+
slotRevision: 0
|
|
18
23
|
})
|
package/src/teardown-registry.js
CHANGED
|
@@ -1,44 +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
|
-
}
|
|
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
|
+
}
|