@deepseek-ai/dsh-client-hmr 0.1.1-rc.1 → 0.1.2-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
3
  # after editing either side, bring the other along and re-record with:
4
4
  # pnpm run verify-translation-pairing --write packages/client/hmr/README.md
5
- README.md: c355595dd53ddcb74be629a6d5e730c6c5fcebbf
6
- README.zh.md: 6ed4d0e79cb755f84784823749994b448ff209b8
5
+ README.md: de420ef2c809ccc13feea320ae3ffda3720f82a3
6
+ README.zh.md: c7066e369b38fa3ffda6888831bba7780c5da8af
package/README.md CHANGED
@@ -1,14 +1,106 @@
1
+ ---
2
+ description: "Development-only hot reload for browser client plugins: rebuilding a plugin bundle swaps the running plugin in place, for developers iterating on the web GUI."
3
+ kind: "package-reference"
4
+ ---
5
+
1
6
  # @deepseek-ai/dsh-client-hmr
2
7
 
3
8
  English | [中文](README.zh.md)
4
9
 
5
- Hot reload for script-loaded client plugins. The web bundle mounts the row unconditionally; without a rebuild watcher (`pnpm run dev:web`) rewriting client bundles, the poll observes no changes and the chain stays idle.
10
+ ## Summary
11
+
12
+ `dsh-client-hmr` reloads a browser client plugin in place when its bundle is rebuilt, so a developer editing plugin source sees the change without a full page reload. The reload chain stays idle without a rebuild watcher: only a `pnpm run dev:web`-style process rewriting client bundles produces the rebuilds it reacts to. Each reload swaps one plugin with fresh component state while the data layer (connection, runtime, and Session objects) stays untouched. Everything here is development machinery in the browser; the model never sees it.
13
+
14
+ ## Table of Contents
15
+
16
+ - [Use this package](#use-this-package)
17
+ - [Understand the implementation](#understand-the-implementation)
18
+ - [Further Exploration](#further-exploration)
19
+ - [Model Experience](#model-experience)
20
+ - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
21
+ - [Dev Note](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## Use this package
27
+
28
+ Enable the rebuild watcher for the plugin you are editing, then save: the browser picks up the rebuilt bundle from the dev server and swaps the plugin without reloading the page. Use it during client development; nothing observable happens in a production build, where no watcher rewrites bundles.
29
+
30
+ ### Starting the reload chain
31
+
32
+ Run `pnpm run dev:web` (or any tsdown watch process that writes the plugin's `lib/client.js`) against the same host; rebuilt plugins are then swapped into the running browser automatically, one at a time.
33
+
34
+ ### What a reload does
35
+
36
+ Each reload re-executes the plugin bundle and remounts the plugin with fresh state. Plugins that depend on the reloaded one reload with it automatically. A reload that fails is reported visibly and retried from scratch on the next rebuild.
37
+
38
+ ### Configuration
39
+
40
+ | Field | Default | Meaning |
41
+ |---|---|---|
42
+ | `pollIntervalMs` | `500` | Bundle stat-poll interval in milliseconds |
43
+
44
+ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-client-hmr) is the exhaustive source for every accepted field and its JSDoc.
45
+
46
+ ### Observing success
47
+
48
+ A successful swap shows the edited UI immediately with no page reload, and the plugin keeps working after the swap. Remember the trade-off: React state inside the reloaded plugin is lost, while session, workspace, and connection state survives.
49
+
50
+ -----
51
+
52
+ <a id="understand-the-implementation"></a>
53
+ ## Understand the implementation
54
+
55
+ <details>
56
+ <summary>Implementation internals — click to expand</summary>
57
+
58
+ This section explains how the reload chain is built; observable behavior is covered in [Use this package](#use-this-package).
59
+
60
+ ### Design concept
6
61
 
7
- The browser half subscribes to the system SSE channel (`GET /plugins/events`) and reloads one plugin per `rebuilt` frame through a serialized queue. The sequence per frame `invalidate`, `prefetch` (load and register the new bundle while the old fiber still serves), `registry.delete` (before the fiber: a bare fiber dispose trips the vendored Loader's self-dispose branch, which would mark the entry disabled), drain the old fiber, delete `entry.fiber`, remove owned `<style data-plugin>` tags, `entry.refresh()` re-imports and remounts, `fiber.await()` rethrows startup failures loud. Dependents reload through cordis itself: a fiber's activation epoch strings its service providers' uids, so replacing a provider's fiber cascades every dependent with zero client-side graph analysis. The node half detects rebuilds with one interval that stat-polls each graph bundle from a synchronous baseline, immediately re-hashes after adding a row, retains missing rows as dirty, and broadcasts only real rev changes; any tsdown watch process producing the bundle therefore triggers HMR with no builder→host channel.
62
+ The chain is two halves with one contract: the node half owns bundle detection and notification, the browser half owns the swap. The node half runs one interval that stat-polls each graph bundle from the module host's pre-read baseline. An unchanged startup row starts watching without a content read or hash; a changed row, or a dirty row whose artifact reappears, enters `rebuilt()`, and only real revision changes are broadcast. `rebuilt()` reads the current source map together with the changed bundle; a map-only write does not reload executable code. The node half also serves `/plugins/events`, an SSE channel broadcasting `graph` and `rebuilt` frames.
8
63
 
64
+ ### The browser swap
65
+
66
+ On a `rebuilt` frame the revision makes `invalidate` select that plugin's immutable one-resource combo URL instead of its initial multi-resource URL. `prefetch` loads and registers the new factory while the old fiber still serves. The remaining order is registry-first teardown (`registry.delete` before the fiber's disposer emits `internal/plugin`, or the vendored Loader flags the entry disabled), drain the old fiber's unload, delete `entry.fiber`, remove owned `<style data-plugin>` tags, then `entry.refresh()` re-imports and remounts, and `fiber.await()` rethrows startup failures loud. The swap is safe because execution is pure registration under the lazy-CJS model: every module side effect lives in the factory closure and runs at materialization.
67
+
68
+ ### Cascade and self-reload
69
+
70
+ A fiber's activation epoch strings its service providers' uids, so replacing a provider's fiber re-cascades every dependent through cordis itself with zero HMR-side bookkeeping. This plugin is itself a graph entry, so a rebuilt frame may name it; the in-flight reload keeps running in the old bundle's closure and the new bundle's apply opens a fresh channel.
71
+
72
+ ### Failure policy
73
+
74
+ No rollback: an import failure leaves the entry fiberless (the next rebuilt frame retries from scratch), and an apply failure leaves a FAILED fiber visible in the shell's status projection. Both log loudly.
75
+
76
+ ### Source map
77
+
78
+ | File | Role |
79
+ |---|---|
80
+ | [`src/index.ts`](src/index.ts) | Node half: bundle stat-poll, `rebuilt` reporting, `/plugins/events` SSE channel |
81
+ | [`src/client/index.ts`](src/client/index.ts) | Browser half: SSE subscription, serialized reload queue, fiber swap |
82
+ | [`src/events.ts`](src/events.ts) | Shared frame types (`graph` / `rebuilt`) and the endpoint constant |
83
+
84
+ </details>
85
+
86
+ -----
87
+
88
+ <a id="further-exploration"></a>
89
+ ## Further Exploration
90
+
91
+ Read these when the reload contract is not enough: the module system that serves the bundles, the shell that boots them, and the module-graph rules behind the externals.
92
+
93
+ - [Client module system](../modules/README.md) — the lazy-CJS module table and `invalidate`/`prefetch` hooks this driver drives.
94
+ - [Web boot kernel](../web/README.md) — the shell that boots the plugin tree and shows entry status.
95
+ - [Client group map](../README.md) — the browser half this package reloads.
96
+ - [Generated configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-client-hmr) — every accepted config field and its source declaration.
97
+
98
+ -----
99
+
100
+ <a id="model-experience"></a>
9
101
  ## Model Experience
10
102
 
11
- None, as the reload driver is browser-side machinery; nothing here reaches a model request.
103
+ None, as the reload driver is a browser-side UI plugin layer that registers nothing model-facing.
12
104
 
13
105
  #### KV Cache effect
14
106
 
@@ -16,6 +108,21 @@ None; this package neither assembles nor sends a provider request.
16
108
 
17
109
  ## Known Limitations and Deferred Work
18
110
 
19
- - **Reload is coarse by design** — a fresh fiber and fresh components; React state inside the reloaded plugin is lost while the data layer (connection/runtime fibers, Session objects) is untouched. react-refresh-grade state preservation conflicts with "re-executing the bundle re-runs the factory" and is deliberately out.
111
+ <a id="known-limitations-and-deferred-work"></a>
112
+
113
+
114
+ These limits define what the reload driver does not preserve or restore. They are current package constraints, not a task backlog.
115
+
116
+ - **Reload is coarse by design** — a fresh fiber and fresh components; React state inside the reloaded plugin is lost while the data layer (connection/runtime fibers, Session objects) is untouched. react-refresh-grade state preservation conflicts with re-executing the bundle and is deliberately out.
20
117
  - **No failure rollback** — a reload that fails leaves the entry FAILED and visible in the loader status projection; the previous bundle is not restored automatically.
21
- - **Graph rev is not refreshed by rebuilt frames** — the stale rev is harmless because the bundle endpoint serves no-cache; only reconnect refreshes it.
118
+ - **Rebuilt frames do not replace the boot graph** — each frame carries the plugin-artifact revision needed for its one-resource combo reload; a page reload receives the recomposed startup graph.
119
+
120
+ <a id="dev-note"></a>
121
+ ### Dev Note
122
+
123
+ <details>
124
+ <summary>Working context for maintainers — click to expand</summary>
125
+
126
+ None.
127
+
128
+ </details>
package/README.zh.md CHANGED
@@ -1,21 +1,128 @@
1
+ ---
2
+ description: "面向开发者的浏览器客户端插件热重载说明:重建插件 bundle 后原地替换运行中的插件,用于迭代 web GUI。"
3
+ kind: "package-reference"
4
+ ---
5
+
1
6
  # @deepseek-ai/dsh-client-hmr
2
7
 
3
8
  [English](README.md) | 中文
4
9
 
5
- 为通过脚本加载的客户端插件提供热重载。web 组合包无条件挂载该行;没有重建 watcher(`pnpm run dev:web`)改写客户端 bundle 时,轮询观察不到变化,链路保持空闲。
10
+ ## 概述
11
+
12
+ `dsh-client-hmr` 会在浏览器客户端插件的 bundle 重建后原地重载该插件,让编辑插件源码的开发者无需整页刷新即可看到变更。如果没有重建 watcher,整条链路保持空闲:只有 `pnpm run dev:web` 之类的进程重写客户端 bundle 时才会产生它所响应的重建。每次重载只替换一个插件并携带全新组件状态,而数据层(connection、runtime 与 Session 对象)保持不变。这里的一切都是浏览器侧的开发机制;模型永远看不到它。
13
+
14
+ ## 目录
15
+
16
+ - [使用本包](#use-this-package)
17
+ - [理解实现](#understand-the-implementation)
18
+ - [进一步探索](#further-exploration)
19
+ - [模型体验](#model-experience)
20
+ - [已知限制与延期工作](#known-limitations-and-deferred-work)
21
+ - [开发备注](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## 使用本包
27
+
28
+ 为正在编辑的插件启用重建 watcher,然后保存:浏览器会从 dev server 拾取重建后的 bundle,并在不重载页面的情况下替换该插件。在客户端开发期间使用它;在生产构建中没有任何可观察行为,因为没有 watcher 会重写 bundle。
29
+
30
+ ### 启动重载链路
31
+
32
+ 对同一个宿主运行 `pnpm run dev:web`(或任何写入插件 `lib/client.js` 的 tsdown watch 进程);重建后的插件随后会被自动逐个替换进运行中的浏览器。
33
+
34
+ ### 一次重载做什么
35
+
36
+ 每次重载都会重新执行插件 bundle,并用全新状态重新挂载插件。依赖被重载插件的插件会随之自动重载。失败的重载会被明确报告,并在下一次重建时从头重试。
37
+
38
+ ### 配置
39
+
40
+ | 字段 | 默认值 | 含义 |
41
+ |---|---|---|
42
+ | `pollIntervalMs` | `500` | bundle stat 轮询间隔,单位为毫秒 |
43
+
44
+ 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-client-hmr)是每个受支持字段及其 JSDoc 的穷尽式真源。
45
+
46
+ ### 观察成功
47
+
48
+ 成功的替换会立即显示编辑后的 UI,无需页面重载,且插件在替换后继续工作。请记住权衡:被重载插件内的 React 状态会丢失,而会话、工作区与连接状态会保留。
49
+
50
+ -----
51
+
52
+ <a id="understand-the-implementation"></a>
53
+ ## 理解实现
54
+
55
+ <details>
56
+ <summary>实现细节——点击展开</summary>
57
+
58
+ 本节解释重载链路的构建方式;可观察行为已在[使用本包](#use-this-package)中说明。
59
+
60
+ ### 设计理念
6
61
 
7
- 浏览器侧订阅系统 SSE(Server-Sent Events)通道(`GET /plugins/events`),每个 `rebuilt` 帧重载一个插件,并通过队列串行执行。每帧的顺序是:`invalidate`、`prefetch`(旧 fiber 仍在服务时加载并注册新组合包)、`registry.delete`(在 fiber dispose(资源释放)之前执行:仅 dispose fiber 会触发 vendored Loader self-dispose 分支,把配置项标为禁用)、排空旧 fiber、删除 `entry.fiber`、移除自身拥有的 `<style data-plugin>` 标签、通过 `entry.refresh()` 重新导入并挂载、通过 `fiber.await()` 直接重新抛出启动失败。依赖方由 Cordis 自身重载:fiber 的激活 epoch 会串联其服务提供方的 uid,因此替换提供方 fiber 会级联所有依赖方,无需客户端图分析。node 侧使用一个 interval 检测重建:从同步基线开始 stat-poll 每个图组合包;新增一行后立即重新计算 hash;缺失行保持 dirty;只广播真实 rev 变更。因此,任何生成组合包的 tsdown watch 进程都能触发 HMR(热模块替换),无需 builder→host 通道。
62
+ 链路分为两半,共用一份约定:node 半侧负责 bundle 检测与通知,浏览器半侧负责替换。node 半侧运行一个 interval,从 module host 读取文件前的基线开始 stat 轮询每个图 bundle。未变化的启动 row 无需读取内容或求 hash 即可开始监视;发生变化的 row,或产物恢复后的 dirty row,会进入 `rebuilt()`,且只广播真实 revision 变更。`rebuilt()` 会把当前 source map 与已变化的 bundle 一起读取;仅写入 map 不会重载可执行代码。node 半侧还提供 `/plugins/events`,一个广播 `graph` `rebuilt` 帧的 SSE 通道。
8
63
 
64
+ ### 浏览器侧替换
65
+
66
+ 收到 `rebuilt` 帧后,帧内 revision 会让 `invalidate` 选择该插件不可变的单资源 combo URL,而不是初始多资源 URL。`prefetch` 在旧 fiber 仍在服务时加载并注册新 factory。其余顺序是:先注册表后拆卸(在 fiber 的 disposer 发出 `internal/plugin` 之前执行 `registry.delete`,否则 vendored Loader 会把该 entry 标为禁用)、排空旧 fiber 的卸载、删除 `entry.fiber`、移除自身拥有的 `<style data-plugin>` 标签,然后 `entry.refresh()` 重新导入并挂载,`fiber.await()` 直接把启动失败重新抛出。替换之所以安全,是因为在惰性 CJS 模型下执行只是注册:每个模块副作用都位于 factory 闭包中,在物化时运行。
67
+
68
+ ### 级联与自重载
69
+
70
+ fiber 的激活 epoch 会串联其服务提供方的 uid,因此替换提供方 fiber 会通过 cordis 自身零 HMR 簿记地级联所有依赖方。本插件本身也是一个图 entry,因此 `rebuilt` 帧可能点名它;进行中的重载在旧 bundle 的闭包中继续运行,新 bundle 的 apply 会打开全新通道。
71
+
72
+ ### 失败策略
73
+
74
+ 不回滚:导入失败会让 entry 失去 fiber(下一个 `rebuilt` 帧从头重试),apply 失败则会在外壳的状态投影中留下 FAILED fiber。两者都会大声记录日志。
75
+
76
+ ### 源码地图
77
+
78
+ | 文件 | 职责 |
79
+ |---|---|
80
+ | [`src/index.ts`](src/index.ts) | node 半侧:bundle stat 轮询、`rebuilt` 上报、`/plugins/events` SSE 通道 |
81
+ | [`src/client/index.ts`](src/client/index.ts) | 浏览器半侧:SSE 订阅、串行重载队列、fiber 替换 |
82
+ | [`src/events.ts`](src/events.ts) | 共享帧类型(`graph` / `rebuilt`)与端点常量 |
83
+
84
+ </details>
85
+
86
+ -----
87
+
88
+ <a id="further-exploration"></a>
89
+ ## 进一步探索
90
+
91
+ 当重载约定不够用时阅读以下页面:提供 bundle 的模块系统、启动它们的外壳,以及 external 背后的模块图规则。
92
+
93
+ - [客户端模块系统](../modules/README.zh.md)——本驱动器驱动的惰性 CJS 模块表与 `invalidate`/`prefetch` 钩子。
94
+ - [Web 启动内核](../web/README.zh.md)——启动插件树并展示 entry 状态的外壳。
95
+ - [客户端组地图](../README.zh.md)——本包重载的浏览器半侧。
96
+ - [生成配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-client-hmr)——每个受支持配置字段及其源声明。
97
+
98
+ -----
99
+
100
+ <a id="model-experience"></a>
9
101
  ## 模型体验
10
102
 
11
- 无。重载驱动器属于浏览器侧机制;这里没有任何内容进入模型请求。
103
+ 无。重载驱动器属于浏览器侧 UI 插件层,不注册任何面向模型的内容。
12
104
 
13
105
  #### KV Cache 影响
14
106
 
15
- 无;该包(package)既不组装也不发送提供方请求。
107
+ 无;该包既不组装也不发送提供方请求。
108
+
109
+ ## 已知限制与延期工作
110
+
111
+ <a id="known-limitations-and-deferred-work"></a>
112
+
113
+
114
+ 这些限制说明重载驱动器不会保留或恢复什么。它们是当前包约束,不是任务积压。
115
+
116
+ - **重载有意保持粗粒度**——全新 fiber 与全新组件;被重载插件内的 React 状态会丢失,而数据层(connection/runtime fiber、Session 对象)不受影响。react-refresh 级状态保留与重新执行 bundle 冲突,因此有意排除。
117
+ - **失败时不回滚**——失败的重载会让该 entry 保持 FAILED 并在 loader 状态投影中可见;系统不会自动恢复先前 bundle。
118
+ - **重建帧不会替换启动图**——每个帧都携带单资源 combo 重载所需的插件产物 revision;页面重载时才接收重新组合的启动图。
119
+
120
+ <a id="dev-note"></a>
121
+ ### 开发备注
122
+
123
+ <details>
124
+ <summary>维护者的工作上下文——点击展开</summary>
16
125
 
17
- ## 已知限制与暂缓事项
126
+ 无。
18
127
 
19
- - **重载有意保持粗粒度**:会创建全新的 fiber 和组件;重载插件中的 React 状态会丢失,数据层(连接 fiber、运行时 fiber 和 Session 对象)不受影响。react-refresh 级状态保留与「重新执行组合包会重新运行 factory」冲突,因此有意排除。
20
- - **失败时不回滚**:失败的重载会使配置项处于 FAILED 状态,并在 loader 状态投影中显示;系统不会自动恢复先前组合包。
21
- - **重建帧不会刷新图 rev**:陈旧 rev 无害,因为组合包端点以 no-cache 提供内容;只有重新连接时才会刷新。
128
+ </details>
package/lib/client.js CHANGED
@@ -11,6 +11,33 @@ window.__ModuleLoader__.load({
11
11
  * browser half validates them at its JSON parse point; sharing the type keeps
12
12
  * the two ends from drifting, not from parsing.
13
13
  */
14
+ /**
15
+ * Validate one JSON-decoded SSE payload before it can mutate module state.
16
+ * @param value - Parsed JSON value from the EventSource message.
17
+ * @returns the known frame, an unknown-type marker, or an invalid marker.
18
+ */
19
+ function parsePluginsEventFrame(value) {
20
+ if (typeof value !== "object" || value === null) return { kind: "invalid" };
21
+ const record = value;
22
+ switch (record.type) {
23
+ case "rebuilt": return typeof record.id === "string" && typeof record.rev === "string" ? {
24
+ kind: "frame",
25
+ frame: {
26
+ type: "rebuilt",
27
+ id: record.id,
28
+ rev: record.rev
29
+ }
30
+ } : { kind: "invalid" };
31
+ case "graph": return typeof record.graph === "object" && record.graph !== null ? {
32
+ kind: "frame",
33
+ frame: {
34
+ type: "graph",
35
+ graph: record.graph
36
+ }
37
+ } : { kind: "invalid" };
38
+ default: return typeof record.type === "string" ? { kind: "unknown" } : { kind: "invalid" };
39
+ }
40
+ }
14
41
  /** System SSE endpoint pushing graph/rebuilt frames (wire protocol constant). */
15
42
  const EVENTS_ENDPOINT = "/plugins/events";
16
43
  //#endregion
@@ -35,13 +62,13 @@ window.__ModuleLoader__.load({
35
62
  function apply(ctx) {
36
63
  const modLoader = ctx.modules;
37
64
  const loader = ctx.loader;
38
- async function reload(id) {
65
+ async function reload(id, rev) {
39
66
  const entry = findEntry(loader, id);
40
67
  if (entry === void 0) {
41
68
  ctx.logger.warn(`client-hmr: rebuilt frame for unknown entry "${id}" (not in the loader tree)`);
42
69
  return;
43
70
  }
44
- modLoader.invalidate(id);
71
+ modLoader.invalidate(id, rev);
45
72
  await modLoader.prefetch(id);
46
73
  const oldFiber = entry.fiber;
47
74
  if (oldFiber !== void 0) {
@@ -58,7 +85,7 @@ window.__ModuleLoader__.load({
58
85
  const handle = (frame) => {
59
86
  switch (frame.type) {
60
87
  case "rebuilt":
61
- queue = queue.then(() => reload(frame.id)).catch((error) => {
88
+ queue = queue.then(() => reload(frame.id, frame.rev)).catch((error) => {
62
89
  ctx.logger.error(`client-hmr: reload of "${frame.id}" failed`);
63
90
  ctx.logger.error(error);
64
91
  });
@@ -70,14 +97,16 @@ window.__ModuleLoader__.load({
70
97
  ctx.effect(() => {
71
98
  const source = new EventSource(EVENTS_ENDPOINT);
72
99
  source.addEventListener("message", (event) => {
73
- let frame;
100
+ let value;
74
101
  try {
75
- frame = JSON.parse(event.data);
102
+ value = JSON.parse(event.data);
76
103
  } catch {
77
104
  ctx.logger.warn(`client-hmr: unparseable event frame: ${event.data}`);
78
105
  return;
79
106
  }
80
- handle(frame);
107
+ const parsed = parsePluginsEventFrame(value);
108
+ if (parsed.kind === "invalid") ctx.logger.warn(`client-hmr: invalid event frame: ${event.data}`);
109
+ else if (parsed.kind === "frame") handle(parsed.frame);
81
110
  });
82
111
  return () => {
83
112
  source.close();
package/lib/index.js CHANGED
@@ -1,20 +1,14 @@
1
1
  import { statSync } from "node:fs";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  //#region lib/types/events.js
4
- /**
5
- * Wire protocol of the `/plugins/events` dev SSE channel — single source for
6
- * both halves of this package. Frames still cross a wire boundary: the
7
- * browser half validates them at its JSON parse point; sharing the type keeps
8
- * the two ends from drifting, not from parsing.
9
- */
10
4
  /** System SSE endpoint pushing graph/rebuilt frames (wire protocol constant). */
11
5
  const EVENTS_ENDPOINT = "/plugins/events";
12
6
  //#endregion
13
7
  //#region lib/types/index.js
14
8
  /**
15
9
  * HMR plugin, node half: the host end of the dev reload chain. One interval
16
- * stat-polls every graph row's client bundle (polling by design: network
17
- * mounts deliver no inotify events), reports content changes through
10
+ * stat-polls every graph row's client bundle (polling by design: network mounts
11
+ * deliver no inotify events), reports changes through
18
12
  * `clientModuleHost.rebuilt(id)`, and serves the `/plugins/events` SSE channel
19
13
  * broadcasting graph/rebuilt frames to the browser half (src/client/).
20
14
  * The web bundle mounts this row unconditionally: without a rebuild
@@ -30,6 +24,18 @@ const Config = z.object({ pollIntervalMs: z.number().step(1).min(1).default(500)
30
24
  function sseData(frame) {
31
25
  return `data: ${JSON.stringify(frame)}\n\n`;
32
26
  }
27
+ /** Snapshot the executable bundle metadata that drives reloads. */
28
+ function bundleStat(path) {
29
+ const bundle = statSync(path);
30
+ return {
31
+ mtimeMs: bundle.mtimeMs,
32
+ size: bundle.size
33
+ };
34
+ }
35
+ /** Whether the executable bundle is unchanged since the last successful re-hash. */
36
+ function sameBundleStat(left, right) {
37
+ return left.mtimeMs === right.mtimeMs && left.size === right.size;
38
+ }
33
39
  /**
34
40
  * Mount the dev chain: bundle watches, rebuilt reporting, and the SSE channel.
35
41
  * @param ctx - host plugin context carrying clientModuleHost and webServer.
@@ -52,54 +58,47 @@ function apply(ctx, config) {
52
58
  watch.size = current.size;
53
59
  watch.dirty = false;
54
60
  };
55
- const watchRow = (id, path) => {
56
- let baseline;
61
+ const watchRow = (id, baseline) => {
62
+ const watch = {
63
+ ...baseline,
64
+ dirty: false
65
+ };
66
+ watched.set(id, watch);
67
+ let current;
57
68
  try {
58
- baseline = statSync(path);
69
+ current = bundleStat(baseline.path);
59
70
  } catch (error) {
60
- watched.set(id, {
61
- path,
62
- mtimeMs: 0,
63
- size: 0,
64
- dirty: true
65
- });
71
+ watch.dirty = true;
66
72
  if (error.code !== "ENOENT") ctx.logger.warn(error);
67
73
  return;
68
74
  }
69
- const watch = {
70
- path,
71
- mtimeMs: baseline.mtimeMs,
72
- size: baseline.size,
73
- dirty: false
74
- };
75
- watched.set(id, watch);
76
- rehash(id, watch, baseline);
75
+ if (!sameBundleStat(current, watch)) rehash(id, watch, current);
77
76
  };
78
77
  const pollWatches = () => {
79
78
  for (const [id, watch] of watched) {
80
79
  let current;
81
80
  try {
82
- current = statSync(watch.path);
81
+ current = bundleStat(watch.path);
83
82
  } catch (error) {
84
83
  watch.dirty = true;
85
84
  if (error.code !== "ENOENT") ctx.logger.warn(error);
86
85
  continue;
87
86
  }
88
- if (!watch.dirty && current.mtimeMs === watch.mtimeMs && current.size === watch.size) continue;
87
+ if (!watch.dirty && sameBundleStat(current, watch)) continue;
89
88
  rehash(id, watch, current);
90
89
  }
91
90
  };
92
91
  const syncWatches = () => {
93
92
  const rows = /* @__PURE__ */ new Map();
94
93
  for (const row of ctx.clientModules.graph().entries) {
95
- const path = ctx.clientModules.clientPath(row.id);
96
- if (path !== void 0) rows.set(row.id, path);
94
+ const watch = ctx.clientModules.artifactBaseline(row.id);
95
+ if (watch !== void 0) rows.set(row.id, watch);
97
96
  }
98
97
  for (const [id, watch] of watched) {
99
- if (rows.get(id) === watch.path) continue;
98
+ if (rows.get(id)?.path === watch.path) continue;
100
99
  watched.delete(id);
101
100
  }
102
- for (const [id, path] of rows) if (!watched.has(id)) watchRow(id, path);
101
+ for (const [id, watch] of rows) if (!watched.has(id)) watchRow(id, watch);
103
102
  };
104
103
  ctx.effect(() => {
105
104
  syncWatches();
@@ -14,6 +14,21 @@ export type PluginsEventFrame = {
14
14
  id: string;
15
15
  rev: string;
16
16
  };
17
+ /** Browser wire-parse result: known frame, forward-compatible unknown type, or malformed payload. */
18
+ export type PluginsEventParseResult = {
19
+ kind: 'frame';
20
+ frame: PluginsEventFrame;
21
+ } | {
22
+ kind: 'unknown';
23
+ } | {
24
+ kind: 'invalid';
25
+ };
26
+ /**
27
+ * Validate one JSON-decoded SSE payload before it can mutate module state.
28
+ * @param value - Parsed JSON value from the EventSource message.
29
+ * @returns the known frame, an unknown-type marker, or an invalid marker.
30
+ */
31
+ export declare function parsePluginsEventFrame(value: unknown): PluginsEventParseResult;
17
32
  /** System SSE endpoint pushing graph/rebuilt frames (wire protocol constant). */
18
33
  export declare const EVENTS_ENDPOINT = "/plugins/events";
19
34
  //# sourceMappingURL=events.d.ts.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@deepseek-ai/dsh-client-hmr",
3
3
  "description": "Dev-only hot-reload driver for script-loaded client entries: SSE rebuilt frames → invalidate/prefetch → fiber swap through the vendored Loader entry",
4
- "version": "0.1.1-rc.1",
4
+ "version": "0.1.2-alpha.2",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -38,21 +38,17 @@
38
38
  },
39
39
  "license": "MIT",
40
40
  "dependencies": {
41
- "@deepseek-ai/schemastery": "^3.18.1"
41
+ "@deepseek-ai/schemastery": "^3.18.2"
42
42
  },
43
43
  "peerDependencies": {
44
- "@deepseek-ai/cordis-plugin-loader": "^1.0.2",
45
- "@deepseek-ai/dsh-client-modules": "^0.1.1-rc.1",
46
- "@deepseek-ai/dsh-host-webserver": "^0.1.1-rc.1",
47
- "@deepseek-ai/dsh-invariants": "^0.1.1-rc.1",
48
- "@deepseek-ai/cordis": "^4.0.1"
44
+ "@deepseek-ai/cordis": "^4.0.2"
49
45
  },
50
46
  "devDependencies": {
51
- "@deepseek-ai/cordis-plugin-loader": "^1.0.2",
52
- "@deepseek-ai/dsh-client-modules": "^0.1.1-rc.1",
53
- "@deepseek-ai/dsh-host-webserver": "^0.1.1-rc.1",
54
- "@deepseek-ai/dsh-invariants": "^0.1.1-rc.1",
55
- "@deepseek-ai/cordis": "^4.0.1"
47
+ "@deepseek-ai/cordis-plugin-loader": "^1.0.3",
48
+ "@deepseek-ai/dsh-host-webserver": "^0.1.2-alpha.2",
49
+ "@deepseek-ai/dsh-client-modules": "^0.1.2-alpha.2",
50
+ "@deepseek-ai/dsh-invariants": "^0.1.2-alpha.2",
51
+ "@deepseek-ai/cordis": "^4.0.2"
56
52
  },
57
53
  "files": [
58
54
  "lib/index.js",