dsh-hot-reload 0.1.4 → 0.2.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/CHANGELOG.md +95 -5
- package/README.md +107 -4
- package/README.zh.md +92 -3
- package/lib/client.js +213 -0
- package/lib/index.js +183 -14
- package/package.json +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,96 @@
|
|
|
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.1
|
|
7
|
+
|
|
8
|
+
Code-review fixes to the 0.2.0 notification surfaces. No config or API changes,
|
|
9
|
+
and detection and reloading are untouched.
|
|
10
|
+
|
|
11
|
+
**Fixes**
|
|
12
|
+
|
|
13
|
+
- **The pop-ups come back on their own after dsh restarts.** A browser tab whose
|
|
14
|
+
message channel closed for good used to stay silent until you reloaded the
|
|
15
|
+
page. This was easy to hit: the web server opens its socket before this plugin
|
|
16
|
+
adds its route, so a tab retrying in that gap got the web app's HTML instead of
|
|
17
|
+
a message channel, which the browser treats as a permanent failure. The tab now
|
|
18
|
+
opens a new channel after 1s, 2s, 4s, 8s, 16s, then every 30s, up to ten tries
|
|
19
|
+
(about three minutes). If all ten fail it says so in the console and stops, so
|
|
20
|
+
a profile that simply has no host half does not knock at the door forever. Each
|
|
21
|
+
successful connect gives the next outage a fresh set of tries.
|
|
22
|
+
- **A broken `ctx.inject` can no longer stop dsh from starting.** Setting up the
|
|
23
|
+
message channel now runs last and inside a `try`. Before, it ran before the
|
|
24
|
+
file watcher and was the one unguarded call on that path: if a future cordis
|
|
25
|
+
changed `ctx.inject`, `apply()` would throw, dsh's start-up check would refuse
|
|
26
|
+
to boot, and the watcher would never have been created — losing reloading over
|
|
27
|
+
a feature that only reports on it.
|
|
28
|
+
- **A `HEAD` request no longer hangs.** `HEAD` was answered with a never-ending
|
|
29
|
+
message stream, but Node throws away a `HEAD` body, so the caller waited until
|
|
30
|
+
its own timeout while its connection sat in the plugin's list of subscribers
|
|
31
|
+
collecting writes nobody would read. Health checks and link pre-fetchers do
|
|
32
|
+
send `HEAD`. It now gets the headers and a clean close.
|
|
33
|
+
- **"No running copy to swap out" is announced once per version, not forever.**
|
|
34
|
+
That case is deliberately left uncommitted so a plugin that was merely still
|
|
35
|
+
starting gets picked up later — which meant every later lockfile write, even
|
|
36
|
+
for a completely unrelated package, showed the same pop-up again with no way to
|
|
37
|
+
dismiss it. The retry is unchanged; only the repeat announcing is dropped.
|
|
38
|
+
|
|
39
|
+
**Docs**
|
|
40
|
+
|
|
41
|
+
- The README told you to watch for the "no running copy" message *repeating* as
|
|
42
|
+
your sign to restart dsh. It no longer repeats, so that advice was pointing at
|
|
43
|
+
a signal that never comes. Both READMEs now say what to watch instead.
|
|
44
|
+
- **New limitation written down: the lockfile is only a trigger.** Version
|
|
45
|
+
numbers come from each package's installed `package.json`. On pnpm 11 the
|
|
46
|
+
files on disk are written before the lockfile, so what this plugin reads has
|
|
47
|
+
settled — but nothing checks that. A future pnpm that wrote the lockfile first
|
|
48
|
+
could make an upgrade be missed silently. This was a known gap; it was only
|
|
49
|
+
ever recorded in the repo's internal notes, and one of those notes had the
|
|
50
|
+
write order backwards.
|
|
51
|
+
|
|
52
|
+
## 0.2.0
|
|
53
|
+
|
|
54
|
+
You can now see reload results without reading the logs. Config did not change:
|
|
55
|
+
`debounce` and `profileDir` are still the only keys. Detection and reloading did
|
|
56
|
+
not change either.
|
|
57
|
+
|
|
58
|
+
**New messages**
|
|
59
|
+
|
|
60
|
+
- **One terminal line for each reload that worked.** dsh never prints its log to
|
|
61
|
+
the terminal, so until now nothing this plugin wrote showed up there. A
|
|
62
|
+
successful reload now also writes one line. This works in every profile.
|
|
63
|
+
- **A short pop-up message in the dsh web app.** You get one when a reload works,
|
|
64
|
+
and one in every case that leaves the old code running: a reload that failed
|
|
65
|
+
and was rolled back, a plugin with no running copy, `dsh.hotReload: false`, and
|
|
66
|
+
missing loader internals. If one upgrade reloads several plugins, the messages
|
|
67
|
+
show one after another.
|
|
68
|
+
- The web part ships as `lib/client.js` (`exports["./client"]`, plus `dsh.client`
|
|
69
|
+
with `platform: "web"`) and attaches to the `shell.overlay` slot. It is written
|
|
70
|
+
by hand in the format the browser loader expects, so this package still has
|
|
71
|
+
**no build step** and **no new dependencies**. `react` and
|
|
72
|
+
`@deepseek-ai/dsh-client-ui-primitives` come from the web app's own modules.
|
|
73
|
+
- Messages travel over a `GET /dsh-hot-reload/events` route added to
|
|
74
|
+
`ctx.webServer`. Nothing is saved: if no browser is connected, the message is
|
|
75
|
+
gone. The log is still the lasting record.
|
|
76
|
+
- One `report()` call now writes the log line, the terminal line, and the browser
|
|
77
|
+
message from a single message string, so those surfaces cannot disagree.
|
|
78
|
+
|
|
79
|
+
**When parts are missing**
|
|
80
|
+
|
|
81
|
+
- The route is added through `ctx.inject(["webServer"], …)`. A top-level `inject`
|
|
82
|
+
would have made the whole plugin wait forever in a profile with no web server,
|
|
83
|
+
because cordis treats every injected name as required — `tui` would have
|
|
84
|
+
stopped reloading anything. A single `ctx.get` call would have been unreliable:
|
|
85
|
+
it only returns a service once that service is fully started, and the web
|
|
86
|
+
server starts later, after it opens its socket. It would also never recover if
|
|
87
|
+
the web server were replaced. `ctx.inject` only makes a small child part wait,
|
|
88
|
+
and it registers the route again each time the web server is replaced.
|
|
89
|
+
- A missing web server, a repeated route, a renamed slot, a browser module that
|
|
90
|
+
no longer loads, or a dsh build without `Toast` each cost you the pop-up only.
|
|
91
|
+
Reloading still works and the web app still starts.
|
|
92
|
+
- If the message channel cannot be reached at all, the browser half says so once
|
|
93
|
+
in the console instead of staying quiet. Otherwise a dead channel looks exactly
|
|
94
|
+
like "no reloads have happened yet".
|
|
95
|
+
|
|
6
96
|
## 0.1.4
|
|
7
97
|
|
|
8
98
|
Two rounds of code-review fixes (engine + CI). No config or API changes.
|
|
@@ -93,11 +183,11 @@ Two rounds of code-review fixes (engine + CI). No config or API changes.
|
|
|
93
183
|
skipped the release). E404 is detected structurally via `--json` rather than
|
|
94
184
|
by grepping npm's error prose.
|
|
95
185
|
- Concurrency is keyed per tag, so distinct releases never share a queue slot
|
|
96
|
-
(a shared group could silently cancel a pending release's run).
|
|
97
|
-
|
|
98
|
-
`latest`
|
|
99
|
-
|
|
100
|
-
|
|
186
|
+
(a shared group could silently cancel a pending release's run). Two releases
|
|
187
|
+
cut within a couple of minutes can therefore publish concurrently, leaving
|
|
188
|
+
the `latest` dist-tag on whichever finished last; releases are cut one at a
|
|
189
|
+
time and the repair is a single `npm dist-tag add`, so this is accepted
|
|
190
|
+
rather than automated.
|
|
101
191
|
|
|
102
192
|
## 0.1.3
|
|
103
193
|
|
package/README.md
CHANGED
|
@@ -36,12 +36,81 @@ Two cases produce no reload, by design:
|
|
|
36
36
|
new code anyway.
|
|
37
37
|
- **A plugin that has no live fiber *yet*** (still importing, or it failed to
|
|
38
38
|
load earlier) is reported as `no live fiber to reload right now` and left
|
|
39
|
-
alone. Nothing was torn down, so this one *is* re-examined on
|
|
40
|
-
lockfile change
|
|
39
|
+
alone. Nothing was torn down, so this one *is* re-examined on every later
|
|
40
|
+
lockfile change, and it reloads by itself as soon as a running copy appears.
|
|
41
|
+
You are told **once per version**, not once per check — so if you still see
|
|
42
|
+
the same version reported after the plugin has had time to start, restart dsh.
|
|
41
43
|
|
|
42
44
|
It **never restarts dsh for you** — restarting is left to you (and your
|
|
43
45
|
supervisor, if any).
|
|
44
46
|
|
|
47
|
+
## How you see what happened
|
|
48
|
+
|
|
49
|
+
The plugin writes every result to dsh's log. But dsh does not print its log to
|
|
50
|
+
your terminal, so those lines are easy to miss. Two extra places show you what
|
|
51
|
+
happened.
|
|
52
|
+
|
|
53
|
+
**1. One line in your terminal, for each reload that worked.** You get this in
|
|
54
|
+
every profile:
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
dsh-hot-reload: hot-reloaded some-plugin@1.2.0 (1 module(s))
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**2. A short pop-up message in the dsh web app.** You get one when a reload
|
|
61
|
+
works. You also get one in every case where the new code did *not* load, so the
|
|
62
|
+
old code is still running:
|
|
63
|
+
|
|
64
|
+
- the reload failed, and the old version was put back
|
|
65
|
+
- the plugin has no running copy to swap out
|
|
66
|
+
- the plugin turned off hot reload with `dsh.hotReload: false`
|
|
67
|
+
- dsh did not provide the internal parts the reload needs
|
|
68
|
+
|
|
69
|
+
The second case is announced **once per version**, not once per check. That case
|
|
70
|
+
is retried on every later lockfile write — including writes for other packages —
|
|
71
|
+
so without this limit you would get the same pop-up over and over, with no way
|
|
72
|
+
to dismiss it. The other three are announced each time they happen.
|
|
73
|
+
|
|
74
|
+
The message slides in, stays a few seconds, then fades out. If one upgrade
|
|
75
|
+
reloads several plugins, the messages line up and show one after another.
|
|
76
|
+
|
|
77
|
+
The web part only loads in a profile that runs a web server. It sends the
|
|
78
|
+
messages over `GET /dsh-hot-reload/events`. A profile with no web server, such
|
|
79
|
+
as `tui`, still gets the terminal line and the log.
|
|
80
|
+
|
|
81
|
+
Messages are not saved. If no browser tab is open when a reload happens, that
|
|
82
|
+
message is gone. The log still has the record.
|
|
83
|
+
|
|
84
|
+
If you restart dsh with a tab open, the tab opens a new channel by itself and
|
|
85
|
+
pop-ups keep working. It tries for about three minutes. If it still cannot
|
|
86
|
+
connect after that, it writes one line to the browser console and stops trying;
|
|
87
|
+
reload the page to start again.
|
|
88
|
+
|
|
89
|
+
### If you want every line in your terminal
|
|
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:
|
|
94
|
+
|
|
95
|
+
```sh
|
|
96
|
+
dsh plugin --profile web add @deepseek-ai/cordis-plugin-logger-console
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Then add a row for it in that profile's `cordis.patch.yml` and restart dsh:
|
|
100
|
+
|
|
101
|
+
```yaml
|
|
102
|
+
- insert:
|
|
103
|
+
- id: logger-console
|
|
104
|
+
name: '@deepseek-ai/cordis-plugin-logger-console'
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
This prints all dsh log lines, not only this plugin's.
|
|
108
|
+
|
|
109
|
+
> **Note for full-screen profiles.** The terminal line is written straight to the
|
|
110
|
+
> screen. In a profile that draws a full-screen interface, such as `tui`, the
|
|
111
|
+
> line can land in the middle of the drawing and make the screen look wrong. It
|
|
112
|
+
> looks wrong only until the screen is drawn again.
|
|
113
|
+
|
|
45
114
|
## Install
|
|
46
115
|
|
|
47
116
|
```sh
|
|
@@ -68,13 +137,32 @@ future dsh that changes any of them may require an update:
|
|
|
68
137
|
|---|---|
|
|
69
138
|
| `loader.internal.loadCache` | invalidating the ESM module cache |
|
|
70
139
|
| `loader.internal.resolve` / `resolveSync` | resolving a specifier to a URL (dispatched on `internal.version`) |
|
|
140
|
+
| `loader.import` / `loader.unwrapExports` | re-importing the fresh module and unwrapping its plugin export |
|
|
71
141
|
| `registry.plugin` / `registry.delete` | swapping the plugin instance |
|
|
72
142
|
| `fiber.entry`, `fiber.runtime` | re-attaching the new plugin to the running rows |
|
|
73
143
|
| `entry.disabled` | skipping disabled rows (inherited getter) |
|
|
74
144
|
| `entry.options.group` | skipping group container rows |
|
|
75
145
|
|
|
76
|
-
|
|
77
|
-
|
|
146
|
+
The pop-up message in the web app (and only that part) also uses:
|
|
147
|
+
|
|
148
|
+
| dsh part | Used for |
|
|
149
|
+
|---|---|
|
|
150
|
+
| `ctx.webServer.register` | serving the message channel |
|
|
151
|
+
| `window.__ModuleLoader__` | loading the browser half |
|
|
152
|
+
| the `shell.overlay` slot | placing the message over the app |
|
|
153
|
+
| `Toast` from `@deepseek-ai/dsh-client-ui-primitives` | drawing it |
|
|
154
|
+
|
|
155
|
+
The plugin fails safe. If a part it needs is missing, it reports "restart needed"
|
|
156
|
+
instead of breaking dsh. The pop-up behaves the same way. A missing web server,
|
|
157
|
+
a browser module it cannot load, an unknown slot, a repeated registration, or a
|
|
158
|
+
dsh build with no `Toast` each cost you the pop-up only. Reloading still works,
|
|
159
|
+
and the web app still starts.
|
|
160
|
+
|
|
161
|
+
One exception: the browser half asks dsh for a service named `slots`. dsh's web
|
|
162
|
+
app refuses to start if any plugin never becomes ready. So if some future dsh
|
|
163
|
+
build had no `slots` service at all, this part would wait forever and show up in
|
|
164
|
+
dsh's start-up error list. Every other failure listed above is caught and simply
|
|
165
|
+
does nothing.
|
|
78
166
|
|
|
79
167
|
## Opting out
|
|
80
168
|
|
|
@@ -117,6 +205,21 @@ live fiber to swap). It does **not** detect *silent* leaks:
|
|
|
117
205
|
[Compatibility](#compatibility). If they are unavailable (no
|
|
118
206
|
`--expose-internals` and no `node-addon-require-builtin` addon), the plugin
|
|
119
207
|
degrades to reporting "restart needed" for every change instead of reloading.
|
|
208
|
+
- The lockfile is only the **trigger**. Version numbers are read from each
|
|
209
|
+
package's installed `package.json`, because that is the only file that says
|
|
210
|
+
what an import would really get. On pnpm 11 (measured on 11.21.0) the files on
|
|
211
|
+
disk are written **first** and the lockfile **last**, so by the time this
|
|
212
|
+
plugin acts, the versions it reads have settled. But nothing here *checks*
|
|
213
|
+
that. If some future pnpm wrote the lockfile first, a check could read the old
|
|
214
|
+
version, skip it, and never look again — the lockfile is the only thing
|
|
215
|
+
watched, so that upgrade would be missed **silently**, with no message, until
|
|
216
|
+
you install another version or restart dsh. The `debounce` setting does not
|
|
217
|
+
help: the gap measured 2.5–4 seconds, far longer than any sane debounce.
|
|
218
|
+
- The message channel (`GET /dsh-hot-reload/events`) has **no password check**,
|
|
219
|
+
the same as dsh's own `/plugins/events`. It sends plugin names and version
|
|
220
|
+
numbers. dsh already shows those through its plugin list, so this adds no new
|
|
221
|
+
secret. But if you bind dsh to `0.0.0.0`, count it as one more address that
|
|
222
|
+
anyone on your network can open.
|
|
120
223
|
|
|
121
224
|
Scope note: this handles **upgrades of already-loaded plugins**. Installing a
|
|
122
225
|
*brand-new* plugin is a separate concern (adding its row to `cordis.patch.yml`,
|
package/README.zh.md
CHANGED
|
@@ -30,11 +30,72 @@ dsh 自带的热重载(`cordis-plugin-hmr`)刻意忽略 `node_modules`,所
|
|
|
30
30
|
- **已禁用(disabled)的插件行会被静默跳过。** 已禁用的插件本就没在运行,没有
|
|
31
31
|
可替换的对象;重新启用时 dsh 自然会加载新代码。
|
|
32
32
|
- **尚未挂上 fiber 的插件**(仍在导入中,或此前加载失败)会被报告为
|
|
33
|
-
`no live fiber to reload right now`
|
|
34
|
-
|
|
33
|
+
`no live fiber to reload right now` 并原样保留。由于什么都没有被拆除,之后
|
|
34
|
+
每次 lockfile 变化都**会**重新检查它;一旦出现正在运行的副本,它会自己完成
|
|
35
|
+
重载。提示是**每个版本只给一次**,不是每次检查都给——所以,如果该插件已有
|
|
36
|
+
足够时间启动,你却仍看到同一个版本被报告,请重启 dsh。
|
|
35
37
|
|
|
36
38
|
它**绝不会替你重启 dsh**——重启交给你(以及你的守护进程,如果有的话)。
|
|
37
39
|
|
|
40
|
+
## 你如何知道发生了什么
|
|
41
|
+
|
|
42
|
+
插件会把每个结果写进 dsh 的日志。但 dsh 不会把日志打印到你的终端,所以这些内容
|
|
43
|
+
很容易被忽略。另有两个地方会告诉你发生了什么。
|
|
44
|
+
|
|
45
|
+
**1. 每次重载成功,在你的终端里输出一行。** 任意 profile 都有:
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
dsh-hot-reload: hot-reloaded some-plugin@1.2.0 (1 module(s))
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
**2. dsh web 应用里的一条短提示。** 重载成功时会出现一条。凡是新代码*没有*加载、
|
|
52
|
+
旧代码仍在运行的情况,也都会出现一条:
|
|
53
|
+
|
|
54
|
+
- 重载失败,已换回旧版本
|
|
55
|
+
- 该插件没有正在运行的副本可供替换
|
|
56
|
+
- 该插件用 `dsh.hotReload: false` 关闭了热重载
|
|
57
|
+
- dsh 没有提供重载所需的内部接口
|
|
58
|
+
|
|
59
|
+
其中第二种情况是**每个版本只提示一次**,而不是每次检查都提示。这种情况在此后
|
|
60
|
+
每次 lockfile 写入时都会重试——包括为别的包发生的写入——所以若不加这个限制,
|
|
61
|
+
同一条提示会反复出现,而且没有办法关掉它。另外三种情况每次发生都会提示。
|
|
62
|
+
|
|
63
|
+
提示会滑入,停留数秒,然后淡出。如果一次升级重载了多个插件,提示会排队逐条显示。
|
|
64
|
+
|
|
65
|
+
web 那一部分只在运行 web 服务器的 profile 中加载,并通过
|
|
66
|
+
`GET /dsh-hot-reload/events` 发送提示。没有 web 服务器的 profile(例如 `tui`)
|
|
67
|
+
仍然有终端那一行和日志。
|
|
68
|
+
|
|
69
|
+
提示不会被保存。如果重载发生时没有打开任何浏览器标签页,那条提示就没有了。
|
|
70
|
+
日志里仍有记录。
|
|
71
|
+
|
|
72
|
+
如果你在标签页开着的时候重启 dsh,标签页会自己重新建立通道,提示继续可用。
|
|
73
|
+
它大约会尝试三分钟。若仍然连不上,就在浏览器控制台写一行提示并停止重试;
|
|
74
|
+
刷新页面即可重新开始。
|
|
75
|
+
|
|
76
|
+
### 如果你想在终端里看到全部内容
|
|
77
|
+
|
|
78
|
+
上面那一行只覆盖成功的重载。若想看到本插件写进日志的全部内容(包括失败),请把
|
|
79
|
+
dsh 的控制台日志插件加进你的 profile。它是一个独立的包:
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
dsh plugin --profile web add @deepseek-ai/cordis-plugin-logger-console
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
然后在该 profile 的 `cordis.patch.yml` 中加入一行,并重启 dsh:
|
|
86
|
+
|
|
87
|
+
```yaml
|
|
88
|
+
- insert:
|
|
89
|
+
- id: logger-console
|
|
90
|
+
name: '@deepseek-ai/cordis-plugin-logger-console'
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
这会打印 dsh 的所有日志,而不只是本插件的。
|
|
94
|
+
|
|
95
|
+
> **全屏界面 profile 的注意事项。** 终端那一行是直接写到屏幕上的。在绘制全屏
|
|
96
|
+
> 界面的 profile(例如 `tui`)中,这一行可能落在画面中间,让屏幕看起来乱掉。
|
|
97
|
+
> 这只会持续到屏幕下一次重绘为止。
|
|
98
|
+
|
|
38
99
|
## 安装
|
|
39
100
|
|
|
40
101
|
```sh
|
|
@@ -60,12 +121,29 @@ dsh plugin --profile web add some-plugin@newer # 自动热重载
|
|
|
60
121
|
|---|---|
|
|
61
122
|
| `loader.internal.loadCache` | 使 ESM 模块缓存失效 |
|
|
62
123
|
| `loader.internal.resolve` / `resolveSync` | 把 specifier 解析为 URL(按 `internal.version` 分派) |
|
|
124
|
+
| `loader.import` / `loader.unwrapExports` | 重新导入新模块,并取出其中的插件导出 |
|
|
63
125
|
| `registry.plugin` / `registry.delete` | 替换插件实例 |
|
|
64
126
|
| `fiber.entry`、`fiber.runtime` | 把新插件重新挂到运行中的行上 |
|
|
65
127
|
| `entry.disabled` | 跳过已禁用的行(继承式 getter) |
|
|
66
128
|
| `entry.options.group` | 跳过 group 容器行 |
|
|
67
129
|
|
|
68
|
-
|
|
130
|
+
web 应用里的提示(且仅这一部分)还用到:
|
|
131
|
+
|
|
132
|
+
| dsh 的部件 | 用途 |
|
|
133
|
+
|---|---|
|
|
134
|
+
| `ctx.webServer.register` | 提供提示通道 |
|
|
135
|
+
| `window.__ModuleLoader__` | 加载浏览器侧那一半 |
|
|
136
|
+
| `shell.overlay` 插槽 | 把提示放到应用之上 |
|
|
137
|
+
| `@deepseek-ai/dsh-client-ui-primitives` 的 `Toast` | 绘制提示 |
|
|
138
|
+
|
|
139
|
+
本插件是失败安全的。若所需部件缺失,它会报告“需要重启”,而不会弄坏 dsh。提示
|
|
140
|
+
也一样:缺少 web 服务器、浏览器模块加载不了、插槽名未知、重复注册、或 dsh 构建
|
|
141
|
+
中没有 `Toast`,代价都只是没有提示。重载照常工作,web 应用也照常启动。
|
|
142
|
+
|
|
143
|
+
有一个例外:浏览器侧那一半会向 dsh 索取名为 `slots` 的服务。只要有任何插件始终
|
|
144
|
+
没有就绪,dsh 的 web 应用就会拒绝启动。所以,假如将来某个 dsh 构建完全没有
|
|
145
|
+
`slots` 服务,这一部分就会一直等待,并出现在 dsh 的启动错误列表里。上面列出的
|
|
146
|
+
其他失败都会被捕获,只是什么都不做。
|
|
69
147
|
|
|
70
148
|
## 退出热重载(opt-out)
|
|
71
149
|
|
|
@@ -102,6 +180,17 @@ dsh plugin --profile web add some-plugin@newer # 自动热重载
|
|
|
102
180
|
- 重载路径依赖[兼容性](#兼容性)一节列出的 cordis/loader 内部接口。若这些内部
|
|
103
181
|
不可用(既无 `--expose-internals`,也无 `node-addon-require-builtin` 原生
|
|
104
182
|
插件),本插件会退化为对每次变化只报告“需要重启”,而不做重载。
|
|
183
|
+
- lockfile 只是**触发器**。版本号是从每个包已安装的 `package.json` 读取的,因为
|
|
184
|
+
只有这个文件才说明一次 import 实际会拿到什么。在 pnpm 11 上(实测 11.21.0),
|
|
185
|
+
磁盘上的文件**先**写、lockfile **最后**写,所以本插件动作时读到的版本已经稳定。
|
|
186
|
+
但插件并不会去*核实*这一点。如果将来某个 pnpm 版本改成先写 lockfile,一次检查
|
|
187
|
+
就可能读到旧版本、跳过它,而且不再回头看——被监听的只有 lockfile,那次升级就会
|
|
188
|
+
被**静默**漏掉,不给任何提示,直到你安装另一个版本或重启 dsh。`debounce` 帮不上
|
|
189
|
+
忙:实测这个间隔有 2.5–4 秒,远超任何合理的 debounce 取值。
|
|
190
|
+
- 提示通道(`GET /dsh-hot-reload/events`)**不做任何密码校验**,与 dsh 自带的
|
|
191
|
+
`/plugins/events` 相同。它发送的是插件名和版本号。dsh 的插件列表本来就会显示
|
|
192
|
+
这些内容,所以并没有多暴露什么秘密。但如果你把 dsh 绑定到 `0.0.0.0`,请把它
|
|
193
|
+
算作局域网里任何人都能打开的又一个地址。
|
|
105
194
|
|
|
106
195
|
范围说明:本插件处理的是**已加载插件的升级**。安装一个**全新**插件是另一回事
|
|
107
196
|
(把它的行加入 `cordis.patch.yml`,这个 dsh 本身已经会热应用)。
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// dsh-hot-reload — web half: raise a transient banner when the host half
|
|
2
|
+
// reloads (or fails to reload) a plugin package.
|
|
3
|
+
//
|
|
4
|
+
// This file is hand-written in the shape a built client bundle takes, because
|
|
5
|
+
// the package deliberately has no build step: a classic script that REGISTERS a
|
|
6
|
+
// factory with the browser module loader, whose body runs at materialization
|
|
7
|
+
// rather than at script execution. Consequences for editing it:
|
|
8
|
+
//
|
|
9
|
+
// - no JSX (React.createElement instead) and no import/export syntax — the
|
|
10
|
+
// factory takes a synchronous `require` and RETURNS its exports;
|
|
11
|
+
// - only the platform seed modules may be required, under their exact keys:
|
|
12
|
+
// react, react/jsx-runtime, react-dom, react-dom/client,
|
|
13
|
+
// @deepseek-ai/cordis, and the @deepseek-ai/dsh-client-{ui-slots,
|
|
14
|
+
// web-react, ui-primitives, ui-attachment, schema-form} set. They come from
|
|
15
|
+
// the web shell's own build, so this half needs no other plugin bundle;
|
|
16
|
+
// - `id` must be the package name: the loader resolves "<id>/client" and the
|
|
17
|
+
// bare id to these same exports.
|
|
18
|
+
//
|
|
19
|
+
// The host half only serves this to browsers (package.json's dsh.client pins
|
|
20
|
+
// platform "web"), and nothing here is required for reloading to work. Every
|
|
21
|
+
// failure path below degrades to "no banner" — but note the shell fails its
|
|
22
|
+
// boot if a plugin entry never activates, so a throw at factory scope would
|
|
23
|
+
// cost the page: that is why the requires are guarded rather than bare.
|
|
24
|
+
|
|
25
|
+
window.__ModuleLoader__.load({
|
|
26
|
+
id: "dsh-hot-reload",
|
|
27
|
+
factory: (require) => {
|
|
28
|
+
// Guarded because a throw here escapes the factory, leaves this entry
|
|
29
|
+
// without a fiber, and the web shell's boot-time sweep turns any entry that
|
|
30
|
+
// did not reach ACTIVE into a thrown boot failure — i.e. an unguarded
|
|
31
|
+
// require miss costs the whole page, not just the banner. Degrade to a
|
|
32
|
+
// no-op plugin instead, so the entry still activates.
|
|
33
|
+
let React = null;
|
|
34
|
+
let primitives = null;
|
|
35
|
+
let seedError = null;
|
|
36
|
+
try {
|
|
37
|
+
React = require("react");
|
|
38
|
+
primitives = require("@deepseek-ai/dsh-client-ui-primitives");
|
|
39
|
+
} catch (error) {
|
|
40
|
+
seedError = error;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Must match EVENTS_ENDPOINT in lib/index.js. The two halves ship as
|
|
44
|
+
* separate bundles with no module in common, so this constant is duplicated
|
|
45
|
+
* rather than shared — change one, change the other. */
|
|
46
|
+
const EVENTS_ENDPOINT = "/dsh-hot-reload/events";
|
|
47
|
+
|
|
48
|
+
/** Root-scoped list slot that the shell frame renders over the whole app,
|
|
49
|
+
* and dsh's documented home for a plugin's own floating surface. Root scope
|
|
50
|
+
* matters here: reloads are triggered from a terminal, so a notice must be
|
|
51
|
+
* able to appear with no conversation open. (The `root` slot itself is
|
|
52
|
+
* single-occupancy — registering there would shadow the entire app frame.) */
|
|
53
|
+
const SLOT = "shell.overlay";
|
|
54
|
+
|
|
55
|
+
/** Waits before each attempt to re-open a channel that closed for good, in
|
|
56
|
+
* ms. The list is the whole retry policy: its values are the schedule and
|
|
57
|
+
* its LENGTH is the budget — run off the end and the tab gives up, so a
|
|
58
|
+
* host half that is simply not there costs ten requests, not a request
|
|
59
|
+
* every few seconds forever. 16s of fast retries covers a dsh restart;
|
|
60
|
+
* the 30s tail covers a slow one. */
|
|
61
|
+
const RETRY_DELAYS_MS = [1000, 2000, 4000, 8000, 16000, 30000, 30000, 30000, 30000, 30000];
|
|
62
|
+
|
|
63
|
+
/** Cordis plugin name. */
|
|
64
|
+
const name = "dsh-hot-reload";
|
|
65
|
+
/** Required services: the slot registry this half contributes its banner to. */
|
|
66
|
+
const inject = ["slots"];
|
|
67
|
+
|
|
68
|
+
/** Leading glyphs, built once: `primitives` is fixed for the life of the
|
|
69
|
+
* factory, so rebuilding these per render would only churn element identity
|
|
70
|
+
* and force the icon span to reconcile. Undefined when this dsh build no
|
|
71
|
+
* longer ships the icon — the banner reads fine without one. */
|
|
72
|
+
const icon = (Icon) => (typeof Icon === "function" ? React.createElement(Icon) : undefined);
|
|
73
|
+
const ICONS = seedError !== null ? {} : {
|
|
74
|
+
reloaded: icon(primitives.IconRefreshOutline16),
|
|
75
|
+
other: icon(primitives.IconWarningOutline16),
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The shell.overlay entry: subscribes to the host's notice channel and shows
|
|
80
|
+
* one banner at a time, oldest first.
|
|
81
|
+
*
|
|
82
|
+
* Notices queue rather than replace: one lockfile write can reload several
|
|
83
|
+
* packages, and showing only the newest would silently drop the rest.
|
|
84
|
+
*
|
|
85
|
+
* @param props.warn - reports a dead channel; supplied by apply() through the
|
|
86
|
+
* wrapper it registers, so nothing about this component is factory-global and
|
|
87
|
+
* a second plugin row cannot repoint the first row's logger.
|
|
88
|
+
*/
|
|
89
|
+
function ReloadNotices({ warn }) {
|
|
90
|
+
const [queue, setQueue] = React.useState([]);
|
|
91
|
+
// Stable identity is load-bearing: Toast restarts its hold-and-fade timer
|
|
92
|
+
// whenever `onDone` changes, so a fresh arrow per render would let a burst
|
|
93
|
+
// of arrivals keep resetting the banner already on screen instead of
|
|
94
|
+
// letting it finish and hand over to the next one.
|
|
95
|
+
const shift = React.useCallback(() => setQueue((q) => q.slice(1)), []);
|
|
96
|
+
|
|
97
|
+
React.useEffect(() => {
|
|
98
|
+
let seq = 0;
|
|
99
|
+
let source = null;
|
|
100
|
+
let timer = null;
|
|
101
|
+
let retries = 0;
|
|
102
|
+
let stopped = false;
|
|
103
|
+
|
|
104
|
+
const connect = () => {
|
|
105
|
+
const es = new EventSource(EVENTS_ENDPOINT);
|
|
106
|
+
source = es;
|
|
107
|
+
// A successful connect gives the next outage a full retry budget
|
|
108
|
+
// again, so the bound below is per outage rather than per tab. It
|
|
109
|
+
// still bounds a host half that is simply absent: with nothing ever
|
|
110
|
+
// answering there is no open event, so the budget never refills.
|
|
111
|
+
es.addEventListener("open", () => {
|
|
112
|
+
retries = 0;
|
|
113
|
+
});
|
|
114
|
+
es.addEventListener("message", (event) => {
|
|
115
|
+
let frame;
|
|
116
|
+
try {
|
|
117
|
+
frame = JSON.parse(event.data);
|
|
118
|
+
} catch {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (frame === null || typeof frame !== "object") return;
|
|
122
|
+
if (frame.type !== "notice" || typeof frame.text !== "string") return;
|
|
123
|
+
seq += 1;
|
|
124
|
+
setQueue((q) => q.concat({ seq, kind: frame.kind, text: frame.text }));
|
|
125
|
+
});
|
|
126
|
+
es.addEventListener("error", () => {
|
|
127
|
+
// A transient drop leaves readyState CONNECTING and EventSource
|
|
128
|
+
// retries it on its own — nothing to do. CLOSED is the fatal case
|
|
129
|
+
// and the one that needs us: with no route registered the request
|
|
130
|
+
// falls through to the SPA fallback and answers 200 text/html,
|
|
131
|
+
// which EventSource treats as a permanent failure. That happens on
|
|
132
|
+
// every dsh restart, because the web server binds its socket before
|
|
133
|
+
// this plugin's route is registered — so without re-arming here, one
|
|
134
|
+
// ill-timed retry would cost the banner for the life of the tab.
|
|
135
|
+
if (stopped || es.readyState !== 2 /* CLOSED */) return;
|
|
136
|
+
es.close();
|
|
137
|
+
const delay = RETRY_DELAYS_MS[retries];
|
|
138
|
+
if (delay === undefined) {
|
|
139
|
+
warn(
|
|
140
|
+
`dsh-hot-reload: notice channel ${EVENTS_ENDPOINT} still unavailable after ` +
|
|
141
|
+
`${RETRY_DELAYS_MS.length} retries — no reload banners until this page is reloaded`
|
|
142
|
+
);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
// Say it once per outage, so "the feature is off" stays
|
|
146
|
+
// distinguishable from "broken", without a warning per retry.
|
|
147
|
+
if (retries === 0) {
|
|
148
|
+
warn(`dsh-hot-reload: notice channel ${EVENTS_ENDPOINT} is unavailable — retrying`);
|
|
149
|
+
}
|
|
150
|
+
retries += 1;
|
|
151
|
+
timer = setTimeout(connect, delay);
|
|
152
|
+
});
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
connect();
|
|
156
|
+
// The host holds no per-tab state, so a reconnect costs nothing and
|
|
157
|
+
// misses only the notices raised while it was down.
|
|
158
|
+
return () => {
|
|
159
|
+
stopped = true;
|
|
160
|
+
if (timer !== null) clearTimeout(timer);
|
|
161
|
+
if (source !== null) source.close();
|
|
162
|
+
};
|
|
163
|
+
}, []);
|
|
164
|
+
|
|
165
|
+
const head = queue[0];
|
|
166
|
+
if (head === undefined) return null;
|
|
167
|
+
// Keyed by arrival sequence so two identical texts in a row remount and
|
|
168
|
+
// replay the slide/hold/fade, instead of reusing an already-faded banner.
|
|
169
|
+
return React.createElement(primitives.Toast, {
|
|
170
|
+
key: head.seq,
|
|
171
|
+
text: `dsh-hot-reload: ${head.text}`,
|
|
172
|
+
icon: head.kind === "reloaded" ? ICONS.reloaded : ICONS.other,
|
|
173
|
+
onDone: shift,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Client plugin body: mount the banner into the shell overlay.
|
|
179
|
+
* @param ctx - client root context.
|
|
180
|
+
*/
|
|
181
|
+
function apply(ctx) {
|
|
182
|
+
if (seedError !== null) {
|
|
183
|
+
ctx.logger?.warn?.("dsh-hot-reload: a platform module is unavailable — reload notices disabled");
|
|
184
|
+
ctx.logger?.warn?.(seedError);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (typeof primitives.Toast !== "function") {
|
|
188
|
+
ctx.logger?.warn?.("dsh-hot-reload: this dsh build ships no Toast primitive — reload notices disabled");
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const warn = (message) => ctx.logger?.warn?.(message);
|
|
192
|
+
// slots.inject waits for the slot to be declared and disposes with this
|
|
193
|
+
// fiber, so an unknown slot name parks quietly instead of throwing.
|
|
194
|
+
ctx.slots.inject(SLOT, () => {
|
|
195
|
+
try {
|
|
196
|
+
return ctx.slots.register({ name: SLOT, id: "dsh-hot-reload.notices", order: 100 }, () =>
|
|
197
|
+
React.createElement(ReloadNotices, { warn })
|
|
198
|
+
);
|
|
199
|
+
} catch (error) {
|
|
200
|
+
// A changed registration contract, or a duplicate id from a second
|
|
201
|
+
// dsh-hot-reload row: lose the notices, never the page.
|
|
202
|
+
ctx.logger?.warn?.("dsh-hot-reload: could not mount reload notices");
|
|
203
|
+
ctx.logger?.warn?.(error);
|
|
204
|
+
return () => {};
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// The loader takes the factory's return value AS the module exports, so the
|
|
210
|
+
// CJS `module.exports` preamble a built bundle carries is not needed here.
|
|
211
|
+
return { apply, inject, name };
|
|
212
|
+
},
|
|
213
|
+
});
|
package/lib/index.js
CHANGED
|
@@ -16,6 +16,11 @@
|
|
|
16
16
|
// Disabled rows are skipped silently (nothing is running to swap); an enabled
|
|
17
17
|
// row with no fiber attached yet is reported and left for a later change.
|
|
18
18
|
//
|
|
19
|
+
// 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().
|
|
23
|
+
//
|
|
19
24
|
// NOTE: the reload path uses cordis/loader internals (loader.internal.loadCache,
|
|
20
25
|
// registry.plugin/delete, fiber.entry) — the same ones HMR uses. If a future
|
|
21
26
|
// cordis changes them, reloads will fail closed to "restart needed", never
|
|
@@ -32,6 +37,12 @@ export const name = "dsh-hot-reload";
|
|
|
32
37
|
const getOuterStack = () => [];
|
|
33
38
|
const cjsRequire = createRequire(import.meta.url);
|
|
34
39
|
|
|
40
|
+
/** SSE channel the web half subscribes to for reload notices. Duplicated
|
|
41
|
+
* verbatim in lib/client.js: the two halves are separate bundles (Node ESM
|
|
42
|
+
* here, a browser classic script there) with no module in common, and this
|
|
43
|
+
* package has no build step to generate a shared one from. */
|
|
44
|
+
const EVENTS_ENDPOINT = "/dsh-hot-reload/events";
|
|
45
|
+
|
|
35
46
|
/** handlePackage outcome: a reload was attempted and failed — never retry it. */
|
|
36
47
|
const TERMINAL = Symbol("dsh-hot-reload:terminal");
|
|
37
48
|
|
|
@@ -69,6 +80,57 @@ export function apply(ctx, config = {}) {
|
|
|
69
80
|
);
|
|
70
81
|
}
|
|
71
82
|
|
|
83
|
+
// ---- notification surfaces ----
|
|
84
|
+
//
|
|
85
|
+
// Both are strictly ADDITIVE to ctx.logger, which stays the record of truth,
|
|
86
|
+
// and neither may throw into a reload cycle: a broken notification must never
|
|
87
|
+
// turn a working reload into a failed one.
|
|
88
|
+
//
|
|
89
|
+
// - stderr, successful reloads only. cordis's logger fans messages out to
|
|
90
|
+
// registered exporters, and the dsh host process registers none (only the
|
|
91
|
+
// 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
|
+
// - an SSE channel the web half subscribes to (lib/client.js) and renders as
|
|
94
|
+
// a transient toast. Registered only when a webServer service exists, so a
|
|
95
|
+
// profile without one — tui — behaves exactly as it does today.
|
|
96
|
+
//
|
|
97
|
+
// Fire and forget: nothing is buffered and no delivery is confirmed. A notice
|
|
98
|
+
// raised while no browser is connected is simply lost. That is deliberate —
|
|
99
|
+
// the logger already holds the durable record, and replaying on connect would
|
|
100
|
+
// need a per-tab cursor to avoid re-announcing old reloads on every reload of
|
|
101
|
+
// the page itself.
|
|
102
|
+
const connections = new Set();
|
|
103
|
+
|
|
104
|
+
/** Announce one cycle outcome on every surface, from ONE message.
|
|
105
|
+
*
|
|
106
|
+
* Call this for outcomes; call `log.*` directly for diagnostics. Writing the
|
|
107
|
+
* message once is the point: an earlier version had each site author a log
|
|
108
|
+
* string and a near-identical notice string, which is the one code path whose
|
|
109
|
+
* whole job is telling the truth about what happened — the two can drift and
|
|
110
|
+
* nothing catches it. Here the terminal and the banner cannot disagree.
|
|
111
|
+
*
|
|
112
|
+
* `kind` is "reloaded" (it worked), "failed" (attempted and rolled back), or
|
|
113
|
+
* "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
|
+
function report(kind, message) {
|
|
117
|
+
if (kind === "reloaded") {
|
|
118
|
+
log.info?.(`dsh-hot-reload: ${message}`);
|
|
119
|
+
try {
|
|
120
|
+
process.stderr.write(`dsh-hot-reload: ${message}\n`);
|
|
121
|
+
} catch {}
|
|
122
|
+
} else {
|
|
123
|
+
log.warn?.(`dsh-hot-reload: ${message}`);
|
|
124
|
+
}
|
|
125
|
+
if (!connections.size) return;
|
|
126
|
+
const line = `data: ${JSON.stringify({ type: "notice", kind, text: message })}\n\n`;
|
|
127
|
+
for (const res of connections) {
|
|
128
|
+
try {
|
|
129
|
+
res.write(line);
|
|
130
|
+
} catch {} // a half-dead socket is the browser's problem, not the reloader's
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
72
134
|
// ---- package <-> loader-entry helpers ----
|
|
73
135
|
|
|
74
136
|
/** Package name backing a loader entry's module specifier, or null for local/builtin. */
|
|
@@ -84,11 +146,20 @@ export function apply(ctx, config = {}) {
|
|
|
84
146
|
}
|
|
85
147
|
|
|
86
148
|
// Versions come from node_modules/<pkg>/package.json, never from the lockfile
|
|
87
|
-
// we watch
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
149
|
+
// we watch. The lockfile records what pnpm RESOLVED; only the installed
|
|
150
|
+
// package.json describes what an import would actually get, and nothing ties
|
|
151
|
+
// the write we observe to the state of the tree on disk.
|
|
152
|
+
//
|
|
153
|
+
// Measured on pnpm 11.21.0 (`add`, `update` and `install`, four runs): the
|
|
154
|
+
// tree is materialized FIRST and the lockfile written LAST, 20-40ms after the
|
|
155
|
+
// final package lands — so a cycle triggered by that write reads settled
|
|
156
|
+
// versions. The design does not DEPEND on that order, but it is exposed to
|
|
157
|
+
// it: a pnpm that wrote the lockfile first would have a cycle read stale
|
|
158
|
+
// versions, `continue` past them, and never re-arm, because nothing but the
|
|
159
|
+
// lockfile is watched — the upgrade would be lost silently and permanently.
|
|
160
|
+
// The debounce is no defense; the materialization window measured 2.5-4s.
|
|
161
|
+
// A settle/confirm pass would close it. Not implemented; see README
|
|
162
|
+
// "Limitations", which states the exposure plainly.
|
|
92
163
|
function readPkgJson(pkg) {
|
|
93
164
|
try {
|
|
94
165
|
return JSON.parse(readFileSync(join(nodeModules, pkg, "package.json"), "utf8"));
|
|
@@ -286,22 +357,33 @@ export function apply(ctx, config = {}) {
|
|
|
286
357
|
const { version, live, fiberless } = rec;
|
|
287
358
|
|
|
288
359
|
if (rec.json?.dsh?.hotReload === false) {
|
|
289
|
-
|
|
360
|
+
report("stale", `${pkg}@${version} sets dsh.hotReload:false — restart dsh to load the new version`);
|
|
290
361
|
return version;
|
|
291
362
|
}
|
|
292
363
|
if (!internal) {
|
|
293
|
-
|
|
364
|
+
report("stale", `${pkg}@${version} changed — restart dsh to load the new version`);
|
|
294
365
|
return version;
|
|
295
366
|
}
|
|
296
367
|
|
|
297
368
|
if (!live.length) {
|
|
298
369
|
if (!fiberless) return version; // only disabled rows — nothing to do, nothing to say
|
|
299
|
-
// Enabled but nothing attached: mid-import or a load failure. Say so
|
|
370
|
+
// Enabled but nothing attached: mid-import or a load failure. Say so,
|
|
300
371
|
// don't commit, and stay retryable — no reload was attempted, so a plugin
|
|
301
372
|
// that was merely still activating picks this up on a later event.
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
373
|
+
//
|
|
374
|
+
// Announce it at most once per version, though. Staying retryable means
|
|
375
|
+
// the version is deliberately NOT committed, so every later lockfile
|
|
376
|
+
// write — including ones for entirely unrelated packages — re-detects the
|
|
377
|
+
// same change and would re-announce it. Tolerable as a log line; as an
|
|
378
|
+
// undismissable banner it would follow the user through routine `pnpm
|
|
379
|
+
// add` work forever. Only the announcing is suppressed; the retry is not.
|
|
380
|
+
if (noticedVersions[pkg] !== version) {
|
|
381
|
+
noticedVersions[pkg] = version;
|
|
382
|
+
report(
|
|
383
|
+
"stale",
|
|
384
|
+
`${pkg}@${version} has no live fiber to reload right now — restart dsh if it stays on the old version`
|
|
385
|
+
);
|
|
386
|
+
}
|
|
305
387
|
return false;
|
|
306
388
|
}
|
|
307
389
|
if (fiberless) {
|
|
@@ -325,12 +407,13 @@ export function apply(ctx, config = {}) {
|
|
|
325
407
|
committed ??= imported;
|
|
326
408
|
}
|
|
327
409
|
committed ??= version;
|
|
328
|
-
|
|
410
|
+
report("reloaded", `hot-reloaded ${pkg}@${committed} (${live.length} module(s))`);
|
|
329
411
|
return committed;
|
|
330
412
|
} catch (err) {
|
|
331
413
|
if (disposed) return false; // aborted by teardown, not a real failure — stay quiet
|
|
332
|
-
|
|
333
|
-
|
|
414
|
+
report(
|
|
415
|
+
"failed",
|
|
416
|
+
`could not hot-reload ${pkg}@${version} — not retrying; restart dsh (or install a different version) to load it`
|
|
334
417
|
);
|
|
335
418
|
log.warn?.(err);
|
|
336
419
|
return TERMINAL; // attempted and failed: never retried for this version
|
|
@@ -347,6 +430,7 @@ export function apply(ctx, config = {}) {
|
|
|
347
430
|
// silently (old code keeps running, no notice). Deliberate, not an oversight.
|
|
348
431
|
let versions = boot ? currentVersions(boot) : null;
|
|
349
432
|
const failedVersions = Object.create(null); // pkg -> version whose reload failed (never retried)
|
|
433
|
+
const noticedVersions = Object.create(null); // pkg -> version already announced on a retryable path
|
|
350
434
|
let timer = null;
|
|
351
435
|
let pending = false; // at most ONE cycle queued behind the running one; bursts coalesce into it
|
|
352
436
|
let disposed = false;
|
|
@@ -390,6 +474,7 @@ export function apply(ctx, config = {}) {
|
|
|
390
474
|
} else if (commit) {
|
|
391
475
|
versions[pkg] = commit; // the version actually imported, not the cycle-start one
|
|
392
476
|
delete failedVersions[pkg];
|
|
477
|
+
delete noticedVersions[pkg];
|
|
393
478
|
}
|
|
394
479
|
// commit === false: not attempted (teardown, or nothing attached yet) —
|
|
395
480
|
// leave it uncommitted and retryable on a later event.
|
|
@@ -402,6 +487,7 @@ export function apply(ctx, config = {}) {
|
|
|
402
487
|
if (!(pkg in snap)) {
|
|
403
488
|
delete versions[pkg];
|
|
404
489
|
delete failedVersions[pkg];
|
|
490
|
+
delete noticedVersions[pkg];
|
|
405
491
|
}
|
|
406
492
|
}
|
|
407
493
|
}
|
|
@@ -441,6 +527,89 @@ export function apply(ctx, config = {}) {
|
|
|
441
527
|
} catch {}
|
|
442
528
|
});
|
|
443
529
|
|
|
530
|
+
// ---- notice channel ----
|
|
531
|
+
//
|
|
532
|
+
// LAST on purpose, and wrapped. Everything above is the reloader; everything
|
|
533
|
+
// here is an optional extra surface for talking about it. Registered earlier,
|
|
534
|
+
// any throw from this block would abort apply() before the watcher exists —
|
|
535
|
+
// so a change in a cordis API this plugin only uses for NOTICES would cost the
|
|
536
|
+
// reloading too, and dsh's boot sweep turns a FAILED entry into a refusal to
|
|
537
|
+
// start at all. Ordered and guarded, the worst case is "no banners".
|
|
538
|
+
//
|
|
539
|
+
// ctx.inject, NOT a module-level `export const inject`, and NOT a one-shot
|
|
540
|
+
// ctx.get. The distinction matters three ways:
|
|
541
|
+
//
|
|
542
|
+
// - a module-level inject is REQUIRED (Inject.resolve maps every declared
|
|
543
|
+
// name to a wait), so it would park the whole plugin forever in a profile
|
|
544
|
+
// that has no web server — tui would stop reloading anything at all;
|
|
545
|
+
// - ctx.inject parks only this CHILD fiber, leaving the reloader running;
|
|
546
|
+
// - ctx.get would be both racy and one-shot. It resolves strictly, returning
|
|
547
|
+
// undefined unless the providing fiber is already ACTIVE, and WebServer
|
|
548
|
+
// only becomes active after its async listen() binds — while loader entries
|
|
549
|
+
// start concurrently, so whether we win that race is chance. Being a single
|
|
550
|
+
// read, it also never recovers: a web server that reloads (port change, a
|
|
551
|
+
// dsh HMR cycle) comes back with an empty route table and nothing would
|
|
552
|
+
// re-register. ctx.inject re-runs this body on exactly that event.
|
|
553
|
+
try {
|
|
554
|
+
ctx.inject(["webServer"], (webCtx) => {
|
|
555
|
+
// Acquire and release in one effect, as dsh's own client-hmr channel does:
|
|
556
|
+
// the disposer drops the route and every open stream when this child fiber
|
|
557
|
+
// unloads — on shutdown, and before the body re-runs for a replaced server.
|
|
558
|
+
webCtx.effect(() => {
|
|
559
|
+
let disposeRoute;
|
|
560
|
+
try {
|
|
561
|
+
disposeRoute = webCtx.webServer.register({
|
|
562
|
+
kind: "exact",
|
|
563
|
+
path: EVENTS_ENDPOINT,
|
|
564
|
+
handler: (req, res) => {
|
|
565
|
+
if (req.method === "HEAD") {
|
|
566
|
+
// Node discards a HEAD response body, so the usual stream would
|
|
567
|
+
// never reach the caller: it would block until its own timeout
|
|
568
|
+
// while its socket sat in `connections` collecting writes nobody
|
|
569
|
+
// reads. Health checks and link prefetchers do send HEAD.
|
|
570
|
+
// Answer what the headers would be, then close.
|
|
571
|
+
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
|
|
572
|
+
res.end();
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
if (req.method !== "GET") {
|
|
576
|
+
res.writeHead(405);
|
|
577
|
+
res.end();
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
res.writeHead(200, {
|
|
581
|
+
"content-type": "text/event-stream",
|
|
582
|
+
"cache-control": "no-cache",
|
|
583
|
+
connection: "keep-alive",
|
|
584
|
+
});
|
|
585
|
+
res.write(": connected\n\n");
|
|
586
|
+
connections.add(res);
|
|
587
|
+
res.on("close", () => connections.delete(res));
|
|
588
|
+
},
|
|
589
|
+
});
|
|
590
|
+
} catch (err) {
|
|
591
|
+
// Duplicate path (a second dsh-hot-reload row) or a webserver API change.
|
|
592
|
+
// The notices are optional; the reloader is not — degrade, never throw.
|
|
593
|
+
log.warn?.("dsh-hot-reload: could not register the notice channel; web notices are disabled");
|
|
594
|
+
log.warn?.(err);
|
|
595
|
+
return () => {};
|
|
596
|
+
}
|
|
597
|
+
return () => {
|
|
598
|
+
disposeRoute();
|
|
599
|
+
for (const res of connections) {
|
|
600
|
+
try {
|
|
601
|
+
res.destroy();
|
|
602
|
+
} catch {}
|
|
603
|
+
}
|
|
604
|
+
connections.clear();
|
|
605
|
+
};
|
|
606
|
+
}, "dsh-hot-reload: notice channel");
|
|
607
|
+
});
|
|
608
|
+
} catch (err) {
|
|
609
|
+
log.warn?.("dsh-hot-reload: could not set up the notice channel; web notices are disabled");
|
|
610
|
+
log.warn?.(err);
|
|
611
|
+
}
|
|
612
|
+
|
|
444
613
|
log.info?.(
|
|
445
614
|
`dsh-hot-reload: watching ${lockfile} (${Object.keys(versions ?? {}).length} plugin package(s) tracked)`
|
|
446
615
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-hot-reload",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
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": [
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"main": "lib/index.js",
|
|
30
30
|
"exports": {
|
|
31
31
|
".": "./lib/index.js",
|
|
32
|
+
"./client": "./lib/client.js",
|
|
32
33
|
"./package.json": "./package.json"
|
|
33
34
|
},
|
|
34
35
|
"files": [
|
|
@@ -42,6 +43,9 @@
|
|
|
42
43
|
"dsh": {
|
|
43
44
|
"bundle": {
|
|
44
45
|
"patch": "./cordis.patch.yml"
|
|
46
|
+
},
|
|
47
|
+
"client": {
|
|
48
|
+
"platform": "web"
|
|
45
49
|
}
|
|
46
50
|
},
|
|
47
51
|
"dependencies": {
|