dsh-hot-reload 0.1.3 → 0.2.0
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 +140 -0
- package/README.md +113 -11
- package/README.zh.md +101 -12
- package/cordis.patch.yml +5 -6
- package/lib/client.js +167 -0
- package/lib/index.js +345 -69
- package/package.json +6 -2
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,146 @@
|
|
|
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.0
|
|
7
|
+
|
|
8
|
+
You can now see reload results without reading the logs. Config did not change:
|
|
9
|
+
`debounce` and `profileDir` are still the only keys. Detection and reloading did
|
|
10
|
+
not change either.
|
|
11
|
+
|
|
12
|
+
**New messages**
|
|
13
|
+
|
|
14
|
+
- **One terminal line for each reload that worked.** dsh never prints its log to
|
|
15
|
+
the terminal, so until now nothing this plugin wrote showed up there. A
|
|
16
|
+
successful reload now also writes one line. This works in every profile.
|
|
17
|
+
- **A short pop-up message in the dsh web app.** You get one when a reload works,
|
|
18
|
+
and one in every case that leaves the old code running: a reload that failed
|
|
19
|
+
and was rolled back, a plugin with no running copy, `dsh.hotReload: false`, and
|
|
20
|
+
missing loader internals. If one upgrade reloads several plugins, the messages
|
|
21
|
+
show one after another.
|
|
22
|
+
- The web part ships as `lib/client.js` (`exports["./client"]`, plus `dsh.client`
|
|
23
|
+
with `platform: "web"`) and attaches to the `shell.overlay` slot. It is written
|
|
24
|
+
by hand in the format the browser loader expects, so this package still has
|
|
25
|
+
**no build step** and **no new dependencies**. `react` and
|
|
26
|
+
`@deepseek-ai/dsh-client-ui-primitives` come from the web app's own modules.
|
|
27
|
+
- Messages travel over a `GET /dsh-hot-reload/events` route added to
|
|
28
|
+
`ctx.webServer`. Nothing is saved: if no browser is connected, the message is
|
|
29
|
+
gone. The log is still the lasting record.
|
|
30
|
+
- One `report()` call now writes the log line, the terminal line, and the browser
|
|
31
|
+
message from a single message string, so those surfaces cannot disagree.
|
|
32
|
+
|
|
33
|
+
**When parts are missing**
|
|
34
|
+
|
|
35
|
+
- The route is added through `ctx.inject(["webServer"], …)`. A top-level `inject`
|
|
36
|
+
would have made the whole plugin wait forever in a profile with no web server,
|
|
37
|
+
because cordis treats every injected name as required — `tui` would have
|
|
38
|
+
stopped reloading anything. A single `ctx.get` call would have been unreliable:
|
|
39
|
+
it only returns a service once that service is fully started, and the web
|
|
40
|
+
server starts later, after it opens its socket. It would also never recover if
|
|
41
|
+
the web server were replaced. `ctx.inject` only makes a small child part wait,
|
|
42
|
+
and it registers the route again each time the web server is replaced.
|
|
43
|
+
- A missing web server, a repeated route, a renamed slot, a browser module that
|
|
44
|
+
no longer loads, or a dsh build without `Toast` each cost you the pop-up only.
|
|
45
|
+
Reloading still works and the web app still starts.
|
|
46
|
+
- If the message channel cannot be reached at all, the browser half says so once
|
|
47
|
+
in the console instead of staying quiet. Otherwise a dead channel looks exactly
|
|
48
|
+
like "no reloads have happened yet".
|
|
49
|
+
|
|
50
|
+
## 0.1.4
|
|
51
|
+
|
|
52
|
+
Two rounds of code-review fixes (engine + CI). No config or API changes.
|
|
53
|
+
|
|
54
|
+
**Correct detection**
|
|
55
|
+
|
|
56
|
+
- **One consistent view per cycle**: each cycle now enumerates loader entries
|
|
57
|
+
once and reads each `package.json` once (the sole exception being a deliberate
|
|
58
|
+
re-read at import time, which is what makes the committed version truthful),
|
|
59
|
+
and every decision uses that view. A
|
|
60
|
+
transient loader failure mid-cycle could previously make a detected upgrade
|
|
61
|
+
look like "not a loaded plugin", committing it as loaded while the old code
|
|
62
|
+
kept running — silently, forever.
|
|
63
|
+
- **De-duplicate reloads by runtime, not specifier string** — aliased specifiers
|
|
64
|
+
(`pkg` vs `pkg/index.js`) resolving to one runtime no longer double-apply, and
|
|
65
|
+
one specifier mounted under two loader trees (two runtimes) now reloads both.
|
|
66
|
+
- **Commit the version actually imported** — captured at import time inside the
|
|
67
|
+
reload, never re-read afterwards. A version that lands while a slow `apply()`
|
|
68
|
+
is still activating is therefore *not* recorded as loaded; it stays visible as
|
|
69
|
+
a change and is picked up on the next cycle, so the running code converges on
|
|
70
|
+
the newest version instead of silently stalling on an older one.
|
|
71
|
+
- **Track by loader membership, not filesystem probes**: a package is dropped
|
|
72
|
+
when no loader entry is backed by it, not when a directory check fails. A
|
|
73
|
+
dangling pnpm symlink mid-swap can no longer evict a live plugin, and a
|
|
74
|
+
removed plugin row no longer stays tracked forever. A momentarily unreadable
|
|
75
|
+
`package.json` leaves the package tracked at its old version.
|
|
76
|
+
- **Newly loaded rows are adopted, not reloaded** — dsh already loaded them.
|
|
77
|
+
A package whose `package.json` was unreadable at boot is tracked as
|
|
78
|
+
version-unknown rather than untracked, so its first readable version is
|
|
79
|
+
loaded instead of being mistaken for a fresh row and adopted silently.
|
|
80
|
+
- **Disabled plugin rows are ignored**, using cordis's inherited `entry.disabled`
|
|
81
|
+
getter (an ancestor entry can disable a row, and the raw option may be a
|
|
82
|
+
`!!js` expression). Upgrading a disabled plugin no longer produces a spurious
|
|
83
|
+
"restart dsh" warning for something that isn't running. Group rows are skipped
|
|
84
|
+
too — they are containers, not plugin packages.
|
|
85
|
+
- Degraded snapshots (`loader.entries()` throwing) leave state untouched; the
|
|
86
|
+
next event retries. If the loader is degraded at boot, the first successful
|
|
87
|
+
snapshot is simply adopted as the tracked state.
|
|
88
|
+
|
|
89
|
+
**Honest reporting**
|
|
90
|
+
|
|
91
|
+
- **A failed reload is never retried automatically.** Each attempt tears down
|
|
92
|
+
the working rolled-back plugin, and unrelated lockfile writes used to
|
|
93
|
+
re-trigger it indefinitely. One clear message says what to do (install a
|
|
94
|
+
different version, or restart dsh); later cycles stay quiet.
|
|
95
|
+
- **No more false successes**: a changed package with no live fiber to reload
|
|
96
|
+
now warns and is *not* committed, instead of logging "hot-reloaded (0
|
|
97
|
+
module(s))". A package with a mix of live and fiberless entries reloads the
|
|
98
|
+
live ones and reports how many were skipped. An enabled row that simply has
|
|
99
|
+
no fiber *yet* (still importing) stays retryable — only a reload that was
|
|
100
|
+
attempted and failed is terminal.
|
|
101
|
+
- **Explicit `profileDir` config always wins** — it is no longer silently
|
|
102
|
+
overridden by the auto-detected dir when its lockfile is missing.
|
|
103
|
+
|
|
104
|
+
**Shutdown**
|
|
105
|
+
|
|
106
|
+
- The disposer **never waits** on an in-flight reload, so dsh's shutdown can't
|
|
107
|
+
hang on an arbitrary plugin's `apply()`. A reload caught mid-activation by
|
|
108
|
+
teardown skips its rollback rather than re-registering fibers into a context
|
|
109
|
+
that is already tearing down — and if that activation *succeeds* after
|
|
110
|
+
teardown, the new plugin is dropped rather than left running (with its timers
|
|
111
|
+
and sockets live) past shutdown.
|
|
112
|
+
- Lockfile churn during a long reload queues at most **one** follow-up cycle
|
|
113
|
+
instead of one per debounce window.
|
|
114
|
+
|
|
115
|
+
**Docs**
|
|
116
|
+
|
|
117
|
+
- `cordis.patch.yml` no longer advertises a **`reloadable` config key that was
|
|
118
|
+
never implemented** — a leftover from an abandoned opt-in design. The only
|
|
119
|
+
config keys are `debounce` and `profileDir`.
|
|
120
|
+
- Corrected the "safe plugins are reloaded, unsafe ones are flagged" framing in
|
|
121
|
+
the package description and bundle patch: nothing judges a plugin's safety.
|
|
122
|
+
Every upgrade is attempted optimistically, a throw rolls back, and
|
|
123
|
+
`dsh.hotReload: false` is the only opt-out.
|
|
124
|
+
- Both READMEs now document the disabled-row and not-yet-attached cases, and
|
|
125
|
+
list **every** cordis/loader internal the reload path depends on (previously
|
|
126
|
+
only three of six), which is what the Compatibility section is for.
|
|
127
|
+
|
|
128
|
+
**CI**
|
|
129
|
+
|
|
130
|
+
- Releases are now cut **only by pushing a `v*` tag**; pushes to `main` no
|
|
131
|
+
longer publish. One tag = one run = one version, which removes the E403 race
|
|
132
|
+
between the push- and tag-triggered runs of the same version.
|
|
133
|
+
- The run **fails loudly** if the tag disagrees with `package.json`'s version,
|
|
134
|
+
if the version is a **prerelease** (this project publishes stable versions
|
|
135
|
+
only — an unflagged prerelease would land on the `latest` dist-tag), or if
|
|
136
|
+
`npm view` fails for a non-404 reason (previously a green run that silently
|
|
137
|
+
skipped the release). E404 is detected structurally via `--json` rather than
|
|
138
|
+
by grepping npm's error prose.
|
|
139
|
+
- Concurrency is keyed per tag, so distinct releases never share a queue slot
|
|
140
|
+
(a shared group could silently cancel a pending release's run). Two releases
|
|
141
|
+
cut within a couple of minutes can therefore publish concurrently, leaving
|
|
142
|
+
the `latest` dist-tag on whichever finished last; releases are cut one at a
|
|
143
|
+
time and the repair is a single `npm dist-tag add`, so this is accepted
|
|
144
|
+
rather than automated.
|
|
145
|
+
|
|
6
146
|
## 0.1.3
|
|
7
147
|
|
|
8
148
|
Code-review fixes (engine + CI):
|
package/README.md
CHANGED
|
@@ -25,9 +25,80 @@ On a plugin package upgrade, for each affected plugin:
|
|
|
25
25
|
- a failure while *initializing* it (the new `apply` throws, sync **or** async)
|
|
26
26
|
is rolled back — the old version is re-instantiated in place.
|
|
27
27
|
|
|
28
|
+
A version that failed is **not retried automatically** — retrying would tear
|
|
29
|
+
down the working plugin again on every later lockfile write. Install a
|
|
30
|
+
different version, or restart dsh, to pick the new code up.
|
|
31
|
+
|
|
32
|
+
Two cases produce no reload, by design:
|
|
33
|
+
|
|
34
|
+
- **Disabled plugin rows are skipped silently.** A disabled plugin isn't
|
|
35
|
+
running, so there is nothing to swap — and re-enabling it makes dsh load the
|
|
36
|
+
new code anyway.
|
|
37
|
+
- **A plugin that has no live fiber *yet*** (still importing, or it failed to
|
|
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 the next
|
|
40
|
+
lockfile change — if it keeps repeating, restart dsh.
|
|
41
|
+
|
|
28
42
|
It **never restarts dsh for you** — restarting is left to you (and your
|
|
29
43
|
supervisor, if any).
|
|
30
44
|
|
|
45
|
+
## How you see what happened
|
|
46
|
+
|
|
47
|
+
The plugin writes every result to dsh's log. But dsh does not print its log to
|
|
48
|
+
your terminal, so those lines are easy to miss. Two extra places show you what
|
|
49
|
+
happened.
|
|
50
|
+
|
|
51
|
+
**1. One line in your terminal, for each reload that worked.** You get this in
|
|
52
|
+
every profile:
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
dsh-hot-reload: hot-reloaded some-plugin@1.2.0 (1 module(s))
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
**2. A short pop-up message in the dsh web app.** You get one when a reload
|
|
59
|
+
works. You also get one in every case where the new code did *not* load, so the
|
|
60
|
+
old code is still running:
|
|
61
|
+
|
|
62
|
+
- the reload failed, and the old version was put back
|
|
63
|
+
- the plugin has no running copy to swap out
|
|
64
|
+
- the plugin turned off hot reload with `dsh.hotReload: false`
|
|
65
|
+
- dsh did not provide the internal parts the reload needs
|
|
66
|
+
|
|
67
|
+
The message slides in, stays a few seconds, then fades out. If one upgrade
|
|
68
|
+
reloads several plugins, the messages line up and show one after another.
|
|
69
|
+
|
|
70
|
+
The web part only loads in a profile that runs a web server. It sends the
|
|
71
|
+
messages over `GET /dsh-hot-reload/events`. A profile with no web server, such
|
|
72
|
+
as `tui`, still gets the terminal line and the log.
|
|
73
|
+
|
|
74
|
+
Messages are not saved. If no browser tab is open when a reload happens, that
|
|
75
|
+
message is gone. The log still has the record.
|
|
76
|
+
|
|
77
|
+
### If you want every line in your terminal
|
|
78
|
+
|
|
79
|
+
The terminal line above only covers reloads that worked. To see everything this
|
|
80
|
+
plugin writes to the log, including failures, add dsh's console logger to your
|
|
81
|
+
profile. It is a separate package:
|
|
82
|
+
|
|
83
|
+
```sh
|
|
84
|
+
dsh plugin --profile web add @deepseek-ai/cordis-plugin-logger-console
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Then add a row for it in that profile's `cordis.patch.yml` and restart dsh:
|
|
88
|
+
|
|
89
|
+
```yaml
|
|
90
|
+
- insert:
|
|
91
|
+
- id: logger-console
|
|
92
|
+
name: '@deepseek-ai/cordis-plugin-logger-console'
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
This prints all dsh log lines, not only this plugin's.
|
|
96
|
+
|
|
97
|
+
> **Note for full-screen profiles.** The terminal line is written straight to the
|
|
98
|
+
> screen. In a profile that draws a full-screen interface, such as `tui`, the
|
|
99
|
+
> line can land in the middle of the drawing and make the screen look wrong. It
|
|
100
|
+
> looks wrong only until the screen is drawn again.
|
|
101
|
+
|
|
31
102
|
## Install
|
|
32
103
|
|
|
33
104
|
```sh
|
|
@@ -47,11 +118,38 @@ the profile it's loaded into.
|
|
|
47
118
|
## Compatibility
|
|
48
119
|
|
|
49
120
|
Built and tested against **dsh `0.1.0-rc.6`** (Node 22 / 24). It reaches into
|
|
50
|
-
cordis/loader internals
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
121
|
+
cordis/loader internals — mostly the same ones `cordis-plugin-hmr` uses — so a
|
|
122
|
+
future dsh that changes any of them may require an update:
|
|
123
|
+
|
|
124
|
+
| Internal | Used for |
|
|
125
|
+
|---|---|
|
|
126
|
+
| `loader.internal.loadCache` | invalidating the ESM module cache |
|
|
127
|
+
| `loader.internal.resolve` / `resolveSync` | resolving a specifier to a URL (dispatched on `internal.version`) |
|
|
128
|
+
| `registry.plugin` / `registry.delete` | swapping the plugin instance |
|
|
129
|
+
| `fiber.entry`, `fiber.runtime` | re-attaching the new plugin to the running rows |
|
|
130
|
+
| `entry.disabled` | skipping disabled rows (inherited getter) |
|
|
131
|
+
| `entry.options.group` | skipping group container rows |
|
|
132
|
+
|
|
133
|
+
The pop-up message in the web app (and only that part) also uses:
|
|
134
|
+
|
|
135
|
+
| dsh part | Used for |
|
|
136
|
+
|---|---|
|
|
137
|
+
| `ctx.webServer.register` | serving the message channel |
|
|
138
|
+
| `window.__ModuleLoader__` | loading the browser half |
|
|
139
|
+
| the `shell.overlay` slot | placing the message over the app |
|
|
140
|
+
| `Toast` from `@deepseek-ai/dsh-client-ui-primitives` | drawing it |
|
|
141
|
+
|
|
142
|
+
The plugin fails safe. If a part it needs is missing, it reports "restart needed"
|
|
143
|
+
instead of breaking dsh. The pop-up behaves the same way. A missing web server,
|
|
144
|
+
a browser module it cannot load, an unknown slot, a repeated registration, or a
|
|
145
|
+
dsh build with no `Toast` each cost you the pop-up only. Reloading still works,
|
|
146
|
+
and the web app still starts.
|
|
147
|
+
|
|
148
|
+
One exception: the browser half asks dsh for a service named `slots`. dsh's web
|
|
149
|
+
app refuses to start if any plugin never becomes ready. So if some future dsh
|
|
150
|
+
build had no `slots` service at all, this part would wait forever and show up in
|
|
151
|
+
dsh's start-up error list. Every other failure listed above is caught and simply
|
|
152
|
+
does nothing.
|
|
55
153
|
|
|
56
154
|
## Opting out
|
|
57
155
|
|
|
@@ -73,9 +171,9 @@ Set on the `hot-reload` row in your profile's `cordis.patch.yml`:
|
|
|
73
171
|
|
|
74
172
|
## Limitations — read this
|
|
75
173
|
|
|
76
|
-
This plugin is **optimistic**, not verified. It attempts the reload and
|
|
77
|
-
|
|
78
|
-
*silent* leaks:
|
|
174
|
+
This plugin is **optimistic**, not verified. It attempts the reload and falls
|
|
175
|
+
back to "restart needed" only when something **throws** (or when there is no
|
|
176
|
+
live fiber to swap). It does **not** detect *silent* leaks:
|
|
79
177
|
|
|
80
178
|
- A plugin that acquires a **raw resource outside cordis** — a bare
|
|
81
179
|
`setInterval`, a `net`/`http` server, a `WebSocketServer`, an `fs.watch`,
|
|
@@ -90,11 +188,15 @@ falls back to "restart needed" on a **thrown** error. It does **not** detect
|
|
|
90
188
|
- Reloading a plugin that holds **live connections** (e.g. a WebSocket bridge)
|
|
91
189
|
drops and re-establishes them; clients must reconnect. That's expected, not an
|
|
92
190
|
error.
|
|
93
|
-
- The reload path relies on cordis/loader internals
|
|
94
|
-
(
|
|
95
|
-
same ones `cordis-plugin-hmr` uses. If those internals are unavailable (no
|
|
191
|
+
- The reload path relies on the cordis/loader internals listed under
|
|
192
|
+
[Compatibility](#compatibility). If they are unavailable (no
|
|
96
193
|
`--expose-internals` and no `node-addon-require-builtin` addon), the plugin
|
|
97
194
|
degrades to reporting "restart needed" for every change instead of reloading.
|
|
195
|
+
- The message channel (`GET /dsh-hot-reload/events`) has **no password check**,
|
|
196
|
+
the same as dsh's own `/plugins/events`. It sends plugin names and version
|
|
197
|
+
numbers. dsh already shows those through its plugin list, so this adds no new
|
|
198
|
+
secret. But if you bind dsh to `0.0.0.0`, count it as one more address that
|
|
199
|
+
anyone on your network can open.
|
|
98
200
|
|
|
99
201
|
Scope note: this handles **upgrades of already-loaded plugins**. Installing a
|
|
100
202
|
*brand-new* plugin is a separate concern (adding its row to `cordis.patch.yml`,
|
package/README.zh.md
CHANGED
|
@@ -22,8 +22,70 @@ dsh 自带的热重载(`cordis-plugin-hmr`)刻意忽略 `node_modules`,所
|
|
|
22
22
|
- 新代码在**初始化**阶段失败(新的 `apply` 抛错,**同步或异步**)会被回滚——
|
|
23
23
|
旧版本就地重新实例化。
|
|
24
24
|
|
|
25
|
+
失败的版本**不会自动重试**——重试会在此后每次 lockfile 写入时再次拆除正在
|
|
26
|
+
正常工作的插件。请安装另一个版本,或重启 dsh,以加载新代码。
|
|
27
|
+
|
|
28
|
+
有两种情况按设计不做重载:
|
|
29
|
+
|
|
30
|
+
- **已禁用(disabled)的插件行会被静默跳过。** 已禁用的插件本就没在运行,没有
|
|
31
|
+
可替换的对象;重新启用时 dsh 自然会加载新代码。
|
|
32
|
+
- **尚未挂上 fiber 的插件**(仍在导入中,或此前加载失败)会被报告为
|
|
33
|
+
`no live fiber to reload right now` 并原样保留。由于什么都没有被拆除,这种
|
|
34
|
+
情况**会**在下次 lockfile 变化时重新检查;若反复出现,请重启 dsh。
|
|
35
|
+
|
|
25
36
|
它**绝不会替你重启 dsh**——重启交给你(以及你的守护进程,如果有的话)。
|
|
26
37
|
|
|
38
|
+
## 你如何知道发生了什么
|
|
39
|
+
|
|
40
|
+
插件会把每个结果写进 dsh 的日志。但 dsh 不会把日志打印到你的终端,所以这些内容
|
|
41
|
+
很容易被忽略。另有两个地方会告诉你发生了什么。
|
|
42
|
+
|
|
43
|
+
**1. 每次重载成功,在你的终端里输出一行。** 任意 profile 都有:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
dsh-hot-reload: hot-reloaded some-plugin@1.2.0 (1 module(s))
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**2. dsh web 应用里的一条短提示。** 重载成功时会出现一条。凡是新代码*没有*加载、
|
|
50
|
+
旧代码仍在运行的情况,也都会出现一条:
|
|
51
|
+
|
|
52
|
+
- 重载失败,已换回旧版本
|
|
53
|
+
- 该插件没有正在运行的副本可供替换
|
|
54
|
+
- 该插件用 `dsh.hotReload: false` 关闭了热重载
|
|
55
|
+
- dsh 没有提供重载所需的内部接口
|
|
56
|
+
|
|
57
|
+
提示会滑入,停留数秒,然后淡出。如果一次升级重载了多个插件,提示会排队逐条显示。
|
|
58
|
+
|
|
59
|
+
web 那一部分只在运行 web 服务器的 profile 中加载,并通过
|
|
60
|
+
`GET /dsh-hot-reload/events` 发送提示。没有 web 服务器的 profile(例如 `tui`)
|
|
61
|
+
仍然有终端那一行和日志。
|
|
62
|
+
|
|
63
|
+
提示不会被保存。如果重载发生时没有打开任何浏览器标签页,那条提示就没有了。
|
|
64
|
+
日志里仍有记录。
|
|
65
|
+
|
|
66
|
+
### 如果你想在终端里看到全部内容
|
|
67
|
+
|
|
68
|
+
上面那一行只覆盖成功的重载。若想看到本插件写进日志的全部内容(包括失败),请把
|
|
69
|
+
dsh 的控制台日志插件加进你的 profile。它是一个独立的包:
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
dsh plugin --profile web add @deepseek-ai/cordis-plugin-logger-console
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
然后在该 profile 的 `cordis.patch.yml` 中加入一行,并重启 dsh:
|
|
76
|
+
|
|
77
|
+
```yaml
|
|
78
|
+
- insert:
|
|
79
|
+
- id: logger-console
|
|
80
|
+
name: '@deepseek-ai/cordis-plugin-logger-console'
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
这会打印 dsh 的所有日志,而不只是本插件的。
|
|
84
|
+
|
|
85
|
+
> **全屏界面 profile 的注意事项。** 终端那一行是直接写到屏幕上的。在绘制全屏
|
|
86
|
+
> 界面的 profile(例如 `tui`)中,这一行可能落在画面中间,让屏幕看起来乱掉。
|
|
87
|
+
> 这只会持续到屏幕下一次重绘为止。
|
|
88
|
+
|
|
27
89
|
## 安装
|
|
28
90
|
|
|
29
91
|
```sh
|
|
@@ -41,11 +103,36 @@ dsh plugin --profile web add some-plugin@newer # 自动热重载
|
|
|
41
103
|
|
|
42
104
|
## 兼容性
|
|
43
105
|
|
|
44
|
-
基于并测试于 **dsh `0.1.0-rc.6`**(Node 22 / 24
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
106
|
+
基于并测试于 **dsh `0.1.0-rc.6`**(Node 22 / 24)。它会用到 cordis/loader 的内部
|
|
107
|
+
接口——大多与 `cordis-plugin-hmr` 相同——因此未来若某个 dsh 版本改动了其中任何
|
|
108
|
+
一项,可能需要更新本插件:
|
|
109
|
+
|
|
110
|
+
| 内部接口 | 用途 |
|
|
111
|
+
|---|---|
|
|
112
|
+
| `loader.internal.loadCache` | 使 ESM 模块缓存失效 |
|
|
113
|
+
| `loader.internal.resolve` / `resolveSync` | 把 specifier 解析为 URL(按 `internal.version` 分派) |
|
|
114
|
+
| `registry.plugin` / `registry.delete` | 替换插件实例 |
|
|
115
|
+
| `fiber.entry`、`fiber.runtime` | 把新插件重新挂到运行中的行上 |
|
|
116
|
+
| `entry.disabled` | 跳过已禁用的行(继承式 getter) |
|
|
117
|
+
| `entry.options.group` | 跳过 group 容器行 |
|
|
118
|
+
|
|
119
|
+
web 应用里的提示(且仅这一部分)还用到:
|
|
120
|
+
|
|
121
|
+
| dsh 的部件 | 用途 |
|
|
122
|
+
|---|---|
|
|
123
|
+
| `ctx.webServer.register` | 提供提示通道 |
|
|
124
|
+
| `window.__ModuleLoader__` | 加载浏览器侧那一半 |
|
|
125
|
+
| `shell.overlay` 插槽 | 把提示放到应用之上 |
|
|
126
|
+
| `@deepseek-ai/dsh-client-ui-primitives` 的 `Toast` | 绘制提示 |
|
|
127
|
+
|
|
128
|
+
本插件是失败安全的。若所需部件缺失,它会报告“需要重启”,而不会弄坏 dsh。提示
|
|
129
|
+
也一样:缺少 web 服务器、浏览器模块加载不了、插槽名未知、重复注册、或 dsh 构建
|
|
130
|
+
中没有 `Toast`,代价都只是没有提示。重载照常工作,web 应用也照常启动。
|
|
131
|
+
|
|
132
|
+
有一个例外:浏览器侧那一半会向 dsh 索取名为 `slots` 的服务。只要有任何插件始终
|
|
133
|
+
没有就绪,dsh 的 web 应用就会拒绝启动。所以,假如将来某个 dsh 构建完全没有
|
|
134
|
+
`slots` 服务,这一部分就会一直等待,并出现在 dsh 的启动错误列表里。上面列出的
|
|
135
|
+
其他失败都会被捕获,只是什么都不做。
|
|
49
136
|
|
|
50
137
|
## 退出热重载(opt-out)
|
|
51
138
|
|
|
@@ -67,8 +154,8 @@ dsh plugin --profile web add some-plugin@newer # 自动热重载
|
|
|
67
154
|
|
|
68
155
|
## 局限——务必阅读
|
|
69
156
|
|
|
70
|
-
|
|
71
|
-
|
|
157
|
+
本插件是**乐观式**的,并非验证式。它尝试重载,且仅在**抛出**错误时(或没有可
|
|
158
|
+
替换的活动 fiber 时)回退到“需要重启”。它**无法**检测*静默*泄漏:
|
|
72
159
|
|
|
73
160
|
- 一个在 cordis 之外获取**裸资源**的插件——裸 `setInterval`、`net`/`http`
|
|
74
161
|
服务器、`WebSocketServer`、`fs.watch`、`child_process`——**且没有用
|
|
@@ -79,11 +166,13 @@ dsh plugin --profile web add some-plugin@newer # 自动热重载
|
|
|
79
166
|
仅限于绕过 `ctx` 的插件。拿不准时,让这类插件设 `dsh.hotReload: false`。
|
|
80
167
|
- 重载一个持有**活动连接**的插件(例如 WebSocket 桥接)会断开并重建这些连接;
|
|
81
168
|
客户端需要重连。这是预期行为,不是错误。
|
|
82
|
-
- 重载路径依赖 cordis/loader
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
169
|
+
- 重载路径依赖[兼容性](#兼容性)一节列出的 cordis/loader 内部接口。若这些内部
|
|
170
|
+
不可用(既无 `--expose-internals`,也无 `node-addon-require-builtin` 原生
|
|
171
|
+
插件),本插件会退化为对每次变化只报告“需要重启”,而不做重载。
|
|
172
|
+
- 提示通道(`GET /dsh-hot-reload/events`)**不做任何密码校验**,与 dsh 自带的
|
|
173
|
+
`/plugins/events` 相同。它发送的是插件名和版本号。dsh 的插件列表本来就会显示
|
|
174
|
+
这些内容,所以并没有多暴露什么秘密。但如果你把 dsh 绑定到 `0.0.0.0`,请把它
|
|
175
|
+
算作局域网里任何人都能打开的又一个地址。
|
|
87
176
|
|
|
88
177
|
范围说明:本插件处理的是**已加载插件的升级**。安装一个**全新**插件是另一回事
|
|
89
178
|
(把它的行加入 `cordis.patch.yml`,这个 dsh 本身已经会热应用)。
|
package/cordis.patch.yml
CHANGED
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
# dsh-hot-reload bundle patch — mounts the watcher plugin that live-reloads
|
|
2
2
|
# upgraded plugin packages without restarting dsh.
|
|
3
3
|
#
|
|
4
|
-
# Behavior: on a plugin package
|
|
5
|
-
#
|
|
6
|
-
#
|
|
4
|
+
# Behavior: on a plugin package upgrade it attempts a live reload, and on any
|
|
5
|
+
# thrown error rolls back to the working old version and logs that a manual
|
|
6
|
+
# `dsh` restart is needed. A plugin can opt out with `dsh.hotReload: false` in
|
|
7
|
+
# its own package.json. It never restarts dsh itself. See README.md for the
|
|
8
|
+
# limitations (silent leaks it cannot detect).
|
|
7
9
|
- insert:
|
|
8
10
|
- id: hot-reload
|
|
9
11
|
name: 'dsh-hot-reload'
|
|
10
12
|
# config:
|
|
11
|
-
# # Extra package names you personally vouch are safe to hot-reload,
|
|
12
|
-
# # even if they don't declare `dsh.hotReload: true` themselves.
|
|
13
|
-
# reloadable: []
|
|
14
13
|
# # Debounce (ms) after a lockfile change before acting.
|
|
15
14
|
# debounce: 300
|
|
16
15
|
# # Absolute path to the profile dir to watch; auto-detected if omitted.
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
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
|
+
/** Cordis plugin name. */
|
|
56
|
+
const name = "dsh-hot-reload";
|
|
57
|
+
/** Required services: the slot registry this half contributes its banner to. */
|
|
58
|
+
const inject = ["slots"];
|
|
59
|
+
|
|
60
|
+
/** Leading glyphs, built once: `primitives` is fixed for the life of the
|
|
61
|
+
* factory, so rebuilding these per render would only churn element identity
|
|
62
|
+
* and force the icon span to reconcile. Undefined when this dsh build no
|
|
63
|
+
* longer ships the icon — the banner reads fine without one. */
|
|
64
|
+
const icon = (Icon) => (typeof Icon === "function" ? React.createElement(Icon) : undefined);
|
|
65
|
+
const ICONS = seedError !== null ? {} : {
|
|
66
|
+
reloaded: icon(primitives.IconRefreshOutline16),
|
|
67
|
+
other: icon(primitives.IconWarningOutline16),
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The shell.overlay entry: subscribes to the host's notice channel and shows
|
|
72
|
+
* one banner at a time, oldest first.
|
|
73
|
+
*
|
|
74
|
+
* Notices queue rather than replace: one lockfile write can reload several
|
|
75
|
+
* packages, and showing only the newest would silently drop the rest.
|
|
76
|
+
*
|
|
77
|
+
* @param props.warn - reports a dead channel; supplied by apply() through the
|
|
78
|
+
* wrapper it registers, so nothing about this component is factory-global and
|
|
79
|
+
* a second plugin row cannot repoint the first row's logger.
|
|
80
|
+
*/
|
|
81
|
+
function ReloadNotices({ warn }) {
|
|
82
|
+
const [queue, setQueue] = React.useState([]);
|
|
83
|
+
// Stable identity is load-bearing: Toast restarts its hold-and-fade timer
|
|
84
|
+
// whenever `onDone` changes, so a fresh arrow per render would let a burst
|
|
85
|
+
// of arrivals keep resetting the banner already on screen instead of
|
|
86
|
+
// letting it finish and hand over to the next one.
|
|
87
|
+
const shift = React.useCallback(() => setQueue((q) => q.slice(1)), []);
|
|
88
|
+
|
|
89
|
+
React.useEffect(() => {
|
|
90
|
+
let seq = 0;
|
|
91
|
+
const source = new EventSource(EVENTS_ENDPOINT);
|
|
92
|
+
source.addEventListener("message", (event) => {
|
|
93
|
+
let frame;
|
|
94
|
+
try {
|
|
95
|
+
frame = JSON.parse(event.data);
|
|
96
|
+
} catch {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (frame === null || typeof frame !== "object") return;
|
|
100
|
+
if (frame.type !== "notice" || typeof frame.text !== "string") return;
|
|
101
|
+
seq += 1;
|
|
102
|
+
setQueue((q) => q.concat({ seq, kind: frame.kind, text: frame.text }));
|
|
103
|
+
});
|
|
104
|
+
// A dead channel is otherwise invisible: with no route registered, the
|
|
105
|
+
// request falls through to the SPA fallback and answers 200 text/html,
|
|
106
|
+
// which EventSource rejects as a permanent failure rather than retrying.
|
|
107
|
+
// Say so once, so "the feature is off" is distinguishable from "broken".
|
|
108
|
+
let reported = false;
|
|
109
|
+
source.addEventListener("error", () => {
|
|
110
|
+
if (reported || source.readyState !== 2 /* CLOSED */) return;
|
|
111
|
+
reported = true;
|
|
112
|
+
warn(`dsh-hot-reload: notice channel ${EVENTS_ENDPOINT} is unavailable — no reload banners`);
|
|
113
|
+
});
|
|
114
|
+
// Otherwise EventSource reconnects on its own; the host holds no per-tab
|
|
115
|
+
// state, so a reconnect costs nothing and misses only what it was down for.
|
|
116
|
+
return () => source.close();
|
|
117
|
+
}, []);
|
|
118
|
+
|
|
119
|
+
const head = queue[0];
|
|
120
|
+
if (head === undefined) return null;
|
|
121
|
+
// Keyed by arrival sequence so two identical texts in a row remount and
|
|
122
|
+
// replay the slide/hold/fade, instead of reusing an already-faded banner.
|
|
123
|
+
return React.createElement(primitives.Toast, {
|
|
124
|
+
key: head.seq,
|
|
125
|
+
text: `dsh-hot-reload: ${head.text}`,
|
|
126
|
+
icon: head.kind === "reloaded" ? ICONS.reloaded : ICONS.other,
|
|
127
|
+
onDone: shift,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Client plugin body: mount the banner into the shell overlay.
|
|
133
|
+
* @param ctx - client root context.
|
|
134
|
+
*/
|
|
135
|
+
function apply(ctx) {
|
|
136
|
+
if (seedError !== null) {
|
|
137
|
+
ctx.logger?.warn?.("dsh-hot-reload: a platform module is unavailable — reload notices disabled");
|
|
138
|
+
ctx.logger?.warn?.(seedError);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (typeof primitives.Toast !== "function") {
|
|
142
|
+
ctx.logger?.warn?.("dsh-hot-reload: this dsh build ships no Toast primitive — reload notices disabled");
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const warn = (message) => ctx.logger?.warn?.(message);
|
|
146
|
+
// slots.inject waits for the slot to be declared and disposes with this
|
|
147
|
+
// fiber, so an unknown slot name parks quietly instead of throwing.
|
|
148
|
+
ctx.slots.inject(SLOT, () => {
|
|
149
|
+
try {
|
|
150
|
+
return ctx.slots.register({ name: SLOT, id: "dsh-hot-reload.notices", order: 100 }, () =>
|
|
151
|
+
React.createElement(ReloadNotices, { warn })
|
|
152
|
+
);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
// A changed registration contract, or a duplicate id from a second
|
|
155
|
+
// dsh-hot-reload row: lose the notices, never the page.
|
|
156
|
+
ctx.logger?.warn?.("dsh-hot-reload: could not mount reload notices");
|
|
157
|
+
ctx.logger?.warn?.(error);
|
|
158
|
+
return () => {};
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// The loader takes the factory's return value AS the module exports, so the
|
|
164
|
+
// CJS `module.exports` preamble a built bundle carries is not needed here.
|
|
165
|
+
return { apply, inject, name };
|
|
166
|
+
},
|
|
167
|
+
});
|
package/lib/index.js
CHANGED
|
@@ -13,6 +13,13 @@
|
|
|
13
13
|
// ctx.effect disposer can reload without error yet leave that resource dangling.
|
|
14
14
|
// A plugin can opt out of reload entirely with `dsh.hotReload: false` in its
|
|
15
15
|
// package.json, which forces the restart-needed path without a reload attempt.
|
|
16
|
+
// Disabled rows are skipped silently (nothing is running to swap); an enabled
|
|
17
|
+
// row with no fiber attached yet is reported and left for a later change.
|
|
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().
|
|
16
23
|
//
|
|
17
24
|
// NOTE: the reload path uses cordis/loader internals (loader.internal.loadCache,
|
|
18
25
|
// registry.plugin/delete, fiber.entry) — the same ones HMR uses. If a future
|
|
@@ -23,13 +30,22 @@ import { watch } from "chokidar";
|
|
|
23
30
|
import { readFileSync, existsSync } from "node:fs";
|
|
24
31
|
import { createRequire } from "node:module";
|
|
25
32
|
import { fileURLToPath } from "node:url";
|
|
26
|
-
import {
|
|
33
|
+
import { join } from "node:path";
|
|
27
34
|
|
|
28
35
|
export const name = "dsh-hot-reload";
|
|
29
36
|
|
|
30
37
|
const getOuterStack = () => [];
|
|
31
38
|
const cjsRequire = createRequire(import.meta.url);
|
|
32
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
|
+
|
|
46
|
+
/** handlePackage outcome: a reload was attempted and failed — never retry it. */
|
|
47
|
+
const TERMINAL = Symbol("dsh-hot-reload:terminal");
|
|
48
|
+
|
|
33
49
|
export function apply(ctx, config = {}) {
|
|
34
50
|
const log = ctx.logger ?? console;
|
|
35
51
|
const loader = ctx.loader;
|
|
@@ -64,6 +80,116 @@ export function apply(ctx, config = {}) {
|
|
|
64
80
|
);
|
|
65
81
|
}
|
|
66
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
|
+
|
|
134
|
+
// ctx.inject, NOT a module-level `export const inject`, and NOT a one-shot
|
|
135
|
+
// ctx.get. The distinction matters three ways:
|
|
136
|
+
//
|
|
137
|
+
// - a module-level inject is REQUIRED (Inject.resolve maps every declared
|
|
138
|
+
// name to a wait), so it would park the whole plugin forever in a profile
|
|
139
|
+
// that has no web server — tui would stop reloading anything at all;
|
|
140
|
+
// - ctx.inject parks only this CHILD fiber, leaving the reloader running;
|
|
141
|
+
// - ctx.get would be both racy and one-shot. It resolves strictly, returning
|
|
142
|
+
// undefined unless the providing fiber is already ACTIVE, and WebServer
|
|
143
|
+
// only becomes active after its async listen() binds — while loader entries
|
|
144
|
+
// start concurrently, so whether we win that race is chance. Being a single
|
|
145
|
+
// read, it also never recovers: a web server that reloads (port change, a
|
|
146
|
+
// dsh HMR cycle) comes back with an empty route table and nothing would
|
|
147
|
+
// re-register. ctx.inject re-runs this body on exactly that event.
|
|
148
|
+
ctx.inject(["webServer"], (webCtx) => {
|
|
149
|
+
// Acquire and release in one effect, as dsh's own client-hmr channel does:
|
|
150
|
+
// the disposer drops the route and every open stream when this child fiber
|
|
151
|
+
// unloads — on shutdown, and before the body re-runs for a replaced server.
|
|
152
|
+
webCtx.effect(() => {
|
|
153
|
+
let disposeRoute;
|
|
154
|
+
try {
|
|
155
|
+
disposeRoute = webCtx.webServer.register({
|
|
156
|
+
kind: "exact",
|
|
157
|
+
path: EVENTS_ENDPOINT,
|
|
158
|
+
handler: (req, res) => {
|
|
159
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
160
|
+
res.writeHead(405);
|
|
161
|
+
res.end();
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
res.writeHead(200, {
|
|
165
|
+
"content-type": "text/event-stream",
|
|
166
|
+
"cache-control": "no-cache",
|
|
167
|
+
connection: "keep-alive",
|
|
168
|
+
});
|
|
169
|
+
res.write(": connected\n\n");
|
|
170
|
+
connections.add(res);
|
|
171
|
+
res.on("close", () => connections.delete(res));
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
} catch (err) {
|
|
175
|
+
// Duplicate path (a second dsh-hot-reload row) or a webserver API change.
|
|
176
|
+
// The notices are optional; the reloader is not — degrade, never throw.
|
|
177
|
+
log.warn?.("dsh-hot-reload: could not register the notice channel; web notices are disabled");
|
|
178
|
+
log.warn?.(err);
|
|
179
|
+
return () => {};
|
|
180
|
+
}
|
|
181
|
+
return () => {
|
|
182
|
+
disposeRoute();
|
|
183
|
+
for (const res of connections) {
|
|
184
|
+
try {
|
|
185
|
+
res.destroy();
|
|
186
|
+
} catch {}
|
|
187
|
+
}
|
|
188
|
+
connections.clear();
|
|
189
|
+
};
|
|
190
|
+
}, "dsh-hot-reload: notice channel");
|
|
191
|
+
});
|
|
192
|
+
|
|
67
193
|
// ---- package <-> loader-entry helpers ----
|
|
68
194
|
|
|
69
195
|
/** Package name backing a loader entry's module specifier, or null for local/builtin. */
|
|
@@ -78,6 +204,12 @@ export function apply(ctx, config = {}) {
|
|
|
78
204
|
return specifier.split("/")[0];
|
|
79
205
|
}
|
|
80
206
|
|
|
207
|
+
// Versions come from node_modules/<pkg>/package.json, never from the lockfile
|
|
208
|
+
// we watch: pnpm writes the lockfile BEFORE materializing node_modules, so a
|
|
209
|
+
// cycle triggered by that write can see the new version there while the old
|
|
210
|
+
// code is still on disk — re-importing would load the OLD module while
|
|
211
|
+
// committing the NEW version as loaded. The lockfile is the trigger; the
|
|
212
|
+
// installed package.json is the truth about what an import would actually get.
|
|
81
213
|
function readPkgJson(pkg) {
|
|
82
214
|
try {
|
|
83
215
|
return JSON.parse(readFileSync(join(nodeModules, pkg, "package.json"), "utf8"));
|
|
@@ -90,31 +222,71 @@ export function apply(ctx, config = {}) {
|
|
|
90
222
|
return readPkgJson(pkg)?.version ?? null;
|
|
91
223
|
}
|
|
92
224
|
|
|
93
|
-
function optedOut(pkg) {
|
|
94
|
-
return readPkgJson(pkg)?.dsh?.hotReload === false;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
225
|
function entries() {
|
|
98
226
|
try {
|
|
99
227
|
return [...loader.entries()];
|
|
100
228
|
} catch {
|
|
101
|
-
return
|
|
229
|
+
return null; // degraded — callers must not confuse this with "no plugins loaded"
|
|
102
230
|
}
|
|
103
231
|
}
|
|
104
232
|
|
|
105
|
-
|
|
106
|
-
|
|
233
|
+
/** One consistent per-cycle view: pkg -> { json, version, live, fiberless }
|
|
234
|
+
* for every package backing a loader entry, built from a SINGLE loader
|
|
235
|
+
* enumeration and a SINGLE package.json read per package (the whole cycle
|
|
236
|
+
* consumes this, so a loader/fs hiccup after the diff can't be misread as
|
|
237
|
+
* "not a loaded plugin"; `reloadEntry` deliberately re-reads the version at
|
|
238
|
+
* import time, and nothing else does).
|
|
239
|
+
*
|
|
240
|
+
* Every question about what a row IS gets answered here, once:
|
|
241
|
+
* - group rows are containers, not plugin packages — excluded entirely;
|
|
242
|
+
* - disabled rows aren't running, so there is nothing to reload and nothing
|
|
243
|
+
* to report. `disabled` is an inherited getter (an ancestor entry can
|
|
244
|
+
* disable a row, and the raw option may be a !!js expression node), so
|
|
245
|
+
* never read options.disabled — excluded entirely;
|
|
246
|
+
* - `live` holds one entry per RUNTIME: reloadEntry swaps all of a runtime's
|
|
247
|
+
* fibers at once, so aliased specifiers ("pkg" vs "pkg/index.js") sharing
|
|
248
|
+
* a runtime must reload once, while one specifier under two loader trees
|
|
249
|
+
* is two runtimes and must reload twice;
|
|
250
|
+
* - `fiberless` counts enabled rows with nothing attached (mid-import, or
|
|
251
|
+
* failed to load) — reportable, but not reloadable.
|
|
252
|
+
*
|
|
253
|
+
* Returns null when the loader can't enumerate right now — treating that as
|
|
254
|
+
* "everything uninstalled" would wipe the tracked versions and spuriously
|
|
255
|
+
* reload everything next cycle. A package whose package.json is momentarily
|
|
256
|
+
* unreadable (mid pnpm swap) still appears, with version null. */
|
|
257
|
+
function snapshot() {
|
|
258
|
+
const list = entries();
|
|
259
|
+
if (!list) return null;
|
|
260
|
+
const pkgs = Object.create(null);
|
|
261
|
+
const seenRuntimes = new Map();
|
|
262
|
+
for (const e of list) {
|
|
263
|
+
if (e?.options?.group || e?.disabled) continue;
|
|
264
|
+
const pkg = pkgOf(e?.options?.name);
|
|
265
|
+
if (!pkg) continue;
|
|
266
|
+
let rec = pkgs[pkg];
|
|
267
|
+
if (!rec) {
|
|
268
|
+
const json = readPkgJson(pkg);
|
|
269
|
+
rec = pkgs[pkg] = { json, version: json?.version ?? null, live: [], fiberless: 0 };
|
|
270
|
+
seenRuntimes.set(pkg, new Set());
|
|
271
|
+
}
|
|
272
|
+
const runtime = e?.fiber?.runtime;
|
|
273
|
+
if (!runtime) {
|
|
274
|
+
rec.fiberless += 1;
|
|
275
|
+
} else if (!seenRuntimes.get(pkg).has(runtime)) {
|
|
276
|
+
seenRuntimes.get(pkg).add(runtime);
|
|
277
|
+
rec.live.push(e);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return pkgs;
|
|
107
281
|
}
|
|
108
282
|
|
|
109
|
-
/**
|
|
110
|
-
|
|
283
|
+
/** Baseline map for a snapshot. A package whose version couldn't be read is
|
|
284
|
+
* tracked with a null version rather than omitted: omitting it would make its
|
|
285
|
+
* first readable version look like a brand-new row and get adopted without a
|
|
286
|
+
* reload, silently leaving the old code running. */
|
|
287
|
+
function currentVersions(snap) {
|
|
111
288
|
const map = Object.create(null);
|
|
112
|
-
for (const
|
|
113
|
-
const pkg = pkgOf(e?.options?.name);
|
|
114
|
-
if (!pkg || pkg in map) continue;
|
|
115
|
-
const v = versionOf(pkg);
|
|
116
|
-
if (v) map[pkg] = v;
|
|
117
|
-
}
|
|
289
|
+
for (const pkg in snap) map[pkg] = snap[pkg].version;
|
|
118
290
|
return map;
|
|
119
291
|
}
|
|
120
292
|
|
|
@@ -150,8 +322,14 @@ export function apply(ctx, config = {}) {
|
|
|
150
322
|
} catch {}
|
|
151
323
|
}
|
|
152
324
|
|
|
153
|
-
/** Reload one loaded entry's module in place. Throws
|
|
154
|
-
|
|
325
|
+
/** Reload one loaded entry's module in place. Throws on failure, after rolling
|
|
326
|
+
* the old plugin back — except when teardown began mid-reload, where it drops
|
|
327
|
+
* the new plugin and does NOT roll back (the dying context disposes what is
|
|
328
|
+
* still registered). Returns the package version read at IMPORT time — a
|
|
329
|
+
* version read afterwards could record one that was never imported (a bump
|
|
330
|
+
* landing during a slow apply()), which would make the next cycle see no
|
|
331
|
+
* change and skip that upgrade forever. */
|
|
332
|
+
async function reloadEntry(entry, pkg) {
|
|
155
333
|
const specifier = entry?.options?.name;
|
|
156
334
|
const parentURL = entry?.parent?.tree?.ctx?.baseUrl ?? ctx.baseUrl;
|
|
157
335
|
const oldFiber = entry.fiber;
|
|
@@ -164,9 +342,16 @@ export function apply(ctx, config = {}) {
|
|
|
164
342
|
|
|
165
343
|
invalidate(url); // matters for in-place edits; harmless no-op for a version bump (new realpath)
|
|
166
344
|
|
|
345
|
+
// Read the version as close to the import as possible: this is what the
|
|
346
|
+
// fresh module actually is, and the only value safe to commit.
|
|
347
|
+
const importedVersion = versionOf(pkg);
|
|
167
348
|
const newPlugin = loader.unwrapExports(await loader.import(url, getOuterStack));
|
|
168
349
|
if (!newPlugin) throw new Error(`fresh import produced no plugin for ${specifier}`);
|
|
169
350
|
|
|
351
|
+
// Re-check after the (slow) import: never start the destructive swap into a
|
|
352
|
+
// context that began tearing down while we were awaiting.
|
|
353
|
+
if (disposed) throw new Error("dsh-hot-reload disposed mid-reload");
|
|
354
|
+
|
|
170
355
|
// Snapshot fibers before disposal, then swap: dispose old (runs ctx disposers),
|
|
171
356
|
// re-instantiate the new plugin against each old fiber's entry + config.
|
|
172
357
|
const fibers = [...runtime.fibers];
|
|
@@ -177,11 +362,18 @@ export function apply(ctx, config = {}) {
|
|
|
177
362
|
// how an async apply() throw is surfaced into this try/catch (a plain
|
|
178
363
|
// reattach would let it escape asynchronously and leave the plugin dead).
|
|
179
364
|
await Promise.all(fresh.map((f) => f?.await?.()));
|
|
365
|
+
// Activation can SUCCEED after teardown began — and the disposer no longer
|
|
366
|
+
// waits for us, so nothing else would ever dispose these fibers. Throwing
|
|
367
|
+
// here routes into the same drop-and-bail path the failure case uses.
|
|
368
|
+
if (disposed) throw new Error("dsh-hot-reload disposed mid-reload");
|
|
180
369
|
} catch (err) {
|
|
181
|
-
// Rollback to the old plugin so a failed reload never leaves it dead.
|
|
182
370
|
try {
|
|
183
|
-
ctx.registry.delete(newPlugin);
|
|
371
|
+
ctx.registry.delete(newPlugin); // every path out of here drops the new plugin
|
|
184
372
|
} catch {}
|
|
373
|
+
// Teardown began while activation was awaiting: do NOT reattach into the
|
|
374
|
+
// dying context — its own teardown disposes whatever is still registered.
|
|
375
|
+
if (disposed) throw err;
|
|
376
|
+
// Otherwise roll back, so a failed reload never leaves the plugin dead.
|
|
185
377
|
const restored = [];
|
|
186
378
|
for (const of of fibers) {
|
|
187
379
|
try {
|
|
@@ -193,6 +385,7 @@ export function apply(ctx, config = {}) {
|
|
|
193
385
|
} catch {}
|
|
194
386
|
throw err;
|
|
195
387
|
}
|
|
388
|
+
return importedVersion;
|
|
196
389
|
}
|
|
197
390
|
|
|
198
391
|
function reattach(plugin, oldFiber) {
|
|
@@ -204,64 +397,136 @@ export function apply(ctx, config = {}) {
|
|
|
204
397
|
|
|
205
398
|
// ---- change handling ----
|
|
206
399
|
|
|
207
|
-
/** Reload
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const version
|
|
215
|
-
|
|
216
|
-
if (
|
|
217
|
-
|
|
218
|
-
return
|
|
400
|
+
/** Reload the live modules of a changed package, as already classified by the
|
|
401
|
+
* cycle's snapshot record. Returns the version string to commit, `false` to
|
|
402
|
+
* leave it uncommitted but RETRYABLE, or TERMINAL when a reload was attempted
|
|
403
|
+
* and failed — that version is never retried (each attempt tears down the
|
|
404
|
+
* working rolled-back plugin; recovery is a different version or a dsh
|
|
405
|
+
* restart). */
|
|
406
|
+
async function handlePackage(pkg, rec) {
|
|
407
|
+
const { version, live, fiberless } = rec;
|
|
408
|
+
|
|
409
|
+
if (rec.json?.dsh?.hotReload === false) {
|
|
410
|
+
report("stale", `${pkg}@${version} sets dsh.hotReload:false — restart dsh to load the new version`);
|
|
411
|
+
return version;
|
|
219
412
|
}
|
|
220
413
|
if (!internal) {
|
|
221
|
-
|
|
222
|
-
return
|
|
414
|
+
report("stale", `${pkg}@${version} changed — restart dsh to load the new version`);
|
|
415
|
+
return version;
|
|
223
416
|
}
|
|
224
417
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
return
|
|
235
|
-
}
|
|
418
|
+
if (!live.length) {
|
|
419
|
+
if (!fiberless) return version; // only disabled rows — nothing to do, nothing to say
|
|
420
|
+
// Enabled but nothing attached: mid-import or a load failure. Say so once,
|
|
421
|
+
// don't commit, and stay retryable — no reload was attempted, so a plugin
|
|
422
|
+
// that was merely still activating picks this up on a later event.
|
|
423
|
+
report(
|
|
424
|
+
"stale",
|
|
425
|
+
`${pkg}@${version} has no live fiber to reload right now — restart dsh if it stays on the old version`
|
|
426
|
+
);
|
|
427
|
+
return false;
|
|
428
|
+
}
|
|
429
|
+
if (fiberless) {
|
|
430
|
+
log.warn?.(`dsh-hot-reload: ${pkg}@${version}: skipping ${fiberless} entry(ies) with no live fiber`);
|
|
431
|
+
}
|
|
236
432
|
|
|
237
433
|
try {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
434
|
+
// Only ever commit a version some module actually imported. If a bump
|
|
435
|
+
// lands mid-cycle the modules can disagree about what they loaded; rather
|
|
436
|
+
// than pick one, leave the package uncommitted (retryable) so the next
|
|
437
|
+
// cycle re-snapshots and converges. Costs one redundant reload in a rare
|
|
438
|
+
// case; the alternative risks recording a version that never loaded.
|
|
439
|
+
let committed = null;
|
|
440
|
+
for (const entry of live) {
|
|
441
|
+
if (disposed) return false; // shutting down — don't touch the registry, don't commit
|
|
442
|
+
const imported = await reloadEntry(entry, pkg);
|
|
443
|
+
if (committed && imported !== committed) {
|
|
444
|
+
log.info?.(`dsh-hot-reload: ${pkg} changed again mid-reload — re-checking on the next change`);
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
committed ??= imported;
|
|
448
|
+
}
|
|
449
|
+
committed ??= version;
|
|
450
|
+
report("reloaded", `hot-reloaded ${pkg}@${committed} (${live.length} module(s))`);
|
|
451
|
+
return committed;
|
|
241
452
|
} catch (err) {
|
|
242
|
-
|
|
453
|
+
if (disposed) return false; // aborted by teardown, not a real failure — stay quiet
|
|
454
|
+
report(
|
|
455
|
+
"failed",
|
|
456
|
+
`could not hot-reload ${pkg}@${version} — not retrying; restart dsh (or install a different version) to load it`
|
|
457
|
+
);
|
|
243
458
|
log.warn?.(err);
|
|
244
|
-
return
|
|
459
|
+
return TERMINAL; // attempted and failed: never retried for this version
|
|
245
460
|
}
|
|
246
461
|
}
|
|
247
462
|
|
|
248
463
|
// ---- watcher ----
|
|
249
464
|
|
|
250
|
-
|
|
465
|
+
const boot = snapshot();
|
|
466
|
+
// null when the loader couldn't enumerate at boot. By design there is no
|
|
467
|
+
// retry and no warning: the first successful snapshot simply becomes the
|
|
468
|
+
// tracked state. Accepted tradeoff — if the loader is degraded at boot AND
|
|
469
|
+
// the very first lockfile event is a real upgrade, that upgrade is adopted
|
|
470
|
+
// silently (old code keeps running, no notice). Deliberate, not an oversight.
|
|
471
|
+
let versions = boot ? currentVersions(boot) : null;
|
|
472
|
+
const failedVersions = Object.create(null); // pkg -> version whose reload failed (never retried)
|
|
251
473
|
let timer = null;
|
|
474
|
+
let pending = false; // at most ONE cycle queued behind the running one; bursts coalesce into it
|
|
252
475
|
let disposed = false;
|
|
253
476
|
let running = Promise.resolve(); // serializes reload cycles across debounce batches
|
|
254
477
|
|
|
255
478
|
async function runCycle() {
|
|
256
479
|
if (disposed) return;
|
|
257
|
-
|
|
258
|
-
|
|
480
|
+
// ONE loader enumeration + ONE package.json read per package for the whole
|
|
481
|
+
// cycle: the diff below and the reloads it drives act on the same facts, so
|
|
482
|
+
// a transient loader/fs failure can never make a detected upgrade look like
|
|
483
|
+
// "not a loaded plugin" and get silently committed. (The single exception is
|
|
484
|
+
// the at-import version re-read in reloadEntry, which must not be cached.)
|
|
485
|
+
const snap = snapshot();
|
|
486
|
+
if (!snap) return; // degraded — keep current state, the next event retries
|
|
487
|
+
if (!versions) {
|
|
488
|
+
versions = currentVersions(snap); // adopt whatever is in the system (see `boot` above)
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
for (const pkg in snap) {
|
|
259
492
|
if (disposed) return;
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
493
|
+
const version = snap[pkg].version;
|
|
494
|
+
if (!version) {
|
|
495
|
+
// package.json unreadable right now (mid pnpm swap). Track it with a
|
|
496
|
+
// null version if we've never had one, so the first readable version
|
|
497
|
+
// reads as a change and reloads, instead of being adopted as a fresh row.
|
|
498
|
+
if (!(pkg in versions)) versions[pkg] = null;
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
if (!(pkg in versions)) {
|
|
502
|
+
versions[pkg] = version; // newly loaded row: dsh just loaded it fresh, nothing to reload
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
if (versions[pkg] === version) continue;
|
|
506
|
+
// A version whose reload failed is never re-attempted: each attempt tears
|
|
507
|
+
// down the working rolled-back plugin again. Recovery is a different
|
|
508
|
+
// version or a dsh restart — the failure log said so once, stay quiet now.
|
|
509
|
+
if (failedVersions[pkg] === version) continue;
|
|
510
|
+
const commit = await handlePackage(pkg, snap[pkg]);
|
|
511
|
+
if (commit === TERMINAL) {
|
|
512
|
+
failedVersions[pkg] = version; // attempted, failed: don't try this version again
|
|
513
|
+
} else if (commit) {
|
|
514
|
+
versions[pkg] = commit; // the version actually imported, not the cycle-start one
|
|
515
|
+
delete failedVersions[pkg];
|
|
516
|
+
}
|
|
517
|
+
// commit === false: not attempted (teardown, or nothing attached yet) —
|
|
518
|
+
// leave it uncommitted and retryable on a later event.
|
|
519
|
+
}
|
|
520
|
+
// Drop a package when the LOADER no longer has an entry backed by it — not
|
|
521
|
+
// when its directory is missing. A point-in-time fs probe is wrong twice
|
|
522
|
+
// over: a dangling pnpm symlink mid-swap would evict a live plugin, and a
|
|
523
|
+
// removed plugin row whose package stays installed would be tracked forever.
|
|
524
|
+
for (const pkg of Object.keys(versions)) {
|
|
525
|
+
if (!(pkg in snap)) {
|
|
526
|
+
delete versions[pkg];
|
|
527
|
+
delete failedVersions[pkg];
|
|
528
|
+
}
|
|
263
529
|
}
|
|
264
|
-
for (const pkg of Object.keys(versions)) if (!(pkg in next)) delete versions[pkg]; // drop uninstalled
|
|
265
530
|
}
|
|
266
531
|
|
|
267
532
|
const trigger = () => {
|
|
@@ -269,7 +534,14 @@ export function apply(ctx, config = {}) {
|
|
|
269
534
|
if (timer) clearTimeout(timer);
|
|
270
535
|
timer = setTimeout(() => {
|
|
271
536
|
timer = null;
|
|
272
|
-
|
|
537
|
+
if (pending) return; // a queued cycle will snapshot fresh state and see this change too
|
|
538
|
+
pending = true;
|
|
539
|
+
running = running
|
|
540
|
+
.then(() => {
|
|
541
|
+
pending = false;
|
|
542
|
+
return runCycle();
|
|
543
|
+
})
|
|
544
|
+
.catch((e) => log.warn?.("dsh-hot-reload: reload cycle error", e));
|
|
273
545
|
}, debounceMs);
|
|
274
546
|
};
|
|
275
547
|
|
|
@@ -279,29 +551,33 @@ export function apply(ctx, config = {}) {
|
|
|
279
551
|
watcher.on("error", (e) => log.warn?.("dsh-hot-reload: watcher error", e));
|
|
280
552
|
|
|
281
553
|
ctx.effect(() => async () => {
|
|
554
|
+
// Never wait on an in-flight reload: dsh's shutdown must not hang on an
|
|
555
|
+
// arbitrary plugin's apply()/fiber.await(). Setting `disposed` first makes
|
|
556
|
+
// any straggling cycle harmless — it stops between modules, and a reload
|
|
557
|
+
// caught mid-activation skips the rollback rather than reattaching fibers
|
|
558
|
+
// into a context that is already tearing down.
|
|
282
559
|
disposed = true;
|
|
283
560
|
if (timer) clearTimeout(timer);
|
|
561
|
+
running.catch(() => {}); // keep an in-flight rejection from going unhandled
|
|
284
562
|
try {
|
|
285
563
|
await watcher.close();
|
|
286
564
|
} catch {}
|
|
287
|
-
await running.catch(() => {}); // let any in-flight cycle settle
|
|
288
565
|
});
|
|
289
566
|
|
|
290
567
|
log.info?.(
|
|
291
|
-
`dsh-hot-reload: watching ${lockfile} (${Object.keys(versions).length} plugin package(s) tracked)`
|
|
568
|
+
`dsh-hot-reload: watching ${lockfile} (${Object.keys(versions ?? {}).length} plugin package(s) tracked)`
|
|
292
569
|
);
|
|
293
570
|
}
|
|
294
571
|
|
|
295
|
-
/**
|
|
296
|
-
*
|
|
572
|
+
/** Profile-dir resolution: an explicit config.profileDir ALWAYS wins — it must
|
|
573
|
+
* never be silently overridden by auto-detection (a fresh profile without a
|
|
574
|
+
* lockfile yet would otherwise get the baseUrl dir, watching and hot-swapping
|
|
575
|
+
* the wrong profile; apply() warns loudly when the lockfile is missing).
|
|
576
|
+
* Auto-detection from the loader base URL applies only when config is absent. */
|
|
297
577
|
function resolveProfileDir(ctx, config) {
|
|
298
|
-
|
|
299
|
-
if (config.profileDir) candidates.push(config.profileDir);
|
|
578
|
+
if (config.profileDir) return config.profileDir;
|
|
300
579
|
try {
|
|
301
|
-
if (ctx.baseUrl)
|
|
580
|
+
if (ctx.baseUrl) return fileURLToPath(new URL(".", ctx.baseUrl)).replace(/\/$/, "");
|
|
302
581
|
} catch {}
|
|
303
|
-
|
|
304
|
-
if (existsSync(join(dir, "pnpm-lock.yaml"))) return dir;
|
|
305
|
-
}
|
|
306
|
-
return candidates[0] ?? null; // fall back (apply() warns if the lockfile is missing)
|
|
582
|
+
return null;
|
|
307
583
|
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-hot-reload",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Live-reload upgraded DeepSeek Harness (dsh) plugins without restarting dsh \u2014
|
|
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": [
|
|
7
7
|
"dsh",
|
|
8
8
|
"dsh-plugin",
|
|
@@ -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": {
|