web-extend-plugin-vue2 0.1.0
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 +22 -0
- package/package.json +39 -0
- package/src/PluginRuntime.js +347 -0
- package/src/bridge.js +22 -0
- package/src/bridge.test.js +29 -0
- package/src/components/ExtensionPoint.vue +62 -0
- package/src/constants.js +2 -0
- package/src/createHostApi.js +93 -0
- package/src/index.js +6 -0
- package/src/registries.js +10 -0
package/README.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# web-extend-plugin-vue2
|
|
2
|
+
|
|
3
|
+
Vue 2.7 宿主侧 **Web 扩展插件**运行时:拉取清单、`hostApi`、菜单与 **`registerSlotComponents` 注册表**,以及 **`ExtensionPoint`**(同一组件覆盖内容区、工具栏等任意扩展点;插件在该点挂载 Vue 组件即可)。
|
|
4
|
+
|
|
5
|
+
## 依赖
|
|
6
|
+
|
|
7
|
+
- `peerDependencies`: `vue@^2.7`, `vue-router@^3.6`
|
|
8
|
+
- `dependencies`: `semver`
|
|
9
|
+
|
|
10
|
+
## 在 Vite 宿主中使用
|
|
11
|
+
|
|
12
|
+
```js
|
|
13
|
+
import { bootstrapPlugins, createHostApi, registries } from 'web-extend-plugin-vue2'
|
|
14
|
+
|
|
15
|
+
bootstrapPlugins(router, (pluginId) => createHostApi(pluginId, router)).catch(console.warn)
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
开发环境下清单前缀、插件 Vite 联调等默认从 `import.meta.env`(`VITE_FRONTEND_PLUGIN_BASE`、`VITE_WEB_PLUGIN_DEV_*`)读取。非 Vite 或需覆盖时可传入第三参数,见源码 `resolveRuntimeOptions` / `WebExtendPluginRuntimeOptions`。
|
|
19
|
+
|
|
20
|
+
## 发布到 npm
|
|
21
|
+
|
|
22
|
+
在包目录执行 `npm publish`(需有权限)。本仓库可用 `file:../web-extend-plugin-vue2` 联调示例宿主。
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "web-extend-plugin-vue2",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Vue 2.7 host runtime for web-extend-plugin: manifest bootstrap, hostApi, registries, ExtensionPoint",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src"
|
|
12
|
+
],
|
|
13
|
+
"peerDependencies": {
|
|
14
|
+
"vue": "^2.7.0",
|
|
15
|
+
"vue-router": "^3.6.0"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"semver": "^7.6.3"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"vitest": "^2.1.9",
|
|
22
|
+
"vue": "^2.7.16",
|
|
23
|
+
"vue-router": "^3.6.5"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"test": "vitest run"
|
|
27
|
+
},
|
|
28
|
+
"license": "Apache-2.0",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "https://github.com/xtemplus/extend-plugin-framework.git",
|
|
32
|
+
"directory": "web-extend-plugin-vue2"
|
|
33
|
+
},
|
|
34
|
+
"keywords": [
|
|
35
|
+
"vue2",
|
|
36
|
+
"plugin",
|
|
37
|
+
"extension"
|
|
38
|
+
]
|
|
39
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import * as semver from 'semver'
|
|
2
|
+
import { HOST_PLUGIN_API_VERSION } from './constants.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @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`
|
|
11
|
+
*/
|
|
12
|
+
|
|
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 = '') {
|
|
17
|
+
try {
|
|
18
|
+
if (typeof import.meta !== 'undefined' && import.meta.env && import.meta.env[key] !== undefined) {
|
|
19
|
+
const v = import.meta.env[key]
|
|
20
|
+
if (v !== '' && v !== undefined) return v
|
|
21
|
+
}
|
|
22
|
+
} catch (_) {
|
|
23
|
+
/* non-Vite bundler */
|
|
24
|
+
}
|
|
25
|
+
return fallback
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function viteIsDev() {
|
|
29
|
+
try {
|
|
30
|
+
return !!import.meta.env.DEV
|
|
31
|
+
} catch (_) {
|
|
32
|
+
return false
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 合并宿主传入项与 Vite 环境变量(便于 npm 包在 Vite 宿主中零配置;亦可在非 Vite 场景显式传入)。
|
|
38
|
+
* @param {WebExtendPluginRuntimeOptions} [user]
|
|
39
|
+
*/
|
|
40
|
+
export function resolveRuntimeOptions(user = {}) {
|
|
41
|
+
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(),
|
|
47
|
+
webPluginDevOrigin:
|
|
48
|
+
user.webPluginDevOrigin !== undefined
|
|
49
|
+
? user.webPluginDevOrigin
|
|
50
|
+
: viteEnv('VITE_WEB_PLUGIN_DEV_ORIGIN', ''),
|
|
51
|
+
webPluginDevIds:
|
|
52
|
+
user.webPluginDevIds !== undefined
|
|
53
|
+
? user.webPluginDevIds
|
|
54
|
+
: viteEnv('VITE_WEB_PLUGIN_DEV_IDS', ''),
|
|
55
|
+
webPluginDevMapJson:
|
|
56
|
+
user.webPluginDevMapJson !== undefined
|
|
57
|
+
? user.webPluginDevMapJson
|
|
58
|
+
: viteEnv('VITE_WEB_PLUGIN_DEV_MAP', '')
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function normalizeHost(hostname) {
|
|
63
|
+
if (!hostname) return ''
|
|
64
|
+
const h = hostname.toLowerCase()
|
|
65
|
+
if (h.startsWith('[') && h.endsWith(']')) {
|
|
66
|
+
return h.slice(1, -1)
|
|
67
|
+
}
|
|
68
|
+
return h
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isScriptHostAllowed(url) {
|
|
72
|
+
try {
|
|
73
|
+
const u = new URL(url, window.location.origin)
|
|
74
|
+
const h = normalizeHost(u.hostname)
|
|
75
|
+
return DEFAULT_ALLOWED_HOSTS.has(h)
|
|
76
|
+
} catch {
|
|
77
|
+
return false
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parseWebPluginDevMapExplicit(opts) {
|
|
82
|
+
if (!opts.isDev) {
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
85
|
+
const raw = opts.webPluginDevMapJson
|
|
86
|
+
if (raw === undefined || raw === null || String(raw).trim() === '') {
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
const map = JSON.parse(String(raw))
|
|
91
|
+
return map && typeof map === 'object' ? map : null
|
|
92
|
+
} catch {
|
|
93
|
+
console.warn('[plugins] VITE_WEB_PLUGIN_DEV_MAP is not valid JSON')
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function buildImplicitWebPluginDevMap(opts) {
|
|
99
|
+
if (!opts.isDev) {
|
|
100
|
+
return {}
|
|
101
|
+
}
|
|
102
|
+
const origin = opts.webPluginDevOrigin === undefined || opts.webPluginDevOrigin === null
|
|
103
|
+
? ''
|
|
104
|
+
: String(opts.webPluginDevOrigin).trim()
|
|
105
|
+
if (!origin) {
|
|
106
|
+
return {}
|
|
107
|
+
}
|
|
108
|
+
if (!isScriptHostAllowed(`${origin}/`)) {
|
|
109
|
+
return {}
|
|
110
|
+
}
|
|
111
|
+
const base = origin.replace(/\/$/, '')
|
|
112
|
+
const pingUrl = `${base}/__web_plugin_dev_ping`
|
|
113
|
+
try {
|
|
114
|
+
const ctrl = new AbortController()
|
|
115
|
+
const timer = setTimeout(() => ctrl.abort(), 500)
|
|
116
|
+
const r = await fetch(pingUrl, {
|
|
117
|
+
mode: 'cors',
|
|
118
|
+
cache: 'no-store',
|
|
119
|
+
signal: ctrl.signal
|
|
120
|
+
})
|
|
121
|
+
clearTimeout(timer)
|
|
122
|
+
if (!r.ok) {
|
|
123
|
+
return {}
|
|
124
|
+
}
|
|
125
|
+
const body = (await r.text()).trim()
|
|
126
|
+
if (body !== 'ok') {
|
|
127
|
+
return {}
|
|
128
|
+
}
|
|
129
|
+
} catch {
|
|
130
|
+
return {}
|
|
131
|
+
}
|
|
132
|
+
|
|
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
|
+
|
|
142
|
+
const map = {}
|
|
143
|
+
for (const id of ids) {
|
|
144
|
+
map[id] = `${base}/src/plugin-entry.js`
|
|
145
|
+
}
|
|
146
|
+
console.info(
|
|
147
|
+
'[plugins] 已检测到插件 Vite(',
|
|
148
|
+
base,
|
|
149
|
+
'),下列 id 将加载源码入口而非清单 dist:',
|
|
150
|
+
ids.join(', ')
|
|
151
|
+
)
|
|
152
|
+
return map
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function mergeDevMaps(implicit, explicit) {
|
|
156
|
+
const i = implicit && typeof implicit === 'object' ? implicit : {}
|
|
157
|
+
const e = explicit && typeof explicit === 'object' ? explicit : {}
|
|
158
|
+
return { ...i, ...e }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** @type {Set<string>} */
|
|
162
|
+
const pluginDevSseStarted = typeof window !== 'undefined' ? new Set() : new Set()
|
|
163
|
+
|
|
164
|
+
function isDevOriginAllowedForSse(origin) {
|
|
165
|
+
try {
|
|
166
|
+
const u = new URL(origin)
|
|
167
|
+
return DEFAULT_ALLOWED_HOSTS.has(normalizeHost(u.hostname))
|
|
168
|
+
} catch {
|
|
169
|
+
return false
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function startPluginDevReloadSse(origin, isDev) {
|
|
174
|
+
if (!isDev || pluginDevSseStarted.has(origin)) {
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
if (!isDevOriginAllowedForSse(origin)) {
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
pluginDevSseStarted.add(origin)
|
|
181
|
+
const base = origin.replace(/\/$/, '')
|
|
182
|
+
const url = `${base}/__web_plugin_reload_stream`
|
|
183
|
+
try {
|
|
184
|
+
const es = new EventSource(url)
|
|
185
|
+
es.addEventListener('reload', () => {
|
|
186
|
+
window.location.reload()
|
|
187
|
+
})
|
|
188
|
+
es.onopen = () => {
|
|
189
|
+
console.info('[plugins] plugin dev reload SSE:', url)
|
|
190
|
+
}
|
|
191
|
+
} catch (e) {
|
|
192
|
+
console.warn('[plugins] EventSource failed', url, e)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function startPluginDevSseForMap(devMap, isDev) {
|
|
197
|
+
if (!isDev || !devMap) {
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
const origins = new Set()
|
|
201
|
+
for (const entry of Object.values(devMap)) {
|
|
202
|
+
if (typeof entry !== 'string') {
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
const t = entry.trim()
|
|
206
|
+
if (!t) {
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
origins.add(new URL(t, window.location.href).origin)
|
|
211
|
+
} catch {
|
|
212
|
+
/* skip */
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
for (const o of origins) {
|
|
216
|
+
startPluginDevReloadSse(o, isDev)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function loadScript(src) {
|
|
221
|
+
return new Promise((resolve, reject) => {
|
|
222
|
+
const s = document.createElement('script')
|
|
223
|
+
s.async = true
|
|
224
|
+
s.src = src
|
|
225
|
+
s.onload = () => resolve()
|
|
226
|
+
s.onerror = () => reject(new Error('loadScript failed: ' + src))
|
|
227
|
+
document.head.appendChild(s)
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function loadPluginEntry(p, entryUrl, devMap) {
|
|
232
|
+
const devEntry = devMap && typeof devMap[p.id] === 'string' ? devMap[p.id].trim() : ''
|
|
233
|
+
if (devEntry) {
|
|
234
|
+
if (!isScriptHostAllowed(devEntry)) {
|
|
235
|
+
console.warn('[plugins] dev entry URL not allowed', p.id, devEntry)
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
await import(/* @vite-ignore */ devEntry)
|
|
240
|
+
} catch (e) {
|
|
241
|
+
console.warn('[plugins] dev module import failed, try manifest entryUrl', p.id, e)
|
|
242
|
+
if (entryUrl && isScriptHostAllowed(entryUrl)) {
|
|
243
|
+
await loadScript(entryUrl)
|
|
244
|
+
}
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
if (!entryUrl || !isScriptHostAllowed(entryUrl)) {
|
|
250
|
+
console.warn('[plugins] skip (entryUrl not allowed)', p.id, entryUrl)
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
await loadScript(entryUrl)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* @param {import('vue-router').default} router
|
|
258
|
+
* @param {(pluginId: string) => object} createHostApi
|
|
259
|
+
* @param {WebExtendPluginRuntimeOptions} [runtimeOptions] 可选;不传则从 Vite `import.meta.env` 读取(若存在)
|
|
260
|
+
*/
|
|
261
|
+
export async function bootstrapPlugins(router, createHostApi, runtimeOptions) {
|
|
262
|
+
const opts = resolveRuntimeOptions(runtimeOptions || {})
|
|
263
|
+
const base = String(opts.manifestBase).replace(/\/$/, '')
|
|
264
|
+
const explicit = parseWebPluginDevMapExplicit(opts)
|
|
265
|
+
|
|
266
|
+
const [manifestResult, implicit] = await Promise.all([
|
|
267
|
+
(async () => {
|
|
268
|
+
try {
|
|
269
|
+
const res = await fetch(`${base}/api/frontend-plugins`, { credentials: 'include' })
|
|
270
|
+
if (!res.ok) {
|
|
271
|
+
return { ok: false, status: res.status, data: null }
|
|
272
|
+
}
|
|
273
|
+
const data = await res.json()
|
|
274
|
+
return { ok: true, data }
|
|
275
|
+
} catch (e) {
|
|
276
|
+
return { ok: false, error: e, data: null }
|
|
277
|
+
}
|
|
278
|
+
})(),
|
|
279
|
+
buildImplicitWebPluginDevMap(opts)
|
|
280
|
+
])
|
|
281
|
+
|
|
282
|
+
const devMap = mergeDevMaps(implicit, explicit)
|
|
283
|
+
startPluginDevSseForMap(devMap, opts.isDev)
|
|
284
|
+
|
|
285
|
+
if (!manifestResult.ok) {
|
|
286
|
+
if (manifestResult.error) {
|
|
287
|
+
console.warn('[plugins] fetch manifest failed', manifestResult.error)
|
|
288
|
+
} else {
|
|
289
|
+
console.warn('[plugins] manifest HTTP', manifestResult.status)
|
|
290
|
+
}
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
/** @type {{ hostPluginApiVersion?: string, plugins?: object[] }} */
|
|
294
|
+
const data = manifestResult.data
|
|
295
|
+
if (!data) {
|
|
296
|
+
return
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const apiVer = data.hostPluginApiVersion
|
|
300
|
+
if (apiVer) {
|
|
301
|
+
const coerced = semver.coerce(apiVer)
|
|
302
|
+
const maj = coerced ? coerced.major : 0
|
|
303
|
+
const range = `^${maj}.0.0`
|
|
304
|
+
if (!semver.satisfies(HOST_PLUGIN_API_VERSION, range, { includePrerelease: true })) {
|
|
305
|
+
console.warn(
|
|
306
|
+
'[plugins] host API version mismatch: host implements',
|
|
307
|
+
HOST_PLUGIN_API_VERSION,
|
|
308
|
+
'server declares',
|
|
309
|
+
apiVer
|
|
310
|
+
)
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
window.__PLUGIN_ACTIVATORS__ = window.__PLUGIN_ACTIVATORS__ || {}
|
|
315
|
+
|
|
316
|
+
const plugins = data.plugins || []
|
|
317
|
+
if (plugins.length === 0) {
|
|
318
|
+
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 条目。'
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
for (const p of plugins) {
|
|
323
|
+
const range = p.engines && p.engines.host
|
|
324
|
+
if (range && !semver.satisfies(HOST_PLUGIN_API_VERSION, range, { includePrerelease: true })) {
|
|
325
|
+
console.warn('[plugins] skip (engines.host)', p.id, range)
|
|
326
|
+
continue
|
|
327
|
+
}
|
|
328
|
+
const entryUrl = p.entryUrl
|
|
329
|
+
try {
|
|
330
|
+
await loadPluginEntry(p, entryUrl, devMap)
|
|
331
|
+
} catch (e) {
|
|
332
|
+
console.warn('[plugins] script load failed', p.id, e)
|
|
333
|
+
continue
|
|
334
|
+
}
|
|
335
|
+
const activator = window.__PLUGIN_ACTIVATORS__[p.id]
|
|
336
|
+
if (typeof activator !== 'function') {
|
|
337
|
+
console.warn('[plugins] no activator for', p.id)
|
|
338
|
+
continue
|
|
339
|
+
}
|
|
340
|
+
const hostApi = createHostApi(p.id)
|
|
341
|
+
try {
|
|
342
|
+
activator(hostApi)
|
|
343
|
+
} catch (e) {
|
|
344
|
+
console.error('[plugins] activate failed', p.id, e)
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
package/src/bridge.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const ALLOWED_PREFIXES = ['/api/']
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @returns {{ request: (path: string, init?: RequestInit) => Promise<Response> }}
|
|
5
|
+
*/
|
|
6
|
+
export function createRequestBridge() {
|
|
7
|
+
return {
|
|
8
|
+
async request(path, init = {}) {
|
|
9
|
+
if (typeof path !== 'string' || !path.startsWith('/')) {
|
|
10
|
+
throw new Error('[bridge] path must be a string starting with /')
|
|
11
|
+
}
|
|
12
|
+
const allowed = ALLOWED_PREFIXES.some((p) => path.startsWith(p))
|
|
13
|
+
if (!allowed) {
|
|
14
|
+
throw new Error('[bridge] path not allowed: ' + path)
|
|
15
|
+
}
|
|
16
|
+
return fetch(path, {
|
|
17
|
+
credentials: 'same-origin',
|
|
18
|
+
...init
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="extension-point" :data-point-id="pointId">
|
|
3
|
+
<SlotErrorBoundary
|
|
4
|
+
v-for="item in items"
|
|
5
|
+
:key="item.key"
|
|
6
|
+
:label="item.pluginId"
|
|
7
|
+
>
|
|
8
|
+
<component :is="item.component" v-bind="forwardProps" />
|
|
9
|
+
</SlotErrorBoundary>
|
|
10
|
+
</div>
|
|
11
|
+
</template>
|
|
12
|
+
|
|
13
|
+
<script>
|
|
14
|
+
import { registries } from '../registries.js'
|
|
15
|
+
|
|
16
|
+
const SlotErrorBoundary = {
|
|
17
|
+
name: 'SlotErrorBoundary',
|
|
18
|
+
props: { label: String },
|
|
19
|
+
data() {
|
|
20
|
+
return { error: null }
|
|
21
|
+
},
|
|
22
|
+
errorCaptured(err) {
|
|
23
|
+
this.error = err && err.message ? err.message : String(err)
|
|
24
|
+
console.error('[ExtensionPoint] render error in', this.label, err)
|
|
25
|
+
return false
|
|
26
|
+
},
|
|
27
|
+
render(h) {
|
|
28
|
+
if (this.error) {
|
|
29
|
+
return h(
|
|
30
|
+
'div',
|
|
31
|
+
{ class: 'plugin-point-error', style: { color: '#c00', fontSize: '12px' } },
|
|
32
|
+
`[插件 ${this.label}] 渲染失败`
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
const d = this.$slots.default
|
|
36
|
+
return d && d[0] ? d[0] : h('span')
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export default {
|
|
41
|
+
name: 'ExtensionPoint',
|
|
42
|
+
components: { SlotErrorBoundary },
|
|
43
|
+
props: {
|
|
44
|
+
pointId: { type: String, required: true },
|
|
45
|
+
slotProps: { type: Object, default: () => ({}) }
|
|
46
|
+
},
|
|
47
|
+
computed: {
|
|
48
|
+
items() {
|
|
49
|
+
return registries.slots[this.pointId] || []
|
|
50
|
+
},
|
|
51
|
+
forwardProps() {
|
|
52
|
+
return this.slotProps || {}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
</script>
|
|
57
|
+
|
|
58
|
+
<style scoped>
|
|
59
|
+
.extension-point {
|
|
60
|
+
min-height: 8px;
|
|
61
|
+
}
|
|
62
|
+
</style>
|
package/src/constants.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import Vue from 'vue'
|
|
2
|
+
import { HOST_PLUGIN_API_VERSION } from './constants.js'
|
|
3
|
+
import { createRequestBridge } from './bridge.js'
|
|
4
|
+
import { registries } from './registries.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {string} pluginId
|
|
8
|
+
* @param {import('vue-router').default} router
|
|
9
|
+
*/
|
|
10
|
+
export function createHostApi(pluginId, router) {
|
|
11
|
+
const bridge = createRequestBridge()
|
|
12
|
+
const teardowns = []
|
|
13
|
+
|
|
14
|
+
function injectStylesheet(href) {
|
|
15
|
+
const link = document.createElement('link')
|
|
16
|
+
link.rel = 'stylesheet'
|
|
17
|
+
link.href = href
|
|
18
|
+
link.setAttribute('data-plugin-asset', pluginId)
|
|
19
|
+
document.head.appendChild(link)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function injectScript(src) {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const s = document.createElement('script')
|
|
25
|
+
s.async = true
|
|
26
|
+
s.src = src
|
|
27
|
+
s.setAttribute('data-plugin-asset', pluginId)
|
|
28
|
+
s.onload = () => resolve()
|
|
29
|
+
s.onerror = () => reject(new Error('script failed: ' + src))
|
|
30
|
+
document.head.appendChild(s)
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
hostPluginApiVersion: HOST_PLUGIN_API_VERSION,
|
|
36
|
+
|
|
37
|
+
registerRoutes(routes) {
|
|
38
|
+
const wrapped = routes.map((r) => ({
|
|
39
|
+
...r,
|
|
40
|
+
meta: { ...(r.meta || {}), pluginId }
|
|
41
|
+
}))
|
|
42
|
+
router.addRoutes(wrapped)
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
registerMenuItems(items) {
|
|
46
|
+
for (const item of items) {
|
|
47
|
+
registries.menus.push({ ...item, pluginId })
|
|
48
|
+
}
|
|
49
|
+
registries.menus.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
registerSlotComponents(pointId, components) {
|
|
53
|
+
if (!pointId) return
|
|
54
|
+
if (!registries.slots[pointId]) {
|
|
55
|
+
Vue.set(registries.slots, pointId, [])
|
|
56
|
+
}
|
|
57
|
+
const list = registries.slots[pointId]
|
|
58
|
+
for (const c of components) {
|
|
59
|
+
list.push({
|
|
60
|
+
pluginId,
|
|
61
|
+
component: c.component,
|
|
62
|
+
priority: c.priority ?? 0,
|
|
63
|
+
key: `${pluginId}-${pointId}-${list.length}-${Date.now()}`
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
list.sort((a, b) => b.priority - a.priority)
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
registerStylesheetUrls(urls) {
|
|
70
|
+
for (const u of urls || []) {
|
|
71
|
+
if (typeof u === 'string' && u) injectStylesheet(u)
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
registerScriptUrls(urls) {
|
|
76
|
+
const chain = (urls || []).filter((u) => typeof u === 'string' && u).reduce(
|
|
77
|
+
(p, u) => p.then(() => injectScript(u)),
|
|
78
|
+
Promise.resolve()
|
|
79
|
+
)
|
|
80
|
+
chain.catch((e) => console.warn('[plugins] registerScriptUrls', pluginId, e))
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
registerSanitizedHtmlSnippet() {
|
|
84
|
+
throw new Error('registerSanitizedHtmlSnippet is not enabled in MVP')
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
getBridge: () => bridge,
|
|
88
|
+
|
|
89
|
+
onTeardown(_pid, fn) {
|
|
90
|
+
if (typeof fn === 'function') teardowns.push(fn)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { bootstrapPlugins, resolveRuntimeOptions } from './PluginRuntime.js'
|
|
2
|
+
export { createHostApi } from './createHostApi.js'
|
|
3
|
+
export { registries } from './registries.js'
|
|
4
|
+
export { createRequestBridge } from './bridge.js'
|
|
5
|
+
export { HOST_PLUGIN_API_VERSION } from './constants.js'
|
|
6
|
+
export { default as ExtensionPoint } from './components/ExtensionPoint.vue'
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import Vue from 'vue'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Reactive registries for menus, extension slots, and button toolbars.
|
|
5
|
+
*/
|
|
6
|
+
export const registries = Vue.observable({
|
|
7
|
+
menus: [],
|
|
8
|
+
/** pointId -> Array<{ pluginId, component, priority, key }>(工具栏等扩展点与内容区共用同一模型) */
|
|
9
|
+
slots: {}
|
|
10
|
+
})
|