@deepseek-ai/dsh-client-hmr 0.1.6-alpha.1 → 0.1.6-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: 26bc640c6cdd213fbc78de920e12444589901990
6
- README.zh.md: 72d941f678e7e29a56bca678a79c4740667e7e6b
5
+ README.md: e459da10332ffdb3af422b75f639ce25df512487
6
+ README.zh.md: 54e2f3af63472dedbfc75c5b4400b5d93e14f311
package/README.md CHANGED
@@ -1,5 +1,5 @@
1
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."
2
+ description: "Live graph synchronization and development bundle reloads for Web client plugins."
3
3
  kind: "package-reference"
4
4
  ---
5
5
 
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
9
9
 
10
10
  ## Summary
11
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.
12
+ `dsh-client-hmr` keeps open Web pages in sync with the Host plugin graph and reloads rebuilt browser bundles. Ordinary plugin enable/disable changes take effect without reloading the page or restarting the Host. Code rebuilds replace the affected plugin with fresh component state. The model sees no new input or output.
13
13
 
14
14
  ## Table of Contents
15
15
 
@@ -25,15 +25,15 @@ English | [中文](README.zh.md)
25
25
  <a id="use-this-package"></a>
26
26
  ## Use this package
27
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.
28
+ The shipped Web composition mounts this transport for live plugin changes. During development, a bundle watcher also supplies code rebuilds. Disabling the transport stops graph delivery to open pages.
29
29
 
30
30
  ### Starting the reload chain
31
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.
32
+ Run `pnpm run dev:web` (or a watch process using the shared Client tsdown preset) against the same host; rebuilt plugins are then swapped into the running browser automatically, one at a time. The preset stamps `lib/client.js` after all package-local chunks are written, so a chunk-only rebuild also advances the package revision without Host-side chunk scanning.
33
33
 
34
34
  ### What a reload does
35
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.
36
+ Each successful reload re-executes the plugin bundle and remounts the plugin with fresh state. Plugins that depend on the reloaded one reload with it automatically. Failures appear in the plugin list, where they can be retried without waiting for another rebuild.
37
37
 
38
38
  ### Configuration
39
39
 
@@ -59,11 +59,11 @@ This section explains how the reload chain is built; observable behavior is cove
59
59
 
60
60
  ### Design concept
61
61
 
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.
62
+ The Host half watches each package's stamped entry artifact and serves `/plugins/events`. It forwards existing graph-change and rebuilt notifications; every new connection receives the current full graph. A graph describes the browser’s desired entries and carries no Host cleanup-completion guarantee. Host activation and cleanup remain owned by the Host lifecycle. The entry bytes plus completed-build timestamp identify the revision; unchanged artifacts require no content read. The browser half delegates both frame kinds to Client Modules, which serializes entry changes and waits for browser resource cleanup.
63
63
 
64
64
  ### The browser swap
65
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 loudly. 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.
66
+ On a `rebuilt` frame, the controller invalidates the old module and prefetches its single-resource script while the old fiber still serves. It then deletes the registry runtime, drains the old fiber, clears its entry reference and removes owned styles. The module system materializes the new exports before `entry.refresh()` mounts them through Loader; this exposes import failures to page diagnostics even when Loader would only log them. CSS is injected after old effects have finished.
67
67
 
68
68
  ### Cascade and self-reload
69
69
 
@@ -71,14 +71,14 @@ A fiber's activation epoch strings its service providers' uids, so replacing a p
71
71
 
72
72
  ### Failure policy
73
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.
74
+ Download failures leave the running plugin active. After the old fiber is torn down, import or activation failure does not restore the previous bundle. Failures appear as page-local synchronization errors. Settings → Plugins → Plugin list retries the latest graph, even when its revision is unchanged; a later rebuild also retries the affected plugin. Successful unrelated plugins remain active.
75
75
 
76
76
  ### Source map
77
77
 
78
78
  | File | Role |
79
79
  |---|---|
80
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 |
81
+ | [`src/client/index.ts`](src/client/index.ts) | Browser half: SSE subscription and delegation to the shared entry controller |
82
82
  | [`src/events.ts`](src/events.ts) | Shared frame types (`graph` / `rebuilt`) and the endpoint constant |
83
83
 
84
84
  </details>
