dsh-hot-reload 0.2.1 → 0.2.3

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/CHANGELOG.md CHANGED
@@ -3,6 +3,46 @@
3
3
  All notable changes to `dsh-hot-reload` are documented here. This project
4
4
  follows [semantic versioning](https://semver.org/).
5
5
 
6
+ ## 0.2.3
7
+
8
+ A plugin that loads after `dsh-hot-reload` in the bundle order is now reloaded
9
+ when it is upgraded, and every reload outcome — not just successes — reaches the
10
+ terminal. Config and API unchanged.
11
+
12
+ **Fixes**
13
+
14
+ - **A plugin loaded after this one now hot-reloads when upgraded.** A first-seen
15
+ package used to be adopted at face value whatever its version, so a plugin that
16
+ appears later in the bundle order (for example `dsh-crew`) kept running old
17
+ code when it had already been upgraded before this plugin's first cycle — the
18
+ change was silently missed. A first-seen package that has a live fiber is now
19
+ reloaded instead of adopted; a first-seen package with no live fiber is still
20
+ adopted, because there is nothing running to replace.
21
+ - **Every outcome now reaches the terminal.** `report()` used to write its stderr
22
+ line only for a successful reload. A failed or stale reload was then visible
23
+ only as a transient pop-up and a log line dsh never prints, so the "restart
24
+ dsh" instruction never reached anyone watching the terminal. stderr is now
25
+ written for every outcome — reloaded, failed, and stale.
26
+
27
+ ## 0.2.2
28
+
29
+ Reloads now re-import a plugin's whole package, not just its entry module.
30
+ Config and API unchanged.
31
+
32
+ **Fixes**
33
+
34
+ - **Multi-file plugins reload as one unit.** The reload used to invalidate only
35
+ the entry URL and re-import it. Under a hoisted linker (`nodeLinker: hoisted`)
36
+ a version bump rewrites the package's files in place, so the entry's relative
37
+ imports (`./routes.js` and friends) resolved to the same URLs and hit the
38
+ stale cache — the running plugin mixed new entry code with old dependency code,
39
+ which for a plugin like `dshmarket` surfaced as a crash looking for a file the
40
+ new version had deleted. The reload now invalidates every cached module under
41
+ the package's own directory (in both the ESM `loadCache` and the CJS
42
+ `require.cache`) before re-importing, so the entry and its in-package imports
43
+ come back together. Shared dependencies live outside that directory and are
44
+ left alone.
45
+
6
46
  ## 0.2.1
7
47
 
8
48
  Code-review fixes to the 0.2.0 notification surfaces. No config or API changes,
@@ -42,7 +42,7 @@ dsh 自带的热重载(`cordis-plugin-hmr`)刻意忽略 `node_modules`,所
42
42
  插件会把每个结果写进 dsh 的日志。但 dsh 不会把日志打印到你的终端,所以这些内容
43
43
  很容易被忽略。另有两个地方会告诉你发生了什么。
44
44
 
45
- **1. 每次重载成功,在你的终端里输出一行。** 任意 profile 都有:
45
+ **1. 每种结果,在你的终端里输出一行。** 任意 profile 都有:
46
46
 
47
47
  ```
48
48
  dsh-hot-reload: hot-reloaded some-plugin@1.2.0 (1 module(s))
@@ -75,8 +75,9 @@ web 那一部分只在运行 web 服务器的 profile 中加载,并通过
75
75
 
76
76
  ### 如果你想在终端里看到全部内容
77
77
 
78
- 上面那一行只覆盖成功的重载。若想看到本插件写进日志的全部内容(包括失败),请把
79
- dsh 的控制台日志插件加进你的 profile。它是一个独立的包:
78
+ 上面那一行覆盖每一种重载结果——成功、失败和 stale(未尝试、旧代码仍在运行)。若想
79
+ 看到本插件写进日志的其它内容(它的警告和诊断信息),请把 dsh 的控制台日志插件加进
80
+ 你的 profile。它是一个独立的包:
80
81
 
81
82
  ```sh
82
83
  dsh plugin --profile web add @deepseek-ai/cordis-plugin-logger-console
@@ -192,6 +193,12 @@ web 应用里的提示(且仅这一部分)还用到:
192
193
  这些内容,所以并没有多暴露什么秘密。但如果你把 dsh 绑定到 `0.0.0.0`,请把它
