dsh-notify-say 0.4.25 → 0.4.27

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/lib/dev-reload.js CHANGED
@@ -1,617 +1,647 @@
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
- import { createRequire, registerHooks } from 'node:module';
78
- import { fileURLToPath } from 'node:url';
79
-
80
- /** 同一时刻只允许一次重载,避免重叠交换。 */
81
- let inFlight = null;
82
-
83
- /**
84
- * 已安装的代际钩子(按 packageUrl 索引)。
85
- *
86
- * `registerHooks` 在同一进程里**不能重复注册同一个 packageUrl**:
87
- * 第二次会叠加一个钩子,导致代际参数被反复改写、URL 越滚越长。
88
- * 所以用这个 Map 记住"这个包已经装过钩子",只装一次。
89
- */
90
- const reloadHooks = new Map();
91
-
92
- /** 代际查询串的键名。带项目前缀,避免与其它插件撞名。 */
93
- const GEN_PARAM = 'dns-reload';
94
-
95
- /**
96
- * 让入口的"代际"穿透到它 import 的每一个本包模块。
97
- *
98
- * ## 为什么必须有
99
- *
100
- * 只在入口 URL 上加 `?reload=xxx` 是不够的:入口里写的是
101
- * `import { ... } from './config-store.js'`,这个相对说明符**不带查询串**,
102
- * 于是 Node 仍然复用缓存里的**旧依赖** —— 结果就是入口是新的、依赖是旧的。
103
- *
104
- * 这正是 2026-10-17 那次"config-store 换了、index 没换"(或反之)的机制。
105
- *
106
- * 钩子的作用:凡是 `parentURL` 带代际、且 `resolved.url` 落在同一个包内的,
107
- * 都给 `resolved.url` 补上同一个代际参数,整棵子树一起重新求值。
108
- *
109
- * @param {string} packageUrl 包根 URL(带尾斜杠)
110
- */
111
- export function enableFreshDependencies(packageUrl) {
112
- if (reloadHooks.has(packageUrl)) return;
113
- try {
114
- const hook = registerHooks({
115
- resolve(specifier, context, nextResolve) {
116
- const resolved = nextResolve(specifier, context);
117
- const parent = context && context.parentURL;
118
- if (!parent || !parent.startsWith(packageUrl)) return resolved;
119
- if (!resolved || typeof resolved.url !== 'string') return resolved;
120
- if (!resolved.url.startsWith(packageUrl)) return resolved;
121
- let generation = null;
122
- try { generation = new URL(parent).searchParams.get(GEN_PARAM); } catch { return resolved; }
123
- if (generation === null) return resolved;
124
- try {
125
- const target = new URL(resolved.url);
126
- target.searchParams.set(GEN_PARAM, generation);
127
- return { ...resolved, url: target.href };
128
- } catch {
129
- return resolved;
130
- }
131
- },
132
- });
133
- reloadHooks.set(packageUrl, hook);
134
- } catch {
135
- // 老版本 Node 没有 registerHooks —— 只影响代际绕过,不影响正常路径
136
- }
137
- }
138
-
139
- /**
140
- * 计算"磁盘上这份代码应该长什么样"的指纹。
141
- *
142
- * 用途:重载前记下,重载后**从真实模块里读回**再比对。
143
- * 这是"报 ok 就必须真生效"的依据 —— 没有它,只能相信
144
- * "re-import 返回了新对象",而那并不等于"新代码在跑"。
145
- *
146
- * 取哪些文件:入口 + lib 下所有 js。排除 test/tools/sounds 等大目录。
147
- *
148
- * @param {string} packageDir 包目录的**文件系统路径**
149
- * @returns {string} 形如 'a1b2c3d4' 的短哈希;读不到时返回 ''
150
- */
151
- export function computeFingerprint(packageDir) {
152
- try {
153
- const base = String(packageDir || '').replace(/[\\/]+$/, '');
154
- if (!base) return '';
155
-
156
- // 目录本身不存在 → 明确返回空串。
157
- // 不能让"一个文件都 stat 不到"悄悄算出空串的哈希值 —— 那会让调用方
158
- // 以为"算出来了",从而拿一个无意义的指纹去比对(实测踩到:
159
- // 不存在的目录返回了 '00001505',看起来像个正常指纹)。
160
- if (!fs.existsSync(base)) return '';
161
-
162
- const files = [];
163
- const libDir = base + '\\lib';
164
- files.push(base + '\\package.json');
165
- if (fs.existsSync(libDir)) {
166
- for (const name of fs.readdirSync(libDir)) {
167
- if (name.endsWith('.js')) files.push(libDir + '\\' + name);
168
- }
169
- }
170
- // 简易内容哈希:把文件名 + 大小 + mtime 混起来。
171
- // 不读全文是刻意的 —— 每次重载读几百 KB 没必要,且 mtime 变化足够灵敏。
172
- let acc = '';
173
- let counted = 0;
174
- for (const f of files.sort()) {
175
- try {
176
- const st = fs.statSync(f);
177
- acc += f + ':' + st.size + ':' + st.mtimeMs + ';';
178
- counted += 1;
179
- } catch { /* 单个文件读不到就跳过 */ }
180
- }
181
- // 一个文件都没读到 → 算不出有意义的指纹,返回空串让调用方知道。
182
- if (counted === 0) return '';
183
-
184
- let hash = 5381;
185
- for (let i = 0; i < acc.length; i++) hash = ((hash * 33) ^ acc.charCodeAt(i)) >>> 0;
186
- return hash.toString(16).padStart(8, '0');
187
- } catch {
188
- return '';
189
- }
190
- }
191
-
192
- /**
193
- * 判定"这次重载是否真的生效"。
194
- *
195
- * ## 为什么抽成独立纯函数
196
- *
197
- * 2026-10-17 的假成功,根源就是**校验逻辑被埋在 selfReload 里**:
198
- * 它只在 `replacement === previous` 时判失败,而这几乎永远不成立
199
- * (重新 import 总会得到新对象),所以校验形同虚设。
200
- *
201
- * 抽出来以后可以:
202
- * · 单独测每条规则的边界(含负向用例)
203
- * · 用变异测试确认规则真的会被触发(改坏就变红)
204
- *
205
- * ## 三条规则
206
- *
207
- * 1. **必须有 fingerprint**(启用校验时)
208
- * 模块不导出 fingerprint → 无法自证 → 判失败。宁可报错,不要假 ok。
209
- *
210
- * 2. **differFrom**:新 fingerprint 必须 ≠ 旧实例的
211
- * 相同就说明"重新 import 拿到的是同一次求值",代码根本没换。
212
- *
213
- * 3. **expectedFingerprint**:必须完全等于调用方给的期望值
214
- * 比 2 更严(调用方若能算出磁盘指纹就该用这个)。
215
- *
216
- * 未传任何选项时返回 `{ ok:true, checked:false }` —— 表示"没做校验",
217
- * 调用方据此知道这次成功**没有**经过验证。
218
- *
219
- * @param {{gotFingerprint?: string, differFrom?: string, expectedFingerprint?: string}} opts
220
- * @returns {{ok: boolean, checked: boolean, error?: string}}
221
- */
222
- export function verifyReload(opts = {}) {
223
- const got = typeof opts.gotFingerprint === 'string' ? opts.gotFingerprint : '';
224
- const wantsDiffer = opts.differFrom !== undefined && opts.differFrom !== null;
225
- const wantsExact = typeof opts.expectedFingerprint === 'string' && opts.expectedFingerprint !== '';
226
-
227
- if (!wantsDiffer && !wantsExact) return { ok: true, checked: false };
228
-
229
- // 规则 1:要有指纹才能自证
230
- if (!got) {
231
- return {
232
- ok: false,
233
- checked: true,
234
- error: 'verification failed: reloaded module does not export fingerprint — '
235
- + 'cannot prove the new code is live',
236
- };
237
- }
238
-
239
- // 规则 2:必须与旧实例不同
240
- if (wantsDiffer && got === opts.differFrom) {
241
- return {
242
- ok: false,
243
- checked: true,
244
- error: `verification failed: fingerprint unchanged (${got}) — `
245
- + 'the module was re-imported but is still the same evaluation as before',
246
- };
247
- }
248
-
249
- // 规则 3:必须与期望值完全一致
250
- if (wantsExact && got !== opts.expectedFingerprint) {
251
- return {
252
- ok: false,
253
- checked: true,
254
- error: `fingerprint mismatch: expected ${opts.expectedFingerprint}, got ${got} `
255
- + '(module was re-evaluated but the code on disk is not what is running)',
256
- };
257
- }
258
-
259
- return { ok: true, checked: true };
260
- }
261
-
262
- /** 是否正在重载。 */
263
-
264
- export function isReloading() {
265
- return inFlight !== null;
266
- }
267
-
268
- /**
269
- * 包目录(`file://` URL,带尾斜杠)。
270
- * @param {string} entryModuleUrl `import.meta.url`
271
- */
272
- export function packageUrlOf(entryModuleUrl) {
273
- return new URL('../', entryModuleUrl).href;
274
- }
275
-
276
- /**
277
- * 入口文件 URL。
278
- *
279
- * ⚠️ 本插件入口是 `lib/index.js`,而本模块就住在 `lib/` 里 ——
280
- * 所以从 `lib/dev-reload.js` 出发是 `./index.js`,**不是** `../index.js`。
281
- *
282
- * 上游 FreeModel 的 `src/reload.js` 用 `../index.js` 是对的(它的入口在包根),
283
- * 照抄会解析成 `<包>/index.js` —— 那个文件不存在,重载必失败。
284
- * 移植时踩到,测试 D7 抓住。
285
- *
286
- * @param {string} entryModuleUrl `import.meta.url`
287
- */
288
- export function entryUrlOf(entryModuleUrl) {
289
- return new URL('./index.js', entryModuleUrl).href;
290
- }
291
-
292
- /**
293
- * 找出 loadCache 里属于本包的所有键。
294
- *
295
- * 键比较要容忍大小写与百分号编码差异(Node 解析器与 `new URL` 的产出可能不同)。
296
- *
297
- * @param {Map} loadCache
298
- * @param {string} packageUrl
299
- * @returns {string[]}
300
- */
301
- export function packageCacheKeys(loadCache, packageUrl) {
302
- const base = decodeURIComponent(packageUrl).replace(/\/+$/, '').toLowerCase() + '/';
303
- const keys = [];
304
- let it;
305
- try {
306
- it = loadCache.keys();
307
- } catch {
308
- return keys;
309
- }
310
- for (const key of it) {
311
- if (typeof key !== 'string') continue;
312
- let normalized;
313
- try { normalized = decodeURIComponent(key).toLowerCase(); } catch { normalized = key.toLowerCase(); }
314
- if (normalized.startsWith(base)) keys.push(key);
315
- }
316
- return keys;
317
- }
318
-
319
- /**
320
- * 清掉本包在 ESM / CJS 两级缓存里的条目,返回可用于回滚的备份。
321
- *
322
- * @param {object} internal `ctx.loader.internal`
323
- * @param {string} packageUrl
324
- * @returns {{ esm: Map<string, unknown>, cjs: Map<string, object> }}
325
- */
326
- export function purgeCaches(internal, packageUrl) {
327
- const backup = { esm: new Map(), cjs: new Map() };
328
- const cache = internal && internal.loadCache;
329
- if (cache && typeof cache.keys === 'function' && typeof cache.get === 'function') {
330
- const read = (key) => {
331
- try { return Map.prototype.get.call(cache, key); } catch { try { return cache.get(key); } catch { return undefined; } }
332
- };
333
- // ⚠️ 必须走 Map.prototype.delete:Node 24 的 LoadCache.delete 只清类型槽,
334
- // 模块本体仍留着,重新 import 会拿回旧对象(整套机制失效)。
335
- const drop = (key) => {
336
- try { Map.prototype.delete.call(cache, key); return; } catch { /* 不是真 Map */ }
337
- try { cache.delete(key); } catch { /* 尽力而为 */ }
338
- };
339
- for (const key of packageCacheKeys(cache, packageUrl)) {
340
- backup.esm.set(key, read(key));
341
- drop(key);
342
- }
343
- }
344
-
345
- const require = createRequire(import.meta.url);
346
- for (const key of [...backup.esm.keys()]) {
347
- try {
348
- const filePath = fileURLToPath(key);
349
- if (require.cache[filePath] !== undefined) {
350
- backup.cjs.set(filePath, require.cache[filePath]);
351
- delete require.cache[filePath];
352
- }
353
- } catch {
354
- // 非 file: URL —— CJS 侧无事可做
355
- }
356
- }
357
- return backup;
358
- }
359
-
360
- /**
361
- * 把备份写回缓存(回滚用)。
362
- * @param {object} internal
363
- * @param {{esm: Map, cjs: Map}} backup
364
- */
365
- export function restoreCaches(internal, backup) {
366
- const cache = internal && internal.loadCache;
367
- for (const [key, job] of backup.esm) {
368
- try { Map.prototype.set.call(cache, key, job); } catch { try { cache.set(key, job); } catch { /* best effort */ } }
369
- }
370
- const require = createRequire(import.meta.url);
371
- for (const [filePath, mod] of backup.cjs) require.cache[filePath] = mod;
372
- }
373
-
374
- /**
375
- * 原地重载本插件。
376
- *
377
- * @param {object} ctx `apply` 收到的 ctx
378
- * @param {object} [options]
379
- * @param {object} [options.logger] 日志器(默认 console)
380
- * @param {string} [options.entryModuleUrl] 默认用本模块的 import.meta.url
381
- * @returns {Promise<{ok:boolean, error?:string, restored?:boolean, fibers?:number}>}
382
- */
383
- export function selfReload(ctx, options = {}) {
384
- if (inFlight !== null) return inFlight;
385
- inFlight = (async () => {
386
- const logger = options.logger || console;
387
- const entryModuleUrl = options.entryModuleUrl || import.meta.url;
388
- const packageUrl = options.packageUrl || packageUrlOf(entryModuleUrl);
389
- const entryUrl = options.entryUrl || entryUrlOf(entryModuleUrl);
390
-
391
- // 取 loader / registry 必须**非 strict**。
392
- //
393
- // ⚠️ 2026-10-18 实测踩到:直接写 `ctx.loader` 在 Cordis 里会抛
394
- // `cannot get property "loader" without inject`
395
- // —— 除非插件在 `inject: ['loader']` 里声明过。本插件没有(也不需要,
396
- // 因为它平时用不到 loader),所以这里必须用 `ctx.get(name, false)`
397
- // 这种"取不到就返回 undefined"的非 strict 方式。
398
- //
399
- // 这个错误当初被 `catch` 吞掉后表现为"热重载没反应",
400
- // 而不像报错 —— 又是那种最难查的形态。
401
- const safeGet = (ctxLike, name) => {
402
- if (!ctxLike) return undefined;
403
- try {
404
- if (typeof ctxLike.get === 'function') {
405
- const v = ctxLike.get(name, false);
406
- if (v !== undefined) return v;
407
- }
408
- } catch { /* 非 strict 也可能抛,继续用下一招 */ }
409
- try {
410
- return ctxLike[name];
411
- } catch {
412
- return undefined;
413
- }
414
- };
415
-
416
- const loader = safeGet(ctx, 'loader');
417
- const registry = safeGet(ctx, 'registry');
418
- if (!loader || !registry) {
419
- return {
420
- ok: false,
421
- error: 'no loader/registry on this context '
422
- + '(需要 ctx.get("loader", false) 可用;若从 ctx.loader 直读会被 Cordis 的 inject 校验拒绝)',
423
- restored: false,
424
- };
425
- }
426
-
427
- const previous = ctx.fiber && ctx.fiber.runtime && ctx.fiber.runtime.callback;
428
- if (!previous) {
429
- return { ok: false, error: 'no runtime to reload (plugin not registered the ordinary way)', restored: false };
430
- }
431
-
432
- const runtime = registry.get(previous);
433
- const fibers = [...((runtime && runtime.fibers) || [])].map((fiber) => {
434
- const entry = fiber.entry && fiber.entry.fiber && fiber.entry.fiber.uid === fiber.uid ? fiber.entry : undefined;
435
- return { fiber, entry, config: entry === undefined ? fiber._config : entry.options.config };
436
- });
437
- if (fibers.length === 0) {
438
- return { ok: false, error: 'runtime has no live fibers', restored: false };
439
- }
440
-
441
- const internal = loader.internal;
442
- const backup = purgeCaches(internal, packageUrl);
443
- try { logger.info?.(`[dsh-notify-say] reload: purged ${backup.esm.size} module(s)`); } catch { /* ignore */ }
444
-
445
- const importFresh = async (url) => {
446
- const mod = await loader.import(url, () => []);
447
- return typeof loader.unwrapExports === 'function' ? loader.unwrapExports(mod) : mod;
448
- };
449
-
450
- /**
451
- * 判断 replacement 是否**就是旧的那个**模块。
452
- *
453
- * 三种都要查(上游也这么查):
454
- * · 整个导出对象相同 → 完全没换
455
- * · apply 函数相同 → 模块对象换了壳、函数还是旧的
456
- * · 旧对象有 apply 且相同 → 同上(previous 可能是函数本身)
457
- */
458
- const isSameAsPrevious = (candidate, prev) => {
459
- if (!candidate) return true;
460
- if (candidate === prev) return true;
461
- if (!prev || typeof prev !== 'object') return false;
462
- if (candidate.apply && prev.apply && candidate.apply === prev.apply) return true;
463
- return false;
464
- };
465
-
466
- let replacement;
467
- let busted = false;
468
- try {
469
- replacement = await importFresh(entryUrl);
470
- if (!replacement || typeof replacement.apply !== 'function') {
471
- throw new Error('reloaded module does not export apply()');
472
- }
473
-
474
- // 缓存没清掉 / 这是新内核的 loadCache 形状 —— 改用代际绕过。
475
- //
476
- // 2026-10-17 的教训:这里原本是"直接失败返回",于是重载报 ok:false,
477
- // 但调用方(watchPackage)把它当普通失败吞掉,用户看到的是"好像没生效"。
478
- // 更要命的是有些内核能清掉**部分**条目,于是入口换了、依赖没换。
479
- // 现在两种异常(purged=0 或 拿到旧对象)都走代际绕过,强制整棵树重求值。
480
- if (backup.esm.size === 0 || isSameAsPrevious(replacement, previous)) {
481
- try { logger.info?.('[dsh-notify-say] reload: falling back to generation bust'); } catch { /* ignore */ }
482
- enableFreshDependencies(packageUrl);
483
- const freshUrl = new URL(entryUrl);
484
- freshUrl.searchParams.set(GEN_PARAM, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
485
- replacement = await importFresh(freshUrl.href);
486
- busted = true;
487
- if (!replacement || typeof replacement.apply !== 'function') {
488
- throw new Error('reloaded module does not export apply() after generation bust');
489
- }
490
- if (isSameAsPrevious(replacement, previous)) {
491
- throw new Error('replacement is still the previous plugin after cache invalidation '
492
- + '(this kernel does not let modules be re-evaluated)');
493
- }
494
- }
495
-
496
- // ── 生效校验(v0.4.20 核心)────────────────────────────────────────
497
- //
498
- // 「报 ok 就必须真生效」。规则抽在 verifyReload 里(纯函数、可单独测),
499
- // 这里只负责把值喂进去、出错就回滚。
500
- //
501
- // 为什么不能只靠"replacement !== previous":那只证明**入口**被重新
502
- // 求值了,不证明它 import 的依赖也换了。2026-10-17 实测出现过
503
- // "入口换了、config-store 没换"的半新半旧,而重载照样报 ok。
504
- const gotFp = typeof replacement.fingerprint === 'string' ? replacement.fingerprint : '';
505
- const verdict = verifyReload({
506
- gotFingerprint: gotFp,
507
- differFrom: options.differFrom,
508
- expectedFingerprint: options.expectedFingerprint,
509
- });
510
- if (!verdict.ok) throw new Error(verdict.error);
511
- if (verdict.checked) {
512
- try { logger.info?.(`[dsh-notify-say] reload: verified (fp=${gotFp})`); } catch { /* ignore */ }
513
- }
514
- } catch (error) {
515
- restoreCaches(internal, backup);
516
- return { ok: false, error: `re-import failed: ${(error && error.message) || error}`, restored: true };
517
- }
518
-
519
- try {
520
- registry.delete(previous);
521
- await Promise.all(fibers.map(({ fiber }) => fiber.await()));
522
- } catch (error) {
523
- try { logger.warn?.(`[dsh-notify-say] reload: dispose reported ${(error && error.message) || error}`); } catch { /* ignore */ }
524
- }
525
-
526
- try {
527
- const created = [];
528
- for (const { fiber, entry, config } of fibers) {
529
- const registered = fiber.parent.registry.plugin(replacement, config);
530
- const fresh = registered.ctx.fiber;
531
- if (entry !== undefined) {
532
- fresh.entry = entry;
533
- entry.fiber = fresh;
534
- }
535
- created.push(fresh);
536
- }
537
- await Promise.all(created.map((fiber) => fiber.await()));
538
- try { logger.info?.(`[dsh-notify-say] reload: complete, ${created.length} fiber(s)`); } catch { /* ignore */ }
539
- return { ok: true, fibers: created.length };
540
- } catch (error) {
541
- restoreCaches(internal, backup);
542
- try { registry.delete(replacement); } catch { /* 可能从未启动 */ }
543
- try {
544
- const restored = [];
545
- for (const { fiber, entry, config } of fibers) {
546
- const registered = fiber.parent.registry.plugin(previous, config);
547
- const fresh = registered.ctx.fiber;
548
- if (entry !== undefined) {
549
- fresh.entry = entry;
550
- entry.fiber = fresh;
551
- }
552
- restored.push(fresh);
553
- }
554
- await Promise.all(restored.map((fiber) => fiber.await()));
555
- return {
556
- ok: false,
557
- error: `new code failed to start, previous version restored: ${(error && error.message) || error}`,
558
- restored: true,
559
- };
560
- } catch (restoreError) {
561
- return {
562
- ok: false,
563
- error: `new code failed to start (${(error && error.message) || error}); restore failed too `
564
- + `(${(restoreError && restoreError.message) || restoreError})`,
565
- restored: false,
566
- };
567
- }
568
- }
569
- })();
570
-
571
- return inFlight.finally(() => { inFlight = null; });
572
- }
573
-
574
- /**
575
- * 监听包目录,变更时触发回调(防抖)。
576
- *
577
- * 刻意**不自动重载**:由调用方决定怎么用(例如只在 devReload 开启时调 selfReload)。
578
- * 自动重载会在你保存到一半的文件上触发,拿到语法错误的半成品。
579
- *
580
- * @param {string} packageDir
581
- * @param {{ onChange?: Function, logger?: object, debounceMs?: number }} [opts]
582
- * @returns {() => void} 停止函数
583
- */
584
- export function watchPackage(packageDir, opts = {}) {
585
- const logger = opts.logger || console;
586
- const debounceMs = Number.isFinite(opts.debounceMs) ? opts.debounceMs : 800;
587
- let timer;
588
- let paused = false;
589
- let watcher;
590
- try {
591
- watcher = fs.watch(packageDir, { recursive: true });
592
- } catch (error) {
593
- try { logger.warn?.(`[dsh-notify-say] cannot watch ${packageDir}: ${(error && error.message) || error}`); } catch { /* ignore */ }
594
- return () => {};
595
- }
596
- const schedule = () => {
597
- if (paused) return;
598
- clearTimeout(timer);
599
- timer = setTimeout(() => {
600
- timer = undefined;
601
- if (paused) return;
602
- try { opts.onChange?.(); } catch (error) {
603
- try { logger.warn?.(`[dsh-notify-say] watch onChange threw: ${(error && error.message) || error}`); } catch { /* ignore */ }
604
- }
605
- }, debounceMs);
606
- if (timer && typeof timer.unref === 'function') timer.unref();
607
- };
608
- watcher.on('change', schedule);
609
- watcher.on('error', (error) => {
610
- try { logger.warn?.(`[dsh-notify-say] watcher error: ${(error && error.message) || error}`); } catch { /* ignore */ }
611
- });
612
- return () => {
613
- paused = true;
614
- clearTimeout(timer);
615
- try { watcher.close(); } catch { /* 已关闭 */ }
616
- };
617
- }
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
+ if (reloadHooks.has(packageUrl)) return;
134
+ if (reloadHooks.has(packageUrl)) return;
135
+ // 标记为"已尝试",避免重复注册导致 Node 抛错
136
+ reloadHooks.set(packageUrl, null);
137
+
138
+ // Node < 22.15 没有 registerHooks:静默跳过即可。
139
+ // 代际绕过不成,重载仍走"清缓存 + 校验"的常规路径。
140
+ await loadRegisterHooks().then((registerHooks) => {
141
+ if (!registerHooks) return;
142
+ try {
143
+ const hook = registerHooks({
144
+ resolve(specifier, context, nextResolve) {
145
+ const resolved = nextResolve(specifier, context);
146
+ const parent = context && context.parentURL;
147
+ if (!parent || !parent.startsWith(packageUrl)) return resolved;
148
+ if (!resolved || typeof resolved.url !== 'string') return resolved;
149
+ if (!resolved.url.startsWith(packageUrl)) return resolved;
150
+ let generation = null;
151
+ try { generation = new URL(parent).searchParams.get(GEN_PARAM); } catch { return resolved; }
152
+ if (generation === null) return resolved;
153
+ try {
154
+ const target = new URL(resolved.url);
155
+ target.searchParams.set(GEN_PARAM, generation);
156
+ return { ...resolved, url: target.href };
157
+ } catch {
158
+ return resolved;
159
+ }
160
+ },
161
+ });
162
+ reloadHooks.set(packageUrl, hook);
163
+ } catch {
164
+ // 重复注册等情况:忽略,不影响正常路径
165
+ }
166
+ }).catch(() => { /* 拿不到 registerHooks:忽略 */ });
167
+ }
168
+
169
+ /**
170
+ * 计算"磁盘上这份代码应该长什么样"的指纹。
171
+ *
172
+ * 用途:重载前记下,重载后**从真实模块里读回**再比对。
173
+ * 这是"报 ok 就必须真生效"的依据 —— 没有它,只能相信
174
+ * "re-import 返回了新对象",而那并不等于"新代码在跑"。
175
+ *
176
+ * 取哪些文件:入口 + lib 下所有 js。排除 test/tools/sounds 等大目录。
177
+ *
178
+ * @param {string} packageDir 包目录的**文件系统路径**
179
+ * @returns {string} 形如 'a1b2c3d4' 的短哈希;读不到时返回 ''
180
+ */
181
+ export function computeFingerprint(packageDir) {
182
+ try {
183
+ const base = String(packageDir || '').replace(/[\\/]+$/, '');
184
+ if (!base) return '';
185
+
186
+ // 目录本身不存在 → 明确返回空串。
187
+ // 不能让"一个文件都 stat 不到"悄悄算出空串的哈希值 —— 那会让调用方
188
+ // 以为"算出来了",从而拿一个无意义的指纹去比对(实测踩到:
189
+ // 不存在的目录返回了 '00001505',看起来像个正常指纹)。
190
+ if (!fs.existsSync(base)) return '';
191
+
192
+ const files = [];
193
+ const libDir = base + '\\lib';
194
+ files.push(base + '\\package.json');
195
+ if (fs.existsSync(libDir)) {
196
+ for (const name of fs.readdirSync(libDir)) {
197
+ if (name.endsWith('.js')) files.push(libDir + '\\' + name);
198
+ }
199
+ }
200
+ // 简易内容哈希:把文件名 + 大小 + mtime 混起来。
201
+ // 不读全文是刻意的 —— 每次重载读几百 KB 没必要,且 mtime 变化足够灵敏。
202
+ let acc = '';
203
+ let counted = 0;
204
+ for (const f of files.sort()) {
205
+ try {
206
+ const st = fs.statSync(f);
207
+ acc += f + ':' + st.size + ':' + st.mtimeMs + ';';
208
+ counted += 1;
209
+ } catch { /* 单个文件读不到就跳过 */ }
210
+ }
211
+ // 一个文件都没读到 → 算不出有意义的指纹,返回空串让调用方知道。
212
+ if (counted === 0) return '';
213
+
214
+ let hash = 5381;
215
+ for (let i = 0; i < acc.length; i++) hash = ((hash * 33) ^ acc.charCodeAt(i)) >>> 0;
216
+ return hash.toString(16).padStart(8, '0');
217
+ } catch {
218
+ return '';
219
+ }
220
+ }
221
+
222
+ /**
223
+ * 判定"这次重载是否真的生效"。
224
+ *
225
+ * ## 为什么抽成独立纯函数
226
+ *
227
+ * 2026-10-17 的假成功,根源就是**校验逻辑被埋在 selfReload 里**:
228
+ * 它只在 `replacement === previous` 时判失败,而这几乎永远不成立
229
+ * (重新 import 总会得到新对象),所以校验形同虚设。
230
+ *
231
+ * 抽出来以后可以:
232
+ * · 单独测每条规则的边界(含负向用例)
233
+ * · 用变异测试确认规则真的会被触发(改坏就变红)
234
+ *
235
+ * ## 三条规则
236
+ *
237
+ * 1. **必须有 fingerprint**(启用校验时)
238
+ * 模块不导出 fingerprint → 无法自证 → 判失败。宁可报错,不要假 ok。
239
+ *
240
+ * 2. **differFrom**:新 fingerprint 必须 ≠ 旧实例的
241
+ * 相同就说明"重新 import 拿到的是同一次求值",代码根本没换。
242
+ *
243
+ * 3. **expectedFingerprint**:必须完全等于调用方给的期望值
244
+ * 比 2 更严(调用方若能算出磁盘指纹就该用这个)。
245
+ *
246
+ * 未传任何选项时返回 `{ ok:true, checked:false }` —— 表示"没做校验",
247
+ * 调用方据此知道这次成功**没有**经过验证。
248
+ *
249
+ * @param {{gotFingerprint?: string, differFrom?: string, expectedFingerprint?: string}} opts
250
+ * @returns {{ok: boolean, checked: boolean, error?: string}}
251
+ */
252
+ export function verifyReload(opts = {}) {
253
+ const got = typeof opts.gotFingerprint === 'string' ? opts.gotFingerprint : '';
254
+ const wantsDiffer = opts.differFrom !== undefined && opts.differFrom !== null;
255
+ const wantsExact = typeof opts.expectedFingerprint === 'string' && opts.expectedFingerprint !== '';
256
+
257
+ if (!wantsDiffer && !wantsExact) return { ok: true, checked: false };
258
+
259
+ // 规则 1:要有指纹才能自证
260
+ if (!got) {
261
+ return {
262
+ ok: false,
263
+ checked: true,
264
+ error: 'verification failed: reloaded module does not export fingerprint — '
265
+ + 'cannot prove the new code is live',
266
+ };
267
+ }
268
+
269
+ // 规则 2:必须与旧实例不同
270
+ if (wantsDiffer && got === opts.differFrom) {
271
+ return {
272
+ ok: false,
273
+ checked: true,
274
+ error: `verification failed: fingerprint unchanged (${got}) — `
275
+ + 'the module was re-imported but is still the same evaluation as before',
276
+ };
277
+ }
278
+
279
+ // 规则 3:必须与期望值完全一致
280
+ if (wantsExact && got !== opts.expectedFingerprint) {
281
+ return {
282
+ ok: false,
283
+ checked: true,
284
+ error: `fingerprint mismatch: expected ${opts.expectedFingerprint}, got ${got} `
285
+ + '(module was re-evaluated but the code on disk is not what is running)',
286
+ };
287
+ }
288
+
289
+ return { ok: true, checked: true };
290
+ }
291
+
292
+ /** 是否正在重载。 */
293
+
294
+ export function isReloading() {
295
+ return inFlight !== null;
296
+ }
297
+
298
+ /**
299
+ * 包目录(`file://` URL,带尾斜杠)。
300
+ * @param {string} entryModuleUrl `import.meta.url`
301
+ */
302
+ export function packageUrlOf(entryModuleUrl) {
303
+ return new URL('../', entryModuleUrl).href;
304
+ }
305
+
306
+ /**
307
+ * 入口文件 URL。
308
+ *
309
+ * ⚠️ 本插件入口是 `lib/index.js`,而本模块就住在 `lib/` 里 ——
310
+ * 所以从 `lib/dev-reload.js` 出发是 `./index.js`,**不是** `../index.js`。
311
+ *
312
+ * 上游 FreeModel 的 `src/reload.js` 用 `../index.js` 是对的(它的入口在包根),
313
+ * 照抄会解析成 `<包>/index.js` —— 那个文件不存在,重载必失败。
314
+ * 移植时踩到,测试 D7 抓住。
315
+ *
316
+ * @param {string} entryModuleUrl `import.meta.url`
317
+ */
318
+ export function entryUrlOf(entryModuleUrl) {
319
+ return new URL('./index.js', entryModuleUrl).href;
320
+ }
321
+
322
+ /**
323
+ * 找出 loadCache 里属于本包的所有键。
324
+ *
325
+ * 键比较要容忍大小写与百分号编码差异(Node 解析器与 `new URL` 的产出可能不同)。
326
+ *
327
+ * @param {Map} loadCache
328
+ * @param {string} packageUrl
329
+ * @returns {string[]}
330
+ */
331
+ export function packageCacheKeys(loadCache, packageUrl) {
332
+ const base = decodeURIComponent(packageUrl).replace(/\/+$/, '').toLowerCase() + '/';
333
+ const keys = [];
334
+ let it;
335
+ try {
336
+ it = loadCache.keys();
337
+ } catch {
338
+ return keys;
339
+ }
340
+ for (const key of it) {
341
+ if (typeof key !== 'string') continue;
342
+ let normalized;
343
+ try { normalized = decodeURIComponent(key).toLowerCase(); } catch { normalized = key.toLowerCase(); }
344
+ if (normalized.startsWith(base)) keys.push(key);
345
+ }
346
+ return keys;
347
+ }
348
+
349
+ /**
350
+ * 清掉本包在 ESM / CJS 两级缓存里的条目,返回可用于回滚的备份。
351
+ *
352
+ * @param {object} internal `ctx.loader.internal`
353
+ * @param {string} packageUrl
354
+ * @returns {{ esm: Map<string, unknown>, cjs: Map<string, object> }}
355
+ */
356
+ export function purgeCaches(internal, packageUrl) {
357
+ const backup = { esm: new Map(), cjs: new Map() };
358
+ const cache = internal && internal.loadCache;
359
+ if (cache && typeof cache.keys === 'function' && typeof cache.get === 'function') {
360
+ const read = (key) => {
361
+ try { return Map.prototype.get.call(cache, key); } catch { try { return cache.get(key); } catch { return undefined; } }
362
+ };
363
+ // ⚠️ 必须走 Map.prototype.delete:Node 24 的 LoadCache.delete 只清类型槽,
364
+ // 模块本体仍留着,重新 import 会拿回旧对象(整套机制失效)。
365
+ const drop = (key) => {
366
+ try { Map.prototype.delete.call(cache, key); return; } catch { /* 不是真 Map */ }
367
+ try { cache.delete(key); } catch { /* 尽力而为 */ }
368
+ };
369
+ for (const key of packageCacheKeys(cache, packageUrl)) {
370
+ backup.esm.set(key, read(key));
371
+ drop(key);
372
+ }
373
+ }
374
+
375
+ const require = createRequire(import.meta.url);
376
+ for (const key of [...backup.esm.keys()]) {
377
+ try {
378
+ const filePath = fileURLToPath(key);
379
+ if (require.cache[filePath] !== undefined) {
380
+ backup.cjs.set(filePath, require.cache[filePath]);
381
+ delete require.cache[filePath];
382
+ }
383
+ } catch {
384
+ // 非 file: URL —— CJS 侧无事可做
385
+ }
386
+ }
387
+ return backup;
388
+ }
389
+
390
+ /**
391
+ * 把备份写回缓存(回滚用)。
392
+ * @param {object} internal
393
+ * @param {{esm: Map, cjs: Map}} backup
394
+ */
395
+ export function restoreCaches(internal, backup) {
396
+ const cache = internal && internal.loadCache;
397
+ for (const [key, job] of backup.esm) {
398
+ try { Map.prototype.set.call(cache, key, job); } catch { try { cache.set(key, job); } catch { /* best effort */ } }
399
+ }
400
+ const require = createRequire(import.meta.url);
401
+ for (const [filePath, mod] of backup.cjs) require.cache[filePath] = mod;
402
+ }
403
+
404
+ /**
405
+ * 原地重载本插件。
406
+ *
407
+ * @param {object} ctx `apply` 收到的 ctx
408
+ * @param {object} [options]
409
+ * @param {object} [options.logger] 日志器(默认 console)
410
+ * @param {string} [options.entryModuleUrl] 默认用本模块的 import.meta.url
411
+ * @returns {Promise<{ok:boolean, error?:string, restored?:boolean, fibers?:number}>}
412
+ */
413
+ export function selfReload(ctx, options = {}) {
414
+ if (inFlight !== null) return inFlight;
415
+ inFlight = (async () => {
416
+ const logger = options.logger || console;
417
+ const entryModuleUrl = options.entryModuleUrl || import.meta.url;
418
+ const packageUrl = options.packageUrl || packageUrlOf(entryModuleUrl);
419
+ const entryUrl = options.entryUrl || entryUrlOf(entryModuleUrl);
420
+
421
+ // 取 loader / registry 必须**非 strict**。
422
+ //
423
+ // ⚠️ 2026-10-18 实测踩到:直接写 `ctx.loader` 在 Cordis 里会抛
424
+ // `cannot get property "loader" without inject`
425
+ // —— 除非插件在 `inject: ['loader']` 里声明过。本插件没有(也不需要,
426
+ // 因为它平时用不到 loader),所以这里必须用 `ctx.get(name, false)`
427
+ // 这种"取不到就返回 undefined"的非 strict 方式。
428
+ //
429
+ // 这个错误当初被 `catch` 吞掉后表现为"热重载没反应",
430
+ // 而不像报错 —— 又是那种最难查的形态。
431
+ const safeGet = (ctxLike, name) => {
432
+ if (!ctxLike) return undefined;
433
+ try {
434
+ if (typeof ctxLike.get === 'function') {
435
+ const v = ctxLike.get(name, false);
436
+ if (v !== undefined) return v;
437
+ }
438
+ } catch { /* 非 strict 也可能抛,继续用下一招 */ }
439
+ try {
440
+ return ctxLike[name];
441
+ } catch {
442
+ return undefined;
443
+ }
444
+ };
445
+
446
+ const loader = safeGet(ctx, 'loader');
447
+ const registry = safeGet(ctx, 'registry');
448
+ if (!loader || !registry) {
449
+ return {
450
+ ok: false,
451
+ error: 'no loader/registry on this context '
452
+ + '(需要 ctx.get("loader", false) 可用;若从 ctx.loader 直读会被 Cordis 的 inject 校验拒绝)',
453
+ restored: false,
454
+ };
455
+ }
456
+
457
+ const previous = ctx.fiber && ctx.fiber.runtime && ctx.fiber.runtime.callback;
458
+ if (!previous) {
459
+ return { ok: false, error: 'no runtime to reload (plugin not registered the ordinary way)', restored: false };
460
+ }
461
+
462
+ const runtime = registry.get(previous);
463
+ const fibers = [...((runtime && runtime.fibers) || [])].map((fiber) => {
464
+ const entry = fiber.entry && fiber.entry.fiber && fiber.entry.fiber.uid === fiber.uid ? fiber.entry : undefined;
465
+ return { fiber, entry, config: entry === undefined ? fiber._config : entry.options.config };
466
+ });
467
+ if (fibers.length === 0) {
468
+ return { ok: false, error: 'runtime has no live fibers', restored: false };
469
+ }
470
+
471
+ const internal = loader.internal;
472
+ const backup = purgeCaches(internal, packageUrl);
473
+ try { logger.info?.(`[dsh-notify-say] reload: purged ${backup.esm.size} module(s)`); } catch { /* ignore */ }
474
+
475
+ const importFresh = async (url) => {
476
+ const mod = await loader.import(url, () => []);
477
+ return typeof loader.unwrapExports === 'function' ? loader.unwrapExports(mod) : mod;
478
+ };
479
+
480
+ /**
481
+ * 判断 replacement 是否**就是旧的那个**模块。
482
+ *
483
+ * 三种都要查(上游也这么查):
484
+ * · 整个导出对象相同 → 完全没换
485
+ * · apply 函数相同 → 模块对象换了壳、函数还是旧的
486
+ * · 旧对象有 apply 且相同 → 同上(previous 可能是函数本身)
487
+ */
488
+ const isSameAsPrevious = (candidate, prev) => {
489
+ if (!candidate) return true;
490
+ if (candidate === prev) return true;
491
+ if (!prev || typeof prev !== 'object') return false;
492
+ if (candidate.apply && prev.apply && candidate.apply === prev.apply) return true;
493
+ return false;
494
+ };
495
+
496
+ let replacement;
497
+ let busted = false;
498
+ try {
499
+ replacement = await importFresh(entryUrl);
500
+ if (!replacement || typeof replacement.apply !== 'function') {
501
+ throw new Error('reloaded module does not export apply()');
502
+ }
503
+
504
+ // 缓存没清掉 / 这是新内核的 loadCache 形状 —— 改用代际绕过。
505
+ //
506
+ // 2026-10-17 的教训:这里原本是"直接失败返回",于是重载报 ok:false,
507
+ // 但调用方(watchPackage)把它当普通失败吞掉,用户看到的是"好像没生效"。
508
+ // 更要命的是有些内核能清掉**部分**条目,于是入口换了、依赖没换。
509
+ // 现在两种异常(purged=0 或 拿到旧对象)都走代际绕过,强制整棵树重求值。
510
+ if (backup.esm.size === 0 || isSameAsPrevious(replacement, previous)) {
511
+ try { logger.info?.('[dsh-notify-say] reload: falling back to generation bust'); } catch { /* ignore */ }
512
+ enableFreshDependencies(packageUrl);
513
+ const freshUrl = new URL(entryUrl);
514
+ freshUrl.searchParams.set(GEN_PARAM, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
515
+ replacement = await importFresh(freshUrl.href);
516
+ busted = true;
517
+ if (!replacement || typeof replacement.apply !== 'function') {
518
+ throw new Error('reloaded module does not export apply() after generation bust');
519
+ }
520
+ if (isSameAsPrevious(replacement, previous)) {
521
+ throw new Error('replacement is still the previous plugin after cache invalidation '
522
+ + '(this kernel does not let modules be re-evaluated)');
523
+ }
524
+ }
525
+
526
+ // ── 生效校验(v0.4.20 核心)────────────────────────────────────────
527
+ //
528
+ // 「报 ok 就必须真生效」。规则抽在 verifyReload 里(纯函数、可单独测),
529
+ // 这里只负责把值喂进去、出错就回滚。
530
+ //
531
+ // 为什么不能只靠"replacement !== previous":那只证明**入口**被重新
532
+ // 求值了,不证明它 import 的依赖也换了。2026-10-17 实测出现过
533
+ // "入口换了、config-store 没换"的半新半旧,而重载照样报 ok。
534
+ const gotFp = typeof replacement.fingerprint === 'string' ? replacement.fingerprint : '';
535
+ const verdict = verifyReload({
536
+ gotFingerprint: gotFp,
537
+ differFrom: options.differFrom,
538
+ expectedFingerprint: options.expectedFingerprint,
539
+ });
540
+ if (!verdict.ok) throw new Error(verdict.error);
541
+ if (verdict.checked) {
542
+ try { logger.info?.(`[dsh-notify-say] reload: verified (fp=${gotFp})`); } catch { /* ignore */ }
543
+ }
544
+ } catch (error) {
545
+ restoreCaches(internal, backup);
546
+ return { ok: false, error: `re-import failed: ${(error && error.message) || error}`, restored: true };
547
+ }
548
+
549
+ try {
550
+ registry.delete(previous);
551
+ await Promise.all(fibers.map(({ fiber }) => fiber.await()));
552
+ } catch (error) {
553
+ try { logger.warn?.(`[dsh-notify-say] reload: dispose reported ${(error && error.message) || error}`); } catch { /* ignore */ }
554
+ }
555
+
556
+ try {
557
+ const created = [];
558
+ for (const { fiber, entry, config } of fibers) {
559
+ const registered = fiber.parent.registry.plugin(replacement, config);
560
+ const fresh = registered.ctx.fiber;
561
+ if (entry !== undefined) {
562
+ fresh.entry = entry;
563
+ entry.fiber = fresh;
564
+ }
565
+ created.push(fresh);
566
+ }
567
+ await Promise.all(created.map((fiber) => fiber.await()));
568
+ try { logger.info?.(`[dsh-notify-say] reload: complete, ${created.length} fiber(s)`); } catch { /* ignore */ }
569
+ return { ok: true, fibers: created.length };
570
+ } catch (error) {
571
+ restoreCaches(internal, backup);
572
+ try { registry.delete(replacement); } catch { /* 可能从未启动 */ }
573
+ try {
574
+ const restored = [];
575
+ for (const { fiber, entry, config } of fibers) {
576
+ const registered = fiber.parent.registry.plugin(previous, config);
577
+ const fresh = registered.ctx.fiber;
578
+ if (entry !== undefined) {
579
+ fresh.entry = entry;
580
+ entry.fiber = fresh;
581
+ }
582
+ restored.push(fresh);
583
+ }
584
+ await Promise.all(restored.map((fiber) => fiber.await()));
585
+ return {
586
+ ok: false,
587
+ error: `new code failed to start, previous version restored: ${(error && error.message) || error}`,
588
+ restored: true,
589
+ };
590
+ } catch (restoreError) {
591
+ return {
592
+ ok: false,
593
+ error: `new code failed to start (${(error && error.message) || error}); restore failed too `
594
+ + `(${(restoreError && restoreError.message) || restoreError})`,
595
+ restored: false,
596
+ };
597
+ }
598
+ }
599
+ })();
600
+
601
+ return inFlight.finally(() => { inFlight = null; });
602
+ }
603
+
604
+ /**
605
+ * 监听包目录,变更时触发回调(防抖)。
606
+ *
607
+ * 刻意**不自动重载**:由调用方决定怎么用(例如只在 devReload 开启时调 selfReload)。
608
+ * 自动重载会在你保存到一半的文件上触发,拿到语法错误的半成品。
609
+ *
610
+ * @param {string} packageDir
611
+ * @param {{ onChange?: Function, logger?: object, debounceMs?: number }} [opts]
612
+ * @returns {() => void} 停止函数
613
+ */
614
+ export function watchPackage(packageDir, opts = {}) {
615
+ const logger = opts.logger || console;
616
+ const debounceMs = Number.isFinite(opts.debounceMs) ? opts.debounceMs : 800;
617
+ let timer;
618
+ let paused = false;
619
+ let watcher;
620
+ try {
621
+ watcher = fs.watch(packageDir, { recursive: true });
622
+ } catch (error) {
623
+ try { logger.warn?.(`[dsh-notify-say] cannot watch ${packageDir}: ${(error && error.message) || error}`); } catch { /* ignore */ }
624
+ return () => {};
625
+ }
626
+ const schedule = () => {
627
+ if (paused) return;
628
+ clearTimeout(timer);
629
+ timer = setTimeout(() => {
630
+ timer = undefined;
631
+ if (paused) return;
632
+ try { opts.onChange?.(); } catch (error) {
633
+ try { logger.warn?.(`[dsh-notify-say] watch onChange threw: ${(error && error.message) || error}`); } catch { /* ignore */ }
634
+ }
635
+ }, debounceMs);
636
+ if (timer && typeof timer.unref === 'function') timer.unref();
637
+ };
638
+ watcher.on('change', schedule);
639
+ watcher.on('error', (error) => {
640
+ try { logger.warn?.(`[dsh-notify-say] watcher error: ${(error && error.message) || error}`); } catch { /* ignore */ }
641
+ });
642
+ return () => {
643
+ paused = true;
644
+ clearTimeout(timer);
645
+ try { watcher.close(); } catch { /* 已关闭 */ }
646
+ };
647
+ }