@@ -114,8 +114,8 @@ None; this package neither assembles nor sends a provider request.
114
114
  These limits define what the reload driver does not preserve or restore. They are current package constraints, not a task backlog.
115
115
 
116
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.
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.
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.
117
+ - **No failure rollback** — after the old fiber is torn down, a failed replacement does not restore the previous bundle.
118
+ - **Web transport only** — Electron installation and backend restart handling do not use this SSE path. Entry reconciliation itself is transport-independent.
119
119
 
120
120
  <a id="dev-note"></a>
121
121
  ### Dev Note
package/README.zh.md CHANGED
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: "仅用于开发环境的浏览器客户端插件热重载:重建插件 bundle 后原地替换运行中的插件,供开发者迭代 web GUI。"
2
+ description: "Web 客户端插件的动态图同步与开发时 bundle 重载。"
3
3
  kind: "package-reference"
4
4
  ---
5
5
 
@@ -9,7 +9,7 @@ kind: "package-reference"
9
9
 
10
10
  ## 概述
11
11
 
12
- `dsh-client-hmr` 会在浏览器客户端插件的 bundle 重建后原地重载该插件,让编辑插件源码的开发者无需整页刷新即可看到变更。如果没有重建 watcher,整条链路保持空闲:只有 `pnpm run dev:web` 之类的进程重写客户端 bundle 时才会产生它所响应的重建。每次重载只替换一个插件并携带全新组件状态,而数据层(连接、运行时与 Session 对象)保持不变。这里的一切都是浏览器侧的开发机制;模型永远看不到它。
12
+ `dsh-client-hmr` 让已打开的 Web 页面与 Host 插件图保持同步,并重载重建后的浏览器 bundle。普通插件的启停无需刷新页面或重启 Host 即可生效。代码重建会替换受影响插件并重置其组件状态。模型不会收到新的输入或输出。
13
13
 
14
14
  ## 目录
15
15
 
@@ -25,15 +25,15 @@ kind: "package-reference"
25
25
  <a id="use-this-package"></a>
26
26
  ## 使用本包
27
27
 
28
- 为正在编辑的插件启用重建 watcher,然后保存:浏览器会从 dev server 拾取重建后的 bundle,并在不重载页面的情况下替换该插件。在客户端开发期间使用它;在生产构建中没有任何可观察行为,因为没有 watcher 会重写 bundle。
28
+ 随包提供的 Web 组合挂载此传输,以交付插件动态变更。开发时,bundle watcher 还会提供代码重建。停用此传输会停止向已打开的页面交付图更新。
29
29
 
30
30
  ### 启动重载链路
31
31
 
32
- 对同一个宿主运行 `pnpm run dev:web`(或任何写入插件 `lib/client.js` tsdown watch 进程);重建后的插件随后会被自动逐个替换进运行中的浏览器。
32
+ 对同一个宿主运行 `pnpm run dev:web`(或使用共享 Client tsdown 预设的 watch 进程);重建后的插件随后会被自动逐个替换进运行中的浏览器。该预设会在所有包内 chunk 写完后标记 `lib/client.js`,因此仅 chunk 发生重建也会推进包 revision,无需 Host 扫描 chunk。
33
33
 
34
34
  ### 一次重载做什么
35
35
 
36
- 每次重载都会重新执行插件 bundle,并用全新状态重新挂载插件。依赖被重载插件的插件会随之自动重载。失败的重载会以可见方式报告,并在下一次重建时从头重试。
36
+ 每次成功的重载都会重新执行插件 bundle,并用全新状态重新挂载插件。依赖被重载插件的插件会随之自动重载。失败会显示在插件列表中,可直接重试,无需等待下一次重建。
37
37
 
38
38
  ### 配置
39
39
 
@@ -59,11 +59,11 @@ kind: "package-reference"
59
59
 
60
60
  ### 设计理念
61
61
 
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(Server-Sent Events)通道。
62
+ Host 半侧监听每个包带完成标记的入口产物,并提供 `/plugins/events`。它转发现有的图变化与重建通知;每个新连接都会收到当前完整图。图描述浏览器的目标条目,不保证 Host 清理已经完成。Host 的激活与清理仍由 Host 生命周期管理。入口字节与构建完成时间戳共同标识 revision;未变化的产物无需读取内容。浏览器半侧将两种帧都交给 Client Modules,由它串行处理条目变更并等待浏览器资源清理。
63
63
 
