@deepseek-ai/cordis-plugin-hmr 1.0.16-rc.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2021-present Shigma
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # @cordisjs/plugin-hmr
2
+
3
+ Hot module replacement for loader-managed Cordis plugins.
4
+
5
+ The HMR plugin watches source files, traces Node's module graph, clears affected
6
+ module caches, and reloads only the plugin entries that depend on changed
7
+ application files. Changes to framework-level dependencies fall back to
8
+ `loader.exit()`, letting the host process restart.
9
+
10
+ Module watches canonicalize their existing base directory before opening
11
+ Chokidar. Exact config watches likewise canonicalize the deepest existing
12
+ ancestor, then restore any missing suffix. Callbacks and diagnostics retain the
13
+ requested absolute filename, while the native backend receives one filesystem
14
+ spelling even when Windows supplied an 8.3 alias.
15
+
16
+ ## Requirements
17
+
18
+ - `@cordisjs/plugin-loader`
19
+ - `@cordisjs/plugin-timer`
20
+ - A runtime that exposes Node's internal module loader. The package throws if
21
+ the loader service has no internal module loader available.
22
+
23
+ ## Usage
24
+
25
+ ```yaml
26
+ - id: timer
27
+ name: '@cordisjs/plugin-timer'
28
+ - id: hmr
29
+ name: '@cordisjs/plugin-hmr'
30
+ config:
31
+ root:
32
+ - src
33
+ ignored:
34
+ - '**/node_modules'
35
+ - '**/.*'
36
+ debounce: 100
37
+ ```
38
+
39
+ ## Config
40
+
41
+ | Field | Description |
42
+ | --- | --- |
43
+ | `base` | Optional base directory resolved from `ctx.baseUrl`. |
44
+ | `root` | Chokidar roots to watch. Defaults to `['.']`. |
45
+ | `ignored` | Picomatch patterns excluded from watch and reload analysis. |
46
+ | `debounce` | Milliseconds to wait before processing a burst of changes. |
47
+
48
+ ## Events
49
+
50
+ | Event | Description |
51
+ | --- | --- |
52
+ | `hmr/change` | Emitted for changed files that are not handled by plugin reload or config reload. |
53
+ | `hmr/reload` | Emitted after one or more plugin entries are reloaded. |
package/lib/index.js ADDED
@@ -0,0 +1,452 @@
1
+ import { createRequire } from "node:module";
2
+ import { Service } from "@deepseek-ai/cordis";
3
+ import { watch } from "chokidar";
4
+ import { dirname, relative, resolve } from "node:path";
5
+ import { realpath, stat } from "node:fs/promises";
6
+ import { codeFrameColumns } from "@babel/code-frame";
7
+ import { readFileSync } from "node:fs";
8
+ import { fileURLToPath, pathToFileURL } from "node:url";
9
+ import picomatch from "picomatch";
10
+ import z from "@deepseek-ai/schemastery";
11
+ //#region lib/types/error.js
12
+ function isBuildFailure(e) {
13
+ return Array.isArray(e?.errors) && e.errors.every((error) => error.text);
14
+ }
15
+ /** Log HMR build failures with code frames when source locations are available. */
16
+ function handleError(ctx, e) {
17
+ if (!isBuildFailure(e)) {
18
+ ctx.logger.warn(e);
19
+ return;
20
+ }
21
+ for (const error of e.errors) {
22
+ if (!error.location) {
23
+ ctx.logger.warn(error.text);
24
+ continue;
25
+ }
26
+ try {
27
+ const { file, line, column } = error.location;
28
+ const formatted = codeFrameColumns(readFileSync(file, "utf8"), { start: {
29
+ line,
30
+ column
31
+ } }, {
32
+ highlightCode: true,
33
+ message: error.text
34
+ });
35
+ ctx.logger.warn(`File: ${file}:${line}:${column}\n` + formatted);
36
+ } catch (e) {
37
+ ctx.logger.warn(e);
38
+ }
39
+ }
40
+ }
41
+ //#endregion
42
+ //#region lib/types/index.js
43
+ /**
44
+ * Recursively collect all module dependencies from a ModuleJob.
45
+ * Skips node: builtins and node_modules to focus on user code.
46
+ */
47
+ async function loadDependencies(job, ignored = /* @__PURE__ */ new Set()) {
48
+ const dependencies = /* @__PURE__ */ new Set();
49
+ async function traverse(job) {
50
+ if (ignored.has(job.url) || dependencies.has(job.url)) return;
51
+ if (job.url.startsWith("node:") || job.url.includes("/node_modules/")) return;
52
+ dependencies.add(job.url);
53
+ const children = await job.linked;
54
+ await Promise.all(Array.prototype.map.call(children, traverse));
55
+ }
56
+ await traverse(job);
57
+ return dependencies;
58
+ }
59
+ async function findWatchRoot(filename) {
60
+ let root = dirname(filename);
61
+ let depth = 0;
62
+ while (true) try {
63
+ if (!(await stat(root)).isDirectory()) throw new Error(`config watch parent is not a directory: ${root}`);
64
+ const canonicalRoot = await realpath(root);
65
+ return {
66
+ filename: resolve(canonicalRoot, relative(root, filename)),
67
+ root: canonicalRoot,
68
+ depth
69
+ };
70
+ } catch (error) {
71
+ if (error.code !== "ENOENT") throw error;
72
+ const parent = dirname(root);
73
+ if (parent === root) throw error;
74
+ root = parent;
75
+ depth += 1;
76
+ }
77
+ }
78
+ var Hmr = class extends Service {
79
+ config;
80
+ static inject = ["loader", "timer"];
81
+ baseDir;
82
+ internal;
83
+ watcher;
84
+ configs = /* @__PURE__ */ new Map();
85
+ configRefreshes = /* @__PURE__ */ new WeakMap();
86
+ refreshTasks = /* @__PURE__ */ new Set();
87
+ /**
88
+ * Changes from externals will always trigger a full reload.
89
+ * Externals are the dependency tree of the CLI worker entry point.
90
+ */
91
+ externals;
92
+ /**
93
+ * Files that should be reloaded (accepted changes).
94
+ * Includes all stashed files and their dependents.
95
+ */
96
+ accepted;
97
+ /**
98
+ * Files that should NOT be reloaded.
99
+ * Includes externals and files whose dependents are all declined.
100
+ */
101
+ declined;
102
+ /** Stashed file changes waiting to be processed */
103
+ stashed = /* @__PURE__ */ new Set();
104
+ constructor(ctx, config) {
105
+ super(ctx, "hmr");
106
+ this.config = config;
107
+ if (!this.ctx.loader.internal) throw new Error("--expose-internals is required for HMR service");
108
+ this.internal = this.ctx.loader.internal;
109
+ this.baseDir = fileURLToPath(new URL(config.base || ".", ctx.baseUrl));
110
+ }
111
+ /**
112
+ * Watch one exact config path outside the configured module roots.
113
+ * @param filename - Config path, resolved against the HMR base directory.
114
+ * @param refresh - Refresh callback run serially on add, change, or unlink.
115
+ * @returns an asynchronous disposer once the exact watch is ready.
116
+ * @throws when HMR is inactive, the path is already registered, or watcher startup fails.
117
+ */
118
+ async registerConfig(filename, refresh) {
119
+ if (!this.watcher) throw new Error("HMR is not active");
120
+ filename = resolve(this.baseDir, filename);
121
+ const target = await findWatchRoot(filename);
122
+ const watchFilename = target.filename;
123
+ if (this.configs.has(watchFilename)) throw new Error(`config path already registered: ${filename}`);
124
+ const { root, depth } = target;
125
+ const watcher = watch(root, {
126
+ ...this.config,
127
+ cwd: void 0,
128
+ depth,
129
+ ignored: void 0,
130
+ ignoreInitial: false
131
+ });
132
+ const registration = { watcher };
133
+ this.configs.set(watchFilename, registration);
134
+ const onChange = (path) => {
135
+ const observed = resolve(path);
136
+ if (observed !== filename && observed !== watchFilename) return;
137
+ this.refreshConfig(registration, filename, refresh);
138
+ };
139
+ watcher.on("add", onChange);
140
+ watcher.on("change", onChange);
141
+ watcher.on("unlink", onChange);
142
+ const ready = Promise.withResolvers();
143
+ let readyState = "pending";
144
+ watcher.once("ready", () => {
145
+ readyState = "resolved";
146
+ ready.resolve();
147
+ });
148
+ watcher.on("error", (error) => {
149
+ if (readyState === "pending") {
150
+ readyState = "rejected";
151
+ ready.reject(error);
152
+ } else this.ctx.logger.warn(error);
153
+ });
154
+ try {
155
+ await ready.promise;
156
+ return this.ctx.effect(() => async () => {
157
+ if (this.configs.get(watchFilename) === registration) this.configs.delete(watchFilename);
158
+ await watcher.close();
159
+ await this.configRefreshes.get(registration)?.running;
160
+ }, "hmr.registerConfig()");
161
+ } catch (error) {
162
+ this.configs.delete(watchFilename);
163
+ await watcher.close();
164
+ throw error;
165
+ }
166
+ }
167
+ /**
168
+ * Resolve a module specifier to a URL, compatible with Node 22-24.
169
+ */
170
+ async _resolve(specifier, parentURL, attrs) {
171
+ switch (this.internal.version) {
172
+ case "v1": return await this.internal.resolve(specifier, parentURL, attrs);
173
+ case "v2": return this.internal.resolveSync(parentURL, {
174
+ specifier,
175
+ attributes: attrs
176
+ });
177
+ }
178
+ }
179
+ async *[Service.init]() {
180
+ yield async () => {
181
+ await this.watcher?.close();
182
+ await Promise.allSettled([...this.configs.values()].map((registration) => registration.watcher.close()));
183
+ this.configs.clear();
184
+ await Promise.allSettled([...this.refreshTasks]);
185
+ };
186
+ const { loader } = this.ctx;
187
+ const { root, ignored } = this.config;
188
+ if (!this.config.base) this.ctx.logger.info("watching %o", root);
189
+ else this.ctx.logger.info("watching %o in %s", root, this.baseDir);
190
+ const match = picomatch(ignored);
191
+ const watchBaseDir = await realpath(this.baseDir);
192
+ const mainUrl = pathToFileURL(resolve(process.argv[1])).href;
193
+ const mainJob = this.internal.loadCache.get(mainUrl);
194
+ if (mainJob) this.externals = await loadDependencies(mainJob);
195
+ else this.externals = /* @__PURE__ */ new Set();
196
+ this.watcher = watch(root, {
197
+ ...this.config,
198
+ cwd: watchBaseDir,
199
+ ignored: (path) => match(relative(watchBaseDir, path)),
200
+ ignoreInitial: true
201
+ });
202
+ const partialReload = this.ctx.debounce(() => this.partialReload(), this.config.debounce);
203
+ const onChange = (kind, path) => {
204
+ this.ctx.logger.debug("%s detected at %C", kind, path);
205
+ const filename = resolve(watchBaseDir, path);
206
+ const configuredFilename = resolve(this.baseDir, path);
207
+ for (const entry of loader.entries()) {
208
+ const include = entry.subtree;
209
+ if (include?.filename !== filename && include?.filename !== configuredFilename) continue;
210
+ this.refreshConfig(include, include.filename, () => include.refresh());
211
+ return;
212
+ }
213
+ if (kind !== "change") return;
214
+ const url = pathToFileURL(filename).href;
215
+ if (this.externals.has(url)) return loader.exit();
216
+ if (loader.internal.loadCache.has(url)) {
217
+ this.stashed.add(url);
218
+ return partialReload();
219
+ }
220
+ this.ctx.emit("hmr/change", url);
221
+ };
222
+ this.watcher.on("add", (path) => onChange("add", path));
223
+ this.watcher.on("change", (path) => onChange("change", path));
224
+ this.watcher.on("unlink", (path) => onChange("unlink", path));
225
+ const ready = Promise.withResolvers();
226
+ let readyState = root.length === 0 ? "resolved" : "pending";
227
+ if (root.length === 0) ready.resolve();
228
+ else this.watcher.once("ready", () => {
229
+ readyState = "resolved";
230
+ ready.resolve();
231
+ });
232
+ this.watcher.on("error", (error) => {
233
+ if (readyState === "pending") {
234
+ readyState = "rejected";
235
+ ready.reject(error);
236
+ } else this.ctx.logger.warn(error);
237
+ });
238
+ await ready.promise;
239
+ }
240
+ refreshConfig(key, filename, refresh) {
241
+ const state = this.configRefreshes.get(key) ?? { dirty: false };
242
+ this.configRefreshes.set(key, state);
243
+ state.dirty = true;
244
+ if (state.running) return;
245
+ const task = (async () => {
246
+ do {
247
+ state.dirty = false;
248
+ try {
249
+ await refresh();
250
+ } catch (reason) {
251
+ const error = reason instanceof Error ? reason : new Error(String(reason), { cause: reason });
252
+ this.ctx.logger.warn("config reload at %C failed", filename);
253
+ this.ctx.logger.warn(error);
254
+ try {
255
+ await this.ctx.parallel("hmr/config-update-failed", filename, error);
256
+ } catch (rejection) {
257
+ this.ctx.logger.warn(rejection);
258
+ }
259
+ }
260
+ } while (state.dirty);
261
+ })().finally(() => {
262
+ state.running = void 0;
263
+ this.refreshTasks.delete(task);
264
+ });
265
+ state.running = task;
266
+ this.refreshTasks.add(task);
267
+ }
268
+ getOuterStack = () => [];
269
+ async getLinked(url) {
270
+ const job = this.internal.loadCache.get(url);
271
+ if (!job) return [];
272
+ const linked = await job.linked;
273
+ return Array.prototype.map.call(linked, (job) => job.url);
274
+ }
275
+ /**
276
+ * Classify changed files into accepted (should reload) and declined (should not).
277
+ *
278
+ * A file is accepted if it's directly changed (stashed) or if any of its
279
+ * dependents are accepted. A file is declined if all its dependents are
280
+ * declined or if it's an external.
281
+ */
282
+ async analyzeChanges() {
283
+ const pending = [];
284
+ this.accepted = new Set(this.stashed);
285
+ this.declined = new Set(this.externals);
286
+ const isExcluded = (url) => url.startsWith("node:") || url.includes("/node_modules/");
287
+ await Promise.all([...this.stashed].map(async (url) => {
288
+ const children = await this.getLinked(url);
289
+ for (const child of children) {
290
+ if (this.accepted.has(child) || this.declined.has(child) || isExcluded(child)) continue;
291
+ pending.push(child);
292
+ }
293
+ }));
294
+ while (pending.length) {
295
+ let index = 0, hasUpdate = false;
296
+ while (index < pending.length) {
297
+ const url = pending[index];
298
+ const children = await this.getLinked(url);
299
+ let isDeclined = true, isAccepted = false;
300
+ for (const child of children) {
301
+ if (this.declined.has(child) || isExcluded(child)) continue;
302
+ if (this.accepted.has(child)) {
303
+ isAccepted = true;
304
+ break;
305
+ } else {
306
+ isDeclined = false;
307
+ if (!pending.includes(child)) {
308
+ hasUpdate = true;
309
+ pending.push(child);
310
+ }
311
+ }
312
+ }
313
+ if (isAccepted || isDeclined) {
314
+ hasUpdate = true;
315
+ pending.splice(index, 1);
316
+ if (isAccepted) this.accepted.add(url);
317
+ else this.declined.add(url);
318
+ } else index++;
319
+ }
320
+ if (!hasUpdate) break;
321
+ }
322
+ for (const url of pending) this.declined.add(url);
323
+ }
324
+ async partialReload() {
325
+ await this.analyzeChanges();
326
+ const pending = /* @__PURE__ */ new Map();
327
+ const reloads = /* @__PURE__ */ new Map();
328
+ const nameMap = Object.create(null);
329
+ for (const entry of this.ctx.loader.entries()) (nameMap[entry.parent.tree.ctx.baseUrl] ??= /* @__PURE__ */ new Set()).add(entry.options.name);
330
+ for (const baseUrl in nameMap) for (const name of nameMap[baseUrl]) try {
331
+ const { url } = await this._resolve(name, baseUrl, {});
332
+ if (this.declined.has(url)) continue;
333
+ const job = this.internal.loadCache.get(url);
334
+ const plugin = this.ctx.loader.unwrapExports(job?.module?.getNamespace());
335
+ if (!job || !plugin) continue;
336
+ pending.set(job, plugin);
337
+ this.declined.add(url);
338
+ } catch (err) {
339
+ this.ctx.logger.warn(err);
340
+ }
341
+ for (const [job, plugin] of pending) {
342
+ this.declined.delete(job.url);
343
+ const dependencies = [...await loadDependencies(job, this.declined)];
344
+ this.declined.add(job.url);
345
+ if (!dependencies.some((dep) => this.accepted.has(dep))) continue;
346
+ dependencies.forEach((dep) => this.accepted.add(dep));
347
+ reloads.set(plugin, {
348
+ filename: job.url,
349
+ runtime: this.ctx.registry.get(plugin)
350
+ });
351
+ }
352
+ /**
353
+ * Clear module caches for all accepted files before re-importing.
354
+ *
355
+ * We need to clear both:
356
+ * 1. ESM loadCache — managed by Node's internal ModuleLoader
357
+ * 2. CJS Module._cache — for CJS modules that were imported via import()
358
+ *
359
+ * In Node 24, CJS modules loaded via import() appear in both caches.
360
+ * If we only clear loadCache, the CJS cache may serve stale modules.
361
+ *
362
+ * We use Map.prototype methods directly on loadCache because:
363
+ * - In Node 22/23, loadCache is a plain Map<url, ModuleJob>
364
+ * - In Node 24, loadCache is a LoadCache extends Map<url, { [type]: ModuleJob }>
365
+ * where .delete() only sets the type slot to undefined (doesn't remove the entry)
366
+ * Using Map.prototype.delete ensures complete removal in both versions.
367
+ */
368
+ const esmBackup = Object.create(null);
369
+ const cjsBackup = Object.create(null);
370
+ const require = createRequire(import.meta.url);
371
+ for (const filename of this.accepted) {
372
+ esmBackup[filename] = Map.prototype.get.call(this.internal.loadCache, filename);
373
+ Map.prototype.delete.call(this.internal.loadCache, filename);
374
+ try {
375
+ const filepath = fileURLToPath(filename);
376
+ if (require.cache[filepath]) {
377
+ cjsBackup[filepath] = require.cache[filepath];
378
+ delete require.cache[filepath];
379
+ }
380
+ } catch {}
381
+ }
382
+ const rollback = () => {
383
+ for (const filename in esmBackup) Map.prototype.set.call(this.internal.loadCache, filename, esmBackup[filename]);
384
+ for (const filepath in cjsBackup) require.cache[filepath] = cjsBackup[filepath];
385
+ };
386
+ const attempts = {};
387
+ try {
388
+ for (const [, { filename }] of reloads) attempts[filename] = this.ctx.loader.unwrapExports(await this.ctx.loader.import(filename, this.getOuterStack));
389
+ } catch (e) {
390
+ handleError(this.ctx, e);
391
+ return rollback();
392
+ }
393
+ const reload = (plugin, runtime) => {
394
+ if (!runtime) return;
395
+ for (const oldFiber of runtime.fibers) {
396
+ const fiber = oldFiber.parent.registry.plugin(plugin, oldFiber._config, this.getOuterStack);
397
+ fiber.entry = oldFiber.entry;
398
+ if (fiber.entry) fiber.entry.fiber = fiber;
399
+ }
400
+ };
401
+ try {
402
+ for (const [plugin, { filename, runtime }] of reloads) {
403
+ if (!runtime) continue;
404
+ const path = relative(this.baseDir, fileURLToPath(filename));
405
+ try {
406
+ this.ctx.registry.delete(plugin);
407
+ } catch (err) {
408
+ this.ctx.logger.warn("failed to dispose plugin at %C", path);
409
+ this.ctx.logger.warn(err);
410
+ }
411
+ try {
412
+ reload(attempts[filename], runtime);
413
+ this.ctx.logger.info("reload plugin at %C", path);
414
+ } catch (err) {
415
+ this.ctx.logger.warn("failed to reload plugin at %C", path);
416
+ this.ctx.logger.warn(err);
417
+ throw err;
418
+ }
419
+ }
420
+ } catch {
421
+ rollback();
422
+ for (const [plugin, { filename, runtime }] of reloads) {
423
+ if (!runtime) continue;
424
+ try {
425
+ this.ctx.registry.delete(attempts[filename]);
426
+ reload(plugin, runtime);
427
+ } catch (err) {
428
+ this.ctx.logger.warn(err);
429
+ }
430
+ }
431
+ return;
432
+ }
433
+ this.ctx.emit("hmr/reload", reloads);
434
+ this.stashed = /* @__PURE__ */ new Set();
435
+ }
436
+ };
437
+ (function(Hmr) {
438
+ Hmr.Config = z.object({
439
+ base: z.string(),
440
+ root: z.array(String).role("table").default(["."]),
441
+ ignored: z.array(String).role("table").default([
442
+ "**/node_modules",
443
+ "**/.*",
444
+ "cache",
445
+ "data"
446
+ ]),
447
+ debounce: z.natural().role("ms").default(100)
448
+ });
449
+ })(Hmr || (Hmr = {}));
450
+ var types_default = Hmr;
451
+ //#endregion
452
+ export { types_default as default };
@@ -0,0 +1,4 @@
1
+ import { Context } from '@deepseek-ai/cordis';
2
+ /** Log HMR build failures with code frames when source locations are available. */
3
+ export declare function handleError(ctx: Context, e: any): void;
4
+ //# sourceMappingURL=error.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"error.d.ts","sourceRoot":"","sources":["../../src/error.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAS7C,mFAAmF;AACnF,wBAAgB,WAAW,CAAC,GAAG,EAAE,OAAO,EAAE,CAAC,EAAE,GAAG,QAyB/C"}
@@ -0,0 +1,87 @@
1
+ import { Context, Service, type Plugin } from '@deepseek-ai/cordis';
2
+ import { type ChokidarOptions } from 'chokidar';
3
+ import z from '@deepseek-ai/schemastery';
4
+ declare module '@deepseek-ai/cordis' {
5
+ interface Context {
6
+ hmr: Hmr;
7
+ }
8
+ interface Events {
9
+ 'hmr/change'(url: string): void;
10
+ 'hmr/reload'(reloads: Map<Plugin, Reload>): void;
11
+ /**
12
+ * A watched config-file refresh failed.
13
+ * @param filename - Absolute path observed by HMR.
14
+ * @param error - Normalized refresh failure.
15
+ * @mode parallel
16
+ */
17
+ 'hmr/config-update-failed'(filename: string, error: Error): Promise<void> | void;
18
+ }
19
+ }
20
+ interface Reload {
21
+ filename: string;
22
+ runtime?: Plugin.Runtime;
23
+ }
24
+ declare class Hmr extends Service {
25
+ config: Hmr.Config;
26
+ static inject: string[];
27
+ baseDir: string;
28
+ private internal;
29
+ private watcher;
30
+ private readonly configs;
31
+ private readonly configRefreshes;
32
+ private readonly refreshTasks;
33
+ /**
34
+ * Changes from externals will always trigger a full reload.
35
+ * Externals are the dependency tree of the CLI worker entry point.
36
+ */
37
+ private externals;
38
+ /**
39
+ * Files that should be reloaded (accepted changes).
40
+ * Includes all stashed files and their dependents.
41
+ */
42
+ private accepted;
43
+ /**
44
+ * Files that should NOT be reloaded.
45
+ * Includes externals and files whose dependents are all declined.
46
+ */
47
+ private declined;
48
+ /** Stashed file changes waiting to be processed */
49
+ private stashed;
50
+ constructor(ctx: Context, config: Hmr.Config);
51
+ /**
52
+ * Watch one exact config path outside the configured module roots.
53
+ * @param filename - Config path, resolved against the HMR base directory.
54
+ * @param refresh - Refresh callback run serially on add, change, or unlink.
55
+ * @returns an asynchronous disposer once the exact watch is ready.
56
+ * @throws when HMR is inactive, the path is already registered, or watcher startup fails.
57
+ */
58
+ registerConfig(filename: string, refresh: () => Promise<void> | void): Promise<() => Promise<void>>;
59
+ /**
60
+ * Resolve a module specifier to a URL, compatible with Node 22-24.
61
+ */
62
+ private _resolve;
63
+ [Service.init](): AsyncGenerator<() => Promise<void>, void, unknown>;
64
+ private refreshConfig;
65
+ getOuterStack: () => string[];
66
+ getLinked(url: string): Promise<string[]>;
67
+ /**
68
+ * Classify changed files into accepted (should reload) and declined (should not).
69
+ *
70
+ * A file is accepted if it's directly changed (stashed) or if any of its
71
+ * dependents are accepted. A file is declined if all its dependents are
72
+ * declined or if it's an external.
73
+ */
74
+ private analyzeChanges;
75
+ private partialReload;
76
+ }
77
+ declare namespace Hmr {
78
+ interface Config extends ChokidarOptions {
79
+ base?: string;
80
+ root: string[];
81
+ debounce: number;
82
+ ignored: string[];
83
+ }
84
+ const Config: z<Config>;
85
+ }
86
+ export default Hmr;
87
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,KAAK,MAAM,EAAE,MAAM,qBAAqB,CAAA;AAInE,OAAO,EAAoB,KAAK,eAAe,EAAE,MAAM,UAAU,CAAA;AAQjE,OAAO,CAAC,MAAM,0BAA0B,CAAA;AAExC,OAAO,QAAQ,qBAAqB,CAAC;IACnC,UAAU,OAAO;QACf,GAAG,EAAE,GAAG,CAAA;KACT;IAED,UAAU,MAAM;QACd,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;QAC/B,YAAY,CAAC,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CAAA;QAChD;;;;;WAKG;QACH,0BAA0B,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,GAAG,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAA;KACjF;CACF;AAmBD,UAAU,MAAM;IACd,QAAQ,EAAE,MAAM,CAAA;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC,OAAO,CAAA;CACzB;AAiCD,cAAM,GAAI,SAAQ,OAAO;IAgCU,MAAM,EAAE,GAAG,CAAC,MAAM;IA/BnD,MAAM,CAAC,MAAM,WAAsB;IAE5B,OAAO,EAAE,MAAM,CAAA;IAEtB,OAAO,CAAC,QAAQ,CAAc;IAC9B,OAAO,CAAC,OAAO,CAAY;IAC3B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAwC;IAChE,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAuC;IACvE,OAAO,CAAC,QAAQ,CAAC,YAAY,CAA2B;IAExD;;;OAGG;IACH,OAAO,CAAC,SAAS,CAAc;IAE/B;;;OAGG;IACH,OAAO,CAAC,QAAQ,CAAc;IAE9B;;;OAGG;IACH,OAAO,CAAC,QAAQ,CAAc;IAE9B,mDAAmD;IACnD,OAAO,CAAC,OAAO,CAAoB;gBAEvB,GAAG,EAAE,OAAO,EAAS,MAAM,EAAE,GAAG,CAAC,MAAM;IASnD;;;;;;OAMG;IACG,cAAc,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,GAAG,OAAO,CAAC,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAuDzG;;OAEG;YACW,QAAQ;IAOf,CAAC,OAAO,CAAC,IAAI,CAAC;IAkGrB,OAAO,CAAC,aAAa;IA8BrB,aAAa,QAAO,MAAM,EAAE,CAE3B;IAEK,SAAS,CAAC,GAAG,EAAE,MAAM;IAO3B;;;;;;OAMG;YACW,cAAc;YAuDd,aAAa;CAsJ5B;AAED,kBAAU,GAAG,CAAC;IACZ,UAAiB,MAAO,SAAQ,eAAe;QAC7C,IAAI,CAAC,EAAE,MAAM,CAAA;QACb,IAAI,EAAE,MAAM,EAAE,CAAA;QACd,QAAQ,EAAE,MAAM,CAAA;QAChB,OAAO,EAAE,MAAM,EAAE,CAAA;KAClB;IAEM,MAAM,MAAM,EAAE,CAAC,CAAC,MAAM,CAU3B,CAAA;CAIH;AAED,eAAe,GAAG,CAAA"}
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@deepseek-ai/cordis-plugin-hmr",
3
+ "description": "Hot Module Replacement Plugin for Cordis",
4
+ "version": "1.0.16-rc.1",
5
+ "publishConfig": {
6
+ "access": "restricted"
7
+ },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
11
+ "directory": "vendor/hmr"
12
+ },
13
+ "type": "module",
14
+ "main": "lib/index.js",
15
+ "types": "lib/types/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./lib/types/index.d.ts",
19
+ "default": "./lib/index.js"
20
+ },
21
+ "./src/*": "./src/*",
22
+ "./package.json": "./package.json"
23
+ },
24
+ "files": [
25
+ "lib/index.js",
26
+ "lib/types/**/*.d.ts",
27
+ "lib/types/**/*.d.ts.map",
28
+ "src"
29
+ ],
30
+ "author": "Shigma <shigma10826@gmail.com>",
31
+ "license": "MIT",
32
+ "@deepseek-ai/cordis": {
33
+ "services": {
34
+ "required": [
35
+ "timer"
36
+ ]
37
+ },
38
+ "description": {
39
+ "en": "Hot Module Replacement",
40
+ "zh": "模块热替换"
41
+ }
42
+ },
43
+ "peerDependencies": {
44
+ "@deepseek-ai/cordis": "^4.0.1-rc.1",
45
+ "@deepseek-ai/cordis-plugin-timer": "^1.1.3-rc.1"
46
+ },
47
+ "dependencies": {
48
+ "@babel/code-frame": "^7.29.0",
49
+ "chokidar": "^4.0.3",
50
+ "picomatch": "^4.0.3",
51
+ "@deepseek-ai/cosmokit": "^1.8.2-rc.1",
52
+ "@deepseek-ai/schemastery": "^3.18.1-rc.1"
53
+ },
54
+ "devDependencies": {
55
+ "@types/babel__code-frame": "^7.27.0",
56
+ "@types/picomatch": "^3.0.2",
57
+ "esbuild": "^0.28.1"
58
+ }
59
+ }
package/src/error.ts ADDED
@@ -0,0 +1,36 @@
1
+ import { Context } from '@deepseek-ai/cordis'
2
+ import type { BuildFailure } from 'esbuild'
3
+ import { codeFrameColumns } from '@babel/code-frame'
4
+ import { readFileSync } from 'node:fs'
5
+
6
+ function isBuildFailure(e: any): e is BuildFailure {
7
+ return Array.isArray(e?.errors) && e.errors.every((error: any) => error.text)
8
+ }
9
+
10
+ /** Log HMR build failures with code frames when source locations are available. */
11
+ export function handleError(ctx: Context, e: any) {
12
+ if (!isBuildFailure(e)) {
13
+ ctx.logger.warn(e)
14
+ return
15
+ }
16
+
17
+ for (const error of e.errors) {
18
+ if (!error.location) {
19
+ ctx.logger.warn(error.text)
20
+ continue
21
+ }
22
+ try {
23
+ const { file, line, column } = error.location
24
+ const source = readFileSync(file, 'utf8')
25
+ const formatted = codeFrameColumns(source, {
26
+ start: { line, column },
27
+ }, {
28
+ highlightCode: true,
29
+ message: error.text,
30
+ })
31
+ ctx.logger.warn(`File: ${file}:${line}:${column}\n` + formatted)
32
+ } catch (e) {
33
+ ctx.logger.warn(e)
34
+ }
35
+ }
36
+ }
package/src/index.ts ADDED
@@ -0,0 +1,576 @@
1
+ import { Context, Service, type Plugin } from '@deepseek-ai/cordis'
2
+ import type { Dict } from '@deepseek-ai/cosmokit'
3
+ import { ModuleLoader, type ModuleJob, type ResolveResult } from '@deepseek-ai/cordis-plugin-loader'
4
+ import type { Include } from '@deepseek-ai/cordis-plugin-include'
5
+ import { FSWatcher, watch, type ChokidarOptions } from 'chokidar'
6
+ import { dirname, relative, resolve } from 'node:path'
7
+ import { realpath, stat } from 'node:fs/promises'
8
+ import { handleError } from './error.ts'
9
+ import type {} from '@deepseek-ai/cordis-plugin-timer'
10
+ import { fileURLToPath, pathToFileURL } from 'node:url'
11
+ import { createRequire } from 'node:module'
12
+ import picomatch from 'picomatch'
13
+ import z from '@deepseek-ai/schemastery'
14
+
15
+ declare module '@deepseek-ai/cordis' {
16
+ interface Context {
17
+ hmr: Hmr
18
+ }
19
+
20
+ interface Events {
21
+ 'hmr/change'(url: string): void
22
+ 'hmr/reload'(reloads: Map<Plugin, Reload>): void
23
+ /**
24
+ * A watched config-file refresh failed.
25
+ * @param filename - Absolute path observed by HMR.
26
+ * @param error - Normalized refresh failure.
27
+ * @mode parallel
28
+ */
29
+ 'hmr/config-update-failed'(filename: string, error: Error): Promise<void> | void
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Recursively collect all module dependencies from a ModuleJob.
35
+ * Skips node: builtins and node_modules to focus on user code.
36
+ */
37
+ async function loadDependencies(job: ModuleJob, ignored = new Set<string>()) {
38
+ const dependencies = new Set<string>()
39
+ async function traverse(job: ModuleJob) {
40
+ if (ignored.has(job.url) || dependencies.has(job.url)) return
41
+ if (job.url.startsWith('node:') || job.url.includes('/node_modules/')) return
42
+ dependencies.add(job.url)
43
+ const children = await job.linked
44
+ await Promise.all(Array.prototype.map.call(children, traverse))
45
+ }
46
+ await traverse(job)
47
+ return dependencies
48
+ }
49
+
50
+ interface Reload {
51
+ filename: string
52
+ runtime?: Plugin.Runtime
53
+ }
54
+
55
+ interface ConfigRefresh {
56
+ dirty: boolean
57
+ running?: Promise<void>
58
+ }
59
+
60
+ interface ConfigRegistration {
61
+ watcher: FSWatcher
62
+ }
63
+
64
+ async function findWatchRoot(filename: string): Promise<{ filename: string; root: string; depth: number }> {
65
+ let root = dirname(filename)
66
+ let depth = 0
67
+ while (true) {
68
+ try {
69
+ if (!(await stat(root)).isDirectory()) throw new Error(`config watch parent is not a directory: ${root}`)
70
+ const canonicalRoot = await realpath(root)
71
+ return {
72
+ filename: resolve(canonicalRoot, relative(root, filename)),
73
+ root: canonicalRoot,
74
+ depth,
75
+ }
76
+ } catch (error) {
77
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
78
+ const parent = dirname(root)
79
+ if (parent === root) throw error
80
+ root = parent
81
+ depth += 1
82
+ }
83
+ }
84
+ }
85
+
86
+ class Hmr extends Service {
87
+ static inject = ['loader', 'timer']
88
+
89
+ public baseDir: string
90
+
91
+ private internal: ModuleLoader
92
+ private watcher!: FSWatcher
93
+ private readonly configs = new Map<string, ConfigRegistration>()
94
+ private readonly configRefreshes = new WeakMap<object, ConfigRefresh>()
95
+ private readonly refreshTasks = new Set<Promise<void>>()
96
+
97
+ /**
98
+ * Changes from externals will always trigger a full reload.
99
+ * Externals are the dependency tree of the CLI worker entry point.
100
+ */
101
+ private externals!: Set<string>
102
+
103
+ /**
104
+ * Files that should be reloaded (accepted changes).
105
+ * Includes all stashed files and their dependents.
106
+ */
107
+ private accepted!: Set<string>
108
+
109
+ /**
110
+ * Files that should NOT be reloaded.
111
+ * Includes externals and files whose dependents are all declined.
112
+ */
113
+ private declined!: Set<string>
114
+
115
+ /** Stashed file changes waiting to be processed */
116
+ private stashed = new Set<string>()
117
+
118
+ constructor(ctx: Context, public config: Hmr.Config) {
119
+ super(ctx, 'hmr')
120
+ if (!this.ctx.loader.internal) {
121
+ throw new Error('--expose-internals is required for HMR service')
122
+ }
123
+ this.internal = this.ctx.loader.internal
124
+ this.baseDir = fileURLToPath(new URL(config.base || '.', ctx.baseUrl))
125
+ }
126
+
127
+ /**
128
+ * Watch one exact config path outside the configured module roots.
129
+ * @param filename - Config path, resolved against the HMR base directory.
130
+ * @param refresh - Refresh callback run serially on add, change, or unlink.
131
+ * @returns an asynchronous disposer once the exact watch is ready.
132
+ * @throws when HMR is inactive, the path is already registered, or watcher startup fails.
133
+ */
134
+ async registerConfig(filename: string, refresh: () => Promise<void> | void): Promise<() => Promise<void>> {
135
+ if (!this.watcher) throw new Error('HMR is not active')
136
+ filename = resolve(this.baseDir, filename)
137
+ const target = await findWatchRoot(filename)
138
+ const watchFilename = target.filename
139
+ if (this.configs.has(watchFilename)) throw new Error(`config path already registered: ${filename}`)
140
+
141
+ const { root, depth } = target
142
+ const watcher = watch(root, {
143
+ ...this.config,
144
+ cwd: undefined,
145
+ depth,
146
+ ignored: undefined,
147
+ ignoreInitial: false,
148
+ })
149
+ const registration = { watcher }
150
+ this.configs.set(watchFilename, registration)
151
+ const onChange = (path: string) => {
152
+ const observed = resolve(path)
153
+ if (observed !== filename && observed !== watchFilename) return
154
+ this.refreshConfig(registration, filename, refresh)
155
+ }
156
+ watcher.on('add', onChange)
157
+ watcher.on('change', onChange)
158
+ watcher.on('unlink', onChange)
159
+
160
+ const ready = Promise.withResolvers<void>()
161
+ let readyState: 'pending' | 'resolved' | 'rejected' = 'pending'
162
+ watcher.once('ready', () => {
163
+ readyState = 'resolved'
164
+ ready.resolve()
165
+ })
166
+ watcher.on('error', (error) => {
167
+ if (readyState === 'pending') {
168
+ readyState = 'rejected'
169
+ ready.reject(error)
170
+ } else {
171
+ this.ctx.logger.warn(error)
172
+ }
173
+ })
174
+
175
+ try {
176
+ await ready.promise
177
+ return this.ctx.effect(() => async () => {
178
+ if (this.configs.get(watchFilename) === registration) this.configs.delete(watchFilename)
179
+ await watcher.close()
180
+ await this.configRefreshes.get(registration)?.running
181
+ }, 'hmr.registerConfig()')
182
+ } catch (error) {
183
+ this.configs.delete(watchFilename)
184
+ await watcher.close()
185
+ throw error
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Resolve a module specifier to a URL, compatible with Node 22-24.
191
+ */
192
+ private async _resolve(specifier: string, parentURL: string, attrs: ImportAttributes): Promise<ResolveResult> {
193
+ switch (this.internal.version) {
194
+ case 'v1': return await this.internal.resolve(specifier, parentURL, attrs)
195
+ case 'v2': return this.internal.resolveSync(parentURL, { specifier, attributes: attrs })
196
+ }
197
+ }
198
+
199
+ async* [Service.init]() {
200
+ yield async () => {
201
+ await this.watcher?.close()
202
+ await Promise.allSettled([...this.configs.values()].map(registration => registration.watcher.close()))
203
+ this.configs.clear()
204
+ await Promise.allSettled([...this.refreshTasks])
205
+ }
206
+
207
+ const { loader } = this.ctx
208
+ const { root, ignored } = this.config
209
+ if (!this.config.base) {
210
+ this.ctx.logger.info('watching %o', root)
211
+ } else {
212
+ this.ctx.logger.info('watching %o in %s', root, this.baseDir)
213
+ }
214
+
215
+ const match = picomatch(ignored)
216
+ const watchBaseDir = await realpath(this.baseDir)
217
+
218
+ // Collect externals before opening the watcher so every post-ready change
219
+ // is observed by listeners that already have their classification state.
220
+ const mainUrl = pathToFileURL(resolve(process.argv[1])).href
221
+ const mainJob = this.internal.loadCache.get(mainUrl)
222
+ if (mainJob) {
223
+ this.externals = await loadDependencies(mainJob)
224
+ } else {
225
+ this.externals = new Set()
226
+ }
227
+
228
+ this.watcher = watch(root, {
229
+ ...this.config,
230
+ cwd: watchBaseDir,
231
+ ignored: path => match(relative(watchBaseDir, path)),
232
+ // The initial scan re-announces files the boot just consumed: an `add`
233
+ // for a config file refreshes an include whose initial apply may still
234
+ // be in flight, and a failing apply then rolls this plugin back while
235
+ // the scan-triggered refresh waits on that apply — a teardown deadlock
236
+ // that strands boot without a diagnostic. Only events after the scan
237
+ // matter here; `registerConfig` keeps its own initial scan because a
238
+ // user patch layer present at registration must apply once.
239
+ ignoreInitial: true,
240
+ })
241
+
242
+ const partialReload = this.ctx.debounce(() => this.partialReload(), this.config.debounce)
243
+
244
+ const onChange = (kind: 'add' | 'change' | 'unlink', path: string) => {
245
+ this.ctx.logger.debug('%s detected at %C', kind, path)
246
+ const filename = resolve(watchBaseDir, path)
247
+ const configuredFilename = resolve(this.baseDir, path)
248
+ // Config reload: the file is a loader config file (e.g. cordis.yml).
249
+ for (const entry of loader.entries()) {
250
+ const include = entry.subtree as Include | undefined
251
+ if (include?.filename !== filename && include?.filename !== configuredFilename) continue
252
+ this.refreshConfig(include, include.filename, () => include.refresh())
253
+ return
254
+ }
255
+
256
+ if (kind !== 'change') return
257
+ const url = pathToFileURL(filename).href
258
+
259
+ // Full reload: the changed file is part of the framework
260
+ if (this.externals.has(url)) return loader.exit()
261
+
262
+ // Partial reload: the file is in the ESM loadCache
263
+ // In Node 24, both CJS and ESM modules imported via import() end up
264
+ // in loadCache, so this check covers all module formats.
265
+ if (loader.internal!.loadCache.has(url)) {
266
+ this.stashed.add(url)
267
+ return partialReload()
268
+ }
269
+
270
+ this.ctx.emit('hmr/change', url)
271
+ }
272
+ this.watcher.on('add', path => onChange('add', path))
273
+ this.watcher.on('change', path => onChange('change', path))
274
+ this.watcher.on('unlink', path => onChange('unlink', path))
275
+
276
+ const ready = Promise.withResolvers<void>()
277
+ let readyState: 'pending' | 'resolved' | 'rejected' = root.length === 0 ? 'resolved' : 'pending'
278
+ if (root.length === 0) {
279
+ ready.resolve()
280
+ } else {
281
+ this.watcher.once('ready', () => {
282
+ readyState = 'resolved'
283
+ ready.resolve()
284
+ })
285
+ }
286
+ this.watcher.on('error', (error) => {
287
+ if (readyState === 'pending') {
288
+ readyState = 'rejected'
289
+ ready.reject(error)
290
+ } else {
291
+ this.ctx.logger.warn(error)
292
+ }
293
+ })
294
+ await ready.promise
295
+ }
296
+
297
+ private refreshConfig(key: object, filename: string, refresh: () => Promise<void> | void) {
298
+ const state = this.configRefreshes.get(key) ?? { dirty: false }
299
+ this.configRefreshes.set(key, state)
300
+ state.dirty = true
301
+ if (state.running) return
302
+ const task = (async () => {
303
+ do {
304
+ state.dirty = false
305
+ try {
306
+ await refresh()
307
+ } catch (reason) {
308
+ const error = reason instanceof Error ? reason : new Error(String(reason), { cause: reason })
309
+ this.ctx.logger.warn('config reload at %C failed', filename)
310
+ this.ctx.logger.warn(error)
311
+ try {
312
+ await this.ctx.parallel('hmr/config-update-failed', filename, error)
313
+ } catch (rejection) {
314
+ this.ctx.logger.warn(rejection)
315
+ }
316
+ }
317
+ } while (state.dirty)
318
+ })().finally(() => {
319
+ state.running = undefined
320
+ this.refreshTasks.delete(task)
321
+ })
322
+ state.running = task
323
+ this.refreshTasks.add(task)
324
+ }
325
+
326
+ // hide stack trace from HMR
327
+ getOuterStack = (): string[] => [
328
+ // ' at HMR.partialReload (<anonymous>)',
329
+ ]
330
+
331
+ async getLinked(url: string) {
332
+ const job = this.internal.loadCache.get(url)
333
+ if (!job) return []
334
+ const linked = await job.linked
335
+ return Array.prototype.map.call(linked, (job: ModuleJob) => job.url) as string[]
336
+ }
337
+
338
+ /**
339
+ * Classify changed files into accepted (should reload) and declined (should not).
340
+ *
341
+ * A file is accepted if it's directly changed (stashed) or if any of its
342
+ * dependents are accepted. A file is declined if all its dependents are
343
+ * declined or if it's an external.
344
+ */
345
+ private async analyzeChanges() {
346
+ const pending: string[] = []
347
+
348
+ this.accepted = new Set(this.stashed)
349
+ this.declined = new Set(this.externals)
350
+
351
+ const isExcluded = (url: string) => url.startsWith('node:') || url.includes('/node_modules/')
352
+
353
+ await Promise.all([...this.stashed].map(async (url) => {
354
+ const children = await this.getLinked(url)
355
+ for (const child of children) {
356
+ if (this.accepted.has(child) || this.declined.has(child) || isExcluded(child)) continue
357
+ pending.push(child)
358
+ }
359
+ }))
360
+
361
+ while (pending.length) {
362
+ let index = 0, hasUpdate = false
363
+ while (index < pending.length) {
364
+ const url = pending[index]
365
+ const children = await this.getLinked(url)
366
+ let isDeclined = true, isAccepted = false
367
+ for (const child of children) {
368
+ if (this.declined.has(child) || isExcluded(child)) continue
369
+ if (this.accepted.has(child)) {
370
+ isAccepted = true
371
+ break
372
+ } else {
373
+ isDeclined = false
374
+ if (!pending.includes(child)) {
375
+ hasUpdate = true
376
+ pending.push(child)
377
+ }
378
+ }
379
+ }
380
+ if (isAccepted || isDeclined) {
381
+ hasUpdate = true
382
+ pending.splice(index, 1)
383
+ if (isAccepted) {
384
+ this.accepted.add(url)
385
+ } else {
386
+ this.declined.add(url)
387
+ }
388
+ } else {
389
+ index++
390
+ }
391
+ }
392
+ if (!hasUpdate) break
393
+ }
394
+
395
+ for (const url of pending) {
396
+ this.declined.add(url)
397
+ }
398
+ }
399
+
400
+ private async partialReload() {
401
+ await this.analyzeChanges()
402
+
403
+ const pending = new Map<ModuleJob, Plugin>()
404
+ const reloads = new Map<Plugin, Reload>()
405
+
406
+ // Build a map of plugin names per config tree URL.
407
+ // Plugin entry files are treated as atomic reload units.
408
+ const nameMap: Dict<Set<string>> = Object.create(null)
409
+ for (const entry of this.ctx.loader.entries()) {
410
+ (nameMap[entry.parent.tree.ctx.baseUrl!] ??= new Set()).add(entry.options.name)
411
+ }
412
+
413
+ // Resolve each plugin name to its file URL and check if it needs reload
414
+ for (const baseUrl in nameMap) {
415
+ for (const name of nameMap[baseUrl]) {
416
+ try {
417
+ const { url } = await this._resolve(name, baseUrl, {})
418
+ if (this.declined.has(url)) continue
419
+ const job = this.internal.loadCache.get(url)
420
+ const plugin = this.ctx.loader.unwrapExports(job?.module?.getNamespace())
421
+ if (!job || !plugin) continue
422
+ pending.set(job, plugin)
423
+ this.declined.add(url)
424
+ } catch (err) {
425
+ this.ctx.logger.warn(err)
426
+ }
427
+ }
428
+ }
429
+
430
+ // Check each pending plugin's dependency tree for accepted files
431
+ for (const [job, plugin] of pending) {
432
+ this.declined.delete(job.url)
433
+ const dependencies = [...await loadDependencies(job, this.declined)]
434
+ this.declined.add(job.url)
435
+
436
+ if (!dependencies.some(dep => this.accepted.has(dep))) continue
437
+ dependencies.forEach(dep => this.accepted.add(dep))
438
+
439
+ reloads.set(plugin, {
440
+ filename: job.url,
441
+ runtime: this.ctx.registry.get(plugin),
442
+ })
443
+ }
444
+
445
+ /**
446
+ * Clear module caches for all accepted files before re-importing.
447
+ *
448
+ * We need to clear both:
449
+ * 1. ESM loadCache — managed by Node's internal ModuleLoader
450
+ * 2. CJS Module._cache — for CJS modules that were imported via import()
451
+ *
452
+ * In Node 24, CJS modules loaded via import() appear in both caches.
453
+ * If we only clear loadCache, the CJS cache may serve stale modules.
454
+ *
455
+ * We use Map.prototype methods directly on loadCache because:
456
+ * - In Node 22/23, loadCache is a plain Map<url, ModuleJob>
457
+ * - In Node 24, loadCache is a LoadCache extends Map<url, { [type]: ModuleJob }>
458
+ * where .delete() only sets the type slot to undefined (doesn't remove the entry)
459
+ * Using Map.prototype.delete ensures complete removal in both versions.
460
+ */
461
+ const esmBackup: Dict = Object.create(null)
462
+ const cjsBackup: Dict = Object.create(null)
463
+ const require = createRequire(import.meta.url)
464
+ for (const filename of this.accepted) {
465
+ // Backup and clear ESM loadCache
466
+ const job = Map.prototype.get.call(this.internal.loadCache, filename)
467
+ esmBackup[filename] = job
468
+ Map.prototype.delete.call(this.internal.loadCache, filename)
469
+
470
+ // Backup and clear CJS Module._cache
471
+ try {
472
+ const filepath = fileURLToPath(filename)
473
+ if (require.cache[filepath]) {
474
+ cjsBackup[filepath] = require.cache[filepath]
475
+ delete require.cache[filepath]
476
+ }
477
+ } catch {
478
+ // filename might not be a file: URL (e.g. node: protocol), ignore
479
+ }
480
+ }
481
+
482
+ const rollback = () => {
483
+ for (const filename in esmBackup) {
484
+ Map.prototype.set.call(this.internal.loadCache, filename, esmBackup[filename])
485
+ }
486
+ for (const filepath in cjsBackup) {
487
+ require.cache[filepath] = cjsBackup[filepath]
488
+ }
489
+ }
490
+
491
+ // Attempt to re-import all plugin entry files
492
+ const attempts: Dict = {}
493
+ try {
494
+ for (const [, { filename }] of reloads) {
495
+ attempts[filename] = this.ctx.loader.unwrapExports(await this.ctx.loader.import(filename, this.getOuterStack))
496
+ }
497
+ } catch (e) {
498
+ handleError(this.ctx, e)
499
+ return rollback()
500
+ }
501
+
502
+ const reload = (plugin: any, runtime: Plugin.Runtime) => {
503
+ if (!runtime) return
504
+ for (const oldFiber of runtime.fibers) {
505
+ const fiber = oldFiber.parent.registry.plugin(plugin, oldFiber._config, this.getOuterStack)
506
+ fiber.entry = oldFiber.entry
507
+ if (fiber.entry) fiber.entry.fiber = fiber
508
+ }
509
+ }
510
+
511
+ try {
512
+ for (const [plugin, { filename, runtime }] of reloads) {
513
+ if (!runtime) continue
514
+ const path = relative(this.baseDir, fileURLToPath(filename))
515
+
516
+ try {
517
+ this.ctx.registry.delete(plugin)
518
+ } catch (err) {
519
+ this.ctx.logger.warn('failed to dispose plugin at %C', path)
520
+ this.ctx.logger.warn(err)
521
+ }
522
+
523
+ try {
524
+ reload(attempts[filename], runtime)
525
+ this.ctx.logger.info('reload plugin at %C', path)
526
+ } catch (err) {
527
+ this.ctx.logger.warn('failed to reload plugin at %C', path)
528
+ this.ctx.logger.warn(err)
529
+ throw err
530
+ }
531
+ }
532
+ } catch {
533
+ // Rollback: restore caches and re-register old plugins
534
+ rollback()
535
+ for (const [plugin, { filename, runtime }] of reloads) {
536
+ if (!runtime) continue
537
+ try {
538
+ this.ctx.registry.delete(attempts[filename])
539
+ reload(plugin, runtime)
540
+ } catch (err) {
541
+ this.ctx.logger.warn(err)
542
+ }
543
+ }
544
+ return
545
+ }
546
+
547
+ this.ctx.emit('hmr/reload', reloads)
548
+ this.stashed = new Set()
549
+ }
550
+ }
551
+
552
+ namespace Hmr {
553
+ export interface Config extends ChokidarOptions {
554
+ base?: string
555
+ root: string[]
556
+ debounce: number
557
+ ignored: string[]
558
+ }
559
+
560
+ export const Config: z<Config> = z.object({
561
+ base: z.string(),
562
+ root: z.array(String).role('table').default(['.']),
563
+ ignored: z.array(String).role('table').default([
564
+ '**/node_modules',
565
+ '**/.*',
566
+ 'cache',
567
+ 'data',
568
+ ]),
569
+ debounce: z.natural().role('ms').default(100),
570
+ })
571
+ // [deepseek-harness] vendored modification: removed `.i18n({ 'en-US': enUS, 'zh-CN': zhCN })`
572
+ // and the corresponding `./locales/*.yml` imports, to avoid a runtime YAML import hook
573
+ // (@cordisjs/unyaml) that we don't vendor. See vendor/README.md.
574
+ }
575
+
576
+ export default Hmr