193
194
  算作局域网里任何人都能打开的又一个地址。
194
195
 
196
+ - 在 bundle 顺序里排在 `dsh-hot-reload` **之后**加载的插件,会在启动后的第一次
197
+ lockfile 写入时被重载一次——重载到它当前的版本——即使那次写入针对的是另一个
198
+ 包。在本插件跨过一轮循环、跟踪到该包之前,它无法分辨运行中的代码是旧版还是当前
199
+ 版本,所以它选择重载,而不是采纳一个可能从未运行过的版本。对于 HMR 安全的插件,
200
+ 这只是无害的单次多余重载。
201
+
195
202
  范围说明:本插件处理的是**已加载插件的升级**。安装一个**全新**插件是另一回事
196
203
  (把它的行加入 `cordis.patch.yml`,这个 dsh 本身已经会热应用)。
197
204
 
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh-hot-reload
2
2
 
3
- English | [中文](README.zh.md)
3
+ English | [中文](README-zh.md)
4
4
 
5
5
  Live-reload upgraded [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh) plugins **without restarting dsh**.
6
6
 
@@ -50,7 +50,7 @@ The plugin writes every result to dsh's log. But dsh does not print its log to
50
50
  your terminal, so those lines are easy to miss. Two extra places show you what
51
51
  happened.
52
52
 
53
- **1. One line in your terminal, for each reload that worked.** You get this in
53
+ **1. One line in your terminal, for every outcome.** You get this in
54
54
  every profile:
55
55
 
56
56
  ```
@@ -88,9 +88,10 @@ reload the page to start again.
88
88
 
89
89
  ### If you want every line in your terminal
90
90
 
91
- The terminal line above only covers reloads that worked. To see everything this
92
- plugin writes to the log, including failures, add dsh's console logger to your
93
- profile. It is a separate package:
91
+ The terminal line above covers every reload outcome reloaded, failed, and
92
+ stale (not attempted the old code is still running). To see everything else this plugin writes to the log (its warnings and
93
+ diagnostics), add dsh's console logger to your profile. It is a separate
94
+ package:
94
95
 
95
96
  ```sh
96
97
  dsh plugin --profile web add @deepseek-ai/cordis-plugin-logger-console
@@ -221,6 +222,14 @@ live fiber to swap). It does **not** detect *silent* leaks:
221
222
  secret. But if you bind dsh to `0.0.0.0`, count it as one more address that
222
223
  anyone on your network can open.
223
224
 
225
+ - A plugin that loads **after** `dsh-hot-reload` in the bundle order is reloaded
226
+ once — to its current version — on the first lockfile write after boot, even
227
+ when that write was for an unrelated package. Until this plugin has tracked the
228
+ package across one cycle, it cannot tell whether the running code is the old or
229
+ the current version, so it reloads rather than adopting a version that may
230
+ never have run. For an HMR-safe plugin this is a harmless single redundant
231
+ reload.
232
+
224
233
  Scope note: this handles **upgrades of already-loaded plugins**. Installing a
225
234
  *brand-new* plugin is a separate concern (adding its row to `cordis.patch.yml`,
226
235
  which dsh already hot-applies).
package/lib/index.js CHANGED
@@ -1,8 +1,9 @@
1
1
  // dsh-hot-reload — live-reload upgraded dsh plugins without restarting dsh.
2
2
  //
3
3
  // It watches the profile's pnpm-lock.yaml; when an already-loaded plugin
4
- // package's version changes, it invalidates that module's caches, re-imports
5
- // the new code, and swaps the running plugin fiber in place — the same
4
+ // package's version changes, it invalidates that package's cached modules
5
+ // (entry plus in-package imports), re-imports the new code, and swaps the
6
+ // running plugin fiber in place — the same
6
7
  // technique cordis-plugin-hmr uses, but reaching into node_modules (which HMR
7
8
  // deliberately ignores).
8
9
  //
@@ -17,9 +18,9 @@
17
18
  // row with no fiber attached yet is reported and left for a later change.
18
19
  //
19
20
  // Outcomes are announced on two surfaces besides ctx.logger — one stderr line
20
- // per successful reload, and an SSE channel the browser half (lib/client.js)
21
- // turns into a transient toast. Both are additive and best-effort; see the
22
- // "notification surfaces" section in apply().
21
+ // per outcome (reloaded, failed, or stale), and an SSE channel the browser half
22
+ // (lib/client.js) turns into a transient toast. Both are additive and
23
+ // best-effort; see the "notification surfaces" section in apply().
23
24
  //
