@deepseek-ai/cordis-plugin-hmr 1.0.17 → 1.0.18

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/README.md CHANGED
@@ -8,10 +8,8 @@ application files. Changes to framework-level dependencies fall back to
8
8
  `loader.exit()`, letting the host process restart.
9
9
 
10
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.
11
+ Chokidar, so the native backend receives one filesystem spelling even when
12
+ Windows supplied an 8.3 alias.
15
13
 
16
14
  ## Requirements
17
15
 
package/lib/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { createRequire } from "node:module";
2
- import { Service } from "@deepseek-ai/cordis";
2
+ import { Inject, Service } from "@deepseek-ai/cordis";
3
3
  import { watch } from "chokidar";
4
- import { dirname, relative, resolve } from "node:path";
5
- import { realpath, stat } from "node:fs/promises";
4
+ import { relative, resolve } from "node:path";
5
+ import { realpath } from "node:fs/promises";
6
6
  import { codeFrameColumns } from "@babel/code-frame";
7
7
  import { readFileSync } from "node:fs";
8
8
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -40,6 +40,44 @@ function handleError(ctx, e) {
40
40
  }
41
41
  //#endregion
42
42
  //#region lib/types/index.js
43
+ var __esDecorate = function(ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
44
+ function accept(f) {
45
+ if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected");
46
+ return f;
47
+ }
48
+ var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
49
+ var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
50
+ var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
51
+ var _, done = false;
52
+ for (var i = decorators.length - 1; i >= 0; i--) {
53
+ var context = {};
54
+ for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
55
+ for (var p in contextIn.access) context.access[p] = contextIn.access[p];
56
+ context.addInitializer = function(f) {
57
+ if (done) throw new TypeError("Cannot add initializers after decoration has completed");
58
+ extraInitializers.push(accept(f || null));
59
+ };
60
+ var result = (0, decorators[i])(kind === "accessor" ? {
61
+ get: descriptor.get,
62
+ set: descriptor.set
63
+ } : descriptor[key], context);
64
+ if (kind === "accessor") {
65
+ if (result === void 0) continue;
66
+ if (result === null || typeof result !== "object") throw new TypeError("Object expected");
67
+ if (_ = accept(result.get)) descriptor.get = _;
68
+ if (_ = accept(result.set)) descriptor.set = _;
69
+ if (_ = accept(result.init)) initializers.unshift(_);
70
+ } else if (_ = accept(result)) if (kind === "field") initializers.unshift(_);
71
+ else descriptor[key] = _;
72
+ }
73
+ if (target) Object.defineProperty(target, contextIn.name, descriptor);
74
+ done = true;
75
+ };
76
+ var __runInitializers = function(thisArg, initializers, value) {
77
+ var useValue = arguments.length > 2;
78
+ for (var i = 0; i < initializers.length; i++) value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
79
+ return useValue ? value : void 0;
80
+ };
43
81
  /**
44
82
  * Recursively collect all module dependencies from a ModuleJob.
45
83
  * Skips node: builtins and node_modules to focus on user code.
@@ -56,384 +94,295 @@ async function loadDependencies(job, ignored = /* @__PURE__ */ new Set()) {
56
94
  await traverse(job);
57
95
  return dependencies;
58
96
  }
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;
97
+ let Hmr = (() => {
98
+ let _classDecorators = [Inject("loader"), Inject("timer")];
99
+ let _classDescriptor;
100
+ let _classExtraInitializers = [];
101
+ let _classThis;
102
+ let _classSuper = Service;
103
+ var Hmr = class extends _classSuper {
104
+ static {
105
+ _classThis = this;
165
106
  }
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
107
+ static {
108
+ const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
109
+ __esDecorate(null, _classDescriptor = { value: _classThis }, _classDecorators, {
110
+ kind: "class",
111
+ name: _classThis.name,
112
+ metadata: _metadata
113
+ }, null, _classExtraInitializers);
114
+ Hmr = _classThis = _classDescriptor.value;
115
+ if (_metadata) Object.defineProperty(_classThis, Symbol.metadata, {
116
+ enumerable: true,
117
+ configurable: true,
118
+ writable: true,
119
+ value: _metadata
176
120
  });
121
+ __runInitializers(_classThis, _classExtraInitializers);
177
122
  }
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();
123
+ config;
124
+ baseDir;
125
+ internal;
126
+ watcher;
127
+ /**
128
+ * Changes from externals will always trigger a full reload.
129
+ * Externals are the dependency tree of the CLI worker entry point.
130
+ */
131
+ externals;
132
+ /**
133
+ * Files that should be reloaded (accepted changes).
134
+ * Includes all stashed files and their dependents.
135
+ */
136
+ accepted;
137
+ /**
138
+ * Files that should NOT be reloaded.
139
+ * Includes externals and files whose dependents are all declined.
140
+ */
141
+ declined;
142
+ /** Stashed file changes waiting to be processed */
143
+ stashed = /* @__PURE__ */ new Set();
144
+ constructor(ctx, config) {
145
+ super(ctx, "hmr");
146
+ this.config = config;
147
+ if (!this.ctx.loader.internal) throw new Error("--expose-internals is required for HMR service");
148
+ this.internal = this.ctx.loader.internal;
149
+ this.baseDir = fileURLToPath(new URL(config.base || ".", ctx.baseUrl));
150
+ }
151
+ /**
152
+ * Resolve a module specifier to a URL, compatible with Node 22-24.
153
+ */
154
+ async _resolve(specifier, parentURL, attrs) {
155
+ switch (this.internal.version) {
156
+ case "v1": return await this.internal.resolve(specifier, parentURL, attrs);
157
+ case "v2": return this.internal.resolveSync(parentURL, {
158
+ specifier,
159
+ attributes: attrs
160
+ });
219
161
  }
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
- }
162
+ }
163
+ async *[Service.init]() {
164
+ yield () => this.watcher?.close();
165
+ const { loader } = this.ctx;
166
+ const { root, ignored } = this.config;
167
+ if (!this.config.base) this.ctx.logger.info("watching %o", root);
168
+ else this.ctx.logger.info("watching %o in %s", root, this.baseDir);
169
+ const match = picomatch(ignored);
170
+ const watchBaseDir = await realpath(this.baseDir);
171
+ const mainUrl = pathToFileURL(resolve(process.argv[1])).href;
172
+ const mainJob = this.internal.loadCache.get(mainUrl);
173
+ if (mainJob) this.externals = await loadDependencies(mainJob);
174
+ else this.externals = /* @__PURE__ */ new Set();
175
+ this.watcher = watch(root, {
176
+ ...this.config,
177
+ cwd: watchBaseDir,
178
+ ignored: (path) => match(relative(watchBaseDir, path)),
179
+ ignoreInitial: true
180
+ });
181
+ const partialReload = this.ctx.debounce(() => this.partialReload(), this.config.debounce);
182
+ this.watcher.on("change", async (path) => {
183
+ this.ctx.logger.debug("change detected at %C", path);
184
+ const filename = resolve(watchBaseDir, path);
185
+ const configuredFilename = resolve(this.baseDir, path);
186
+ const url = pathToFileURL(filename).href;
187
+ if (this.externals.has(url)) return loader.exit();
188
+ if (loader.internal.loadCache.has(url)) {
189
+ this.stashed.add(url);
190
+ return partialReload();
259
191
  }
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
- }
192
+ for (const entry of loader.entries()) {
193
+ const include = entry.subtree;
194
+ if (include?.filename !== filename && include?.filename !== configuredFilename) continue;
195
+ await include.refresh();
196
+ return;
312
197
  }
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;
198
+ this.ctx.emit("hmr/change", url);
199
+ });
200
+ const ready = Promise.withResolvers();
201
+ let readyState = root.length === 0 ? "resolved" : "pending";
202
+ if (root.length === 0) ready.resolve();
203
+ else this.watcher.once("ready", () => {
204
+ readyState = "resolved";
205
+ ready.resolve();
206
+ });
207
+ this.watcher.on("error", (error) => {
208
+ if (readyState === "pending") {
209
+ readyState = "rejected";
210
+ ready.reject(error);
211
+ } else this.ctx.logger.warn(error);
212
+ });
213
+ await ready.promise;
321
214
  }
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;
215
+ getOuterStack = () => [];
216
+ async getLinked(url) {
333
217
  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
- });
218
+ if (!job) return [];
219
+ const linked = await job.linked;
220
+ return Array.prototype.map.call(linked, (job) => job.url);
351
221
  }
