@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 +2 -4
- package/lib/index.js +309 -360
- package/lib/types/index.d.ts +0 -20
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/index.ts +16 -165
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
|
|
12
|
-
|
|
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 {
|
|
5
|
-
import { realpath
|
|
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
|
-
|
|
60
|
-
let
|
|
61
|
-
let
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
this.
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
return;
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
360
|
-
*
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
380
|
+
this.ctx.emit("hmr/reload", reloads);
|
|
381
|
+
this.stashed = /* @__PURE__ */ new Set();
|
|
432
382
|
}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
};
|
|
383
|
+
};
|
|
384
|
+
return _classThis;
|
|
385
|
+
})();
|
|
437
386
|
(function(Hmr) {
|
|
438
387
|
Hmr.Config = z.object({
|
|
439
388
|
base: z.string(),
|
package/lib/types/index.d.ts
CHANGED
|
@@ -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
|
/**
|
package/lib/types/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,
|
|
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.
|
|
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": "^
|
|
45
|
-
"@deepseek-ai/cordis
|
|
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.
|
|
52
|
-
"@deepseek-ai/schemastery": "^3.18.
|
|
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 {
|
|
7
|
-
import { realpath
|
|
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
|
-
|
|
56
|
-
|
|
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
|
|
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
|
-
|
|
245
|
-
this.ctx.logger.debug('
|
|
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>)',
|