64
64
  ### 浏览器侧替换
65
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 闭包中,在物化时运行。
66
+ 收到 `rebuilt` 帧后,控制器使旧模块失效,并在旧 fiber 仍然服务时预取其单资源脚本。随后删除注册表 runtime、等待旧 fiber 清理、清除条目中的 fiber 引用并移除自身样式。模块系统先物化新导出,再由 `entry.refresh()` 通过 Loader 挂载;这样即使 Loader 只记录导入错误,页面诊断仍能获得失败原因。CSS 在旧 effect 完成清理后注入。
67
67
 
68
68
  ### 级联与自重载
69
69
 
@@ -71,14 +71,14 @@ fiber 的激活 epoch 会串联其服务提供方的 uid,因此替换提供方
71
71
 
72
72
  ### 失败策略
73
73
 
74
- 不回滚:导入失败会让 entry 失去 fiber(下一个 `rebuilt` 帧从头重试),apply 失败则会在外壳的状态投影中留下 FAILED fiber。两者都会输出醒目的错误日志。
74
+ 下载失败时,正在运行的插件保持活动。旧 fiber 被卸载后,导入或激活失败不会恢复先前的 bundle。失败会显示为当前页面的同步错误。「设置 插件 插件列表」会针对最新图重试,即使其 revision 未变化;后续重建也会重试受影响的插件。无关且已成功运行的插件保持活动。
75
75
 
76
76
  ### 源码地图
77
77
 
78
78
  | 文件 | 职责 |
79
79
  |---|---|
80
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 替换 |
81
+ | [`src/client/index.ts`](src/client/index.ts) | 浏览器半侧:SSE 订阅与共享条目控制器调用 |
82
82
  | [`src/events.ts`](src/events.ts) | 共享帧类型(`graph` / `rebuilt`)与端点常量 |
83
83
 
84
84
  </details>
@@ -114,8 +114,8 @@ fiber 的激活 epoch 会串联其服务提供方的 uid,因此替换提供方
114
114
  这些限制说明重载驱动器不会保留或恢复什么。它们是当前包约束,不是任务积压。
115
115
 
116
116
  - **重载有意保持粗粒度**——全新 fiber 与全新组件;被重载插件内的 React 状态会丢失,而数据层(连接 fiber、运行时 fiber、Session 对象)不受影响。react-refresh 级状态保留与重新执行 bundle 冲突,因此有意排除。
117
- - **失败时不回滚**——失败的重载会让该 entry 保持 FAILED 并在 loader 状态投影中可见;系统不会自动恢复先前 bundle。
118
- - **重建帧不会替换启动图**——每个帧都携带单资源 combo 重载所需的插件产物 revision;页面重载时才接收重新组合的启动图。
117
+ - **失败时不回滚**——旧 fiber 被卸载后,替换失败不会恢复先前的 bundle。
118
+ - **仅负责 Web 传输**——Electron 的安装和后端重启流程不使用此 SSE 路径。条目对账本身不依赖传输。
119
119
 
120
120
  <a id="dev-note"></a>
121
121
  ### 开发备注