352
222
  /**
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()
223
+ * Classify changed files into accepted (should reload) and declined (should not).
358
224
  *
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.
225
+ * A file is accepted if it's directly changed (stashed) or if any of its
226
+ * dependents are accepted. A file is declined if all its dependents are
227
+ * declined or if it's an external.
367
228
  */
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];
229
+ async analyzeChanges() {
230
+ const pending = [];
231
+ this.accepted = new Set(this.stashed);
232
+ this.declined = new Set(this.externals);
233
+ const isExcluded = (url) => url.startsWith("node:") || url.includes("/node_modules/");
234
+ await Promise.all([...this.stashed].map(async (url) => {
235
+ const children = await this.getLinked(url);
236
+ for (const child of children) {
237
+ if (this.accepted.has(child) || this.declined.has(child) || isExcluded(child)) continue;
238
+ pending.push(child);
379
239
  }
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();
240
+ }));
241
+ while (pending.length) {
242
+ let index = 0, hasUpdate = false;
243
+ while (index < pending.length) {
244
+ const url = pending[index];
245
+ const children = await this.getLinked(url);
246
+ let isDeclined = true, isAccepted = false;
247
+ for (const child of children) {
248
+ if (this.declined.has(child) || isExcluded(child)) continue;
249
+ if (this.accepted.has(child)) {
250
+ isAccepted = true;
251
+ break;
252
+ } else {
253
+ isDeclined = false;
254
+ if (!pending.includes(child)) {
255
+ hasUpdate = true;
256
+ pending.push(child);
257
+ }
258
+ }
259
+ }
260
+ if (isAccepted || isDeclined) {
261
+ hasUpdate = true;
262
+ pending.splice(index, 1);
263
+ if (isAccepted) this.accepted.add(url);
264
+ else this.declined.add(url);
265
+ } else index++;
266
+ }
267
+ if (!hasUpdate) break;
268
+ }
269
+ for (const url of pending) this.declined.add(url);
392
270
  }
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;
271
+ async partialReload() {
272
+ await this.analyzeChanges();
273
+ const pending = /* @__PURE__ */ new Map();
274
+ const reloads = /* @__PURE__ */ new Map();
275
+ const nameMap = Object.create(null);
276
+ for (const entry of this.ctx.loader.entries()) (nameMap[entry.parent.tree.ctx.baseUrl] ??= /* @__PURE__ */ new Set()).add(entry.options.name);
277
+ for (const baseUrl in nameMap) for (const name of nameMap[baseUrl]) try {
278
+ const { url } = await this._resolve(name, baseUrl, {});
279
+ if (this.declined.has(url)) continue;
280
+ const job = this.internal.loadCache.get(url);
281
+ const plugin = this.ctx.loader.unwrapExports(job?.module?.getNamespace());
282
+ if (!job || !plugin) continue;
283
+ pending.set(job, plugin);
284
+ this.declined.add(url);
285
+ } catch (err) {
286
+ this.ctx.logger.warn(err);
399
287
  }
400
- };
401
- try {
402
- for (const [plugin, { filename, runtime }] of reloads) {
403
- if (!runtime) continue;
404
- const path = relative(this.baseDir, fileURLToPath(filename));
288
+ for (const [job, plugin] of pending) {
289
+ this.declined.delete(job.url);
290
+ const dependencies = [...await loadDependencies(job, this.declined)];
291
+ this.declined.add(job.url);
292
+ if (!dependencies.some((dep) => this.accepted.has(dep))) continue;
293
+ dependencies.forEach((dep) => this.accepted.add(dep));
294
+ reloads.set(plugin, {
295
+ filename: job.url,
296
+ runtime: this.ctx.registry.get(plugin)
297
+ });
298
+ }
299
+ /**
300
+ * Clear module caches for all accepted files before re-importing.
301
+ *
302
+ * We need to clear both:
303
+ * 1. ESM loadCache — managed by Node's internal ModuleLoader
304
+ * 2. CJS Module._cache — for CJS modules that were imported via import()
305
+ *
306
+ * In Node 24, CJS modules loaded via import() appear in both caches.
307
+ * If we only clear loadCache, the CJS cache may serve stale modules.
308
+ *
309
+ * We use Map.prototype methods directly on loadCache because:
310
+ * - In Node 22/23, loadCache is a plain Map<url, ModuleJob>
311
+ * - In Node 24, loadCache is a LoadCache extends Map<url, { [type]: ModuleJob }>
312
+ * where .delete() only sets the type slot to undefined (doesn't remove the entry)
313
+ * Using Map.prototype.delete ensures complete removal in both versions.
314
+ */
315
+ const esmBackup = Object.create(null);
316
+ const cjsBackup = Object.create(null);
317
+ const require = createRequire(import.meta.url);
318
+ for (const filename of this.accepted) {
319
+ esmBackup[filename] = Map.prototype.get.call(this.internal.loadCache, filename);
320
+ Map.prototype.delete.call(this.internal.loadCache, filename);
405
321
  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);
322
+ const filepath = fileURLToPath(filename);
323
+ if (require.cache[filepath]) {
324
+ cjsBackup[filepath] = require.cache[filepath];
325
+ delete require.cache[filepath];
326
+ }
327
+ } catch {}
328
+ }
329
+ const rollback = () => {
330
+ for (const filename in esmBackup) Map.prototype.set.call(this.internal.loadCache, filename, esmBackup[filename]);
331
+ for (const filepath in cjsBackup) require.cache[filepath] = cjsBackup[filepath];
332
+ };
333
+ const attempts = {};
334
+ try {
335
+ for (const [, { filename }] of reloads) attempts[filename] = this.ctx.loader.unwrapExports(await this.ctx.loader.import(filename, this.getOuterStack));
336
+ } catch (e) {
337
+ handleError(this.ctx, e);
338
+ return rollback();
339
+ }
340
+ const reload = (plugin, runtime) => {
341
+ if (!runtime) return;
342
+ for (const oldFiber of runtime.fibers) {
343
+ const fiber = oldFiber.parent.registry.plugin(plugin, oldFiber._config, this.getOuterStack);
344
+ fiber.entry = oldFiber.entry;
345
+ if (fiber.entry) fiber.entry.fiber = fiber;
410
346
  }
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;
347
+ };
348
+ try {
349
+ for (const [plugin, { filename, runtime }] of reloads) {
350
+ if (!runtime) continue;
351
+ const path = relative(this.baseDir, fileURLToPath(filename));
352
+ try {
353
+ this.ctx.registry.delete(plugin);
354
+ } catch (err) {
355
+ this.ctx.logger.warn("failed to dispose plugin at %C", path);
356
+ this.ctx.logger.warn(err);
357
+ }
358
+ try {
359
+ reload(attempts[filename], runtime);
360
+ this.ctx.logger.info("reload plugin at %C", path);
361
+ } catch (err) {
362
+ this.ctx.logger.warn("failed to reload plugin at %C", path);
363
+ this.ctx.logger.warn(err);
364
+ throw err;
365
+ }
418
366
  }
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);
367
+ } catch {
368
+ rollback();
369
+ for (const [plugin, { filename, runtime }] of reloads) {
370
+ if (!runtime) continue;
371
+ try {
372
+ this.ctx.registry.delete(attempts[filename]);
373
+ reload(plugin, runtime);
374
+ } catch (err) {
375
+ this.ctx.logger.warn(err);
376
+ }
429
377
  }
378
+ return;
430
379
  }
