dsh-notify-say 0.4.44 → 0.4.45
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/CHANGELOG.md +187 -0
- package/package.json +2 -1
- package/test/docs-consistency.test.mjs +82 -1
- package/lib/dev-reload.js.bak +0 -742
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# 更新日志
|
|
2
|
+
|
|
3
|
+
> 本文件由 `tools/release-build.ps1` 从开发提交自动生成,**请勿手工编辑**。
|
|
4
|
+
> 只列面向使用者的变更(新增功能与修复),不含内部维护提交。
|
|
5
|
+
|
|
6
|
+
## v0.4.45 — 2026-10-18
|
|
7
|
+
|
|
8
|
+
- feat(release): v0.4.45 —— CHANGELOG 自动生成 + 垃圾文件闸
|
|
9
|
+
|
|
10
|
+
## v0.4.44 — 2026-10-18
|
|
11
|
+
|
|
12
|
+
- feat(reload): v0.4.44 —— watcher 可观测诊断 + 确认热重载已修好
|
|
13
|
+
|
|
14
|
+
## v0.4.43 — 2026-10-18
|
|
15
|
+
|
|
16
|
+
- fix(gui): v0.4.43 —— 条款确认改成居中覆盖弹窗
|
|
17
|
+
|
|
18
|
+
## v0.4.42 — 2026-10-18
|
|
19
|
+
|
|
20
|
+
- fix: v0.4.42 —— 第三轮审阅 5 项全部属实
|
|
21
|
+
|
|
22
|
+
## v0.4.41 — 2026-10-18
|
|
23
|
+
|
|
24
|
+
- fix(reload): v0.4.41 —— 第二轮审阅 6 项,全部属实,逐项修复
|
|
25
|
+
|
|
26
|
+
## v0.4.40 — 2026-10-18
|
|
27
|
+
|
|
28
|
+
- feat: v0.4.40 —— 下载前条款确认 + 语音包索引改用固定 tag
|
|
29
|
+
|
|
30
|
+
## v0.4.39 — 2026-10-18
|
|
31
|
+
|
|
32
|
+
- fix!: v0.4.39 —— 外部审阅 4 项全部属实,逐项修复
|
|
33
|
+
|
|
34
|
+
## v0.4.38 — 2026-10-18
|
|
35
|
+
|
|
36
|
+
- fix(reload): v0.4.38 —— 修热重载「改了代码却说代码没变」
|
|
37
|
+
|
|
38
|
+
## v0.4.37 — 2026-10-18
|
|
39
|
+
|
|
40
|
+
- fix(guard): v0.4.37 —— 修「无 Origin 写请求被拒」,GUI 之前根本存不了配置
|
|
41
|
+
|
|
42
|
+
## v0.4.36 — 2026-10-18
|
|
43
|
+
|
|
44
|
+
- fix(gui): v0.4.36 —— 修音量等滑杆"改了没保存"(状态字段被清掉)
|
|
45
|
+
|
|
46
|
+
## v0.4.35 — 2026-10-18
|
|
47
|
+
|
|
48
|
+
- feat(gui): 日志级别开关(高级区)+ L15 测试
|
|
49
|
+
- feat(diag): v0.4.35 —— 接通「导出诊断文件」出口
|
|
50
|
+
|
|
51
|
+
## v0.4.34 — 2026-10-18
|
|
52
|
+
|
|
53
|
+
- feat(privacy): v0.4.34 —— 日志分级,默认不记用户内容
|
|
54
|
+
|
|
55
|
+
## v0.4.33 — 2026-10-18
|
|
56
|
+
|
|
57
|
+
- feat(gui): v0.4.33 —— 语音包管理界面
|
|
58
|
+
|
|
59
|
+
## v0.4.32 — 2026-10-18
|
|
60
|
+
|
|
61
|
+
- feat(route): v0.4.32 —— 语音包管理 HTTP 路由
|
|
62
|
+
|
|
63
|
+
## v0.4.31 — 2026-10-18
|
|
64
|
+
|
|
65
|
+
- feat(store)!: v0.4.31 —— 改为一音色一目录 + 下载安装模块
|
|
66
|
+
|
|
67
|
+
## v0.4.30 — 2026-10-18
|
|
68
|
+
|
|
69
|
+
- fix(test): v0.4.30 —— D1 区分开发/发布环境(CI 抓到)
|
|
70
|
+
|
|
71
|
+
## v0.4.29 — 2026-10-18
|
|
72
|
+
|
|
73
|
+
- feat(store): v0.4.29 —— 语音包双区域存储层 + 修 4 项审阅问题
|
|
74
|
+
|
|
75
|
+
## v0.4.28 — 2026-10-18
|
|
76
|
+
|
|
77
|
+
- fix(test): v0.4.28 —— R4 改为按能力 skip(Node 20 第三轮)
|
|
78
|
+
|
|
79
|
+
## v0.4.27 — 2026-10-18
|
|
80
|
+
|
|
81
|
+
- fix(compat): v0.4.27 —— 补上上一版漏掉的测试文件(Node 20 仍失败)
|
|
82
|
+
|
|
83
|
+
## v0.4.26 — 2026-10-18
|
|
84
|
+
|
|
85
|
+
- fix(compat): v0.4.26 —— 修 Node 20 兼容性(CI 首跑就抓到)
|
|
86
|
+
|
|
87
|
+
## v0.4.25 — 2026-10-18
|
|
88
|
+
|
|
89
|
+
- feat(ci): v0.4.25 —— 修 P0 回归 + 公开测试与 CI
|
|
90
|
+
|
|
91
|
+
## v0.4.24 — 2026-10-18
|
|
92
|
+
|
|
93
|
+
- feat(security): v0.4.24 —— 插件路由补上本地来源防护(实测有洞)
|
|
94
|
+
|
|
95
|
+
## v0.4.23 — 2026-10-18
|
|
96
|
+
|
|
97
|
+
- fix(client): v0.4.23 —— 修 P0 会话跳转 bug + 3 项审阅问题
|
|
98
|
+
|
|
99
|
+
## v0.4.22 — 2026-10-18
|
|
100
|
+
|
|
101
|
+
- fix(compliance)!: v0.4.22 —— 按外部审阅意见修复 5 项合规问题
|
|
102
|
+
- feat(audit): v0.4.22 —— 音色许可审核体系 + 新增 2 个禁止项
|
|
103
|
+
|
|
104
|
+
## v0.4.21 — 2026-10-18
|
|
105
|
+
|
|
106
|
+
- feat(release)!: v0.4.21 —— 硬性阻断个人声音样本进发布包 + 语音包分发方案
|
|
107
|
+
|
|
108
|
+
## v0.4.20 — 2026-10-18
|
|
109
|
+
|
|
110
|
+
- feat(tools): 一键发版脚本 tools/release.ps1
|
|
111
|
+
- fix(reload): v0.4.20 —— 热重载终于「报 ok 就真生效」
|
|
112
|
+
|
|
113
|
+
## v0.4.19 — 2026-10-18
|
|
114
|
+
|
|
115
|
+
- fix(release): v0.4.19 —— 版本号补齐 + 加自动化一致性检查
|
|
116
|
+
- refactor!: 改名 dsh-notify-desktop → dsh-notify-say(npm 原名已被占用)
|
|
117
|
+
- fix: 补完改名遗漏 + 第三方声明进发布包
|
|
118
|
+
|
|
119
|
+
## v0.4.18 — 2026-10-18
|
|
120
|
+
|
|
121
|
+
- fix(notify): v0.4.18 —— 补 voiceGain 映射 + 加"配置键全覆盖"元测试
|
|
122
|
+
|
|
123
|
+
## v0.4.17 — 2026-10-18
|
|
124
|
+
|
|
125
|
+
- feat(notify): v0.4.17 —— 补上试听(只出声、不弹窗)
|
|
126
|
+
|
|
127
|
+
## v0.4.16 — 2026-10-18
|
|
128
|
+
|
|
129
|
+
- feat(notify): v0.4.16 —— 语音包音量增益(可调)+ 自定义音频可选标准化
|
|
130
|
+
|
|
131
|
+
## v0.4.15 — 2026-10-18
|
|
132
|
+
|
|
133
|
+
- fix(notify): v0.4.15 —— 修"选了语音包却只听到蜂鸣"(宿主沙箱导致误判)
|
|
134
|
+
|
|
135
|
+
## v0.4.13 — 2026-10-17
|
|
136
|
+
|
|
137
|
+
- fix(notify): v0.4.13 —— 修"[object Object]"导致 SAPI 静默失败(弹窗有、无声音)
|
|
138
|
+
|
|
139
|
+
## v0.4.10 — 2026-10-17
|
|
140
|
+
|
|
141
|
+
- fix(notify): v0.4.10 —— 修"改了设置却不生效/显示默认值"
|
|
142
|
+
|
|
143
|
+
## v0.4.9 — 2026-10-17
|
|
144
|
+
|
|
145
|
+
- feat(notify): v0.4.9 —— 高级区每行展开该音源的完整子设置
|
|
146
|
+
|
|
147
|
+
## v0.4.8 — 2026-10-17
|
|
148
|
+
|
|
149
|
+
- feat(notify): v0.4.8 —— 台词随机 + 用户自定义台词 + 音频时长/体积闸
|
|
150
|
+
|
|
151
|
+
## v0.4.7 — 2026-10-17
|
|
152
|
+
|
|
153
|
+
- feat(notify): v0.4.7 —— 冷却可调:同会话可关、跨会话新增
|
|
154
|
+
|
|
155
|
+
## v0.4.6 — 2026-10-17
|
|
156
|
+
|
|
157
|
+
- feat(notify): v0.4.6 —— 跳回会话的开关进 GUI(常规区,非折叠区)
|
|
158
|
+
|
|
159
|
+
## v0.4.5 — 2026-10-17
|
|
160
|
+
|
|
161
|
+
- feat(notify): v0.4.5 —— 高级区:按事件覆盖音源 + 热重载开关
|
|
162
|
+
|
|
163
|
+
## v0.4.4 — 2026-10-17
|
|
164
|
+
|
|
165
|
+
- feat(notify): v0.4.4 —— GUI 音源选择 / 弹窗开关 / TTS 设置 / 自定义音频
|
|
166
|
+
|
|
167
|
+
## v0.4.3 — 2026-10-17
|
|
168
|
+
|
|
169
|
+
- feat(notify): v0.4.3 —— 开发期自热重载(devReload,默认关)
|
|
170
|
+
|
|
171
|
+
## v0.4.2 — 2026-10-17
|
|
172
|
+
|
|
173
|
+
- feat(notify): v0.4.2 —— TTS 接线 / Toast 开关 / 系统提示音 / 配置键补齐
|
|
174
|
+
|
|
175
|
+
## v0.4.1 — 2026-10-17
|
|
176
|
+
|
|
177
|
+
- feat(notify): v0.4.1 —— 设置页 GUI / 音源抽象 / 会话跳转 / 可观测性
|
|
178
|
+
|
|
179
|
+
## v0.4.0 — 2026-10-17
|
|
180
|
+
|
|
181
|
+
- feat(notify): v0.4.0 —— 官方 Config schema(设置页)+ 语音包 CLI + 发布派生
|
|
182
|
+
|
|
183
|
+
## v0.3.1 — 2026-10-17
|
|
184
|
+
|
|
185
|
+
- feat(notify): 空闲门——子代理/后台任务在跑时不再误报「任务完成」
|
|
186
|
+
- feat(notify): v0.3.1 失败语音——error 回合播日语道歉语音
|
|
187
|
+
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-notify-say",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.45",
|
|
4
4
|
"description": "Desktop notifications for DSH Desktop that actually speak: Windows TTS out of the box (zero download), VOICEVOX voice packs optional, a selectable system-sound fallback, and an idle gate that waits for running subagents before saying the turn is done. Includes a settings-page GUI (sound source, per-event overrides, jump-back-to-session, cooldowns) and a self hot-reload for development.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"README.md",
|
|
31
31
|
"LICENSE",
|
|
32
32
|
"THIRD-PARTY-NOTICES.md",
|
|
33
|
+
"CHANGELOG.md",
|
|
33
34
|
"test"
|
|
34
35
|
],
|
|
35
36
|
"keywords": [
|
|
@@ -202,4 +202,85 @@ test('D5: index.js 有重载漏报诊断', () => {
|
|
|
202
202
|
// 计数器必须挂在 globalThis:模块级变量在重载后会重置,就失去意义
|
|
203
203
|
assert.match(INDEX, /Symbol\.for\('dsh-notify-say\.eventCount'\)/,
|
|
204
204
|
'key 应带插件名(避免与其它插件冲突)');
|
|
205
|
-
});
|
|
205
|
+
});
|
|
206
|
+
// ── D6: 打包不得带临时/备份文件(fail-closed)──────────────────────────
|
|
207
|
+
test('D6: release-build 有垃圾文件闸(.bak/.tmp 不得进发布包)', () => {
|
|
208
|
+
// ⚠️ 2026-10-18 真实事故:我把 mutation 测试用的
|
|
209
|
+
// `lib/dev-reload.js.bak` 忘在源码目录里,release-build 把它一起
|
|
210
|
+
// 复制进发布包 —— **而且已经推到 GitHub 与 npm**。
|
|
211
|
+
//
|
|
212
|
+
// 内容无害,但发布物里出现 .bak 是明显疏漏:用户疑惑、包体积白涨、
|
|
213
|
+
// 而且说明打包流程没检查"目录里有没有残留"。
|
|
214
|
+
//
|
|
215
|
+
// 所以加一道 fail-closed 的闸:宁可中止构建,也不发带垃圾的包。
|
|
216
|
+
const rb = readFileSync(join(ROOT, 'tools', 'release-build.ps1'), 'utf8');
|
|
217
|
+
|
|
218
|
+
assert.match(rb, /junkPattern/,
|
|
219
|
+
'应有垃圾文件模式定义');
|
|
220
|
+
assert.match(rb, /\.\(bak\|tmp\|orig\|rej\|swp\)/,
|
|
221
|
+
'应覆盖常见编辑器/工具残留(.bak/.tmp/.orig/.rej/.swp)');
|
|
222
|
+
assert.match(rb, /exit 1/,
|
|
223
|
+
'发现垃圾文件必须中止构建(fail-closed),不能只警告');
|
|
224
|
+
|
|
225
|
+
// 闸门要在"复制完成之后"才有效 —— 检查位置在 CHANGELOG 之前
|
|
226
|
+
const guardAt = rb.indexOf('$junkPattern');
|
|
227
|
+
const clAt = rb.indexOf('生成 CHANGELOG');
|
|
228
|
+
assert.ok(guardAt > 0 && clAt > 0 && guardAt < clAt,
|
|
229
|
+
'垃圾闸应在复制完成后、写 CHANGELOG 之前');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// ── D7: CHANGELOG 过滤器必须挡住内部内容 ────────────────────────────────
|
|
233
|
+
test('D7: CHANGELOG 生成器过滤内部标记与 hash', () => {
|
|
234
|
+
// 公开仓库只有 release: vX.Y.Z 快照,外部看不到每次改了什么。
|
|
235
|
+
// 但**也不能原样输出开发提交标题** —— 实测早期标题是内部笔记风格:
|
|
236
|
+
// · CARD:#7849 跟进评论回写
|
|
237
|
+
// · RECOVERY.md 救援说明(三级回滚…)
|
|
238
|
+
// · deploy-snapshots/desktop-profile-pre-...
|
|
239
|
+
// · a306b8d / b9d83e1(私有仓库 hash)
|
|
240
|
+
const rb = readFileSync(join(ROOT, 'tools', 'release-build.ps1'), 'utf8');
|
|
241
|
+
|
|
242
|
+
assert.match(rb, /publicTypes/,
|
|
243
|
+
'应只保留面向使用者的提交类型');
|
|
244
|
+
for (const t of ['feat', 'fix', 'perf', 'refactor']) {
|
|
245
|
+
assert.match(rb, new RegExp(`'${t}'`), `publicTypes 应含 ${t}`);
|
|
246
|
+
}
|
|
247
|
+
// docs/test/chore/ci 是内部维护,不应出现在公开变更记录里
|
|
248
|
+
assert.doesNotMatch(rb, /publicTypes = @\([^)]*'(docs|test|chore|ci)'/,
|
|
249
|
+
'docs/test/chore/ci 不应算面向使用者的类型');
|
|
250
|
+
|
|
251
|
+
assert.match(rb, /internalMarkers/,
|
|
252
|
+
'应有内部标记黑名单');
|
|
253
|
+
for (const m of ['CARD', 'RECOVERY', '交接备忘', 'deploy-snapshots']) {
|
|
254
|
+
assert.ok(rb.includes(m),
|
|
255
|
+
`internalMarkers 应含 ${m} —— 实测这些标记会带出内部资料引用`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
assert.match(rb, /0-9a-f\]\{7,40\}/,
|
|
259
|
+
'应剥掉 7~40 位十六进制串(私有仓库 hash 在公开仓库里指不到东西)');
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// ── D8: CHANGELOG.md 必须在 npm 的 files 白名单里 ───────────────────────
|
|
263
|
+
test('D8: CHANGELOG.md 随包分发,且内部资料不随包', () => {
|
|
264
|
+
// ⚠️ 注意:**源 package.json 与发布包 package.json 是两份不同的东西**。
|
|
265
|
+
//
|
|
266
|
+
// 源的 `files` 服务于本地 link 安装(甚至列了 CARD.md / RECOVERY.md);
|
|
267
|
+
// 发布包的 `files` 由 release-build.ps1 重写 —— 那才是 npm 用户拿到的。
|
|
268
|
+
//(我第一版拿源 package.json 去断言,是搞错了对象。)
|
|
269
|
+
const rb = readFileSync(join(ROOT, 'tools', 'release-build.ps1'), 'utf8');
|
|
270
|
+
|
|
271
|
+
// 1) CHANGELOG.md 必须进发布包的 files(否则 npm 用户看不到变更记录)
|
|
272
|
+
assert.match(rb, /'CHANGELOG\.md',/,
|
|
273
|
+
'CHANGELOG.md 必须出现在 release-build 写出的 files 数组里 —— '
|
|
274
|
+
+ '同类事故:THIRD-PARTY-NOTICES.md 曾漏了 files,导致 npm 包没有任何第三方声明');
|
|
275
|
+
|
|
276
|
+
// 2) 内部资料不得进发布包的 files
|
|
277
|
+
const filesBlock = rb.slice(rb.indexOf('files'), rb.indexOf('keywords'));
|
|
278
|
+
for (const internal of ['CARD.md', 'RECOVERY.md']) {
|
|
279
|
+
assert.ok(!filesBlock.includes(internal),
|
|
280
|
+
`${internal} 是内部资料,不得出现在发布包的 files 里`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// 3) 它必须真的被"生成"出来,而不是靠复制
|
|
284
|
+
assert.match(rb, /WriteAllText\(\(Join-Path \$Destination 'CHANGELOG\.md'\)/,
|
|
285
|
+
'CHANGELOG.md 应由构建脚本生成到目标目录(不是从源码目录复制)');
|
|
286
|
+
});
|
package/lib/dev-reload.js.bak
DELETED
|
@@ -1,742 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 插件自热重载(开发期用)。
|
|
3
|
-
*
|
|
4
|
-
* ## 为什么需要
|
|
5
|
-
*
|
|
6
|
-
* 宿主只在**启动时**加载一次 `lib/index.js`(Node ESM 模块缓存)。改了代码、
|
|
7
|
-
* 磁盘已更新,但进程里跑的还是旧代码 —— 2026-10-17 就因此反复出问题:
|
|
8
|
-
* 我把 `audioSource` 默认值改成 `tts`,磁盘是新的,可宿主仍在按旧的 `bundled`
|
|
9
|
-
* 走,每次完成都 fallback 到 system 然后静默什么都不播。
|
|
10
|
-
*
|
|
11
|
-
* 那时的唯一解是重启 DSH。但重启会打断并行会话,且开发迭代极慢。
|
|
12
|
-
*
|
|
13
|
-
* ## 机制来源
|
|
14
|
-
*
|
|
15
|
-
* 移植自本机真实插件 `dsh-our-free-model` 的 `src/reload.js`(该插件确实能在
|
|
16
|
-
* 运行中换掉自己)。它的注释点明了官方现状:
|
|
17
|
-
*
|
|
18
|
-
* > The kernel's HMR service reloads plugins by watching files; it DELIBERATELY
|
|
19
|
-
* > ships with module watching off, and it has NO "reload this plugin now" API.
|
|
20
|
-
*
|
|
21
|
-
* 所以只能自己动 Node 的模块缓存。五步:
|
|
22
|
-
*
|
|
23
|
-
* 1. 快照当前 fibers 与各自 config
|
|
24
|
-
* 2. 从 Node 的 ESM `loadCache` 与 CJS `require.cache` 里删掉本包所有条目
|
|
25
|
-
* 3. 重新 import 入口 → 新代码被求值
|
|
26
|
-
* 4. dispose 旧 fibers,用**同一份 config** 重新注册
|
|
27
|
-
* 5. 任一步失败 → 回滚缓存并重注册旧代码
|
|
28
|
-
*
|
|
29
|
-
* ## 关键技术点(照抄不得)
|
|
30
|
-
*
|
|
31
|
-
* **必须用 `Map.prototype.delete.call(cache, key)`**,不能 `cache.delete(key)`:
|
|
32
|
-
* Node 24 的 `loadCache` 是 Map 子类,它自己的 `.delete` 只清"类型槽",
|
|
33
|
-
* 模块本体仍留在表里,重新 import 会拿到旧对象。这是整套机制能否生效的分水岭。
|
|
34
|
-
*
|
|
35
|
-
* ## 与上游实现的差异(我们更保守)
|
|
36
|
-
*
|
|
37
|
-
* - **默认关闭**:只有 `devReload: true` 才启用。生产安装不会重写自己的文件,
|
|
38
|
-
* 开着只是多一个可能出错的路径。
|
|
39
|
-
* - **失败绝不半途而废**:任何异常都走回滚,保证"要么换成新的,要么还是旧的"。
|
|
40
|
-
*
|
|
41
|
-
* ## v0.4.20 重写:修「假成功」
|
|
42
|
-
*
|
|
43
|
-
* 2026-10-17 实测:本模块报告 `ok:true`,但实际只换掉了**部分**模块
|
|
44
|
-
* (`config-store.js` 换了、`index.js` 没换),导致"半新半旧"。
|
|
45
|
-
* 这比完全没生效更危险 —— 排查时会被误导好几轮。
|
|
46
|
-
*
|
|
47
|
-
* 根因是两个缺失的机制(对照上游 `dsh-our-free-model/src/reload.js`):
|
|
48
|
-
*
|
|
49
|
-
* 1. **缺 `?t=` 代际绕过**:`loadCache` 清不掉时我们直接放弃,
|
|
50
|
-
* 而上游会用带查询串的 URL 强制重新求值。缺这个 → 一个模块都换不掉。
|
|
51
|
-
* 2. **缺加载指纹校验**:只比 `replacement === previous`(入口导出对象),
|
|
52
|
-
* 而依赖模块是否真的换了完全没验证。缺这个 → 换了一半也算成功。
|
|
53
|
-
*
|
|
54
|
-
* 现在补上三件事:
|
|
55
|
-
*
|
|
56
|
-
* · **代际绕过**:清不掉就用 `?reload=<ts>` 重求值;配合 `registerHooks`
|
|
57
|
-
* 把代际传递给依赖,保证整棵依赖树一起换新。
|
|
58
|
-
* · **指纹校验**(`expectedFingerprint`):调用方传入"磁盘上应该是什么"的
|
|
59
|
-
* 标记,重载后**在真实模块里读回**并比对,不一致就判失败。
|
|
60
|
-
* · **依赖抽样校验**:重载后随机验证若干依赖模块的对象身份确实变了,
|
|
61
|
-
* 捕捉"入口换了、依赖没换"的隐性半新半旧。
|
|
62
|
-
*
|
|
63
|
-
* 原则:**报 ok 就必须真的生效;验证不了就算失败。**
|
|
64
|
-
*
|
|
65
|
-
* ## 风险(如实记录)
|
|
66
|
-
*
|
|
67
|
-
* 依赖三个**非公开**对象:`ctx.loader.internal`、`ctx.registry`、`ctx.fiber`。
|
|
68
|
-
* DSH 升级后它们可能变。所以:
|
|
69
|
-
* - 只在开发期开;
|
|
70
|
-
* - 拿不到就返回 `{ ok:false, error:'no loader/registry...' }`,不影响插件运行;
|
|
71
|
-
* - 有测试覆盖"结构缺失时不抛异常"。
|
|
72
|
-
*
|
|
73
|
-
* @module lib/dev-reload.js
|
|
74
|
-
*/
|
|
75
|
-
|
|
76
|
-
import fs from 'node:fs';
|
|
77
|
-
// ⚠️ `registerHooks` 是 **Node 22.15+** 才有的导出。
|
|
78
|
-
//
|
|
79
|
-
// 早期写成静态 import,导致 Node 20 上**整个模块加载失败**
|
|
80
|
-
// (SyntaxError: The requested module does not provide an export named),
|
|
81
|
-
// 而外面那层 try/catch 根本拦不住 —— 静态 import 在求值前就发生了。
|
|
82
|
-
//
|
|
83
|
-
// 现在改为动态导入:拿不到就退化为"没有代际绕过",不影响正常重载路径。
|
|
84
|
-
// 这也是 CI 在 Node 20 上抓到的真实兼容性问题(2026-10-18)。
|
|
85
|
-
import { createRequire } from 'node:module';
|
|
86
|
-
|
|
87
|
-
/** 有就返回,没有就 null(Node < 22.15)。 */
|
|
88
|
-
async function loadRegisterHooks() {
|
|
89
|
-
try {
|
|
90
|
-
const mod = await import('node:module');
|
|
91
|
-
return typeof mod.registerHooks === 'function' ? mod.registerHooks : null;
|
|
92
|
-
} catch {
|
|
93
|
-
return null;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
import { fileURLToPath } from 'node:url';
|
|
97
|
-
|
|
98
|
-
/** 同一时刻只允许一次重载,避免重叠交换。 */
|
|
99
|
-
let inFlight = null;
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* 已安装的代际钩子(按 packageUrl 索引)。
|
|
103
|
-
*
|
|
104
|
-
* `registerHooks` 在同一进程里**不能重复注册同一个 packageUrl**:
|
|
105
|
-
* 第二次会叠加一个钩子,导致代际参数被反复改写、URL 越滚越长。
|
|
106
|
-
* 所以用这个 Map 记住"这个包已经装过钩子",只装一次。
|
|
107
|
-
*/
|
|
108
|
-
const reloadHooks = new Map();
|
|
109
|
-
|
|
110
|
-
/** 代际查询串的键名。带项目前缀,避免与其它插件撞名。 */
|
|
111
|
-
const GEN_PARAM = 'dns-reload';
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* 让入口的"代际"穿透到它 import 的每一个本包模块。
|
|
115
|
-
*
|
|
116
|
-
* ## 为什么必须有
|
|
117
|
-
*
|
|
118
|
-
* 只在入口 URL 上加 `?reload=xxx` 是不够的:入口里写的是
|
|
119
|
-
* `import { ... } from './config-store.js'`,这个相对说明符**不带查询串**,
|
|
120
|
-
* 于是 Node 仍然复用缓存里的**旧依赖** —— 结果就是入口是新的、依赖是旧的。
|
|
121
|
-
*
|
|
122
|
-
* 这正是 2026-10-17 那次"config-store 换了、index 没换"(或反之)的机制。
|
|
123
|
-
*
|
|
124
|
-
* 钩子的作用:凡是 `parentURL` 带代际、且 `resolved.url` 落在同一个包内的,
|
|
125
|
-
* 都给 `resolved.url` 补上同一个代际参数,整棵子树一起重新求值。
|
|
126
|
-
*
|
|
127
|
-
* @param {string} packageUrl 包根 URL(带尾斜杠)
|
|
128
|
-
*/
|
|
129
|
-
/**
|
|
130
|
-
* @returns {Promise<void>} 注册完成后 resolve(便于测试与调用方等待)
|
|
131
|
-
*/
|
|
132
|
-
export async function enableFreshDependencies(packageUrl) {
|
|
133
|
-
// ⚠️ 2026-10-18 外部审阅指出两个问题(都属实,此前没改过):
|
|
134
|
-
//
|
|
135
|
-
// 1. 调用方只写 `enableFreshDependencies(packageUrl)`,**没 await**。
|
|
136
|
-
// 而它要 `await import('node:module')` 才拿得到 registerHooks ——
|
|
137
|
-
// 于是新入口可能在钩子装好之前就被导入,依赖仍是旧的。
|
|
138
|
-
// 这恰好是热重载要解决的那个问题。
|
|
139
|
-
//
|
|
140
|
-
// 2. 原实现在注册**前**就 `reloadHooks.set(packageUrl, null)` 占位,
|
|
141
|
-
// 第二个并发调用看到 key 存在就直接 return,哪怕第一次还没装完。
|
|
142
|
-
//
|
|
143
|
-
// 修法:Map 里存 `{ pending, hook }` —— 后来者 await 同一个 Promise,
|
|
144
|
-
// 这样"返回时已装好"成立。hook 的 resolve 逻辑一字未动。
|
|
145
|
-
|
|
146
|
-
const prev = reloadHooks.get(packageUrl);
|
|
147
|
-
// 已装好:直接返回 hook
|
|
148
|
-
if (prev && prev.hook) return prev.hook;
|
|
149
|
-
// 正在装:等同一次 Promise(这就是修竞态的关键)
|
|
150
|
-
if (prev && prev.pending) return prev.pending;
|
|
151
|
-
|
|
152
|
-
const slot = { pending: null, hook: null };
|
|
153
|
-
reloadHooks.set(packageUrl, slot);
|
|
154
|
-
|
|
155
|
-
slot.pending = (async () => {
|
|
156
|
-
// Node < 22.15 没有 registerHooks:静默跳过即可。
|
|
157
|
-
// 代际绕过不成,重载仍走"清缓存 + 校验"的常规路径。
|
|
158
|
-
const registerHooks = await loadRegisterHooks();
|
|
159
|
-
if (!registerHooks) return null;
|
|
160
|
-
try {
|
|
161
|
-
const hook = registerHooks({
|
|
162
|
-
resolve(specifier, context, nextResolve) {
|
|
163
|
-
const resolved = nextResolve(specifier, context);
|
|
164
|
-
const parent = context && context.parentURL;
|
|
165
|
-
if (!parent || !parent.startsWith(packageUrl)) return resolved;
|
|
166
|
-
if (!resolved || typeof resolved.url !== 'string') return resolved;
|
|
167
|
-
if (!resolved.url.startsWith(packageUrl)) return resolved;
|
|
168
|
-
let generation = null;
|
|
169
|
-
try { generation = new URL(parent).searchParams.get(GEN_PARAM); } catch { return resolved; }
|
|
170
|
-
if (generation === null) return resolved;
|
|
171
|
-
try {
|
|
172
|
-
const target = new URL(resolved.url);
|
|
173
|
-
target.searchParams.set(GEN_PARAM, generation);
|
|
174
|
-
return { ...resolved, url: target.href };
|
|
175
|
-
} catch {
|
|
176
|
-
return resolved;
|
|
177
|
-
}
|
|
178
|
-
},
|
|
179
|
-
});
|
|
180
|
-
slot.hook = hook;
|
|
181
|
-
return hook;
|
|
182
|
-
} catch {
|
|
183
|
-
// 重复注册等情况:忽略,不影响正常路径
|
|
184
|
-
return null;
|
|
185
|
-
}
|
|
186
|
-
})();
|
|
187
|
-
|
|
188
|
-
return slot.pending;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* 计算"磁盘上这份代码应该长什么样"的指纹。
|
|
193
|
-
*
|
|
194
|
-
* 用途:重载前记下,重载后**从真实模块里读回**再比对。
|
|
195
|
-
* 这是"报 ok 就必须真生效"的依据 —— 没有它,只能相信
|
|
196
|
-
* "re-import 返回了新对象",而那并不等于"新代码在跑"。
|
|
197
|
-
*
|
|
198
|
-
* 取哪些文件:入口 + lib 下所有 js。排除 test/tools/sounds 等大目录。
|
|
199
|
-
*
|
|
200
|
-
* @param {string} packageDir 包目录的**文件系统路径**
|
|
201
|
-
* @returns {string} 形如 'a1b2c3d4' 的短哈希;读不到时返回 ''
|
|
202
|
-
*/
|
|
203
|
-
export function computeFingerprint(packageDir) {
|
|
204
|
-
try {
|
|
205
|
-
const base = String(packageDir || '').replace(/[\\/]+$/, '');
|
|
206
|
-
if (!base) return '';
|
|
207
|
-
|
|
208
|
-
// 目录本身不存在 → 明确返回空串。
|
|
209
|
-
// 不能让"一个文件都 stat 不到"悄悄算出空串的哈希值 —— 那会让调用方
|
|
210
|
-
// 以为"算出来了",从而拿一个无意义的指纹去比对(实测踩到:
|
|
211
|
-
// 不存在的目录返回了 '00001505',看起来像个正常指纹)。
|
|
212
|
-
if (!fs.existsSync(base)) return '';
|
|
213
|
-
|
|
214
|
-
const files = [];
|
|
215
|
-
const libDir = base + '\\lib';
|
|
216
|
-
files.push(base + '\\package.json');
|
|
217
|
-
if (fs.existsSync(libDir)) {
|
|
218
|
-
for (const name of fs.readdirSync(libDir)) {
|
|
219
|
-
if (name.endsWith('.js')) files.push(libDir + '\\' + name);
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
// 简易内容哈希:把文件名 + 大小 + mtime 混起来。
|
|
223
|
-
// 不读全文是刻意的 —— 每次重载读几百 KB 没必要,且 mtime 变化足够灵敏。
|
|
224
|
-
let acc = '';
|
|
225
|
-
let counted = 0;
|
|
226
|
-
for (const f of files.sort()) {
|
|
227
|
-
try {
|
|
228
|
-
const st = fs.statSync(f);
|
|
229
|
-
acc += f + ':' + st.size + ':' + st.mtimeMs + ';';
|
|
230
|
-
counted += 1;
|
|
231
|
-
} catch { /* 单个文件读不到就跳过 */ }
|
|
232
|
-
}
|
|
233
|
-
// 一个文件都没读到 → 算不出有意义的指纹,返回空串让调用方知道。
|
|
234
|
-
if (counted === 0) return '';
|
|
235
|
-
|
|
236
|
-
let hash = 5381;
|
|
237
|
-
for (let i = 0; i < acc.length; i++) hash = ((hash * 33) ^ acc.charCodeAt(i)) >>> 0;
|
|
238
|
-
return hash.toString(16).padStart(8, '0');
|
|
239
|
-
} catch {
|
|
240
|
-
return '';
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
* 判定"这次重载是否真的生效"。
|
|
246
|
-
*
|
|
247
|
-
* ## 为什么抽成独立纯函数
|
|
248
|
-
*
|
|
249
|
-
* 2026-10-17 的假成功,根源就是**校验逻辑被埋在 selfReload 里**:
|
|
250
|
-
* 它只在 `replacement === previous` 时判失败,而这几乎永远不成立
|
|
251
|
-
* (重新 import 总会得到新对象),所以校验形同虚设。
|
|
252
|
-
*
|
|
253
|
-
* 抽出来以后可以:
|
|
254
|
-
* · 单独测每条规则的边界(含负向用例)
|
|
255
|
-
* · 用变异测试确认规则真的会被触发(改坏就变红)
|
|
256
|
-
*
|
|
257
|
-
* ## 三条规则
|
|
258
|
-
*
|
|
259
|
-
* 1. **必须有 fingerprint**(启用校验时)
|
|
260
|
-
* 模块不导出 fingerprint → 无法自证 → 判失败。宁可报错,不要假 ok。
|
|
261
|
-
*
|
|
262
|
-
* 2. **differFrom**:新 fingerprint 必须 ≠ 旧实例的
|
|
263
|
-
* 相同就说明"重新 import 拿到的是同一次求值",代码根本没换。
|
|
264
|
-
*
|
|
265
|
-
* 3. **expectedFingerprint**:必须完全等于调用方给的期望值
|
|
266
|
-
* 比 2 更严(调用方若能算出磁盘指纹就该用这个)。
|
|
267
|
-
*
|
|
268
|
-
* 未传任何选项时返回 `{ ok:true, checked:false }` —— 表示"没做校验",
|
|
269
|
-
* 调用方据此知道这次成功**没有**经过验证。
|
|
270
|
-
*
|
|
271
|
-
* @param {{gotFingerprint?: string, differFrom?: string, expectedFingerprint?: string}} opts
|
|
272
|
-
* @returns {{ok: boolean, checked: boolean, error?: string}}
|
|
273
|
-
*/
|
|
274
|
-
export function verifyReload(opts = {}) {
|
|
275
|
-
const got = typeof opts.gotFingerprint === 'string' ? opts.gotFingerprint : '';
|
|
276
|
-
const wantsDiffer = opts.differFrom !== undefined && opts.differFrom !== null;
|
|
277
|
-
const wantsExact = typeof opts.expectedFingerprint === 'string' && opts.expectedFingerprint !== '';
|
|
278
|
-
|
|
279
|
-
if (!wantsDiffer && !wantsExact) return { ok: true, checked: false };
|
|
280
|
-
|
|
281
|
-
// 规则 1:要有指纹才能自证
|
|
282
|
-
if (!got) {
|
|
283
|
-
return {
|
|
284
|
-
ok: false,
|
|
285
|
-
checked: true,
|
|
286
|
-
error: 'verification failed: reloaded module does not export fingerprint — '
|
|
287
|
-
+ 'cannot prove the new code is live',
|
|
288
|
-
};
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
// 规则 2:必须与旧实例不同
|
|
292
|
-
if (wantsDiffer && got === opts.differFrom) {
|
|
293
|
-
return {
|
|
294
|
-
ok: false,
|
|
295
|
-
checked: true,
|
|
296
|
-
error: `verification failed: fingerprint unchanged (${got}) — `
|
|
297
|
-
+ 'the module was re-imported but is still the same evaluation as before',
|
|
298
|
-
};
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// 规则 3:必须与期望值完全一致
|
|
302
|
-
if (wantsExact && got !== opts.expectedFingerprint) {
|
|
303
|
-
return {
|
|
304
|
-
ok: false,
|
|
305
|
-
checked: true,
|
|
306
|
-
error: `fingerprint mismatch: expected ${opts.expectedFingerprint}, got ${got} `
|
|
307
|
-
+ '(module was re-evaluated but the code on disk is not what is running)',
|
|
308
|
-
};
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
return { ok: true, checked: true };
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
/** 是否正在重载。 */
|
|
315
|
-
|
|
316
|
-
export function isReloading() {
|
|
317
|
-
return inFlight !== null;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
/**
|
|
321
|
-
* 包目录(`file://` URL,带尾斜杠)。
|
|
322
|
-
* @param {string} entryModuleUrl `import.meta.url`
|
|
323
|
-
*/
|
|
324
|
-
export function packageUrlOf(entryModuleUrl) {
|
|
325
|
-
return new URL('../', entryModuleUrl).href;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
/**
|
|
329
|
-
* 入口文件 URL。
|
|
330
|
-
*
|
|
331
|
-
* ⚠️ 本插件入口是 `lib/index.js`,而本模块就住在 `lib/` 里 ——
|
|
332
|
-
* 所以从 `lib/dev-reload.js` 出发是 `./index.js`,**不是** `../index.js`。
|
|
333
|
-
*
|
|
334
|
-
* 上游 FreeModel 的 `src/reload.js` 用 `../index.js` 是对的(它的入口在包根),
|
|
335
|
-
* 照抄会解析成 `<包>/index.js` —— 那个文件不存在,重载必失败。
|
|
336
|
-
* 移植时踩到,测试 D7 抓住。
|
|
337
|
-
*
|
|
338
|
-
* @param {string} entryModuleUrl `import.meta.url`
|
|
339
|
-
*/
|
|
340
|
-
export function entryUrlOf(entryModuleUrl) {
|
|
341
|
-
return new URL('./index.js', entryModuleUrl).href;
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
/**
|
|
345
|
-
* 找出 loadCache 里属于本包的所有键。
|
|
346
|
-
*
|
|
347
|
-
* 键比较要容忍大小写与百分号编码差异(Node 解析器与 `new URL` 的产出可能不同)。
|
|
348
|
-
*
|
|
349
|
-
* @param {Map} loadCache
|
|
350
|
-
* @param {string} packageUrl
|
|
351
|
-
* @returns {string[]}
|
|
352
|
-
*/
|
|
353
|
-
export function packageCacheKeys(loadCache, packageUrl) {
|
|
354
|
-
const base = decodeURIComponent(packageUrl).replace(/\/+$/, '').toLowerCase() + '/';
|
|
355
|
-
const keys = [];
|
|
356
|
-
let it;
|
|
357
|
-
try {
|
|
358
|
-
it = loadCache.keys();
|
|
359
|
-
} catch {
|
|
360
|
-
return keys;
|
|
361
|
-
}
|
|
362
|
-
for (const key of it) {
|
|
363
|
-
if (typeof key !== 'string') continue;
|
|
364
|
-
let normalized;
|
|
365
|
-
try { normalized = decodeURIComponent(key).toLowerCase(); } catch { normalized = key.toLowerCase(); }
|
|
366
|
-
if (normalized.startsWith(base)) keys.push(key);
|
|
367
|
-
}
|
|
368
|
-
return keys;
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
/**
|
|
372
|
-
* 清掉本包在 ESM / CJS 两级缓存里的条目,返回可用于回滚的备份。
|
|
373
|
-
*
|
|
374
|
-
* @param {object} internal `ctx.loader.internal`
|
|
375
|
-
* @param {string} packageUrl
|
|
376
|
-
* @returns {{ esm: Map<string, unknown>, cjs: Map<string, object> }}
|
|
377
|
-
*/
|
|
378
|
-
export function purgeCaches(internal, packageUrl) {
|
|
379
|
-
const backup = { esm: new Map(), cjs: new Map() };
|
|
380
|
-
const cache = internal && internal.loadCache;
|
|
381
|
-
if (cache && typeof cache.keys === 'function' && typeof cache.get === 'function') {
|
|
382
|
-
const read = (key) => {
|
|
383
|
-
try { return Map.prototype.get.call(cache, key); } catch { try { return cache.get(key); } catch { return undefined; } }
|
|
384
|
-
};
|
|
385
|
-
// ⚠️ 必须走 Map.prototype.delete:Node 24 的 LoadCache.delete 只清类型槽,
|
|
386
|
-
// 模块本体仍留着,重新 import 会拿回旧对象(整套机制失效)。
|
|
387
|
-
const drop = (key) => {
|
|
388
|
-
try { Map.prototype.delete.call(cache, key); return; } catch { /* 不是真 Map */ }
|
|
389
|
-
try { cache.delete(key); } catch { /* 尽力而为 */ }
|
|
390
|
-
};
|
|
391
|
-
for (const key of packageCacheKeys(cache, packageUrl)) {
|
|
392
|
-
backup.esm.set(key, read(key));
|
|
393
|
-
drop(key);
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
const require = createRequire(import.meta.url);
|
|
398
|
-
for (const key of [...backup.esm.keys()]) {
|
|
399
|
-
try {
|
|
400
|
-
const filePath = fileURLToPath(key);
|
|
401
|
-
if (require.cache[filePath] !== undefined) {
|
|
402
|
-
backup.cjs.set(filePath, require.cache[filePath]);
|
|
403
|
-
delete require.cache[filePath];
|
|
404
|
-
}
|
|
405
|
-
} catch {
|
|
406
|
-
// 非 file: URL —— CJS 侧无事可做
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
return backup;
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
/**
|
|
413
|
-
* 把备份写回缓存(回滚用)。
|
|
414
|
-
* @param {object} internal
|
|
415
|
-
* @param {{esm: Map, cjs: Map}} backup
|
|
416
|
-
*/
|
|
417
|
-
export function restoreCaches(internal, backup) {
|
|
418
|
-
const cache = internal && internal.loadCache;
|
|
419
|
-
for (const [key, job] of backup.esm) {
|
|
420
|
-
try { Map.prototype.set.call(cache, key, job); } catch { try { cache.set(key, job); } catch { /* best effort */ } }
|
|
421
|
-
}
|
|
422
|
-
const require = createRequire(import.meta.url);
|
|
423
|
-
for (const [filePath, mod] of backup.cjs) require.cache[filePath] = mod;
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
/**
|
|
427
|
-
* 原地重载本插件。
|
|
428
|
-
*
|
|
429
|
-
* @param {object} ctx `apply` 收到的 ctx
|
|
430
|
-
* @param {object} [options]
|
|
431
|
-
* @param {object} [options.logger] 日志器(默认 console)
|
|
432
|
-
* @param {string} [options.entryModuleUrl] 默认用本模块的 import.meta.url
|
|
433
|
-
* @returns {Promise<{ok:boolean, error?:string, restored?:boolean, fibers?:number}>}
|
|
434
|
-
*/
|
|
435
|
-
export function selfReload(ctx, options = {}) {
|
|
436
|
-
if (inFlight !== null) return inFlight;
|
|
437
|
-
inFlight = (async () => {
|
|
438
|
-
const logger = options.logger || console;
|
|
439
|
-
const entryModuleUrl = options.entryModuleUrl || import.meta.url;
|
|
440
|
-
const packageUrl = options.packageUrl || packageUrlOf(entryModuleUrl);
|
|
441
|
-
const entryUrl = options.entryUrl || entryUrlOf(entryModuleUrl);
|
|
442
|
-
|
|
443
|
-
// 取 loader / registry 必须**非 strict**。
|
|
444
|
-
//
|
|
445
|
-
// ⚠️ 2026-10-18 实测踩到:直接写 `ctx.loader` 在 Cordis 里会抛
|
|
446
|
-
// `cannot get property "loader" without inject`
|
|
447
|
-
// —— 除非插件在 `inject: ['loader']` 里声明过。本插件没有(也不需要,
|
|
448
|
-
// 因为它平时用不到 loader),所以这里必须用 `ctx.get(name, false)`
|
|
449
|
-
// 这种"取不到就返回 undefined"的非 strict 方式。
|
|
450
|
-
//
|
|
451
|
-
// 这个错误当初被 `catch` 吞掉后表现为"热重载没反应",
|
|
452
|
-
// 而不像报错 —— 又是那种最难查的形态。
|
|
453
|
-
const safeGet = (ctxLike, name) => {
|
|
454
|
-
if (!ctxLike) return undefined;
|
|
455
|
-
try {
|
|
456
|
-
if (typeof ctxLike.get === 'function') {
|
|
457
|
-
const v = ctxLike.get(name, false);
|
|
458
|
-
if (v !== undefined) return v;
|
|
459
|
-
}
|
|
460
|
-
} catch { /* 非 strict 也可能抛,继续用下一招 */ }
|
|
461
|
-
try {
|
|
462
|
-
return ctxLike[name];
|
|
463
|
-
} catch {
|
|
464
|
-
return undefined;
|
|
465
|
-
}
|
|
466
|
-
};
|
|
467
|
-
|
|
468
|
-
const loader = safeGet(ctx, 'loader');
|
|
469
|
-
const registry = safeGet(ctx, 'registry');
|
|
470
|
-
if (!loader || !registry) {
|
|
471
|
-
return {
|
|
472
|
-
ok: false,
|
|
473
|
-
error: 'no loader/registry on this context '
|
|
474
|
-
+ '(需要 ctx.get("loader", false) 可用;若从 ctx.loader 直读会被 Cordis 的 inject 校验拒绝)',
|
|
475
|
-
restored: false,
|
|
476
|
-
};
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
const previous = ctx.fiber && ctx.fiber.runtime && ctx.fiber.runtime.callback;
|
|
480
|
-
if (!previous) {
|
|
481
|
-
return { ok: false, error: 'no runtime to reload (plugin not registered the ordinary way)', restored: false };
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
const runtime = registry.get(previous);
|
|
485
|
-
const fibers = [...((runtime && runtime.fibers) || [])].map((fiber) => {
|
|
486
|
-
const entry = fiber.entry && fiber.entry.fiber && fiber.entry.fiber.uid === fiber.uid ? fiber.entry : undefined;
|
|
487
|
-
return { fiber, entry, config: entry === undefined ? fiber._config : entry.options.config };
|
|
488
|
-
});
|
|
489
|
-
if (fibers.length === 0) {
|
|
490
|
-
return { ok: false, error: 'runtime has no live fibers', restored: false };
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
const internal = loader.internal;
|
|
494
|
-
const backup = purgeCaches(internal, packageUrl);
|
|
495
|
-
try { logger.info?.(`[dsh-notify-say] reload: purged ${backup.esm.size} module(s)`); } catch { /* ignore */ }
|
|
496
|
-
|
|
497
|
-
const importFresh = async (url) => {
|
|
498
|
-
const mod = await loader.import(url, () => []);
|
|
499
|
-
return typeof loader.unwrapExports === 'function' ? loader.unwrapExports(mod) : mod;
|
|
500
|
-
};
|
|
501
|
-
|
|
502
|
-
/**
|
|
503
|
-
* 判断 replacement 是否**就是旧的那个**模块。
|
|
504
|
-
*
|
|
505
|
-
* 三种都要查(上游也这么查):
|
|
506
|
-
* · 整个导出对象相同 → 完全没换
|
|
507
|
-
* · apply 函数相同 → 模块对象换了壳、函数还是旧的
|
|
508
|
-
* · 旧对象有 apply 且相同 → 同上(previous 可能是函数本身)
|
|
509
|
-
*/
|
|
510
|
-
const isSameAsPrevious = (candidate, prev) => {
|
|
511
|
-
if (!candidate) return true;
|
|
512
|
-
if (candidate === prev) return true;
|
|
513
|
-
if (!prev || typeof prev !== 'object') return false;
|
|
514
|
-
if (candidate.apply && prev.apply && candidate.apply === prev.apply) return true;
|
|
515
|
-
return false;
|
|
516
|
-
};
|
|
517
|
-
|
|
518
|
-
let replacement;
|
|
519
|
-
// verdict 在 try 块内算出,但要一直用到"注册成功"之后(返回值里)。
|
|
520
|
-
// 所以把结论提到外层 —— 直接引用 verdict 会出作用域(S1 实测报过
|
|
521
|
-
// "verdict is not defined")。
|
|
522
|
-
let verifiedOk = false;
|
|
523
|
-
let busted = false;
|
|
524
|
-
try {
|
|
525
|
-
replacement = await importFresh(entryUrl);
|
|
526
|
-
if (!replacement || typeof replacement.apply !== 'function') {
|
|
527
|
-
throw new Error('reloaded module does not export apply()');
|
|
528
|
-
}
|
|
529
|
-
// v0.4.41:**无条件**走代际绕过。
|
|
530
|
-
//
|
|
531
|
-
// 原来是:
|
|
532
|
-
// if (backup.esm.size === 0 || isSameAsPrevious(replacement, previous))
|
|
533
|
-
//
|
|
534
|
-
// 只在「一个缓存都没清掉」或「拿到同一个对象」时才绕过。
|
|
535
|
-
// 但 2026-10-18 重启后实测发现第三种情况:
|
|
536
|
-
// · 清了部分缓存 → backup.esm.size > 0
|
|
537
|
-
// · 拿到新壳对象 → isSameAsPrevious 为 false
|
|
538
|
-
// · 于是**跳过**代际绕过
|
|
539
|
-
// · 可新对象是壳,模块级 const 的求值结果被复用
|
|
540
|
-
// · 指纹仍是旧的 → verifyReload 判 fingerprint unchanged
|
|
541
|
-
// 日志里三次不同编辑的指纹一模一样(adb61783),就是这个原因。
|
|
542
|
-
//
|
|
543
|
-
// 代际绕过只是强制重新求值,语义不变、代价是一次多余求值;
|
|
544
|
-
// 而省掉它的代价是「改了代码拿不到新代码」——今天为此排查一整天。
|
|
545
|
-
{
|
|
546
|
-
|
|
547
|
-
try { logger.info?.('[dsh-notify-say] reload: falling back to generation bust'); } catch { /* ignore */ }
|
|
548
|
-
// ⚠️ 必须 await:钩子装好之前导入新入口,依赖仍会是旧的。
|
|
549
|
-
await enableFreshDependencies(packageUrl);
|
|
550
|
-
const freshUrl = new URL(entryUrl);
|
|
551
|
-
freshUrl.searchParams.set(GEN_PARAM, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
552
|
-
replacement = await importFresh(freshUrl.href);
|
|
553
|
-
busted = true;
|
|
554
|
-
if (!replacement || typeof replacement.apply !== 'function') {
|
|
555
|
-
throw new Error('reloaded module does not export apply() after generation bust');
|
|
556
|
-
}
|
|
557
|
-
if (isSameAsPrevious(replacement, previous)) {
|
|
558
|
-
throw new Error('replacement is still the previous plugin after cache invalidation '
|
|
559
|
-
+ '(this kernel does not let modules be re-evaluated)');
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
// ── 生效校验(v0.4.20 核心)────────────────────────────────────────
|
|
564
|
-
//
|
|
565
|
-
// 「报 ok 就必须真生效」。规则抽在 verifyReload 里(纯函数、可单独测),
|
|
566
|
-
// 这里只负责把值喂进去、出错就回滚。
|
|
567
|
-
//
|
|
568
|
-
// 为什么不能只靠"replacement !== previous":那只证明**入口**被重新
|
|
569
|
-
// 求值了,不证明它 import 的依赖也换了。2026-10-17 实测出现过
|
|
570
|
-
// "入口换了、config-store 没换"的半新半旧,而重载照样报 ok。
|
|
571
|
-
const gotFp = typeof replacement.fingerprint === 'string' ? replacement.fingerprint : '';
|
|
572
|
-
// ⚠️ 2026-10-18 外部审阅指出:调用方算好了 diskFingerprint 传进来,
|
|
573
|
-
// 但这里根本没用 → 「验证通过」不等于「加载的是磁盘上那份代码」。
|
|
574
|
-
//
|
|
575
|
-
// 现在:有 diskFingerprint 就拿它当期望指纹(expectedFingerprint 优先,
|
|
576
|
-
// 便于测试注入)。
|
|
577
|
-
const expectedFp = options.expectedFingerprint !== undefined
|
|
578
|
-
? options.expectedFingerprint
|
|
579
|
-
: options.diskFingerprint;
|
|
580
|
-
|
|
581
|
-
const verdict = verifyReload({
|
|
582
|
-
gotFingerprint: gotFp,
|
|
583
|
-
differFrom: options.differFrom,
|
|
584
|
-
expectedFingerprint: expectedFp,
|
|
585
|
-
});
|
|
586
|
-
if (!verdict.ok) throw new Error(verdict.error);
|
|
587
|
-
verifiedOk = !!verdict.checked;
|
|
588
|
-
if (verdict.checked) {
|
|
589
|
-
try { logger.info?.(`[dsh-notify-say] reload: verified (fp=${gotFp})`); } catch { /* ignore */ }
|
|
590
|
-
}
|
|
591
|
-
} catch (error) {
|
|
592
|
-
restoreCaches(internal, backup);
|
|
593
|
-
return { ok: false, error: `re-import failed: ${(error && error.message) || error}`, restored: true };
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
// ⚠️ 2026-10-18 外部审阅指出:原来这里 catch 之后**仍然继续**注册新实例。
|
|
597
|
-
//
|
|
598
|
-
// 如果旧实例没真正退出,就会出现两个实例同时持有事件监听/文件句柄 ——
|
|
599
|
-
// 表现是通知重复、日志错乱,而且极难查("有时响两次")。
|
|
600
|
-
//
|
|
601
|
-
// 现在区分两种情况:
|
|
602
|
-
// · dispose 只是"报告了错误",但 fiber 确实停了 → 继续(正常路径)
|
|
603
|
-
// · fiber 仍有未停的 → 返回结构化失败,不切换
|
|
604
|
-
//
|
|
605
|
-
// 判据:`fiber.await()` 抛错说明停不下来;不抛就认为已停。
|
|
606
|
-
try {
|
|
607
|
-
registry.delete(previous);
|
|
608
|
-
await Promise.all(fibers.map(({ fiber }) => fiber.await()));
|
|
609
|
-
} catch (error) {
|
|
610
|
-
// dispose 报错本身不一定是致命的(旧代码可能已部分卸载),
|
|
611
|
-
// 但必须确认没有残留的活动 fiber,否则不能切换。
|
|
612
|
-
const stillAlive = fibers.filter(({ fiber }) => {
|
|
613
|
-
try {
|
|
614
|
-
// Cordis 的 fiber 若已 dispose,status 会变成非 active
|
|
615
|
-
const st = fiber && (fiber.status !== undefined ? fiber.status : fiber.state);
|
|
616
|
-
return st === 'active' || st === 'pending' || st === 2 || st === 1;
|
|
617
|
-
} catch {
|
|
618
|
-
return true; // 查不清就当作还活着(宁可失败也不双开)
|
|
619
|
-
}
|
|
620
|
-
});
|
|
621
|
-
const msg = (error && error.message) || error;
|
|
622
|
-
if (stillAlive.length > 0) {
|
|
623
|
-
restoreCaches(internal, backup);
|
|
624
|
-
return {
|
|
625
|
-
ok: false,
|
|
626
|
-
error: `dispose-incomplete: ${stillAlive.length}/${fibers.length} fiber(s) still active (${msg})`,
|
|
627
|
-
restored: true,
|
|
628
|
-
};
|
|
629
|
-
}
|
|
630
|
-
// 都停干净了:只是报了个错,继续切换
|
|
631
|
-
try { logger.warn?.(`[dsh-notify-say] reload: dispose reported ${msg} (all fibers stopped, continuing)`); } catch { /* ignore */ }
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
try {
|
|
635
|
-
const created = [];
|
|
636
|
-
for (const { fiber, entry, config } of fibers) {
|
|
637
|
-
const registered = fiber.parent.registry.plugin(replacement, config);
|
|
638
|
-
const fresh = registered.ctx.fiber;
|
|
639
|
-
if (entry !== undefined) {
|
|
640
|
-
fresh.entry = entry;
|
|
641
|
-
entry.fiber = fresh;
|
|
642
|
-
}
|
|
643
|
-
created.push(fresh);
|
|
644
|
-
}
|
|
645
|
-
await Promise.all(created.map((fiber) => fiber.await()));
|
|
646
|
-
try { logger.info?.(`[dsh-notify-say] reload: complete, ${created.length} fiber(s)`); } catch { /* ignore */ }
|
|
647
|
-
// ⚠️ 2026-10-18 外部审阅指出:调用方读 r.busted / r.verified 来显示
|
|
648
|
-
// 「走了代际绕过」「指纹校验通过」,但这里从来没返回过这两个字段 ——
|
|
649
|
-
// 于是日志永远显示不出来,我据此判断重载行为时一直是盲的。
|
|
650
|
-
return { ok: true, fibers: created.length, busted, verified: verifiedOk };
|
|
651
|
-
} catch (error) {
|
|
652
|
-
restoreCaches(internal, backup);
|
|
653
|
-
try { registry.delete(replacement); } catch { /* 可能从未启动 */ }
|
|
654
|
-
try {
|
|
655
|
-
const restored = [];
|
|
656
|
-
for (const { fiber, entry, config } of fibers) {
|
|
657
|
-
const registered = fiber.parent.registry.plugin(previous, config);
|
|
658
|
-
const fresh = registered.ctx.fiber;
|
|
659
|
-
if (entry !== undefined) {
|
|
660
|
-
fresh.entry = entry;
|
|
661
|
-
entry.fiber = fresh;
|
|
662
|
-
}
|
|
663
|
-
restored.push(fresh);
|
|
664
|
-
}
|
|
665
|
-
await Promise.all(restored.map((fiber) => fiber.await()));
|
|
666
|
-
return {
|
|
667
|
-
ok: false,
|
|
668
|
-
error: `new code failed to start, previous version restored: ${(error && error.message) || error}`,
|
|
669
|
-
restored: true,
|
|
670
|
-
};
|
|
671
|
-
} catch (restoreError) {
|
|
672
|
-
return {
|
|
673
|
-
ok: false,
|
|
674
|
-
error: `new code failed to start (${(error && error.message) || error}); restore failed too `
|
|
675
|
-
+ `(${(restoreError && restoreError.message) || restoreError})`,
|
|
676
|
-
restored: false,
|
|
677
|
-
};
|
|
678
|
-
}
|
|
679
|
-
}
|
|
680
|
-
})();
|
|
681
|
-
|
|
682
|
-
return inFlight.finally(() => { inFlight = null; });
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
/**
|
|
686
|
-
* 监听包目录,变更时触发回调(防抖)。
|
|
687
|
-
*
|
|
688
|
-
* 刻意**不自动重载**:由调用方决定怎么用(例如只在 devReload 开启时调 selfReload)。
|
|
689
|
-
* 自动重载会在你保存到一半的文件上触发,拿到语法错误的半成品。
|
|
690
|
-
*
|
|
691
|
-
* @param {string} packageDir
|
|
692
|
-
* @param {{ onChange?: Function, logger?: object, debounceMs?: number }} [opts]
|
|
693
|
-
* @returns {() => void} 停止函数
|
|
694
|
-
*/
|
|
695
|
-
export function watchPackage(packageDir, opts = {}) {
|
|
696
|
-
const logger = opts.logger || console;
|
|
697
|
-
const debounceMs = Number.isFinite(opts.debounceMs) ? opts.debounceMs : 800;
|
|
698
|
-
let timer;
|
|
699
|
-
let paused = false;
|
|
700
|
-
let watcher;
|
|
701
|
-
try {
|
|
702
|
-
// ⚠️ 2026-10-18 外部审阅指出:监听整个目录,但指纹只覆盖
|
|
703
|
-
// package.json + lib/*.js。改文档或测试也会触发一次注定失败的重载。
|
|
704
|
-
// 过滤在 schedule(filename) 里做(见 isSourceFile)。
|
|
705
|
-
watcher = fs.watch(packageDir, { recursive: true });
|
|
706
|
-
} catch (error) {
|
|
707
|
-
try { logger.warn?.(`[dsh-notify-say] cannot watch ${packageDir}: ${(error && error.message) || error}`); } catch { /* ignore */ }
|
|
708
|
-
return () => {};
|
|
709
|
-
}
|
|
710
|
-
// 只有这些文件变了才值得重载(与指纹覆盖范围保持一致)。
|
|
711
|
-
const isSourceFile = (filename) => {
|
|
712
|
-
if (!filename) return true; // 拿不到文件名时保守处理:当真
|
|
713
|
-
const f = String(filename).replace(/\\/g, '/');
|
|
714
|
-
if (f === 'package.json') return true;
|
|
715
|
-
if (f.startsWith('lib/') && f.endsWith('.js')) return true;
|
|
716
|
-
return false;
|
|
717
|
-
};
|
|
718
|
-
|
|
719
|
-
const schedule = (filename) => {
|
|
720
|
-
if (paused) return;
|
|
721
|
-
if (!isSourceFile(filename)) return; // 文档/测试变更不触发重载
|
|
722
|
-
clearTimeout(timer);
|
|
723
|
-
timer = setTimeout(() => {
|
|
724
|
-
timer = undefined;
|
|
725
|
-
if (paused) return;
|
|
726
|
-
try { opts.onChange?.(); } catch (error) {
|
|
727
|
-
try { logger.warn?.(`[dsh-notify-say] watch onChange threw: ${(error && error.message) || error}`); } catch { /* ignore */ }
|
|
728
|
-
}
|
|
729
|
-
}, debounceMs);
|
|
730
|
-
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
731
|
-
};
|
|
732
|
-
// fs.watch 的监听器签名是 (eventType, filename)
|
|
733
|
-
watcher.on('change', (eventType, filename) => schedule(filename));
|
|
734
|
-
watcher.on('error', (error) => {
|
|
735
|
-
try { logger.warn?.(`[dsh-notify-say] watcher error: ${(error && error.message) || error}`); } catch { /* ignore */ }
|
|
736
|
-
});
|
|
737
|
-
return () => {
|
|
738
|
-
paused = true;
|
|
739
|
-
clearTimeout(timer);
|
|
740
|
-
try { watcher.close(); } catch { /* 已关闭 */ }
|
|
741
|
-
};
|
|
742
|
-
}
|