package/lib/client.js CHANGED
@@ -6,13 +6,13 @@ window.__ModuleLoader__.load({
6
6
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
7
  //#region lib/types/events.js
8
8
  /**
9
- * Wire protocol of the `/plugins/events` dev SSE channel — single source for
9
+ * Wire protocol of the `/plugins/events` SSE channel — single source for
10
10
  * both halves of this package. Frames still cross a wire boundary: the
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
14
  /**
15
- * Validate one JSON-decoded SSE payload before it can mutate module state.
15
+ * Validate the frame envelope; the module controller parses the complete graph before updating its index.
16
16
  * @param value - Parsed JSON value from the EventSource message.
17
17
  * @returns the known frame, an unknown-type marker, or an invalid marker.
18
18
  */
@@ -44,66 +44,18 @@ window.__ModuleLoader__.load({
44
44
  //#region lib/types/client/index.js
45
45
  /** Cordis plugin name. */
46
46
  const name = "client-hmr";
47
- /** Required services: the vendored Loader (entry governance) and the client module system (boot provide, service name `modules`). */
48
- const inject = ["loader", "modules"];
47
+ /** Required service: the client module system whose entry controller handles received frames. */
48
+ const inject = ["modules"];
49
49
  /**
50
- * Registry-first teardown of an entry's running fiber so `entry.refresh()`
51
- * rebuilds it (see the module comment): delete the runtime record before the
52
- * fiber's disposer emits `internal/plugin` (or the Loader flags the entry
53
- * disabled), drain the unload so effect disposers finish before a new apply
54
- * re-registers, then clear `entry.fiber` so `refresh()` re-imports instead of
55
- * no-oping. A fiberless entry is left untouched.
56
- * @param entry - the Loader entry to tear down.
57
- */
58
- async function tearDownEntryFiber(entry) {
59
- const oldFiber = entry.fiber;
60
- if (oldFiber === void 0) return;
61
- const runtime = oldFiber.runtime;
62
- if (runtime !== null) entry.ctx.registry.delete(runtime.callback);
63
- while (oldFiber.inertia !== void 0) await oldFiber.inertia;
64
- delete entry.fiber;
65
- }
66
- /** Find the loader entry whose module specifier is `id` (entry tree ids are random; the package name lives in `options.name`). */
67
- function findEntry(loader, id) {
68
- for (const entry of loader.entries()) if (entry.options.name === id) return entry;
69
- }
70
- /** Remove every `<style data-plugin>` tag owned by `id` (attribute compared verbatim — no CSS-selector escaping pitfalls). */
71
- function removeOwnedStyles(id) {
72
- for (const el of document.querySelectorAll("style[data-plugin]")) if (el.getAttribute("data-plugin") === id) el.remove();
73
- }
74
- /**
75
- * Mount the HMR driver: subscribe to the system SSE channel and hot-swap
76
- * rebuilt entries.
77
- * @param ctx - plugin context with `loader` and `modules` available.
50
+ * Forward graph snapshots and rebuilds to the page's shared serial controller.
51
+ * @param ctx - Plugin context with the client module system.
78
52
  */
79
53
  function apply(ctx) {
80
- const modLoader = ctx.modules;
81
- const loader = ctx.loader;
82
- async function reload(id, rev) {
83
- const entry = findEntry(loader, id);
84
- if (entry === void 0) {
85
- ctx.logger.warn(`client-hmr: rebuilt frame for unknown entry "${id}" (not in the loader tree)`);
86
- return;
87
- }
88
- modLoader.invalidate(id, rev);
89
- await modLoader.prefetch(id);
90
- await tearDownEntryFiber(entry);
91
- removeOwnedStyles(id);
92
- await entry.refresh();
93
- await entry.fiber?.await();
94
- }
95
- let queue = Promise.resolve();
54
+ const entries = ctx.modules.entries;
96
55
  const handle = (frame) => {
97
- switch (frame.type) {
98
- case "rebuilt":
99
- queue = queue.then(() => reload(frame.id, frame.rev)).catch((error) => {
100
- ctx.logger.error(`client-hmr: reload of "${frame.id}" failed`);
101
- ctx.logger.error(error);
102
- });
103
- break;
104
- case "graph": break;
105
- default: break;
106
- }
56
+ (frame.type === "graph" ? Promise.resolve().then(() => entries.sync(frame.graph)) : entries.reload(frame.id, frame.rev)).catch((error) => {
57
+ ctx.logger.error(error);
58
+ });
107
59
  };
108
60
  ctx.effect(() => {
109
61
  const source = new EventSource(EVENTS_ENDPOINT);
@@ -129,7 +81,6 @@ window.__ModuleLoader__.load({
129
81
  exports.apply = apply;
130
82
  exports.inject = inject;
131
83
  exports.name = name;
132
- exports.tearDownEntryFiber = tearDownEntryFiber;
133
84
  return module.exports;
134
85
  }
135
86
  });
package/lib/index.js CHANGED
@@ -6,18 +6,17 @@ const EVENTS_ENDPOINT = "/plugins/events";
6
6
  //#endregion
7
7
  //#region lib/types/index.js
8
8
  /**
9
- * HMR plugin, node half: the host end of the dev reload chain. One interval
9
+ * Host transport for Web client graph changes and rebuilt bundles. One interval
10
10
  * stat-polls every graph row's client bundle (polling by design: network mounts
11
11
  * deliver no inotify events), reports changes through
12
12
  * `clientModules.rebuilt(id)`, and serves the `/plugins/events` SSE channel
13
13
  * broadcasting graph/rebuilt frames to the browser half (src/client/).
14
- * The web bundle mounts this row unconditionally: without a rebuild
15
- * watcher rewriting client bundles, the poll observes no changes and the
16
- * chain stays idle.
14
+ * The Web composition mounts this transport for live graph updates;
15
+ * a development rebuild watcher also supplies bundle changes.
17
16
  */
18
17
  /** Cordis plugin name. */
19
18
  const name = "client-hmr";
20
- /** Required services: the web plugin table and the route registry. */
19
+ /** Required services: the client graph and Web route registry. */
21
20
  const inject = ["clientModules", "webServer"];
22
21
  const Config = z.object({ pollIntervalMs: z.number().step(1).min(1).default(500) });
23
22
  /** Serialize one frame as an SSE data line. */
@@ -37,7 +36,7 @@ function sameBundleStat(left, right) {
37
36
  return left.mtimeMs === right.mtimeMs && left.size === right.size;
38
37
  }
39
38
  /**
40
- * Mount the dev chain: bundle watches, rebuilt reporting, and the SSE channel.
39
+ * Mount bundle watches and graph/rebuilt SSE delivery.
41
40
  * @param ctx - host plugin context carrying clientModules and webServer.
42
41
  * @param config - validated {@link Config}.
43
42
  */
@@ -112,6 +111,13 @@ function apply(ctx, config) {
112
111
  };
113
112
  }, "client-hmr: bundle watches");
114
113
  const connections = /* @__PURE__ */ new Set();
114
+ const publishGraph = () => {
115
+ const line = sseData({
116
+ type: "graph",
117
+ graph: ctx.clientModules.graph()
118
+ });
119
+ for (const res of connections) res.write(line);
120
+ };
115
121
  const connect = (res) => {
116
122
  res.writeHead(200, {
117
123
  "content-type": "text/event-stream",
@@ -119,11 +125,11 @@ function apply(ctx, config) {
119
125
  "connection": "keep-alive"
120
126
  });
121
127
  res.write(": connected\n\n");
128
+ connections.add(res);
122
129
  res.write(sseData({
123
130
  type: "graph",
124
131
  graph: ctx.clientModules.graph()
125
132
  }));
126
- connections.add(res);
127
133
  res.on("close", () => {
128
134
  connections.delete(res);
129
135
  });
@@ -141,6 +147,7 @@ function apply(ctx, config) {
141
147
  connect(res);
142
148
  }
143
149
  });
150
+ const unsubscribeGraph = ctx.clientModules.onGraphChanged(publishGraph);
144
151
  const unsubscribe = ctx.clientModules.onRebuilt((id, rev) => {
145
152
  const line = sseData({
146
153
  type: "rebuilt",
@@ -150,6 +157,7 @@ function apply(ctx, config) {
150
157
  for (const res of connections) res.write(line);
151
158
  });
152
159
  return () => {
160
+ unsubscribeGraph();
153
161
  unsubscribe();
154
162
  disposeRoute();
155
163
  for (const res of connections) res.destroy();
@@ -1,88 +1,14 @@
1
- /**
2
- * client-hmr, browser half: hot-reload driver for client plugin entries.
3
- *
4
- * Listens on the host's system SSE channel (`GET /plugins/events`); on a
5
- * `rebuilt` frame it reloads the entry's bundle and swaps the cordis
6
- * fiber in place. Every graph entry is a plugin bundle
7
- * — `immediately` rows differ only in stage-one prefetch (a boot
8
- * optimization), so all rostered plugin packages share these reload semantics;
9
- * normal packages (react family, cordis, shell, pure libs) are not entries
10
- * and shell changes still mean a page reload. Cascade is zero-touch:
11
- * downstream fibers key their activation epoch on provider fiber uids
12
- * (vendor/cordis/src/fiber.ts `_refresh`), so replacing a provider fiber
13
- * re-cascades natively — reloading a data-layer plugin (connection/runtime)
14
- * cascades into its UI dependents with no HMR-side bookkeeping.
15
- *
16
- * Reload order (lazy CJS table): invalidate (drop the stale factory and
17
- * materialized record) → prefetch (load and register the fresh
18
- * factory) → registry-first teardown → drain old fiber unload → remove
19
- * owned `<style data-plugin>` tags → `entry.refresh()` materializes the new
20
- * factory. Invalidate MUST precede prefetch: a live factory makes prefetch
21
- * a no-op, and re-executing a bundle over an undeleted registration is a
22
- * loud duplicate. The swap is safe because execution is pure registration
23
- * under the lazy model — every module side effect (CSS injection included)
24
- * lives in the factory closure and runs at materialization, inside
25
- * refresh(). That also keeps the CSS ordering guarantee: owned styles are
26
- * removed after the old fiber's disposers drained (SlotCore one-owner
27
- * unregister) and before materialization re-injects tags under the same
28
- * stable tag ids.
29
- *
30
- * Failure window: if prefetch rejects after invalidate, the module is left
31
- * unregistered while the OLD fiber keeps running untouched (teardown never
32
- * started) — degraded but recoverable, the next rebuilt frame retries from
33
- * scratch. Consistent with the no-rollback policy below. Known dev-only
34
- * race: a rebuilt frame overlapping a still-in-flight boot arrival shares
35
- * that arrival's task and may materialize the pre-rebuild bytes; the next
36
- * rebuilt frame self-heals.
37
- *
38
- * Why not the naive `entry.fiber.dispose()` → `entry.refresh()` path:
39
- * 1. `Entry.fiber` is never cleared on dispose (vendor/loader/src/config/
40
- * entry.ts assigns it only in `_init`), so `refresh()` hits its
41
- * `if (this.fiber) return` guard and no-ops.
42
- * 2. A bare `fiber.dispose()` lands in Loader's self-dispose branch
43
- * (vendor/loader/src/index.ts `internal/plugin` case 4: the registry
44
- * still holds the runtime at emit time), which flags the entry
45
- * `disabled: true` — permanently.
46
- * vendor/hmr's reload skeleton documents the fix: delete the runtime record
47
- * FIRST (`registry.delete` → case 4 returns early, the entry stays enabled),
48
- * then rebuild. `entry.fiber` is additionally cleared so
49
- * `entry.refresh()` re-imports and re-plugins through the Loader's own
50
- * `_init` (entry-resolved config, automatic `fiber.entry` rebinding) instead
51
- * of hand-rolling `registry.plugin`. Client entries have exactly one fiber
52
- * per runtime, so `registry.delete` never collaterally disposes siblings.
53
- *
54
- * Self-reload: this plugin is itself a graph entry, so a rebuilt frame may
55
- * name it. The in-flight reload keeps running in the old bundle's closure
56
- * (its EventSource closes with the old fiber's effects); the new bundle's
57
- * apply opens a fresh channel. Frames arriving during the gap are lost —
58
- * acceptable for the dev channel, the next rebuild renotifies.
59
- *
60
- * Failure policy: no rollback. An import failure leaves the entry
61
- * fiberless (the next rebuilt frame retries from scratch); an apply failure
62
- * leaves a FAILED fiber for the shell's status projection. Both log loudly.
63
- */
1
+ /** Web SSE transport for page-owned client entry reconciliation and rebuilt code replacement. */
64
2
  import type { Context } from '@deepseek-ai/cordis';
65
- import type { Entry } from '@deepseek-ai/cordis-plugin-loader';
66
3
  export type { PluginsEventFrame } from '../events.ts';
67
4
  export { EVENTS_ENDPOINT } from '../events.ts';
68
5
  /** Cordis plugin name. */
69
6
  export declare const name = "client-hmr";
70
- /** Required services: the vendored Loader (entry governance) and the client module system (boot provide, service name `modules`). */
7
+ /** Required service: the client module system whose entry controller handles received frames. */
71
8
  export declare const inject: string[];
72
9
  /**
73
- * Registry-first teardown of an entry's running fiber so `entry.refresh()`
74
- * rebuilds it (see the module comment): delete the runtime record before the
75
- * fiber's disposer emits `internal/plugin` (or the Loader flags the entry
76
- * disabled), drain the unload so effect disposers finish before a new apply
77
- * re-registers, then clear `entry.fiber` so `refresh()` re-imports instead of
78
- * no-oping. A fiberless entry is left untouched.
79
- * @param entry - the Loader entry to tear down.
80
- */
81
- export declare function tearDownEntryFiber(entry: Entry): Promise<void>;
82
- /**
83
- * Mount the HMR driver: subscribe to the system SSE channel and hot-swap
84
- * rebuilt entries.
85
- * @param ctx - plugin context with `loader` and `modules` available.
10
+ * Forward graph snapshots and rebuilds to the page's shared serial controller.
11
+ * @param ctx - Plugin context with the client module system.
86
12
  */
87
13
  export declare function apply(ctx: Context): void;
88
14
  //# sourceMappingURL=index.d.ts.map
@@ -1,11 +1,11 @@
1
1
  /**
2
- * Wire protocol of the `/plugins/events` dev SSE channel — single source for
2
+ * Wire protocol of the `/plugins/events` SSE channel — single source for
3
3
  * both halves of this package. Frames still cross a wire boundary: the
4
4
  * browser half validates them at its JSON parse point; sharing the type keeps
5
5
  * the two ends from drifting, not from parsing.
6
6
  */
7
7
  import type { WebBootGraph } from '@deepseek-ai/dsh-client-modules';
8
- /** One SSE frame: the full graph on connect, or one rebuilt bundle notice. */
8
+ /** One SSE frame: the settled graph on connection or change, or one rebuilt bundle notice. */
9
9
  export type PluginsEventFrame = {
10
10
  type: 'graph';
11
11
  graph: WebBootGraph;
@@ -17,14 +17,19 @@ export type PluginsEventFrame = {
17
17
  /** Browser wire-parse result: known frame, forward-compatible unknown type, or malformed payload. */
18
18
  export type PluginsEventParseResult = {
19
19
  kind: 'frame';
20
- frame: PluginsEventFrame;
20
+ frame: {
21
+ type: 'graph';
22
+ graph: unknown;
23
+ } | Extract<PluginsEventFrame, {
24
+ type: 'rebuilt';
25
+ }>;
21
26
  } | {
22
27
  kind: 'unknown';
23
28
  } | {
24
29
  kind: 'invalid';
25
30
  };
26
31
  /**
27
- * Validate one JSON-decoded SSE payload before it can mutate module state.
32
+ * Validate the frame envelope; the module controller parses the complete graph before updating its index.
28
33
  * @param value - Parsed JSON value from the EventSource message.
29
34
  * @returns the known frame, an unknown-type marker, or an invalid marker.
30
35
  */
@@ -4,7 +4,7 @@ export type { PluginsEventFrame } from './events.ts';
4
4
  export { EVENTS_ENDPOINT } from './events.ts';
5
5
  /** Cordis plugin name. */
6
6
  export declare const name = "client-hmr";
7
- /** Required services: the web plugin table and the route registry. */
7
+ /** Required services: the client graph and Web route registry. */
8
8
  export declare const inject: string[];
9
9
  /** Plugin config, validated by the same-named schemastery schema. */
10
10
  export interface Config {
@@ -13,7 +13,7 @@ export interface Config {
13
13
  }
14
14
  export declare const Config: z<Config>;
15
15
  /**
16
- * Mount the dev chain: bundle watches, rebuilt reporting, and the SSE channel.
16
+ * Mount bundle watches and graph/rebuilt SSE delivery.
17
17
  * @param ctx - host plugin context carrying clientModules and webServer.
18
18
  * @param config - validated {@link Config}.
19
19
  */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@deepseek-ai/dsh-client-hmr",
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.6-alpha.1",
3
+ "description": "Web client graph synchronization and rebuilt-bundle reload transport",
4
+ "version": "0.1.6-alpha.2",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -47,9 +47,9 @@
47
47
  },
48
48
  "devDependencies": {
49
49
  "@deepseek-ai/cordis-plugin-loader": "^1.0.3",
50
- "@deepseek-ai/dsh-client-modules": "^0.1.6-alpha.1",
51
- "@deepseek-ai/dsh-host-webserver": "^0.1.6-alpha.1",
52
- "@deepseek-ai/dsh-invariants": "^0.1.6-alpha.1",
50
+ "@deepseek-ai/dsh-client-modules": "^0.1.6-alpha.2",
51
+ "@deepseek-ai/dsh-invariants": "^0.1.6-alpha.2",
52
+ "@deepseek-ai/dsh-host-webserver": "^0.1.6-alpha.2",
53
53
  "@deepseek-ai/cordis": "^4.0.2"
54
54
  },
55
55
  "files": [