@fulgurjs/federation 5.9.0 → 5.9.2

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.
Files changed (46) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.en.md +1 -1
  3. package/README.md +1 -1
  4. package/dist/cli.js +141 -21
  5. package/dist/index.cjs +2 -2
  6. package/dist/index.js +2 -2
  7. package/dist/runtime.js +1 -1
  8. package/docs/API.en.md +2 -2
  9. package/docs/API.md +2 -2
  10. package/examples/templates/README.md +12 -11
  11. package/examples/templates/react-host-vue-remote/README.md +6 -0
  12. package/examples/templates/react-host-vue-remote/pnpm-lock.yaml +7 -7
  13. package/examples/templates/react-host-vue-remote/pnpm-workspace.yaml +1 -1
  14. package/examples/templates/react-host-vue-remote/react-host/package.json +1 -1
  15. package/examples/templates/react-host-vue-remote/scripts/dev.mjs +82 -29
  16. package/examples/templates/react-host-vue-remote/vue-remote/package.json +1 -1
  17. package/examples/templates/react-react/README.md +6 -0
  18. package/examples/templates/react-react/host/package.json +1 -1
  19. package/examples/templates/react-react/pnpm-lock.yaml +7 -7
  20. package/examples/templates/react-react/pnpm-workspace.yaml +1 -1
  21. package/examples/templates/react-react/remote/package.json +1 -1
  22. package/examples/templates/react-react/scripts/dev.mjs +82 -29
  23. package/examples/templates/showcase/README.md +11 -1
  24. package/examples/templates/showcase/pnpm-lock.yaml +11 -11
  25. package/examples/templates/showcase/pnpm-workspace.yaml +1 -1
  26. package/examples/templates/showcase/react-host/index.html +1 -1
  27. package/examples/templates/showcase/react-host/package.json +1 -1
  28. package/examples/templates/showcase/react-remote/package.json +1 -1
  29. package/examples/templates/showcase/scripts/dev.mjs +82 -29
  30. package/examples/templates/showcase/vue-host/package.json +1 -1
  31. package/examples/templates/showcase/vue-remote/index.html +1 -1
  32. package/examples/templates/showcase/vue-remote/package.json +1 -1
  33. package/examples/templates/showcase/vue-remote/src/main.ts +2 -2
  34. package/examples/templates/vue-host-react-remote/README.md +6 -0
  35. package/examples/templates/vue-host-react-remote/pnpm-lock.yaml +7 -7
  36. package/examples/templates/vue-host-react-remote/pnpm-workspace.yaml +1 -1
  37. package/examples/templates/vue-host-react-remote/react-remote/package.json +1 -1
  38. package/examples/templates/vue-host-react-remote/scripts/dev.mjs +82 -29
  39. package/examples/templates/vue-host-react-remote/vue-host/package.json +1 -1
  40. package/examples/templates/vue-vue/README.md +6 -0
  41. package/examples/templates/vue-vue/host/package.json +1 -1
  42. package/examples/templates/vue-vue/pnpm-lock.yaml +7 -7
  43. package/examples/templates/vue-vue/pnpm-workspace.yaml +1 -1
  44. package/examples/templates/vue-vue/remote/package.json +1 -1
  45. package/examples/templates/vue-vue/scripts/dev.mjs +82 -29
  46. package/package.json +1 -1
@@ -9,7 +9,7 @@
9
9
  "preview": "vite preview --port 5303 --strictPort"
10
10
  },