24
25
  // NOTE: the reload path uses cordis/loader internals (loader.internal.loadCache,
25
26
  // registry.plugin/delete, fiber.entry) — the same ones HMR uses. If a future
@@ -29,8 +30,8 @@
29
30
  import { watch } from "chokidar";
30
31
  import { readFileSync, existsSync } from "node:fs";
31
32
  import { createRequire } from "node:module";
32
- import { fileURLToPath } from "node:url";
33
- import { join } from "node:path";
33
+ import { fileURLToPath, pathToFileURL } from "node:url";
34
+ import { dirname, join } from "node:path";
34
35
 
35
36
  export const name = "dsh-hot-reload";
36
37
 
@@ -86,10 +87,11 @@ export function apply(ctx, config = {}) {
86
87
  // and neither may throw into a reload cycle: a broken notification must never
87
88
  // turn a working reload into a failed one.
88
89
  //
89
- // - stderr, successful reloads only. cordis's logger fans messages out to
90
+ // - stderr, every cycle outcome. cordis's logger fans messages out to
90
91
  // registered exporters, and the dsh host process registers none (only the
91
92
  // browser shell does), so nothing this plugin logs reaches the terminal dsh
92
- // runs in. One line per reload is the profile-independent baseline.
93
+ // runs in. One line per outcome — reloaded, failed, or stale — is the
94
+ // profile-independent baseline.
93
95
  // - an SSE channel the web half subscribes to (lib/client.js) and renders as
94
96
  // a transient toast. Registered only when a webServer service exists, so a
95
97
  // profile without one — tui — behaves exactly as it does today.
@@ -111,17 +113,23 @@ export function apply(ctx, config = {}) {
111
113
  *
112
114
  * `kind` is "reloaded" (it worked), "failed" (attempted and rolled back), or
113
115
  * "stale" (not attempted; the old code is still running). It selects the log
114
- * level and the browser's icon, and only "reloaded" reaches stderr. Callers
115
- * pass the bare message every surface adds its own prefix. */
116
+ * level and the browser's icon. Every kind also reaches stderr — successes
117
+ * and failures alikebecause the dsh host registers no cordis logger
118
+ * exporter, so the log.* line above never reaches the terminal on its own.
119
+ * Callers pass the bare message — every surface adds its own prefix. */
116
120
  function report(kind, message) {
117
121
  if (kind === "reloaded") {
118
122
  log.info?.(`dsh-hot-reload: ${message}`);
119
- try {
120
- process.stderr.write(`dsh-hot-reload: ${message}\n`);
121
- } catch {}
122
123
  } else {
123
124
  log.warn?.(`dsh-hot-reload: ${message}`);
124
125
  }
126
+ // One durable, profile-independent line per outcome, written for every kind.
127
+ // A failed/stale reload used to be visible only as a transient toast plus an
128
+ // invisible log line (dsh exports no logger), so "restart needed" never
129
+ // reached anyone looking at the terminal dsh runs in.
130
+ try {
131
+ process.stderr.write(`dsh-hot-reload: ${message}\n`);
132
+ } catch {}
125
133
  if (!connections.size) return;
126
134
  const line = `data: ${JSON.stringify({ type: "notice", kind, text: message })}\n\n`;
127
135
  for (const res of connections) {
@@ -272,6 +280,49 @@ export function apply(ctx, config = {}) {
272
280
  } catch {}
273
281
  }
274
282
 
283
+ /** Directory, as a trailing-slash file:// URL, of the nearest ancestor that
284
+ * has a package.json — the package this module belongs to. A version bump
285
+ * rewrites every file inside it in place under a hoisted linker, so a reload
286
+ * must drop the whole directory, not just the entry. Returns null when no
287
+ * package.json is found (defensive; a node_modules package always has one). */
288
+ function packageRootUrlOf(url) {
289
+ let dir = dirname(fileURLToPath(url));
290
+ for (;;) {
291
+ if (existsSync(join(dir, "package.json"))) {
292
+ const href = pathToFileURL(dir).href;
293
+ return href.endsWith("/") ? href : `${href}/`;
294
+ }
295
+ const parent = dirname(dir);
296
+ if (parent === dir) return null;
297
+ dir = parent;
298
+ }
299
+ }
300
+
301
+ /** Invalidate every cached module under one package directory — the entry and
302
+ * its transitive in-package imports. Re-importing only the entry would leave
303
+ * `./routes.js` and friends on their stale cache entries (the URLs are
304
+ * unchanged under a hoisted linker), so the running plugin would mix old and
305
+ * new code. Shared dependencies live OUTSIDE this directory and are left
306
+ * alone: re-importing them would be wasteful and would fork shared classes. */
307
+ function invalidateTree(rootUrl) {
308
+ if (!rootUrl) return;
309
+ // ESM loadCache.
310
+ try {
311
+ for (const u of Map.prototype.keys.call(internal.loadCache)) {
312
+ if (typeof u === "string" && u.startsWith(rootUrl)) {
313
+ Map.prototype.delete.call(internal.loadCache, u);
314
+ }
315
+ }
316
+ } catch {}
317
+ // CJS: modules imported via import() also land in the require cache on Node 24.
318
+ try {
319
+ const rootPath = fileURLToPath(rootUrl);
320
+ for (const fp of Object.keys(cjsRequire.cache)) {
321
+ if (fp.startsWith(rootPath)) delete cjsRequire.cache[fp];
322
+ }
323
+ } catch {}
324
+ }
325
+
275
326
  /** Reload one loaded entry's module in place. Throws on failure, after rolling
276
327
  * the old plugin back — except when teardown began mid-reload, where it drops
277
328
  * the new plugin and does NOT roll back (the dying context disposes what is
@@ -290,7 +341,14 @@ export function apply(ctx, config = {}) {
290
341
  const url = await resolveUrl(specifier, parentURL);
291
342
  if (!url) throw new Error(`could not resolve ${specifier}`);
292
343
 
293
- invalidate(url); // matters for in-place edits; harmless no-op for a version bump (new realpath)
344
+ // Drop the whole package directory, not just the entry: under a hoisted
345
+ // linker a version bump rewrites files in place (same URLs), so the entry's
346
+ // relative imports would otherwise re-hit the stale cache and mix old and
347
+ // new code. Falls back to the single-URL invalidation only when no
348
+ // package.json is found.
349
+ const rootUrl = packageRootUrlOf(url);
350
+ if (rootUrl) invalidateTree(rootUrl);
351
+ else invalidate(url);
294
352
 
295
353
  // Read the version as close to the import as possible: this is what the
296
354
  // fresh module actually is, and the only value safe to commit.
@@ -460,8 +518,21 @@ export function apply(ctx, config = {}) {
460
518
  continue;
461
519
  }
462
520
  if (!(pkg in versions)) {
463
- versions[pkg] = version; // newly loaded row: dsh just loaded it fresh, nothing to reload
464
- continue;
521
+ // A package our boot snapshot never saw. Two cases:
522
+ // - no live fiber: nothing is running yet (mid-import, or only disabled
523
+ // rows), so there is no stale code to replace — dsh loads fresh code
524
+ // itself. Adopt the version.
525
+ // - has a live fiber: the package was already running before we first
526
+ // saw it — it loaded after our boot snapshot, as a bundle entry
527
+ // ordered after this plugin. Its running code may therefore predate
528
+ // this version. Reload it rather than silently adopting a version
529
+ // that never ran (otherwise a late-loaded plugin upgraded before the
530
+ // first cycle keeps its old code forever, with no notice).
531
+ if (!snap[pkg].live.length) {
532
+ versions[pkg] = version;
533
+ continue;
534
+ }
535
+ // fall through: treat as changed and reload
465
536
  }
466
537
  if (versions[pkg] === version) continue;
467
538
  // A version whose reload failed is never re-attempted: each attempt tears
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-hot-reload",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "type": "module",
5
5
  "description": "Live-reload upgraded DeepSeek Harness (dsh) plugins without restarting dsh \u2014 the running plugin is swapped in place, and a reload that fails rolls back to the working old version and asks for a manual restart.",
6
6
  "keywords": [
@@ -36,7 +36,7 @@
36
36
  "lib",
37
37
  "cordis.patch.yml",
38
38
  "README.md",
39
- "README.zh.md",
39
+ "README-zh.md",
40
40
  "CHANGELOG.md",
41
41
  "LICENSE"
42
42
  ],