431
- return;
380
+ this.ctx.emit("hmr/reload", reloads);
381
+ this.stashed = /* @__PURE__ */ new Set();
432
382
  }
433
- this.ctx.emit("hmr/reload", reloads);
434
- this.stashed = /* @__PURE__ */ new Set();
435
- }
436
- };
383
+ };
384
+ return _classThis;
385
+ })();
437
386
  (function(Hmr) {
438
387
  Hmr.Config = z.object({
439
388
  base: z.string(),
@@ -8,13 +8,6 @@ declare module '@deepseek-ai/cordis' {
8
8
  interface Events {
9
9
  'hmr/change'(url: string): void;
10
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
11
  }
19
12
  }
20
13
  interface Reload {
@@ -23,13 +16,9 @@ interface Reload {
23
16
  }
24
17
  declare class Hmr extends Service {
25
18
  config: Hmr.Config;
26
- static inject: string[];
27
19
  baseDir: string;
28
20
  private internal;
29
21
  private watcher;
30
- private readonly configs;
31
- private readonly configRefreshes;
32
- private readonly refreshTasks;
33
22
  /**
34
23
  * Changes from externals will always trigger a full reload.
35
24
  * Externals are the dependency tree of the CLI worker entry point.
@@ -48,20 +37,11 @@ declare class Hmr extends Service {
48
37
  /** Stashed file changes waiting to be processed */
49
38
  private stashed;
50
39
  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
40
  /**
60
41
  * Resolve a module specifier to a URL, compatible with Node 22-24.
61
42
  */
62
43
  private _resolve;
63
44
  [Service.init](): AsyncGenerator<() => Promise<void>, void, unknown>;
64
- private refreshConfig;
65
45
  getOuterStack: () => string[];
66
46
  getLinked(url: string): Promise<string[]>;
67
47
  /**
@@ -1 +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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAU,OAAO,EAAE,KAAK,MAAM,EAAE,MAAM,qBAAqB,CAAA;AAI3E,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;KACjD;CACF;AAmBD,UAAU,MAAM;IACd,QAAQ,EAAE,MAAM,CAAA;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC,OAAO,CAAA;CACzB;AAED,cAEM,GAAI,SAAQ,OAAO;IA2BU,MAAM,EAAE,GAAG,CAAC,MAAM;IA1B5C,OAAO,EAAE,MAAM,CAAA;IAEtB,OAAO,CAAC,QAAQ,CAAc;IAC9B,OAAO,CAAC,OAAO,CAAY;IAE3B;;;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;;OAEG;YACW,QAAQ;IAOf,CAAC,OAAO,CAAC,IAAI,CAAC;IAkFrB,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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@deepseek-ai/cordis-plugin-hmr",
3
3
  "description": "Hot Module Replacement Plugin for Cordis",
4
- "version": "1.0.17",
4
+ "version": "1.0.18",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -41,15 +41,15 @@
41
41
  }
42
42
  },
43
43
  "peerDependencies": {
44
- "@deepseek-ai/cordis": "^4.0.2",
45
- "@deepseek-ai/cordis-plugin-timer": "^1.1.4"
44
+ "@deepseek-ai/cordis-plugin-timer": "^1.1.5",
45
+ "@deepseek-ai/cordis": "^4.0.3"
46
46
  },
47
47
  "dependencies": {
48
48
  "@babel/code-frame": "^7.29.0",
49
49
  "chokidar": "^4.0.3",
50
50
  "picomatch": "^4.0.3",
51
- "@deepseek-ai/cosmokit": "^1.8.3",
52
- "@deepseek-ai/schemastery": "^3.18.2"
51
+ "@deepseek-ai/cosmokit": "^1.8.4",
52
+ "@deepseek-ai/schemastery": "^3.18.3"
53
53
  },
54
54
  "devDependencies": {
55
55
  "@types/babel__code-frame": "^7.27.0",
package/src/index.ts CHANGED
@@ -1,10 +1,10 @@
1
- import { Context, Service, type Plugin } from '@deepseek-ai/cordis'
1
+ import { Context, Inject, Service, type Plugin } from '@deepseek-ai/cordis'
2
2
  import type { Dict } from '@deepseek-ai/cosmokit'
3
3
  import { ModuleLoader, type ModuleJob, type ResolveResult } from '@deepseek-ai/cordis-plugin-loader'
4
4
  import type { Include } from '@deepseek-ai/cordis-plugin-include'
5
5
  import { FSWatcher, watch, type ChokidarOptions } from 'chokidar'
6
- import { dirname, relative, resolve } from 'node:path'
7
- import { realpath, stat } from 'node:fs/promises'
6
+ import { relative, resolve } from 'node:path'
7
+ import { realpath } from 'node:fs/promises'
8
8
  import { handleError } from './error.ts'
9
9
  import type {} from '@deepseek-ai/cordis-plugin-timer'
10
10
  import { fileURLToPath, pathToFileURL } from 'node:url'
@@ -20,13 +20,6 @@ declare module '@deepseek-ai/cordis' {
20
20
  interface Events {
21
21
  'hmr/change'(url: string): void
22
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
23
  }
31
24
  }
32
25
 
@@ -52,47 +45,13 @@ interface Reload {
52
45
  runtime?: Plugin.Runtime
53
46
  }
54
47
 
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
-
48
+ @Inject('loader')
49
+ @Inject('timer')
86
50
  class Hmr extends Service {
87
- static inject = ['loader', 'timer']
88
-
89
51
  public baseDir: string
90
52
 
91
53
  private internal: ModuleLoader
92
54
  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
55
 
97
56
  /**
98
57
  * Changes from externals will always trigger a full reload.
@@ -124,68 +83,6 @@ class Hmr extends Service {
124
83
  this.baseDir = fileURLToPath(new URL(config.base || '.', ctx.baseUrl))
125
84
  }
126
85
 
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
86
  /**
190
87
  * Resolve a module specifier to a URL, compatible with Node 22-24.
191
88
  */
@@ -197,12 +94,7 @@ class Hmr extends Service {
197
94
  }
198
95
 
199
96
  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
- }
97
+ yield () => this.watcher?.close()
206
98
 
207
99
  const { loader } = this.ctx
208
100
  const { root, ignored } = this.config
@@ -229,31 +121,15 @@ class Hmr extends Service {
229
121
  ...this.config,
230
122
  cwd: watchBaseDir,
231
123
  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
124
  ignoreInitial: true,
240
125
  })
241
126
 
242
127
  const partialReload = this.ctx.debounce(() => this.partialReload(), this.config.debounce)
243
128
 
244
- const onChange = (kind: 'add' | 'change' | 'unlink', path: string) => {
245
- this.ctx.logger.debug('%s detected at %C', kind, path)
129
+ this.watcher.on('change', async (path) => {
130
+ this.ctx.logger.debug('change detected at %C', path)
246
131
  const filename = resolve(watchBaseDir, path)
247
132
  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
133
  const url = pathToFileURL(filename).href
258
134
 
259
135
  // Full reload: the changed file is part of the framework
@@ -267,11 +143,15 @@ class Hmr extends Service {
267
143
  return partialReload()
268
144
  }
269
145
 
146
+ for (const entry of loader.entries()) {
147
+ const include = entry.subtree as Include | undefined
148
+ if (include?.filename !== filename && include?.filename !== configuredFilename) continue
149
+ await include.refresh()
150
+ return
151
+ }
152
+
270
153
  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))
154
+ })
275
155
 
276
156
  const ready = Promise.withResolvers<void>()
277
157
  let readyState: 'pending' | 'resolved' | 'rejected' = root.length === 0 ? 'resolved' : 'pending'
@@ -294,35 +174,6 @@ class Hmr extends Service {
294
174
  await ready.promise
295
175
  }
296
176
 
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
177
  // hide stack trace from HMR
327
178
  getOuterStack = (): string[] => [
328
179
  // ' at HMR.partialReload (<anonymous>)',