11
11
  "dependencies": {
12
- "@fulgurjs/federation": "5.8.0",
12
+ "@fulgurjs/federation": "5.9.1",
13
13
  "react": "^19.1.0",
14
14
  "react-dom": "^19.1.0",
15
15
  "react-router-dom": "^7.1.0"
@@ -9,8 +9,12 @@
9
9
  * 行为契约(与模板 README 一致):
10
10
  * - 启动前预检全部端口:任何端口被占用则拒绝启动并指认处理方法(绝不按端口杀别人的进程);
11
11
  * - 按 dev.config.json 顺序(远程在前宿主在后)启动各应用,逐个探活;
12
- * - 任一应用启动失败/超时/中途退出:整组停止(只杀本次启动的子进程),非零退出并打印该应用日志尾部;
12
+ * - 每个进程从 spawn 起就进入全生命周期监督(启动前/探活中/部分就绪/全组就绪/关闭中):
13
+ * 任一应用在任何阶段异常退出 → 整组停止(只杀本次启动的子进程),非零退出并打印该应用日志尾部;
14
+ * 不存在「探活通过后、全组就绪前」的监督空窗;
13
15
  * - Ctrl+C / SIGTERM:清理本次启动的全部子进程后退出;不依赖 PID 文件;
16
+ * - 停止采用 POSIX 进程组信号:即使包管理器父进程已退出,同组的孙进程(dev server)也会被终止,
17
+ * 不留下仍监听端口的孤儿;Windows 走 taskkill /T /F 兜底路径(父进程已退出时无法追溯孙进程,见 README);
14
18
  * - 全部就绪后打印访问入口;探活 = 端口可连接(dev server 已监听),页面可用以浏览器验收为准。
15
19
  *
16
20
  * 平台:macOS/Linux 实测;Windows 走 taskkill 兜底路径,未经验证(见模板 README)。
@@ -71,7 +75,8 @@ async function preflight() {
71
75
  const win = `netstat -ano | findstr :${app.port}`
72
76
  console.error(
73
77
  ` ${app.name} → 端口 ${app.port} 已监听。查看占用:${process.platform === 'win32' ? win : posix};` +
74
- `结束占用进程,或同步修改 ${app.dir}/package.json 的 dev/preview 端口与宿主 fulgurjs.config.ts 的 remotes dev 地址后重试`,
78
+ `结束占用进程,或同步修改以下整组配置后重试:${app.dir}/package.json 的 dev/preview --port、` +
79
+ `宿主 fulgurjs.config.ts 的 remotes dev 地址、scripts/dev.config.json 里该应用的 port`,
75
80
  )
76
81
  }
77
82
  console.error('[dev] 本启动器只清理自己启动的进程,不会替你结束端口上的既有服务。')
@@ -89,6 +94,18 @@ function spawnApp(app) {
89
94
  children.set(app.name, child)
90
95
  child.stdout.on('data', (c) => { recordOutput(app.name, c); String(c).split('\n').filter(Boolean).forEach((l) => printAppLine(app.name, l)) })
91
96
  child.stderr.on('data', (c) => { recordOutput(app.name, c); String(c).split('\n').filter(Boolean).forEach((l) => printAppLine(app.name, l)) })
97
+ // spawn 起即全生命周期监督:探活中、部分就绪、全组就绪、关闭中任何阶段退出都不漏
98
+ child.once('exit', (code, signal) => {
99
+ if (!shuttingDown) {
100
+ void failStartup(
101
+ app.name,
102
+ `意外退出(退出码 ${code ?? 'null'}${signal ? `,信号 ${signal}` : ''})——整组停止;若是启动命令失败,看下方日志尾部`,
103
+ )
104
+ }
105
+ })
106
+ child.once('error', (e) => {
107
+ if (!shuttingDown) void failStartup(app.name, `进程无法启动:${e.message}(检查 pnpm 是否可用:pnpm -v)`)
108
+ })
92
109
  return child
93
110
  }
94
111
 
@@ -109,33 +126,79 @@ async function waitReady(app, child) {
109
126
  return { ok: false, reason: `${Math.round(readyTimeoutMs / 1000)}s 内端口 ${app.port} 未就绪(dev server 未监听)` }
110
127
  }
111
128
 
129
+ /**
130
+ * 整组终止:向进程组发信号(detached 子进程是组长;组长已退出时组内孙进程仍在,组信号依然送达)。
131
+ * 不检查直接子进程是否已退出——那会跳过组信号、留下仍监听端口的孙进程。
132
+ * Windows 用 taskkill /T /F 树杀;父进程已退出时 taskkill 无法追溯,属平台兜底限制。
133
+ */
112
134
  function killTree(appName) {
113
135
  const child = children.get(appName)
114
- if (!child || child.exitCode !== null || child.signalCode !== null) return Promise.resolve()
115
- return new Promise((resolve) => {
116
- const force = setTimeout(() => {
117
- try { process.kill(process.platform === 'win32' ? child.pid : -child.pid, 'SIGKILL') } catch { /* 已退出 */ }
118
- }, 3000).unref()
119
- child.once('exit', () => { clearTimeout(force); resolve() })
136
+ if (!child) return Promise.resolve()
137
+ const pid = child.pid
138
+ const isWin = process.platform === 'win32'
139
+ const directDead = child.exitCode !== null || child.signalCode !== null
140
+ const signalGroup = (hard) => {
120
141
  try {
121
- if (process.platform === 'win32') spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' })
122
- else process.kill(-child.pid, 'SIGTERM')
123
- } catch { clearTimeout(force); resolve() }
142
+ if (isWin) {
143
+ if (!directDead) spawn('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' })
144
+ } else {
145
+ process.kill(-pid, hard ? 'SIGKILL' : 'SIGTERM')
146
+ }
147
+ } catch {
148
+ /* ESRCH:组内已无进程 */
149
+ }
150
+ }
151
+ return new Promise((resolve) => {
152
+ let done = false
153
+ const finish = () => {
154
+ if (done) return
155
+ done = true
156
+ clearTimeout(forceTimer)
157
+ resolve()
158
+ }
159
+ // 这些定时器必须是 ref 的:直接子进程已退出时它们是事件循环里唯一的句柄,
160
+ // unref 会让循环提前 drain(进程以退出码 13 结束),shutdown 的收尾就永远走不完
161
+ const forceTimer = setTimeout(() => {
162
+ signalGroup(true)
163
+ setTimeout(finish, 500)
164
+ }, 3000)
165
+ if (!directDead) child.once('exit', finish)
166
+ signalGroup(false)
167
+ if (directDead || isWin) {
168
+ // 直接子进程已退出(或 Windows 树杀无法等 exit 事件):给组信号固定传播宽限
169
+ setTimeout(finish, 500)
170
+ }
124
171
  })
125
172
  }
126
173
 
174
+ /** 统一失败通道:任何阶段的失败都从这里走(shuttingDown 幂等,多事件并发不冲突) */
175
+ async function failStartup(name, reason) {
176
+ if (shuttingDown) return
177
+ console.error(color('31', `\n[dev] 启动失败:${name} —— ${reason}`))
178
+ const tail = tailOf.get(name) ?? []
179
+ if (tail.length) {
180
+ console.error(color('31', `[dev] ${name} 日志尾部(最后 ${tail.length} 行):`))
181
+ for (const line of tail) console.error(` [${name}] ${line}`)
182
+ }
183
+ await shutdown(EXIT.startupFailed, `[dev] 正在停止本次启动的其余应用…`)
184
+ }
185
+
127
186
  async function shutdown(exitCode, reason) {
128
187
  if (shuttingDown) return
129
188
  shuttingDown = true
130
189
  if (reason) console.log(`[dev] ${reason}`)
131
190
  console.log(`[dev] 正在停止本次启动的 ${children.size} 个应用…`)
191
+ // 看门狗:清理最多等 15s,绝不因单个 killTree 挂起而无法退出
192
+ const watchdog = setTimeout(() => process.exit(exitCode), 15000)
193
+ watchdog.unref()
132
194
  await Promise.all([...children.keys()].map(killTree))
195
+ clearTimeout(watchdog)
133
196
  console.log('[dev] 已全部停止。')
134
197
  process.exit(exitCode)
135
198
  }
136
199
 
137
- process.on('SIGINT', () => void shutdown(EXIT.signal, '[dev] 收到 Ctrl+C'))
138
- process.on('SIGTERM', () => void shutdown(143, '[dev] 收到 SIGTERM'))
200
+ process.on('SIGINT', () => void shutdown(EXIT.signal, '收到 Ctrl+C'))
201
+ process.on('SIGTERM', () => void shutdown(143, '收到 SIGTERM'))
139
202
 
140
203
  await preflight()
141
204
 
@@ -145,29 +208,19 @@ for (const app of apps) {
145
208
  const child = spawnApp(app)
146
209
  const ready = await waitReady(app, child)
147
210
  if (!ready.ok) {
148
- console.error(color('31', `\n[dev] 启动失败:${app.name} —— ${ready.reason}`))
149
- const tail = tailOf.get(app.name) ?? []
150
- if (tail.length) {
151
- console.error(color('31', `[dev] ${app.name} 日志尾部(最后 ${tail.length} 行):`))
152
- for (const line of tail) console.error(` [${app.name}] ${line}`)
153
- }
154
- await Promise.all([...children.keys()].map(killTree))
155
- console.error(`[dev] 已停止本次启动的全部进程(${children.size} 个)。修复上面的问题后重新运行 pnpm dev。`)
156
- process.exit(EXIT.startupFailed)
211
+ await failStartup(app.name, ready.reason)
212
+ }
213
+ if (shuttingDown) {
214
+ // 失败通道已接管(含并发事件抢先置位的情况):停在这里等 shutdown 退出,绝不宣布就绪
215
+ await new Promise(() => {})
157
216
  }
158
217
  printAppLine(app.name, `就绪 → http://localhost:${app.port}/`)
159
218
  }
160
219
 
161
- console.log(color('32', '\n[dev] 全部应用已就绪:'))
220
+ console.log(color('32', '\n[dev] 全部应用已就绪(持续监督中,任一应用退出会整组停止):'))
162
221
  for (const app of [...apps].reverse()) {
163
222
  console.log(` http://localhost:${app.port}/ ${app.name}`)
164
223
  }
165
224
  console.log('[dev] Ctrl+C 停止全部应用(只清理本次启动的进程)。dev server 已监听 ≠ 页面必然可用,验收请以浏览器实际加载为准。\n')
166
225
 
167
- // 运行期任何子应用退出(崩溃/被外部杀掉)→ 整组停止,不让半残组合冒充正常运行
168
- for (const [name, child] of children) {
169
- child.once('exit', (code) => {
170
- if (!shuttingDown) void shutdown(EXIT.startupFailed, `[dev] ${name} 意外退出(退出码 ${code})——停止其余应用`)
171
- })
172
- }
173
226
  await new Promise(() => {})
@@ -9,7 +9,7 @@
9
9
  "preview": "vite preview --port 5314 --strictPort"
10
10
  },
