@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 +21 -0
- package/README.md +53 -0
- package/lib/index.js +452 -0
- package/lib/types/error.d.ts +4 -0
- package/lib/types/error.d.ts.map +1 -0
- package/lib/types/index.d.ts +87 -0
- package/lib/types/index.d.ts.map +1 -0
- package/package.json +59 -0
- package/src/error.ts +36 -0
- package/src/index.ts +576 -0
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 @@
|
|
|
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
|