web-extend-plugin-vue2 0.1.3 → 0.1.4

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.
@@ -1,729 +1,729 @@
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'
10
- import { HOST_PLUGIN_API_VERSION } from './constants.js'
11
- import { defaultWebExtendPluginRuntime } from './default-runtime-config.js'
12
-
13
- const DEF = defaultWebExtendPluginRuntime
14
-
15
- /**
16
- * @typedef {object} WebExtendPluginRuntimeOptions
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 结束是否打印摘要
32
- */
33
-
34
- /**
35
- * 从 Vite 注入的 `import.meta.env` 读取字符串配置。
36
- * @param {string} key
37
- * @returns {string|undefined}
38
- */
39
- function readImportMetaEnv(key) {
40
- try {
41
- if (typeof import.meta !== 'undefined' && import.meta.env) {
42
- const v = import.meta.env[key]
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
- }
63
- }
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
77
- }
78
- return `PLUGIN_${viteStyleKey.slice(5)}`
79
- }
80
-
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() {
99
- try {
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
156
- }
157
- return fallback
158
- }
159
-
160
- /**
161
- * 合并用户、环境变量与 `defaultWebExtendPluginRuntime`,得到完整运行时选项(宿主可只传需要覆盖的字段)。
162
- * @param {WebExtendPluginRuntimeOptions} [user]
163
- * @returns {object}
164
- */
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
-
219
- return {
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(),
228
- webPluginDevOrigin:
229
- user.webPluginDevOrigin !== undefined ? user.webPluginDevOrigin : resolveBundledEnv('VITE_WEB_PLUGIN_DEV_ORIGIN', ''),
230
- webPluginDevIds:
231
- user.webPluginDevIds !== undefined ? user.webPluginDevIds : resolveBundledEnv('VITE_WEB_PLUGIN_DEV_IDS', ''),
232
- webPluginDevMapJson:
233
- user.webPluginDevMapJson !== undefined
234
- ? user.webPluginDevMapJson
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
275
- }
276
- return resolveBundledIsDev()
277
- }
278
-
279
- /**
280
- * @param {string} hostname
281
- */
282
- function normalizeHost(hostname) {
283
- if (!hostname) {
284
- return ''
285
- }
286
- const h = hostname.toLowerCase()
287
- if (h.startsWith('[') && h.endsWith(']')) {
288
- return h.slice(1, -1)
289
- }
290
- return h
291
- }
292
-
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
- }
316
- try {
317
- const u = new URL(url, window.location.origin)
318
- const h = normalizeHost(u.hostname)
319
- return hostSet.has(h)
320
- } catch {
321
- return false
322
- }
323
- }
324
-
325
- /**
326
- * @param {ReturnType<typeof resolveRuntimeOptions>} opts
327
- */
328
- function parseWebPluginDevMapExplicit(opts) {
329
- if (!opts.isDev) {
330
- return null
331
- }
332
- const raw = opts.webPluginDevMapJson
333
- if (raw === undefined || raw === null || String(raw).trim() === '') {
334
- return null
335
- }
336
- try {
337
- const map = JSON.parse(String(raw))
338
- return map && typeof map === 'object' ? map : null
339
- } catch {
340
- console.warn('[plugins] webPluginDevMapJson / VITE_WEB_PLUGIN_DEV_MAP is not valid JSON')
341
- return null
342
- }
343
- }
344
-
345
- /**
346
- * @param {ReturnType<typeof resolveRuntimeOptions>} opts
347
- * @param {Set<string>} hostSet
348
- */
349
- async function buildImplicitWebPluginDevMap(opts, hostSet) {
350
- if (!opts.isDev) {
351
- return {}
352
- }
353
- const origin =
354
- opts.webPluginDevOrigin === undefined || opts.webPluginDevOrigin === null
355
- ? ''
356
- : String(opts.webPluginDevOrigin).trim()
357
- if (!origin) {
358
- return {}
359
- }
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) {
374
- return {}
375
- }
376
-
377
- const base = origin.replace(/\/$/, '')
378
- const pingUrl = `${base}${opts.devPingPath}`
379
- try {
380
- const ctrl = new AbortController()
381
- const timer = setTimeout(() => ctrl.abort(), opts.devPingTimeoutMs)
382
- const r = await fetch(pingUrl, {
383
- mode: 'cors',
384
- cache: 'no-store',
385
- signal: ctrl.signal
386
- })
387
- clearTimeout(timer)
388
- if (!r.ok) {
389
- return {}
390
- }
391
- const body = (await r.text()).trim()
392
- if (body !== 'ok') {
393
- return {}
394
- }
395
- } catch {
396
- return {}
397
- }
398
-
399
- const pathPart = opts.webPluginDevEntryPath
400
- const map = {}
401
- for (const id of ids) {
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
- )
413
- }
414
- return map
415
- }
416
-
417
- /**
418
- * @param {Record<string, string>} implicit
419
- * @param {Record<string, string>|null} explicit
420
- */
421
- function mergeDevMaps(implicit, explicit) {
422
- const i = implicit && typeof implicit === 'object' ? implicit : {}
423
- const e = explicit && typeof explicit === 'object' ? explicit : {}
424
- return { ...i, ...e }
425
- }
426
-
427
- /** @type {Map<string, EventSource>} */
428
- const pluginDevEventSources = new Map()
429
-
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) {
454
- try {
455
- const u = new URL(origin)
456
- return hostSet.has(normalizeHost(u.hostname))
457
- } catch {
458
- return false
459
- }
460
- }
461
-
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)) {
470
- return
471
- }
472
- if (!isDevOriginAllowedForSse(origin, hostSet)) {
473
- return
474
- }
475
- ensurePluginDevBeforeUnload()
476
- const base = origin.replace(/\/$/, '')
477
- const url = `${base}${ssePath}`
478
- try {
479
- const es = new EventSource(url)
480
- pluginDevEventSources.set(origin, es)
481
- es.addEventListener('reload', () => {
482
- window.location.reload()
483
- })
484
- es.onopen = () => {
485
- console.info('[plugins] plugin dev reload SSE:', url)
486
- }
487
- } catch (e) {
488
- console.warn('[plugins] EventSource failed', url, e)
489
- }
490
- }
491
-
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') {
500
- return
501
- }
502
- const origins = new Set()
503
- for (const entry of Object.values(devMap)) {
504
- if (typeof entry !== 'string') {
505
- continue
506
- }
507
- const t = entry.trim()
508
- if (!t) {
509
- continue
510
- }
511
- try {
512
- origins.add(new URL(t, window.location.href).origin)
513
- } catch {
514
- /* skip */
515
- }
516
- }
517
- for (const o of origins) {
518
- startPluginDevReloadSse(o, isDev, hostSet, ssePath)
519
- }
520
- }
521
-
522
- const loadScriptMemo = new Map()
523
-
524
- function loadScript(src) {
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
- }
552
- const s = document.createElement('script')
553
- s.async = true
554
- s.src = src
555
- s.onload = () => {
556
- s.setAttribute('data-wep-loaded', 'true')
557
- resolve()
558
- }
559
- s.onerror = () => reject(new Error('loadScript failed: ' + src))
560
- document.head.appendChild(s)
561
- })
562
- loadScriptMemo.set(src, p)
563
- p.catch(() => loadScriptMemo.delete(src))
564
- return p
565
- }
566
-
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) {
574
- const devEntry = devMap && typeof devMap[p.id] === 'string' ? devMap[p.id].trim() : ''
575
- if (devEntry) {
576
- if (!isScriptHostAllowed(devEntry, hostSet)) {
577
- console.warn('[plugins] dev entry URL not allowed', p.id, devEntry)
578
- return
579
- }
580
- try {
581
- await import(
582
- /* webpackIgnore: true */
583
- /* @vite-ignore */
584
- devEntry
585
- )
586
- } catch (e) {
587
- console.warn('[plugins] dev module import failed, try manifest entryUrl', p.id, e)
588
- if (entryUrl && isScriptHostAllowed(entryUrl, hostSet)) {
589
- await loadScript(entryUrl)
590
- }
591
- return
592
- }
593
- return
594
- }
595
- if (!entryUrl || !isScriptHostAllowed(entryUrl, hostSet)) {
596
- console.warn('[plugins] skip (entryUrl not allowed)', p.id, entryUrl)
597
- return
598
- }
599
- await loadScript(entryUrl)
600
- }
601
-
602
- /**
603
- * @param {import('vue-router').default} router
604
- * @param {(pluginId: string, router: import('vue-router').default, hostKit?: { bridgeAllowedPathPrefixes: string[] }) => object} createHostApiFactory
605
- * 始终传入三个参数;单参工厂 `(id) => createHostApi(id, router)` 仍可用,后两个实参被忽略。
606
- * @param {WebExtendPluginRuntimeOptions} [runtimeOptions]
607
- */
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
- }
613
- const opts = resolveRuntimeOptions(runtimeOptions || {})
614
- const base = String(opts.manifestBase).replace(/\/$/, '')
615
- const manifestUrl = `${base}${opts.manifestListPath}`
616
- const hostSet = buildAllowedScriptHostsSet(opts.allowedScriptHosts)
617
- const explicit = parseWebPluginDevMapExplicit(opts)
618
-
619
- const [manifestResult, implicit] = await Promise.all([
620
- (async () => {
621
- try {
622
- const res = await fetch(manifestUrl, { credentials: opts.manifestFetchCredentials })
623
- if (!res.ok) {
624
- return { ok: false, status: res.status, data: null }
625
- }
626
- const data = await res.json()
627
- return { ok: true, data }
628
- } catch (e) {
629
- return { ok: false, error: e, data: null }
630
- }
631
- })(),
632
- buildImplicitWebPluginDevMap(opts, hostSet)
633
- ])
634
-
635
- const devMap = mergeDevMaps(implicit, explicit)
636
- startPluginDevSseForMap(devMap, opts.isDev, hostSet, opts.devReloadSsePath)
637
-
638
- const hostKit = { bridgeAllowedPathPrefixes: opts.bridgeAllowedPathPrefixes }
639
-
640
- if (!manifestResult.ok) {
641
- if (manifestResult.error) {
642
- console.warn('[plugins] fetch manifest failed', manifestResult.error)
643
- } else {
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' })
648
- }
649
- return
650
- }
651
- /** @type {{ hostPluginApiVersion?: string, plugins?: object[] }} */
652
- const data = manifestResult.data
653
- if (!data) {
654
- if (shouldShowBootstrapSummary(opts)) {
655
- console.info('[plugins] bootstrap_summary', { ok: false, reason: 'manifest_empty_body' })
656
- }
657
- return
658
- }
659
-
660
- const apiVer = data.hostPluginApiVersion
661
- if (apiVer) {
662
- const coerced = coerce(apiVer)
663
- const maj = coerced ? coerced.major : 0
664
- const range = `^${maj}.0.0`
665
- if (!satisfies(HOST_PLUGIN_API_VERSION, range, { includePrerelease: true })) {
666
- console.warn(
667
- '[plugins] host API version mismatch: host implements',
668
- HOST_PLUGIN_API_VERSION,
669
- 'server declares',
670
- apiVer
671
- )
672
- }
673
- }
674
-
675
- window.__PLUGIN_ACTIVATORS__ = window.__PLUGIN_ACTIVATORS__ || {}
676
-
677
- const plugins = data.plugins || []
678
- if (plugins.length === 0) {
679
- console.info(
680
- '[plugins] 清单为空。请检查:① web-extend-plugin-server 是否已启动;② frontend-plugin.web-plugins-dir 是否指向含各插件子目录及 manifest.json 的路径;③ 浏览器直接访问',
681
- manifestUrl,
682
- '是否返回 plugins 条目。'
683
- )
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
-
695
- for (const p of plugins) {
696
- const range = p.engines && p.engines.host
697
- if (range && !satisfies(HOST_PLUGIN_API_VERSION, range, { includePrerelease: true })) {
698
- console.warn('[plugins] skip (engines.host)', p.id, range)
699
- summary.skipEngines++
700
- continue
701
- }
702
- const entryUrl = p.entryUrl
703
- try {
704
- await loadPluginEntry(p, entryUrl, devMap, hostSet)
705
- } catch (e) {
706
- console.warn('[plugins] script load failed', p.id, e)
707
- summary.skipLoad++
708
- continue
709
- }
710
- const activator = window.__PLUGIN_ACTIVATORS__[p.id]
711
- if (typeof activator !== 'function') {
712
- console.warn('[plugins] no activator for', p.id)
713
- summary.skipNoActivator++
714
- continue
715
- }
716
- const hostApi = createHostApiFactory(p.id, router, hostKit)
717
- try {
718
- activator(hostApi)
719
- summary.activated++
720
- } catch (e) {
721
- console.error('[plugins] activate failed', p.id, e)
722
- summary.activateFail++
723
- }
724
- }
725
-
726
- if (shouldShowBootstrapSummary(opts)) {
727
- console.info('[plugins] bootstrap_summary', { ok: true, ...summary })
728
- }
729
- }
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'
10
+ import { HOST_PLUGIN_API_VERSION } from './constants.js'
11
+ import { defaultWebExtendPluginRuntime } from './default-runtime-config.js'
12
+
13
+ const DEF = defaultWebExtendPluginRuntime
14
+
15
+ /**
16
+ * @typedef {object} WebExtendPluginRuntimeOptions
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 结束是否打印摘要
32
+ */
33
+
34
+ /**
35
+ * 从 Vite 注入的 `import.meta.env` 读取字符串配置。
36
+ * @param {string} key
37
+ * @returns {string|undefined}
38
+ */
39
+ function readImportMetaEnv(key) {
40
+ try {
41
+ if (typeof import.meta !== 'undefined' && import.meta.env) {
42
+ const v = import.meta.env[key]
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
+ }
63
+ }
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
77
+ }
78
+ return `PLUGIN_${viteStyleKey.slice(5)}`
79
+ }
80
+
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() {
99
+ try {
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
156
+ }
157
+ return fallback
158
+ }
159
+
160
+ /**
161
+ * 合并用户、环境变量与 `defaultWebExtendPluginRuntime`,得到完整运行时选项(宿主可只传需要覆盖的字段)。
162
+ * @param {WebExtendPluginRuntimeOptions} [user]
163
+ * @returns {object}
164
+ */
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
+
219
+ return {
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(),
228
+ webPluginDevOrigin:
229
+ user.webPluginDevOrigin !== undefined ? user.webPluginDevOrigin : resolveBundledEnv('VITE_WEB_PLUGIN_DEV_ORIGIN', ''),
230
+ webPluginDevIds:
231
+ user.webPluginDevIds !== undefined ? user.webPluginDevIds : resolveBundledEnv('VITE_WEB_PLUGIN_DEV_IDS', ''),
232
+ webPluginDevMapJson:
233
+ user.webPluginDevMapJson !== undefined
234
+ ? user.webPluginDevMapJson
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
275
+ }
276
+ return resolveBundledIsDev()
277
+ }
278
+
279
+ /**
280
+ * @param {string} hostname
281
+ */
282
+ function normalizeHost(hostname) {
283
+ if (!hostname) {
284
+ return ''
285
+ }
286
+ const h = hostname.toLowerCase()
287
+ if (h.startsWith('[') && h.endsWith(']')) {
288
+ return h.slice(1, -1)
289
+ }
290
+ return h
291
+ }
292
+
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
+ }
316
+ try {
317
+ const u = new URL(url, window.location.origin)
318
+ const h = normalizeHost(u.hostname)
319
+ return hostSet.has(h)
320
+ } catch {
321
+ return false
322
+ }
323
+ }
324
+
325
+ /**
326
+ * @param {ReturnType<typeof resolveRuntimeOptions>} opts
327
+ */
328
+ function parseWebPluginDevMapExplicit(opts) {
329
+ if (!opts.isDev) {
330
+ return null
331
+ }
332
+ const raw = opts.webPluginDevMapJson
333
+ if (raw === undefined || raw === null || String(raw).trim() === '') {
334
+ return null
335
+ }
336
+ try {
337
+ const map = JSON.parse(String(raw))
338
+ return map && typeof map === 'object' ? map : null
339
+ } catch {
340
+ console.warn('[plugins] webPluginDevMapJson / VITE_WEB_PLUGIN_DEV_MAP is not valid JSON')
341
+ return null
342
+ }
343
+ }
344
+
345
+ /**
346
+ * @param {ReturnType<typeof resolveRuntimeOptions>} opts
347
+ * @param {Set<string>} hostSet
348
+ */
349
+ async function buildImplicitWebPluginDevMap(opts, hostSet) {
350
+ if (!opts.isDev) {
351
+ return {}
352
+ }
353
+ const origin =
354
+ opts.webPluginDevOrigin === undefined || opts.webPluginDevOrigin === null
355
+ ? ''
356
+ : String(opts.webPluginDevOrigin).trim()
357
+ if (!origin) {
358
+ return {}
359
+ }
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) {
374
+ return {}
375
+ }
376
+
377
+ const base = origin.replace(/\/$/, '')
378
+ const pingUrl = `${base}${opts.devPingPath}`
379
+ try {
380
+ const ctrl = new AbortController()
381
+ const timer = setTimeout(() => ctrl.abort(), opts.devPingTimeoutMs)
382
+ const r = await fetch(pingUrl, {
383
+ mode: 'cors',
384
+ cache: 'no-store',
385
+ signal: ctrl.signal
386
+ })
387
+ clearTimeout(timer)
388
+ if (!r.ok) {
389
+ return {}
390
+ }
391
+ const body = (await r.text()).trim()
392
+ if (body !== 'ok') {
393
+ return {}
394
+ }
395
+ } catch {
396
+ return {}
397
+ }
398
+
399
+ const pathPart = opts.webPluginDevEntryPath
400
+ const map = {}
401
+ for (const id of ids) {
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
+ )
413
+ }
414
+ return map
415
+ }
416
+
417
+ /**
418
+ * @param {Record<string, string>} implicit
419
+ * @param {Record<string, string>|null} explicit
420
+ */
421
+ function mergeDevMaps(implicit, explicit) {
422
+ const i = implicit && typeof implicit === 'object' ? implicit : {}
423
+ const e = explicit && typeof explicit === 'object' ? explicit : {}
424
+ return { ...i, ...e }
425
+ }
426
+
427
+ /** @type {Map<string, EventSource>} */
428
+ const pluginDevEventSources = new Map()
429
+
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) {
454
+ try {
455
+ const u = new URL(origin)
456
+ return hostSet.has(normalizeHost(u.hostname))
457
+ } catch {
458
+ return false
459
+ }
460
+ }
461
+
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)) {
470
+ return
471
+ }
472
+ if (!isDevOriginAllowedForSse(origin, hostSet)) {
473
+ return
474
+ }
475
+ ensurePluginDevBeforeUnload()
476
+ const base = origin.replace(/\/$/, '')
477
+ const url = `${base}${ssePath}`
478
+ try {
479
+ const es = new EventSource(url)
480
+ pluginDevEventSources.set(origin, es)
481
+ es.addEventListener('reload', () => {
482
+ window.location.reload()
483
+ })
484
+ es.onopen = () => {
485
+ console.info('[plugins] plugin dev reload SSE:', url)
486
+ }
487
+ } catch (e) {
488
+ console.warn('[plugins] EventSource failed', url, e)
489
+ }
490
+ }
491
+
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') {
500
+ return
501
+ }
502
+ const origins = new Set()
503
+ for (const entry of Object.values(devMap)) {
504
+ if (typeof entry !== 'string') {
505
+ continue
506
+ }
507
+ const t = entry.trim()
508
+ if (!t) {
509
+ continue
510
+ }
511
+ try {
512
+ origins.add(new URL(t, window.location.href).origin)
513
+ } catch {
514
+ /* skip */
515
+ }
516
+ }
517
+ for (const o of origins) {
518
+ startPluginDevReloadSse(o, isDev, hostSet, ssePath)
519
+ }
520
+ }
521
+
522
+ const loadScriptMemo = new Map()
523
+
524
+ function loadScript(src) {
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
+ }
552
+ const s = document.createElement('script')
553
+ s.async = true
554
+ s.src = src
555
+ s.onload = () => {
556
+ s.setAttribute('data-wep-loaded', 'true')
557
+ resolve()
558
+ }
559
+ s.onerror = () => reject(new Error('loadScript failed: ' + src))
560
+ document.head.appendChild(s)
561
+ })
562
+ loadScriptMemo.set(src, p)
563
+ p.catch(() => loadScriptMemo.delete(src))
564
+ return p
565
+ }
566
+
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) {
574
+ const devEntry = devMap && typeof devMap[p.id] === 'string' ? devMap[p.id].trim() : ''
575
+ if (devEntry) {
576
+ if (!isScriptHostAllowed(devEntry, hostSet)) {
577
+ console.warn('[plugins] dev entry URL not allowed', p.id, devEntry)
578
+ return
579
+ }
580
+ try {
581
+ await import(
582
+ /* webpackIgnore: true */
583
+ /* @vite-ignore */
584
+ devEntry
585
+ )
586
+ } catch (e) {
587
+ console.warn('[plugins] dev module import failed, try manifest entryUrl', p.id, e)
588
+ if (entryUrl && isScriptHostAllowed(entryUrl, hostSet)) {
589
+ await loadScript(entryUrl)
590
+ }
591
+ return
592
+ }
593
+ return
594
+ }
595
+ if (!entryUrl || !isScriptHostAllowed(entryUrl, hostSet)) {
596
+ console.warn('[plugins] skip (entryUrl not allowed)', p.id, entryUrl)
597
+ return
598
+ }
599
+ await loadScript(entryUrl)
600
+ }
601
+
602
+ /**
603
+ * @param {import('vue-router').default} router
604
+ * @param {(pluginId: string, router: import('vue-router').default, hostKit?: { bridgeAllowedPathPrefixes: string[] }) => object} createHostApiFactory
605
+ * 始终传入三个参数;单参工厂 `(id) => createHostApi(id, router)` 仍可用,后两个实参被忽略。
606
+ * @param {WebExtendPluginRuntimeOptions} [runtimeOptions]
607
+ */
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
+ }
613
+ const opts = resolveRuntimeOptions(runtimeOptions || {})
614
+ const base = String(opts.manifestBase).replace(/\/$/, '')
615
+ const manifestUrl = `${base}${opts.manifestListPath}`
616
+ const hostSet = buildAllowedScriptHostsSet(opts.allowedScriptHosts)
617
+ const explicit = parseWebPluginDevMapExplicit(opts)
618
+
619
+ const [manifestResult, implicit] = await Promise.all([
620
+ (async () => {
621
+ try {
622
+ const res = await fetch(manifestUrl, { credentials: opts.manifestFetchCredentials })
623
+ if (!res.ok) {
624
+ return { ok: false, status: res.status, data: null }
625
+ }
626
+ const data = await res.json()
627
+ return { ok: true, data }
628
+ } catch (e) {
629
+ return { ok: false, error: e, data: null }
630
+ }
631
+ })(),
632
+ buildImplicitWebPluginDevMap(opts, hostSet)
633
+ ])
634
+
635
+ const devMap = mergeDevMaps(implicit, explicit)
636
+ startPluginDevSseForMap(devMap, opts.isDev, hostSet, opts.devReloadSsePath)
637
+
638
+ const hostKit = { bridgeAllowedPathPrefixes: opts.bridgeAllowedPathPrefixes }
639
+
640
+ if (!manifestResult.ok) {
641
+ if (manifestResult.error) {
642
+ console.warn('[plugins] fetch manifest failed', manifestResult.error)
643
+ } else {
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' })
648
+ }
649
+ return
650
+ }
651
+ /** @type {{ hostPluginApiVersion?: string, plugins?: object[] }} */
652
+ const data = manifestResult.data
653
+ if (!data) {
654
+ if (shouldShowBootstrapSummary(opts)) {
655
+ console.info('[plugins] bootstrap_summary', { ok: false, reason: 'manifest_empty_body' })
656
+ }
657
+ return
658
+ }
659
+
660
+ const apiVer = data.hostPluginApiVersion
661
+ if (apiVer) {
662
+ const coerced = coerce(apiVer)
663
+ const maj = coerced ? coerced.major : 0
664
+ const range = `^${maj}.0.0`
665
+ if (!satisfies(HOST_PLUGIN_API_VERSION, range, { includePrerelease: true })) {
666
+ console.warn(
667
+ '[plugins] host API version mismatch: host implements',
668
+ HOST_PLUGIN_API_VERSION,
669
+ 'server declares',
670
+ apiVer
671
+ )
672
+ }
673
+ }
674
+
675
+ window.__PLUGIN_ACTIVATORS__ = window.__PLUGIN_ACTIVATORS__ || {}
676
+
677
+ const plugins = data.plugins || []
678
+ if (plugins.length === 0) {
679
+ console.info(
680
+ '[plugins] 清单为空。请检查:① 后端清单服务(plugin-web-starter)是否已接入;② web-plugin.web-plugins-dir 是否指向含各插件子目录及 manifest.json 的路径;③ 浏览器直接访问',
681
+ manifestUrl,
682
+ '是否返回 plugins 条目。'
683
+ )
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
+
695
+ for (const p of plugins) {
696
+ const range = p.engines && p.engines.host
697
+ if (range && !satisfies(HOST_PLUGIN_API_VERSION, range, { includePrerelease: true })) {
698
+ console.warn('[plugins] skip (engines.host)', p.id, range)
699
+ summary.skipEngines++
700
+ continue
701
+ }
702
+ const entryUrl = p.entryUrl
703
+ try {
704
+ await loadPluginEntry(p, entryUrl, devMap, hostSet)
705
+ } catch (e) {
706
+ console.warn('[plugins] script load failed', p.id, e)
707
+ summary.skipLoad++
708
+ continue
709
+ }
710
+ const activator = window.__PLUGIN_ACTIVATORS__[p.id]
711
+ if (typeof activator !== 'function') {
712
+ console.warn('[plugins] no activator for', p.id)
713
+ summary.skipNoActivator++
714
+ continue
715
+ }
716
+ const hostApi = createHostApiFactory(p.id, router, hostKit)
717
+ try {
718
+ activator(hostApi)
719
+ summary.activated++
720
+ } catch (e) {
721
+ console.error('[plugins] activate failed', p.id, e)
722
+ summary.activateFail++
723
+ }
724
+ }
725
+
726
+ if (shouldShowBootstrapSummary(opts)) {
727
+ console.info('[plugins] bootstrap_summary', { ok: true, ...summary })
728
+ }
729
+ }