11
11
  "dependencies": {
12
- "@fulgurjs/federation": "5.8.0",
12
+ "@fulgurjs/federation": "5.9.1",
13
13
  "react": "^19.1.0",
14
14
  "react-dom": "^19.1.0",
15
15
  "vue": "^3.5.13"
@@ -79,3 +79,9 @@ server {
79
79
 
80
80
  `examples/` 面向**使用者**:拷贝、安装 registry 正式包、独立运行。
81
81
  仓库根的 `fixtures/` 是**插件内部回归夹具**(源码 `link:` 到插件工作区),不要当作使用模板。
82
+
83
+ ## 改端口(四处必须同步,漏一处启动器会被旧端口卡住)
84
+
85
+ - 改remote(默认 5213):① `remote/package.json` 的 `dev` 与 `preview` 两个脚本的 `--port 5213`;② `host/fulgurjs.config.ts` 里 `remotes['vue-remote'].dev` 的 `http://localhost:5213`;③ 根 `scripts/dev.config.json` 里 remote 的 `port`(**必改**:启动器预检/探活都读它);④ 本 README 顶部端口表。
86
+ - 改host(默认 5214):① `host/package.json` 的 `dev`/`preview` `--port 5214`;② `scripts/dev.config.json`;③ 本 README 端口表。
87
+ - 生产部署地址(`prod`,如 `/remote`)是站点路径,与 dev 端口无关,改端口不要动它。改名(容器名)不建议:还需同步 spec 前缀与页面表。
@@ -9,7 +9,7 @@
9
9
  "preview": "vite preview --port 5214 --strictPort"
10
10
  },
11
11
  "dependencies": {
12
- "@fulgurjs/federation": "5.8.0",
12
+ "@fulgurjs/federation": "5.9.1",
13
13
  "vue": "^3.5.13",
14
14
  "vue-router": "^4.5.0"
15
15
  },
@@ -11,8 +11,8 @@ importers:
11
11
  host:
12
12
  dependencies:
13
13
  '@fulgurjs/federation':
14
- specifier: 5.8.0
15
- version: 5.8.0(vite@6.4.3)(vue-router@4.6.4(vue@3.5.43(typescript@5.9.3)))(vue@3.5.43(typescript@5.9.3))
14
+ specifier: 5.9.1
15
+ version: 5.9.1(vite@6.4.3)(vue-router@4.6.4(vue@3.5.43(typescript@5.9.3)))(vue@3.5.43(typescript@5.9.3))
16
16
  vue:
17
17
  specifier: ^3.5.13
18
18
  version: 3.5.43(typescript@5.9.3)
@@ -33,8 +33,8 @@ importers:
33
33
  remote:
34
34
  dependencies:
35
35
  '@fulgurjs/federation':
36
- specifier: 5.8.0
37
- version: 5.8.0(vite@6.4.3)(vue-router@4.6.4(vue@3.5.43(typescript@5.9.3)))(vue@3.5.43(typescript@5.9.3))
36
+ specifier: 5.9.1
37
+ version: 5.9.1(vite@6.4.3)(vue-router@4.6.4(vue@3.5.43(typescript@5.9.3)))(vue@3.5.43(typescript@5.9.3))
38
38
  vue:
39
39
  specifier: ^3.5.13
40
40
  version: 3.5.43(typescript@5.9.3)
@@ -380,8 +380,8 @@ packages:
380
380
  cpu: [x64]
381
381
  os: [win32]
382
382
 
383
- '@fulgurjs/federation@5.8.0':
384
- resolution: {integrity: sha512-uvoCntilxLGFSXxCMBszQRJ3naw8CMKvymoE3Tipld9M84zAgirlEksMRsHUUzjnBqT+IBJ4cJYu13MDCxNdIw==}
383
+ '@fulgurjs/federation@5.9.1':
384
+ resolution: {integrity: sha512-c6/fTaIG08IRA6KJ+F05YnNh2PtVDPV7+frA1WGgAOHuR1uw5ijwaZjsIOSDENuS37ISiTeznv0fl+Yh9WDhQQ==}
385
385
  engines: {node: '>=18'}
386
386
  hasBin: true
387
387
  peerDependencies:
@@ -889,7 +889,7 @@ snapshots:
889
889
  '@esbuild/win32-x64@0.27.7':
890
890
  optional: true
891
891
 
892
- '@fulgurjs/federation@5.8.0(vite@6.4.3)(vue-router@4.6.4(vue@3.5.43(typescript@5.9.3)))(vue@3.5.43(typescript@5.9.3))':
892
+ '@fulgurjs/federation@5.9.1(vite@6.4.3)(vue-router@4.6.4(vue@3.5.43(typescript@5.9.3)))(vue@3.5.43(typescript@5.9.3))':
893
893
  dependencies:
894
894
  es-module-lexer: 1.7.0
895
895
  esbuild: 0.27.7
@@ -2,7 +2,7 @@ packages:
2
2
  - host
3
3
  - remote
4
4
  minimumReleaseAgeExclude:
5
- - '@fulgurjs/federation@5.7.1 || 5.8.0'
5
+ - '@fulgurjs/federation@5.7.1 || 5.8.0 || 5.9.0 || 5.9.1'
6
6
  # pnpm >=12 构建脚本批准(12 起弃用 onlyBuiltDependencies;pnpm 9-11 忽略本键:
7
7
  # esbuild >=0.16 二进制经 optionalDependencies 平台包分发,postinstall 仅校验,不阻塞安装)
8
8
  allowBuilds:
@@ -9,7 +9,7 @@
9
9
  "preview": "vite preview --port 5213 --strictPort"
10
10
  },
11
11
  "dependencies": {
12
- "@fulgurjs/federation": "5.8.0",
12
+ "@fulgurjs/federation": "5.9.1",
13
13
  "vue": "^3.5.13"
14
14
  },
15
15
  "devDependencies": {
@@ -9,8 +9,12 @@
9
9
  * 行为契约(与模板 README 一致):
10
10
  * - 启动前预检全部端口:任何端口被占用则拒绝启动并指认处理方法(绝不按端口杀别人的进程);
11
11
  * - 按 dev.config.json 顺序(远程在前宿主在后)启动各应用,逐个探活;
12
- * - 任一应用启动失败/超时/中途退出:整组停止(只杀本次启动的子进程),非零退出并打印该应用日志尾部;
12
+ * - 每个进程从 spawn 起就进入全生命周期监督(启动前/探活中/部分就绪/全组就绪/关闭中):
13
+ * 任一应用在任何阶段异常退出 → 整组停止(只杀本次启动的子进程),非零退出并打印该应用日志尾部;
14
+ * 不存在「探活通过后、全组就绪前」的监督空窗;
13
15
  * - Ctrl+C / SIGTERM:清理本次启动的全部子进程后退出;不依赖 PID 文件;
16
+ * - 停止采用 POSIX 进程组信号:即使包管理器父进程已退出,同组的孙进程(dev server)也会被终止,
17
+ * 不留下仍监听端口的孤儿;Windows 走 taskkill /T /F 兜底路径(父进程已退出时无法追溯孙进程,见 README);
14
18
  * - 全部就绪后打印访问入口;探活 = 端口可连接(dev server 已监听),页面可用以浏览器验收为准。
15
19
  *
16
20
  * 平台:macOS/Linux 实测;Windows 走 taskkill 兜底路径,未经验证(见模板 README)。
@@ -71,7 +75,8 @@ async function preflight() {
71
75
  const win = `netstat -ano | findstr :${app.port}`
72
76
  console.error(
73
77
  ` ${app.name} → 端口 ${app.port} 已监听。查看占用:${process.platform === 'win32' ? win : posix};` +
74
- `结束占用进程,或同步修改 ${app.dir}/package.json 的 dev/preview 端口与宿主 fulgurjs.config.ts 的 remotes dev 地址后重试`,
78
+ `结束占用进程,或同步修改以下整组配置后重试:${app.dir}/package.json 的 dev/preview --port、` +
79
+ `宿主 fulgurjs.config.ts 的 remotes dev 地址、scripts/dev.config.json 里该应用的 port`,
75
80
  )
76
81
  }
77
82
  console.error('[dev] 本启动器只清理自己启动的进程,不会替你结束端口上的既有服务。')
@@ -89,6 +94,18 @@ function spawnApp(app) {
89
94
  children.set(app.name, child)
90
95
  child.stdout.on('data', (c) => { recordOutput(app.name, c); String(c).split('\n').filter(Boolean).forEach((l) => printAppLine(app.name, l)) })
91
96
  child.stderr.on('data', (c) => { recordOutput(app.name, c); String(c).split('\n').filter(Boolean).forEach((l) => printAppLine(app.name, l)) })
97
+ // spawn 起即全生命周期监督:探活中、部分就绪、全组就绪、关闭中任何阶段退出都不漏
98
+ child.once('exit', (code, signal) => {
99
+ if (!shuttingDown) {
100
+ void failStartup(
101
+ app.name,
102
+ `意外退出(退出码 ${code ?? 'null'}${signal ? `,信号 ${signal}` : ''})——整组停止;若是启动命令失败,看下方日志尾部`,
103
+ )
104
+ }
105
+ })
106
+ child.once('error', (e) => {
107
+ if (!shuttingDown) void failStartup(app.name, `进程无法启动:${e.message}(检查 pnpm 是否可用:pnpm -v)`)
108
+ })
92
109
  return child
93
110
  }
94
111
 
@@ -109,33 +126,79 @@ async function waitReady(app, child) {
109
126
  return { ok: false, reason: `${Math.round(readyTimeoutMs / 1000)}s 内端口 ${app.port} 未就绪(dev server 未监听)` }
110
127
  }
111
128
 
129
+ /**
130
+ * 整组终止:向进程组发信号(detached 子进程是组长;组长已退出时组内孙进程仍在,组信号依然送达)。
131
+ * 不检查直接子进程是否已退出——那会跳过组信号、留下仍监听端口的孙进程。
132
+ * Windows 用 taskkill /T /F 树杀;父进程已退出时 taskkill 无法追溯,属平台兜底限制。
133
+ */
112
134
  function killTree(appName) {
113
135
  const child = children.get(appName)
114
- if (!child || child.exitCode !== null || child.signalCode !== null) return Promise.resolve()
115
- return new Promise((resolve) => {
116
- const force = setTimeout(() => {
117
- try { process.kill(process.platform === 'win32' ? child.pid : -child.pid, 'SIGKILL') } catch { /* 已退出 */ }
118
- }, 3000).unref()
119
- child.once('exit', () => { clearTimeout(force); resolve() })
136
+ if (!child) return Promise.resolve()
137
+ const pid = child.pid
138
+ const isWin = process.platform === 'win32'
139
+ const directDead = child.exitCode !== null || child.signalCode !== null
140
+ const signalGroup = (hard) => {
120
141
  try {
121
- if (process.platform === 'win32') spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' })
122
- else process.kill(-child.pid, 'SIGTERM')
123
- } catch { clearTimeout(force); resolve() }
142
+ if (isWin) {
143
+ if (!directDead) spawn('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' })
144
+ } else {
145
+ process.kill(-pid, hard ? 'SIGKILL' : 'SIGTERM')
146
+ }
147
+ } catch {
148
+ /* ESRCH:组内已无进程 */
149
+ }
150
+ }
151
+ return new Promise((resolve) => {
152
+ let done = false
153
+ const finish = () => {
154
+ if (done) return
155
+ done = true
156
+ clearTimeout(forceTimer)
157
+ resolve()
158
+ }
159
+ // 这些定时器必须是 ref 的:直接子进程已退出时它们是事件循环里唯一的句柄,
160
+ // unref 会让循环提前 drain(进程以退出码 13 结束),shutdown 的收尾就永远走不完
161
+ const forceTimer = setTimeout(() => {
162
+ signalGroup(true)
163
+ setTimeout(finish, 500)
164
+ }, 3000)
165
+ if (!directDead) child.once('exit', finish)
166
+ signalGroup(false)
167
+ if (directDead || isWin) {
168
+ // 直接子进程已退出(或 Windows 树杀无法等 exit 事件):给组信号固定传播宽限
169
+ setTimeout(finish, 500)
170
+ }
124
171
  })
125
172
  }
126
173
 
174
+ /** 统一失败通道:任何阶段的失败都从这里走(shuttingDown 幂等,多事件并发不冲突) */
175
+ async function failStartup(name, reason) {
176
+ if (shuttingDown) return
177
+ console.error(color('31', `\n[dev] 启动失败:${name} —— ${reason}`))
178
+ const tail = tailOf.get(name) ?? []
179
+ if (tail.length) {
180
+ console.error(color('31', `[dev] ${name} 日志尾部(最后 ${tail.length} 行):`))
181
+ for (const line of tail) console.error(` [${name}] ${line}`)
182
+ }
183
+ await shutdown(EXIT.startupFailed, `[dev] 正在停止本次启动的其余应用…`)
184
+ }
185
+
127
186
  async function shutdown(exitCode, reason) {
128
187
  if (shuttingDown) return
129
188
  shuttingDown = true
130
189
  if (reason) console.log(`[dev] ${reason}`)
131
190
  console.log(`[dev] 正在停止本次启动的 ${children.size} 个应用…`)
191
+ // 看门狗:清理最多等 15s,绝不因单个 killTree 挂起而无法退出
192
+ const watchdog = setTimeout(() => process.exit(exitCode), 15000)
193
+ watchdog.unref()
132
194
  await Promise.all([...children.keys()].map(killTree))
195
+ clearTimeout(watchdog)
133
196
  console.log('[dev] 已全部停止。')
134
197
  process.exit(exitCode)
135
198
  }
136
199
 
137
- process.on('SIGINT', () => void shutdown(EXIT.signal, '[dev] 收到 Ctrl+C'))
138
- process.on('SIGTERM', () => void shutdown(143, '[dev] 收到 SIGTERM'))
200
+ process.on('SIGINT', () => void shutdown(EXIT.signal, '收到 Ctrl+C'))
201
+ process.on('SIGTERM', () => void shutdown(143, '收到 SIGTERM'))
139
202
 
140
203
  await preflight()
141
204
 
@@ -145,29 +208,19 @@ for (const app of apps) {
145
208
  const child = spawnApp(app)
146
209
  const ready = await waitReady(app, child)
147
210
  if (!ready.ok) {
148
- console.error(color('31', `\n[dev] 启动失败:${app.name} —— ${ready.reason}`))
149
- const tail = tailOf.get(app.name) ?? []
150
- if (tail.length) {
151
- console.error(color('31', `[dev] ${app.name} 日志尾部(最后 ${tail.length} 行):`))
152
- for (const line of tail) console.error(` [${app.name}] ${line}`)
153
- }
154
- await Promise.all([...children.keys()].map(killTree))
155
- console.error(`[dev] 已停止本次启动的全部进程(${children.size} 个)。修复上面的问题后重新运行 pnpm dev。`)
156
- process.exit(EXIT.startupFailed)
211
+ await failStartup(app.name, ready.reason)
212
+ }
213
+ if (shuttingDown) {
214
+ // 失败通道已接管(含并发事件抢先置位的情况):停在这里等 shutdown 退出,绝不宣布就绪
215
+ await new Promise(() => {})
157
216
  }
158
217
  printAppLine(app.name, `就绪 → http://localhost:${app.port}/`)
159
218
  }
160
219
 
161
- console.log(color('32', '\n[dev] 全部应用已就绪:'))
220
+ console.log(color('32', '\n[dev] 全部应用已就绪(持续监督中,任一应用退出会整组停止):'))
162
221
  for (const app of [...apps].reverse()) {
163
222
  console.log(` http://localhost:${app.port}/ ${app.name}`)
164
223
  }
165
224
  console.log('[dev] Ctrl+C 停止全部应用(只清理本次启动的进程)。dev server 已监听 ≠ 页面必然可用,验收请以浏览器实际加载为准。\n')
166
225
 
167
- // 运行期任何子应用退出(崩溃/被外部杀掉)→ 整组停止,不让半残组合冒充正常运行
168
- for (const [name, child] of children) {
169
- child.once('exit', (code) => {
170
- if (!shuttingDown) void shutdown(EXIT.startupFailed, `[dev] ${name} 意外退出(退出码 ${code})——停止其余应用`)
171
- })
172
- }
173
226
  await new Promise(() => {})
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fulgurjs/federation",
3
- "version": "5.9.0",
3
+ "version": "5.9.2",
4
4
  "type": "module",
5
5
  "description": "Vite Module Federation for Vue and React: remote modules, shared dependencies and app bridges.",
6
6
  "license": "MIT",