Merge pull request #4855 from deepseek-harness/fix/client-revisions-server-restart

fix(web): preserve sessions across server restarts
This commit is contained in:
imccyu authored and GitHub committed 2026-09-21 23:24:51 +08:00
commit d7c238a98d
24 files changed
+504 -76

No files matched your search

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md
2026-07-23-client-plugin-loading-model.md: a8d016a70792ad4ac6a71083677ce5d82a30af0a
2026-07-23-client-plugin-loading-model.zh.md: 1b94909c9611f01e5a6d7fcb50ec3f91ee88fab1
2026-07-23-client-plugin-loading-model.md: b8c23108a7a1cf3a04ded373d8705bae626c70c2
2026-07-23-client-plugin-loading-model.zh.md: efaea843120e5b023bbc8f59663ea47468944c71
@@ -46,7 +46,7 @@ A `require.async("./client.<name>.js")` call requests the exact revisioned `/plu
The shared tsdown preset emits a map for every plugin entry and chunk and rewrites first-party source paths into the browser-resolvable repository form `/packages/<group>/<package>/src/...`. The production Client pass consumes `lib/types`; the preset supplies each tsc map to Rolldown and fills `sourcesContent` from the original files, so the final map reaches TypeScript/TSX instead of stopping at emitted JavaScript. Other workspace sources inlined into a bundle likewise resolve to their `packages/` owner, while dependency paths remain unchanged. Combo generation strips each local debug directive, records its generated-line offset, resolves every authored source against the original per-plugin map URL, and emits an Indexed Source Map v3. An authored map supplies its section; otherwise an identity section embeds the generated bundle and uses the packer's `sourceURL` as its source name when present. The absolute combo map URL mirrors the script resource list by changing every `client.js` suffix to `client.js.map`, so `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` points to `/plugins/??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`. A chunk script likewise points to its own map URL. Source-map files are read and combined only when those map URLs receive `GET`; `HEAD` materializes neither script nor map bodies. The Vite shell also emits source maps, letting shell code and externally loaded plugins map stacks and performance profiles back to TypeScript/TSX.
The graph retains each row's revisioned one-resource combo URL for HMR and adds a revisioned descriptor for every startup combo request; several descriptors may carry the same scheduling phase. Initial row revisions are opaque process nonces rather than content hashes; they keep the snapshotted one-resource response immutable without hashing every plugin at startup. The shared preset stamps the entry after every package output is written. After the watcher observes that completed-build stamp, `rebuilt(id)` hashes the entry bytes together with its timestamp and publishes the resulting revision; a chunk-only build therefore advances the owner revision without a Host-side sibling scan. Startup combo revisions derive from the ordered row revisions. Script bodies are assembled on their first `GET`; source-map files are read and combined separately on their first map `GET`. `HEAD` materializes neither body. Versioned scripts and maps use immutable caching, and unrelated graph recomposition retains already-materialized chunk responses at the same revision. The Host serves only exact generated URLs; stale revisions and unadvertised resource lists return 404 instead of aliasing different bytes. An external script's `error` event exposes neither response status nor body, so failure diagnostics name only the URL; the same-origin Host and build-stamped registration id form the identity boundary, while the post-`load` factory-presence check rejects an artifact that did not register the expected id.
The graph retains each row's revisioned one-resource combo URL for HMR and adds a revisioned descriptor for every startup combo request; several descriptors may carry the same scheduling phase. Initial publication and `rebuilt(id)` use the same hash of entry mtime, ctime, and size; artifact contents never enter the hash. Ctime distinguishes rewrites that preserve mtime and size. A Host restart over unchanged artifacts therefore retains every row revision, so the reconnecting SSE snapshot does not trigger browser plugin replacement. The shared preset stamps the entry after every package output is written; a chunk-only build therefore advances the owner revision without a Host-side sibling scan. Startup combo revisions derive from the ordered row revisions. Script bodies are assembled on their first `GET`; source-map files are read and combined separately on their first map `GET`. `HEAD` materializes neither body. Versioned scripts and maps use immutable caching, and unrelated graph recomposition retains already-materialized chunk responses at the same revision. The Host serves only exact generated URLs; stale revisions and unadvertised resource lists return 404 instead of aliasing different bytes. An external script's `error` event exposes neither response status nor body, so failure diagnostics name only the URL; the same-origin Host and build-stamped registration id form the identity boundary, while the post-`load` factory-presence check rejects an artifact that did not register the expected id.
### The loading flow, end to end
@@ -56,7 +56,7 @@ What happens between `dsh web` starting and the UI appearing? Three stages: the
1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, including the always-mounted `client-hmr` row. `auditStartupEntries` reports failed imports, activation errors with their original stacks, and pending dependencies; optional entries warn and required entries reject startup ([startup policy](2026-09-09-consumer-owned-startup-strictness.md)).
2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) resolves each live Loader entry through the same `name` and owning-tree `baseUrl` inputs that imported its Host face, then reads the nearest owning package.json `dsh.client` declaration and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }], batches: [{ phase, url, rev, entries }] }`. The manifest package name is the browser module identity even when an overlay names a relative source or built entry file. Distinct active Loader sources resolving to one package name fail composition; after one source unloads, the surviving source supplies the row without a fiber restart. The row's three optional fields come from manifests, never hand-copied. Composition orders requested dynamic rows before their consumers, rejects synchronous request cycles, and assigns every row to exactly one initial batch. It refuses declared plugins without built `./client` bundles and groups their package/path rows under one required source-build instruction; malformed declaration fields also fail activation, and the Host audit reports either error from the FAILED fiber.
3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per entry name and owning-tree base URL for the process lifetime and bundle re-hashing reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Initial rows receive an opaque process nonce plus sequence without hashing their artifacts; startup combo revisions hash the combined script inputs plus indexed map, and the rows plus batch descriptors hash into `graph.rev`. The graph types are single-sourced in the modules package's `./client` export — the webserver knows nothing about the graph, while modules registers the combo route and contributes structured index-injection rows.
3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per entry name and owning-tree base URL for the process lifetime and changed bundle publication reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Initial rows hash their pre-read filesystem metadata; startup combo revisions hash the ordered row identities and revisions, and the rows plus batch descriptors hash into `graph.rev`. The graph types are single-sourced in the modules package's `./client` export — the webserver knows nothing about the graph, while modules registers the combo route and contributes structured index-injection rows.
Why is the roster yml rows and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a package declaring `dsh.client` in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call; the node half scans only what the tree actually mounted.
@@ -80,7 +80,7 @@ The Host SSE adapter forwards existing graph-change notifications and sends the
Hot reload is a composition decision: the web bundle mounts the `client-hmr` row (a normal plugin package) unconditionally; its node half brings the bundle watch and the SSE channel, and the chain stays idle until a rebuild watcher rewrites client bundles. A composition that must not expose it disables the row.
How does a rebuilt bundle become a reload signal? The hmr node half observes it itself — no builder tells it. Before reading each startup snapshot, the module host captures the entry stat baseline and exposes it through `ctx.clientModules.artifactBaseline(id)`. One HMR-owned interval compares every current graph row with that baseline. An unchanged row starts watching without a content read or hash; a write after baseline capture is already a stat delta and only that row enters `rebuilt(id)`. This avoids both an initial all-row re-hash and `fs.watchFile`, whose asynchronous first baseline can silently absorb a construction-time rebuild. Watch membership follows `onGraphChanged`; vanished rows drop out, and an entry missing at poll time keeps its row dirty so reappearance forces a new revision even with identical metadata. The shared tsdown preset stamps `client.js` after every sibling output has been written; on an entry mtime or size delta, or a dirty row, `rebuilt(id)` derives the revision from the entry bytes and completed-build timestamp. A chunk-only edit therefore changes the revision, and a partial write followed by the completion stamp supplies a later stat change that self-heals. When the `rev` changes, the node half broadcasts a `rebuilt` frame on `GET /plugins/events` — a system SSE channel that sends the full graph on connect and `rebuilt` frames on change, presentation-only wire that never enters the session log. Polling is deliberate because inotify does not fire on the weka network mount, the same reason the build-side watcher needs `--poll`; one entry stat per row and interval is sufficient, the interval is a validated config field (default 500ms), and disposal clears the one timer. Rebuilding artifacts is any process using the shared Client tsdown preset — `scripts/dev-web.ts` remains the watch-build entry point, discovering its package list through `dsh.client` while scanning `packages/*/*/package.json` at startup — and builder and host share no notification protocol.
How does a rebuilt bundle become a reload signal? The hmr node half observes it itself — no builder tells it. Before reading each startup snapshot, the module host captures the entry stat baseline and exposes it through `ctx.clientModules.artifactBaseline(id)`. One HMR-owned interval compares every current graph row with that baseline. An unchanged row starts watching without a content read or hash; a write after baseline capture is already a stat delta and only that row enters `rebuilt(id)`. This avoids redundant reads when installing the watcher and avoids `fs.watchFile`, whose asynchronous first baseline can silently absorb a construction-time rebuild. Watch membership follows `onGraphChanged`; vanished rows drop out, and an entry missing at poll time keeps its row dirty so reappearance retries publication even when mtime and size match the baseline. The shared tsdown preset stamps `client.js` after every sibling output has been written; on an entry mtime, ctime, or size delta, or a dirty row, `rebuilt(id)` derives the revision from those three metadata values. It reads the new bundle snapshot only if the revision changes. A chunk-only edit therefore changes the revision, and a partial write followed by the completion stamp supplies a later stat change that self-heals. When the `rev` changes, the node half broadcasts a `rebuilt` frame on `GET /plugins/events` — a system SSE channel that sends the full graph on connect and `rebuilt` frames on change, presentation-only wire that never enters the session log. Polling is deliberate because inotify does not fire on the weka network mount, the same reason the build-side watcher needs `--poll`; one entry stat per row and interval is sufficient, the interval is a validated config field (default 500ms), and disposal clears the one timer. Rebuilding artifacts is any process using the shared Client tsdown preset — `scripts/dev-web.ts` remains the watch-build entry point, discovering its package list through `dsh.client` while scanning `packages/*/*/package.json` at startup — and builder and host share no notification protocol.
On the browser side, the transport delegates code replacement to the same modules controller that owns graph reconciliation:
@@ -120,4 +120,4 @@ Roster: it lives in the web bundle's config tree (`packages/bundle/web-app/cordi
| Full ctx-ification now (React and libraries via services, no module table) | Static UI libraries still expose synchronous values, so removing the table would leave those imports without a shared identity |
| Eager instantiation with a frozen table | Runs bundle side effects at script arrival; lazy registration keeps execution at Cordis import while recursive `require` materializes registered requests |
| Fetch response text, then inject an inline `<script>` | Makes the module system buffer the complete source and maintain separate fetch/execute paths; dynamic source execution also breaks the browser-native association among the network resource, source map, and profile |
| Builder-push rebuild channel (`POST /plugins/rebuilt` from the orchestrator's `onSuccess`) | Couples reload to one blessed builder process and a second wire protocol; the webserver already holds every bundle path, and stat polling covers the torn-write race (re-hash on every stat change) that once justified pushing |
| Builder-push rebuild channel (`POST /plugins/rebuilt` from the orchestrator's `onSuccess`) | Couples reload to one blessed builder process and a second wire protocol; the webserver already holds every bundle path, and stat polling covers the torn-write race (publication on every stat change) that once justified pushing |
@@ -46,7 +46,7 @@ Host 会快照每个已构建插件入口 bundle,并把每个调度阶段的
共享 tsdown 预设为每个插件入口与 chunk 产出 map,并把第一方源码路径重写成浏览器可识别的仓库形式 `/packages/<group>/<package>/src/...`。生产 Client 构建会消费 `lib/types`;预设把每份 tsc map 交给 Rolldown,并从原文件补齐 `sourcesContent`,使最终 map 回到 TypeScript/TSX,而不是停在编译后的 JavaScript。内联进 bundle 的其他 workspace 源码同样回到其 `packages/` 归属,依赖包路径保持原样。Combo 生成会移除每个局部调试指令、记录其生成行偏移、以原插件 map URL 解析每个自带 source,再产出 Indexed Source Map v3。插件有自带 map 时直接用于对应 section;没有时则生成 identity section,内嵌构建后 bundle,并在存在时把 packer 写入的 `sourceURL` 用作 source 名。绝对 combo map URL 会平行改写脚本资源列表中的每个 `client.js` 后缀,因此 `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` 指向 `/plugins/??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`。chunk 脚本同样指向自己的 map URL。只有在这些 map URL 收到 `GET` 后,source map 文件才会被读取和组合;`HEAD` 不会物化脚本或 map body。Vite 壳同样产出 sourcemap,使壳代码与经外部加载的插件都能从 stack 和性能 profile 回到 TypeScript/TSX。
图为 HMR 保留每个 row 带 revision 的单资源 combo URL,并为每个启动 combo 请求增加带 revision 的描述;多条描述可以使用同一调度阶段。初始 row revision 是进程级不透明 nonce,而不是内容哈希;它无需在启动时哈希每个插件,也能保证已快照的单资源响应不可变。共享预设会在每个包输出写完后标记入口。watcher 观察到该构建完成标记后,`rebuilt(id)` 会把入口字节与其时间戳一起哈希并发布所得 revision;因此只重建 chunk 也会推进 owner revision,无需 Host 扫描 sibling。启动 combo revision 从有序 row revision 派生。脚本 body 在首次 `GET` 时组合;source map 文件在首次 map `GET` 时单独读取并组合。`HEAD` 不会物化任一 body。版本化脚本与 map 使用 immutable 缓存,无关图重组会保留相同 revision 下已经物化的 chunk 响应。Host 只提供精确生成的 URL;陈旧 revision 与未发布资源列表返回 404,不会别名到其他字节。外部脚本的 `error` 事件不给响应状态与正文,因此失败诊断只报告 URL;同源 Host 与构建期写入的 registration id 是身份边界,`load` 后的 factory 存在性检查负责拒绝未登记预期 id 的产物。
图为 HMR 保留每个 row 带 revision 的单资源 combo URL,并为每个启动 combo 请求增加带 revision 的描述;多条描述可以使用同一调度阶段。首次发布与 `rebuilt(id)` 使用相同的入口 mtime、ctime 和大小哈希;产物内容不参与求哈希。ctime 用于区分保留 mtime 和大小的重写。Host 重启时若产物未变,每个 row 的 revision 都保持不变,因此重连后的 SSE 快照不会触发浏览器插件替换。共享预设会在每个包输出写完后标记入口;因此只重建 chunk 也会推进 owner revision,无需 Host 扫描 sibling。启动 combo revision 从有序 row revision 派生。脚本 body 在首次 `GET` 时组合;source map 文件在首次 map `GET` 时单独读取并组合。`HEAD` 不会物化任一 body。版本化脚本与 map 使用 immutable 缓存,无关图重组会保留相同 revision 下已经物化的 chunk 响应。Host 只提供精确生成的 URL;陈旧 revision 与未发布资源列表返回 404,不会别名到其他字节。外部脚本的 `error` 事件不给响应状态与正文,因此失败诊断只报告 URL;同源 Host 与构建期写入的 registration id 是身份边界,`load` 后的 factory 存在性检查负责拒绝未登记预期 id 的产物。
### 装载流程,端到端
@@ -56,7 +56,7 @@ Host 会快照每个已构建插件入口 bundle,并把每个调度阶段的
1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,包括无条件挂载的 `client-hmr` 行。`auditStartupEntries` 报告 import 失败、带原始 stack 的激活错误,以及待满足的依赖;optional entry 输出 warning,required entry 则使启动失败([启动策略](2026-09-09-consumer-owned-startup-strictness.zh.md))。
2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)使用 Host face import 时相同的 `name` 与所属 tree `baseUrl` 解析每个 live Loader entry,再读取最近归属 package.json 的 `dsh.client` 声明并组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }], batches: [{ phase, url, rev, entries }] }`。即使 overlay 指向相对的 source 或 built entry 文件,manifest 包名仍是浏览器模块身份。若不同的 active Loader source 解析到同一包名,组合会失败;一个来源卸载后,仍存活的来源无需重启 fiber 即可提供该 row。Row 的三个可选字段都来自 manifest,永不人肉抄写。组合会把被请求的动态图 row 排到消费者之前、拒绝同步请求环,并把每个 row 恰好分配给一个初始批次。它会拒绝没有已构建 `./client` bundle 的已声明插件,并把它们的 package/path 行归到一条源码构建要求下;畸形声明字段同样会让激活失败,Host 检查会从 FAILED fiber 报告这两类错误。
3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,包元数据(含「非 client 包」的否定结论)按 entry 名与所属 tree base URL 缓存至进程结束,bundle 重哈希只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。初始 row 使用不透明的进程 nonce 加序号,不对其产物求哈希;启动 combo revision 对合并脚本输入及 indexed map 求哈希,row 与批次描述再共同哈希进 `graph.rev`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知;modules 会注册 combo 路由并贡献结构化 index 注入行。
3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,包元数据(含「非 client 包」的否定结论)按 entry 名与所属 tree base URL 缓存至进程结束,变化后 bundle 的发布只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。初始 row 对读取前的文件系统元数据求哈希;启动 combo revision 对有序 row 的身份与 revision 求哈希,row 与批次描述再共同哈希进 `graph.rev`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知;modules 会注册 combo 路由并贡献结构化 index 注入行。
为什么名册是 yml 行而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个在仓库中声明了 dsh.client 的包,不代表这次部署要挂载它,扫描发现无从替人做这个决定;node 半只扫描配置树实际挂载了的东西。
@@ -80,7 +80,7 @@ Host SSE 适配器转发现有图变化通知,并在连接时发送当前完
热重载是一项组合决策:web 组合包无条件挂载 `client-hmr` 行(一个常规的插件包),其 node 半带来 bundle 监视与 SSE(Server-Sent Events)通道;没有重建 watcher 改写客户端 bundle 时链路保持空闲。不应暴露它的组合可以禁用该行。
重建好的 bundle 怎么变成重载信号?hmr 的 node 半自己观察——没有构建器来通知它。模块 host 在读取每份启动快照前捕获入口 stat 基线,并通过 `ctx.clientModules.artifactBaseline(id)` 暴露它。HMR 自持的单个定时器把当前图的每个 row 与这份基线比较:未变化的 row 直接开始监视,不读取内容也不求哈希;基线捕获后的写入已经形成 stat 差异,只有该 row 会进入 `rebuilt(id)`。这同时消除了启动期的全量重哈希,并避开 `fs.watchFile` 以异步首次 stat 建立基线、可能静默吸收构造期重建的问题。监视集合的成员随 `onGraphChanged` 更新;消失的 row 撤下监视,轮询时缺失的入口则让对应 row 保持标脏状态,文件重现时即使元数据相同也强制生成新 revision。共享 tsdown 预设会在每个 sibling 输出写完后标记 `client.js`;入口 mtime 或 size 变化、或 row 处于标脏状态时,`rebuilt(id)` 从入口字节与构建完成时间戳派生 revision。只修改 chunk 因此也会更换 revision;部分写入之后的完成标记还会提供一次更晚的 stat 变化以完成自愈。`rev` 变化时,node 半在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE 通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因;每个 row 每个间隔只需一次入口 stat,轮询间隔是一个经校验的配置字段(默认 500ms),dispose(资源释放)会清掉那一个定时器。重建产物的进程必须使用共享 Client tsdown 预设;`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dsh.client 发现,构建器与 host 之间没有通知协议。
重建好的 bundle 怎么变成重载信号?hmr 的 node 半自己观察——没有构建器来通知它。模块 host 在读取每份启动快照前捕获入口 stat 基线,并通过 `ctx.clientModules.artifactBaseline(id)` 暴露它。HMR 自持的单个定时器把当前图的每个 row 与这份基线比较:未变化的 row 直接开始监视,不读取内容也不求哈希;基线捕获后的写入已经形成 stat 差异,只有该 row 会进入 `rebuilt(id)`。这既避免安装 watcher 时重复读取,也避开 `fs.watchFile` 以异步首次 stat 建立基线、可能静默吸收构造期重建的问题。监视集合的成员随 `onGraphChanged` 更新;消失的 row 撤下监视,轮询时缺失的入口则让对应 row 保持标脏状态,文件重现时即使 mtime 和大小与基线相同也重试发布。共享 tsdown 预设会在每个 sibling 输出写完后标记 `client.js`;入口 mtime、ctime 或大小变化、或 row 处于标脏状态时,`rebuilt(id)` 从这三个元数据值派生 revision。只有 revision 变化时,它才读取新的 bundle 快照。只修改 chunk 因此也会更换 revision;部分写入之后的完成标记还会提供一次更晚的 stat 变化以完成自愈。`rev` 变化时,node 半在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE 通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因;每个 row 每个间隔只需一次入口 stat,轮询间隔是一个经校验的配置字段(默认 500ms),dispose(资源释放)会清掉那一个定时器。重建产物的进程必须使用共享 Client tsdown 预设;`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dsh.client 发现,构建器与 host 之间没有通知协议。
浏览器侧的传输把代码替换交给负责图对账的同一个 modules 控制器:
@@ -120,4 +120,4 @@ Wire 两侧运行同一份治理实现;浏览器特有层只包含一套模块
| 现在就彻底 ctx 化(React 与库全走服务,不设模块表) | 静态 UI 库仍暴露同步实体,因此删除模块表会让这些 import 失去共享身份 |
| 冻结表 + 到达即实例化 | 会在 script 到达时执行 bundle 副作用;惰性登记把执行推迟到 Cordis import,并由递归 `require` 物化已登记请求 |
| fetch 响应文本后注入内联 `<script>` | 模块系统必须缓冲整份源码并维护 fetch/execute 两条路径;动态源码执行也切断浏览器网络资源、sourcemap 与 profile 的原生关联 |
| 构建器推送重建通道(编排器在 `onSuccess` 里 POST `/plugins/rebuilt`) | 把重载耦合到一个钦定的构建器进程和第二套 wire 协议;webserver 本就握有每个 bundle 路径,stat 轮询(每次 stat 变化即重哈希)已兜住当年为推送辩护的撕裂写竞态 |
| 构建器推送重建通道(编排器在 `onSuccess` 里 POST `/plugins/rebuilt`) | 把重载耦合到一个钦定的构建器进程和第二套 wire 协议;webserver 本就握有每个 bundle 路径,stat 轮询(每次 stat 变化即发布)已兜住当年为推送辩护的撕裂写竞态 |
+5 -1
View File
@@ -2,7 +2,7 @@
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { cp, mkdtemp, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises'
import { chromium, type Page } from 'playwright'
import { expect, it, onTestFailed, onTestFinished } from 'vitest'
import {
@@ -256,6 +256,10 @@ it('reports bootstrap rebuilds without remounting the settings page or navigatin
const originalInput = await draft.elementHandle()
let navigations = 0
page.on('framenavigated', () => { navigations++ })
const clientPath = scaffold.ctx.clientModules.clientPath('@deepseek-ai/dsh-client-modules')!
const originalStat = await stat(clientPath)
onTestFinished(() => utimes(clientPath, originalStat.atime, originalStat.mtime))
await utimes(clientPath, originalStat.atime, new Date(originalStat.mtimeMs + 1_000))
scaffold.ctx.clientModules.rebuilt('@deepseek-ai/dsh-client-modules')
const failure = page.locator('[data-client-sync-failure]')
await failure.getByText(/replacing bootstrap module .* requires a page reload/).waitFor()
+89
View File
@@ -0,0 +1,89 @@
/** Real WebSocket loss without replacing Client plugins or navigating the page. */
import { fileURLToPath } from 'node:url'
import { readFile } from 'node:fs/promises'
import { chromium, type WebSocketRoute } from 'playwright'
import { expect, it, onTestFailed, onTestFinished } from 'vitest'
import { launchWebScaffold, seedSession, watchConsole } from './scaffold.ts'
import { newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts'
it.each([false, true])('retains the mounted application across WebSocket recovery with an active Session: %s', async (activeSession) => {
const scaffold = await launchWebScaffold({
extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)),
})
onTestFinished(() => scaffold.close())
const title = 'Connection recovery session'
const draft = 'Unsent draft retained across connection recovery'
if (activeSession) {
const workspace = await scaffold.ctx.workspaceRegistry.create(scaffold.workspaceCwd)
const seed = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url))
const sessionId = await seedSession(scaffold, await readFile(seed, 'utf8'), 'connection-recovery-session')
await workspace.attachSession(sessionId)
await scaffold.ctx.sessionController.rename({ sessionId, title })
}
const browser = await chromium.launch()
onTestFinished(() => browser.close())
const page = await newEnglishPage(browser)
const console = watchConsole(page)
onTestFailed(() => saveFailureShot(page, 'web-e2e-connection-recovery'))
const sockets: { client: WebSocketRoute; server: WebSocketRoute }[] = []
let readyFrames = 0
let sessionBaselines = 0
await page.routeWebSocket('**/api/remote.mux', (client) => {
const server = client.connectToServer()
sockets.push({ client, server })
const endpoints = new Map<string, string>()
client.onMessage((message) => {
if (typeof message === 'string') {
const frame = JSON.parse(message) as { type?: string; streamId: string; endpoint: string }
if (frame.type === 'open') endpoints.set(frame.streamId, frame.endpoint)
}
server.send(message)
})
server.onMessage((message) => {
if (typeof message === 'string') {
const frame = JSON.parse(message) as { type?: string; streamId: string; value?: { type?: string } }
if (frame.type === 'item' && frame.value?.type === 'ready') readyFrames++
if (frame.type === 'item' && endpoints.get(frame.streamId) === 'session/follow'
&& frame.value?.type === 'snapshot') sessionBaselines++
}
client.send(message)
})
})
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
const composer = page.locator('[data-composer-input][contenteditable="true"]')
if (activeSession) {
await page.getByRole('treeitem').filter({ has: page.getByText(title, { exact: true }) }).click({ timeout: 20_000 })
await writeComposerDraft(page, composer, draft)
await expect.poll(() => sessionBaselines).toBeGreaterThan(0)
} else {
await page.getByText('Into the Unknown', { exact: true }).waitFor({ timeout: 20_000 })
}
await expect.poll(() => readyFrames).toBeGreaterThan(0)
const root = await page.locator('[data-slot="root"]').elementHandle()
expect(root).not.toBeNull()
let navigations = 0
page.on('framenavigated', () => { navigations++ })
for (let attempt = 0; attempt < 3; attempt++) {
const previousReady = readyFrames
const previousBaseline = sessionBaselines
const active = sockets.at(-1)!
await Promise.all([
active.client.close({ code: 1012, reason: 'test connection loss' }),
active.server.close({ code: 1012, reason: 'test connection loss' }),
])
await expect.poll(() => readyFrames, { timeout: 20_000 }).toBeGreaterThan(previousReady)
if (activeSession) {
await expect.poll(() => sessionBaselines, { timeout: 20_000 }).toBeGreaterThan(previousBaseline)
}
await page.evaluate(() => new Promise<void>((resolve) => {
requestAnimationFrame(() => { requestAnimationFrame(() => { resolve() }) })
}))
expect(await root!.evaluate(element => element.isConnected)).toBe(true)
expect(console.pageErrors).toEqual([])
if (activeSession) expect(await composer.textContent()).toBe(draft)
}
expect(navigations).toBe(0)
if (!activeSession) expect(await page.getByText('Into the Unknown', { exact: true }).isVisible()).toBe(true)
})
+199
View File
@@ -0,0 +1,199 @@
/** Persisted Session recovery across two real dsh server processes on the same port. */
import { spawn, type ChildProcess } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { createServer } from 'node:http'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright'
import { expect, it, onTestFailed, onTestFinished } from 'vitest'
import { REPO_ROOT, newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts'
class RestartableServer {
private child: ChildProcess | undefined
private closed: Promise<void> | undefined
private output = ''
constructor(private readonly world: string, private readonly modelUrl: string) {}
async start(port: number): Promise<string> {
if (this.child !== undefined) throw new Error('Server is already running')
this.output = ''
const ready = Promise.withResolvers<string>()
const child = spawn(process.execPath, [
join(REPO_ROOT, 'apps/cli/lib/bin.js'), '--profile', 'web',
'--patch', fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)),
'--no-open', '--port', String(port),
], {
cwd: this.world,
env: {
...process.env, NODE_OPTIONS: '',
DSH_HOME: join(this.world, 'home'), DSH_AGENTS_HOME: join(this.world, 'agents'),
DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: 'keyless-server-restart-fixture',
DEEPSEEK_BASE_URL: this.modelUrl,
},
stdio: ['ignore', 'pipe', 'pipe'],
})
this.child = child
const receive = (data: Buffer): void => {
this.output += data.toString()
const url = /dsh web: (http:\/\/[^\s]+)/u.exec(this.output)?.[1]
if (url !== undefined) ready.resolve(url)
}
child.stdout?.on('data', receive)
child.stderr?.on('data', receive)
child.once('error', (error) => { ready.reject(error) })
this.closed = new Promise((resolve) => {
child.once('close', (code, signal) => {
ready.reject(new Error('Server exited before readiness: ' + JSON.stringify({ code, signal }) + '\n' + this.logs()))
resolve()
})
})
const timer = setTimeout(() => { ready.reject(new Error('Server readiness timed out\n' + this.logs())) }, 30_000)
try { return await ready.promise }
finally { clearTimeout(timer) }
}
async stop(): Promise<void> {
const child = this.child
if (child === undefined) return
let forced = false
const timer = setTimeout(() => { forced = true; child.kill('SIGKILL') }, 15_000)
try {
if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM')
await this.closed
} finally {
clearTimeout(timer)
this.child = undefined
}
if (forced) throw new Error('Server did not stop after SIGTERM\n' + this.logs())
}
logs(): string { return this.output.replace(/([?&]token=)[A-Za-z0-9_-]+/gu, '$1<redacted>') }
}
it('keeps an opened Session and draft when the server restarts on the same port', async () => {
const world = await mkdtemp(join(tmpdir(), 'dsh-server-restart-'))
onTestFinished(() => rm(world, { recursive: true, force: true }))
const model = createServer((request, response) => {
request.resume()
request.once('end', () => {
response.writeHead(200, { 'content-type': 'text/event-stream' })
const events = [
{ type: 'message_start', message: { id: 'restart-fixture', model: 'mock-model', usage: { input_tokens: 3, output_tokens: 0 } } },
{ type: 'content_block_start', index: 0, content_block: { type: 'text', text: 'Persisted server restart fixture.' } },
{ type: 'content_block_stop', index: 0 },
{ type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 1 } },
{ type: 'message_stop' },
]
response.end(events.map(event => 'data: ' + JSON.stringify(event) + '\n\n').join(''))
})
})
onTestFinished(() => new Promise<void>((resolve, reject) => {
model.close((error) => { if (error) reject(error); else resolve() })
model.closeAllConnections()
}))
await new Promise<void>((resolve, reject) => {
model.once('error', reject)
model.listen(0, '127.0.0.1', resolve)
})
const modelAddress = model.address()
if (modelAddress === null || typeof modelAddress === 'string') throw new Error('Model fixture did not listen')
const server = new RestartableServer(world, `http://127.0.0.1:${String(modelAddress.port)}`)
onTestFinished(() => server.stop())
const workspacePath = join(world, 'workspace')
await mkdir(workspacePath)
const url = await server.start(0)
const port = Number(new URL(url).port)
const authenticated = await fetch(url, { redirect: 'manual' })
const cookie = authenticated.headers.get('set-cookie')?.split(';', 1)[0]
if (cookie === undefined) throw new Error('Server did not issue an authentication cookie')
const rpc = async <T>(endpoint: string, args: object): Promise<T> => {
const response = await fetch(new URL('/api/' + endpoint, url), {
method: 'POST', headers: { 'content-type': 'application/json', cookie },
body: JSON.stringify({ type: 'client-request', rpcId: randomUUID(), method: endpoint, payload: { args } }),
})
const result = await response.json() as { result: { ok: boolean; value: T; error?: { message: string } } }
if (!result.result.ok) throw new Error(`${endpoint}: ${String(result.result.error?.message)}`)
return result.result.value
}
await rpc('workspace/create', { request: { path: workspacePath } })
const scenarioId = randomUUID()
const browser = await chromium.launch({ headless: true })
onTestFinished(() => browser.close())
const page = await newEnglishPage(browser)
const errors: string[] = []
const graphs: { rev: string; entries: { id: string; rev: string }[] }[] = []
let readyFrames = 0
let sessionBaselines = 0
let navigations = 0
page.on('pageerror', (error) => { errors.push(String(error)) })
page.on('websocket', (socket) => {
const endpoints = new Map<string, string>()
socket.on('framesent', ({ payload }) => {
if (typeof payload !== 'string') return
const frame = JSON.parse(payload) as { type?: string; streamId: string; endpoint: string }
if (frame.type === 'open') endpoints.set(frame.streamId, frame.endpoint)
})
socket.on('framereceived', ({ payload }) => {
if (typeof payload !== 'string') return
const message = JSON.parse(payload) as { type?: string; streamId: string; value?: { type?: string } }
if (message.type === 'item' && message.value?.type === 'ready') readyFrames++
if (message.type === 'item' && endpoints.get(message.streamId) === 'session/follow'
&& message.value?.type === 'snapshot') sessionBaselines++
})
})
const network = await page.context().newCDPSession(page)
await network.send('Network.enable')
network.on('Network.eventSourceMessageReceived', (event) => {
const message = JSON.parse(event.data) as { type?: string; graph: typeof graphs[number] }
if (message.type === 'graph') graphs.push(message.graph)
})
onTestFailed(async () => {
await saveFailureShot(page, 'web-server-restart-' + scenarioId)
await writeFile(join(REPO_ROOT, '.artifacts', 'server-restart-' + scenarioId + '.json'), JSON.stringify({ errors, graphs, server: server.logs() }, null, 2))
})
await page.goto(url, { waitUntil: 'load' })
await page.locator('[data-slot="root"]').waitFor({ state: 'attached', timeout: 20_000 })
const notice = page.getByRole('button', { name: 'Continue', exact: true })
await notice.click({ timeout: 15_000 })
const composer = page.locator('[data-composer-input][contenteditable="true"]')
await writeComposerDraft(page, composer, 'Create a completed turn for the server restart test.')
await composer.press('Enter')
const response = page.getByRole('paragraph').filter({ hasText: /^Persisted server restart fixture\.$/u })
await response.last().waitFor({ timeout: 20_000 })
const draft = 'Unsent draft across a server restart'
await writeComposerDraft(page, composer, draft)
await expect.poll(() => graphs.length).toBeGreaterThan(0)
await expect.poll(() => sessionBaselines).toBeGreaterThan(0)
const originalRoot = await page.locator('[data-slot="root"]').elementHandle()
expect(originalRoot).not.toBeNull()
const beforeReady = readyFrames
const beforeBaseline = sessionBaselines
const beforeGraphs = graphs.length
page.on('framenavigated', () => { navigations++ })
await server.stop()
const restarted = await server.start(port)
expect(new URL(restarted).origin).toBe(new URL(url).origin)
await expect.poll(() => readyFrames, { timeout: 30_000 }).toBeGreaterThan(beforeReady)
await expect.poll(() => sessionBaselines, { timeout: 30_000 }).toBeGreaterThan(beforeBaseline)
await expect.poll(() => graphs.length, { timeout: 30_000 }).toBeGreaterThan(beforeGraphs)
await page.evaluate(() => new Promise<void>((resolve) => {
requestAnimationFrame(() => { requestAnimationFrame(() => { resolve() }) })
}))
expect(errors).toEqual([])
expect(graphs.at(-1)).toEqual(graphs[beforeGraphs - 1])
expect(await originalRoot!.evaluate(element => element.isConnected)).toBe(true)
expect(await composer.textContent()).toBe(draft)
expect(await response.isVisible()).toBe(true)
await composer.press('Enter')
await expect.poll(() => response.count(), { timeout: 20_000 }).toBe(2)
expect(errors).toEqual([])
expect(navigations).toBe(0)
})
+2
View File
@@ -22,6 +22,8 @@
// this client-registered project must not also hold them (one program
// cannot see both sides of the cordis Context merges).
"exclude": [
"tests/connection-recovery.e2e.ts",
"tests/server-restart.e2e.ts",
"tests/default-product-isolation.e2e.ts",
"tests/diff-context.e2e.ts",
"tests/scaffold.ts",
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/client-modules.md
client-modules.md: 5a62340ff3baa3cd2ed63d453fe2a055b2753ac0
client-modules.zh.md: b5a145460f30f3de6be6434315b94c01e04f6378
client-modules.md: 80c8377502253fd1673731a5f30acbff0c7b0765
client-modules.zh.md: d488e195110abd4b1ec95f901ef26dd56e8e1b73
+7 -4
View File
@@ -73,7 +73,7 @@ interface WebBootGraph {
}
```
Each initial row's `rev` is an opaque process nonce plus sequence, so graph composition does not hash every plugin artifact. After HMR observes a bundle change, that row's revision becomes the hash of its new executable bytes. The initial descriptors partition rows into bootstrap and application scheduling phases, and either phase may contain several descriptors. Their URLs contain only the ordered package-resource list and a revision derived from those row revisions; phase names do not enter the route. Graph composition preserves row order while greedily splitting before the map-form URL exceeds 3 KiB, without concatenating scripts or reading maps. The graph revision hashes the entry and batch descriptors. `immediately` marks the stage-one registration barrier; rows within one combo share its script transport, while separate combos load independently.
Initial publication and HMR derive each row's `rev` from the entry's mtime, ctime, and size, without hashing executable bytes. The same artifacts retain their revisions across Host restarts. The initial descriptors partition rows into bootstrap and application scheduling phases, and either phase may contain several descriptors. Their URLs contain only the ordered package-resource list and a revision derived from those row revisions; phase names do not enter the route. Graph composition preserves row order while greedily splitting before the map-form URL exceeds 3 KiB, without concatenating scripts or reading maps. The graph revision hashes the entry and batch descriptors. `immediately` marks the stage-one registration barrier; rows within one combo share its script transport, while separate combos load independently.
## The scan
@@ -96,12 +96,14 @@ interface ClientArtifactBaseline {
readonly path: string
/** Bundle modification time in milliseconds. */
readonly mtimeMs: number
/** Bundle status-change time in milliseconds, including writes that preserve mtime. */
readonly ctimeMs: number
/** Bundle size in bytes. */
readonly size: number
}
```
`ClientModuleRegistry` (`ctx.clientModules`, defined in [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts)) exposes reads and the rebuild face; signatures are in the generated [service catalog](#ctxclientmodules--clientmoduleregistry). `graph()` returns the current composed graph (a stable object between changes), `clientPath(id)` returns the bundle's absolute path, and `artifactBaseline(id)` returns the bundle stat values captured before the current snapshot was read. `fetchBundle()` resolves the same lazy response used by the HTTP route. `rebuilt(id)` is the only entry point through which changed bundle content reaches the graph: it re-hashes the bundle bytes, and only a real revision change recomposes the graph and notifies. `onRebuilt` fires per changed bundle with the new revision; `onGraphChanged` fires after any flush that recomposed the graph (row added or removed, or a rebuilt revision change) and is pull-model — listeners re-read `graph()`. Both notification paths contain listener exceptions so one throwing subscriber cannot skip later subscribers or kill whatever triggered the flush.
`ClientModuleRegistry` (`ctx.clientModules`, defined in [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts)) exposes reads and the rebuild face; signatures are in the generated [service catalog](#ctxclientmodules--clientmoduleregistry). `graph()` returns the current composed graph (a stable object between changes), `clientPath(id)` returns the bundle's absolute path, and `artifactBaseline(id)` returns the bundle stat values captured before the current snapshot was read. `fetchBundle()` resolves the same lazy response used by the HTTP route. `rebuilt(id)` is the only entry point through which changed bundle content reaches the graph: it derives the revision from filesystem metadata, and only a revision change reads the new bytes, recomposes the graph, and notifies. `onRebuilt` fires per changed bundle with the new revision; `onGraphChanged` fires after any flush that recomposed the graph (row added or removed, or a rebuilt revision change) and is pull-model — listeners re-read `graph()`. Both notification paths contain listener exceptions so one throwing subscriber cannot skip later subscribers or kill whatever triggered the flush.
[`dsh-client-hmr`](../../packages/client/hmr/README.md) delivers live graph snapshots in the shipped Web composition. The Host forwards existing graph-change notifications immediately, and reconnect sends the current full graph. A graph describes desired browser entries without asserting that Host cleanup has completed. Its artifact poll separately reports rebuilt revisions. Source-map changes alone do not trigger a reload; a new combo-map URL appears only after a bundle revision changes, and each map body is fixed by its first `GET`. Client Modules validates snapshots and serializes reconciliation with those rebuilds; it owns the boot-created entry map, single-resource arrivals, asynchronous removal, unused-module/style cleanup and page-local retry status. Static platform modules and the bootstrap retain their page lifetime; Electron installation is a separate flow.
@@ -156,13 +158,14 @@ artifactBaseline(id: string): ClientArtifactBaseline | undefined
/**
* Publish one completed bundle generation (the HMR watch's registration
* hook — the only entry point through which build changes reach the graph).
* Unchanged mtime, ctime and size preserve the graph without reading the bundle.
* @param id - entry id (package name).
* @returns the new rev, or undefined for an unknown id.
* @returns the current artifact rev, or undefined for an unknown id.
*/
rebuilt(id: string): string | undefined
/**
* Subscribe to bundle rebuilds; fires only when the re-hash changed the rev.
* Subscribe to bundle rebuilds; fires only when artifact metadata changes the rev.
* @param listener - receives the entry id and its new bundle rev.
* @returns the unsubscriber.
*/
+7 -4
View File
@@ -73,7 +73,7 @@ interface WebBootGraph {
}
```
每个初始 row 的 `rev` 都是不透明的进程 nonce 加序号,因此组合图时不会哈希每个插件产物。HMR 观察到 bundle 变化后,该 row 的 revision 才改为新可执行字节的哈希。初始 descriptor 把 row 划入 bootstrap 与 application 两个调度阶段,每个阶段都可以包含多条 descriptor。URL 只含有序 package 资源列表和从这些 row revision 派生的 revision,阶段名不会进入路由。图组合保持 row 顺序,并在 map 形式 URL 超过 3 KiB 前贪心切分,不拼接脚本,也不读取 map。图 revision 对 entry 与 batch descriptor 求哈希。`immediately` 标记第一阶段的 registration barrier;同一 combo 中的 row 共享脚本传输,不同 combo 则独立加载。
首次发布与 HMR 都从入口的 mtime、ctime 和大小派生每个 row 的 `rev`,不对可执行字节求哈希。相同产物会跨 Host 重启保持 revision。初始 descriptor 把 row 划入 bootstrap 与 application 两个调度阶段,每个阶段都可以包含多条 descriptor。URL 只含有序 package 资源列表和从这些 row revision 派生的 revision,阶段名不会进入路由。图组合保持 row 顺序,并在 map 形式 URL 超过 3 KiB 前贪心切分,不拼接脚本,也不读取 map。图 revision 对 entry 与 batch descriptor 求哈希。`immediately` 标记第一阶段的 registration barrier;同一 combo 中的 row 共享脚本传输,不同 combo 则独立加载。
## 扫描
@@ -96,12 +96,14 @@ interface ClientArtifactBaseline {
readonly path: string
/** Bundle modification time in milliseconds. */
readonly mtimeMs: number
/** Bundle status-change time in milliseconds, including writes that preserve mtime. */
readonly ctimeMs: number
/** Bundle size in bytes. */
readonly size: number
}
```
`ClientModuleRegistry`(`ctx.clientModules`,定义于 [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts))暴露读取面与重建面;签名见生成的[服务目录](#ctxclientmodules--clientmoduleregistry)。`graph()` 返回当前组合出的图(两次变更之间是同一个稳定对象),`clientPath(id)` 返回 bundle 的绝对路径,`artifactBaseline(id)` 返回读取当前快照前捕获的 bundle stat 值。`fetchBundle()` 解析 HTTP 路由所使用的同一份惰性响应。`rebuilt(id)` 是变化后的 bundle 内容到达图的唯一入口:它重新哈希 bundle 字节,只有 revision 真正变化才会重新组合图并发出通知。`onRebuilt` 按发生变化的 bundle 逐个触发并携带新 revision;`onGraphChanged` 在任何一次重新组合了图的 flush 之后触发(行的增删,或 rebuilt 带来的 revision 变化),并采用拉取模型——监听器自行重读 `graph()`。两条通知路径都会兜住监听器异常,因此一个抛错的订阅者既不能让后续订阅者被跳过,也不能杀死触发这次 flush 的一方。
`ClientModuleRegistry`(`ctx.clientModules`,定义于 [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts))暴露读取面与重建面;签名见生成的[服务目录](#ctxclientmodules--clientmoduleregistry)。`graph()` 返回当前组合出的图(两次变更之间是同一个稳定对象),`clientPath(id)` 返回 bundle 的绝对路径,`artifactBaseline(id)` 返回读取当前快照前捕获的 bundle stat 值。`fetchBundle()` 解析 HTTP 路由所使用的同一份惰性响应。`rebuilt(id)` 是变化后的 bundle 内容到达图的唯一入口:它从文件系统元数据派生 revision,只有 revision 变化才会读取新字节、重新组合图并发出通知。`onRebuilt` 按发生变化的 bundle 逐个触发并携带新 revision;`onGraphChanged` 在任何一次重新组合了图的 flush 之后触发(行的增删,或 rebuilt 带来的 revision 变化),并采用拉取模型——监听器自行重读 `graph()`。两条通知路径都会兜住监听器异常,因此一个抛错的订阅者既不能让后续订阅者被跳过,也不能杀死触发这次 flush 的一方。
随包提供的 Web 组合通过 [`dsh-client-hmr`](../../packages/client/hmr/README.zh.md) 交付动态图快照。Host 立即转发现有图变化通知,重连会发送当前完整图。图描述浏览器的目标条目,不声明 Host 清理已经完成。产物轮询另外报告重建 revision。仅 source map 变化不会触发重载;新 combo-map URL 只会在 bundle revision 变化后出现,每份 map body 由其首次 `GET` 固定。Client Modules 校验快照,并将对账与重建串行协调;它持有启动创建的条目映射,负责单资源到达、异步移除、未使用模块与样式清理,以及页面本地重试状态。静态平台模块与 bootstrap 保持页面生命周期;Electron 安装属于独立流程。
@@ -156,13 +158,14 @@ artifactBaseline(id: string): ClientArtifactBaseline | undefined
/**
* Publish one completed bundle generation (the HMR watch's registration
* hook — the only entry point through which build changes reach the graph).
* Unchanged mtime, ctime and size preserve the graph without reading the bundle.
* @param id - entry id (package name).
* @returns the new rev, or undefined for an unknown id.
* @returns the current artifact rev, or undefined for an unknown id.
*/
rebuilt(id: string): string | undefined
/**
* Subscribe to bundle rebuilds; fires only when the re-hash changed the rev.
* Subscribe to bundle rebuilds; fires only when artifact metadata changes the rev.
* @param listener - receives the entry id and its new bundle rev.
* @returns the unsubscriber.
*/
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/hmr/README.md
README.md: 5dae1463ceed315c3409bf97de80649f10e83fa8
README.zh.md: a77befcf63cd78be66b7e5b636d86367af6fdcdf
README.md: a0c658ea059dbd9f5b7862225baa0831218c4c9f
README.zh.md: 7f6b7d17b764f7506ae7f203436a648667ac21ab
+1 -1
View File
@@ -59,7 +59,7 @@ This section explains how the reload chain is built; observable behavior is cove
### Design concept
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.
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's mtime, ctime, and size identify the revision without hashing its contents; unchanged metadata requires no content read. Host restarts over unchanged artifacts keep the same revisions, so reconnecting the graph stream does not replace browser plugins. The browser half delegates both frame kinds to Client Modules, which serializes entry changes and waits for browser resource cleanup.
### The browser swap
+1 -1
View File
@@ -59,7 +59,7 @@ kind: "package-reference"
### 设计理念
Host 半侧监听每个包带完成标记的入口产物,并提供 `/plugins/events`。它转发现有的图变化与重建通知;每个新连接都会收到当前完整图。图描述浏览器的目标条目,不保证 Host 清理已经完成。Host 的激活与清理仍由 Host 生命周期管理。入口字节与构建完成时间戳共同标识 revision;未变化的产物无需读取内容。浏览器半侧将两种帧都交给 Client Modules,由它串行处理条目变更并等待浏览器资源清理。
Host 半侧监听每个包带完成标记的入口产物,并提供 `/plugins/events`。它转发现有的图变化与重建通知;每个新连接都会收到当前完整图。图描述浏览器的目标条目,不保证 Host 清理已经完成。Host 的激活与清理仍由 Host 生命周期管理。入口的 mtime、ctime 和大小共同标识 revision,无需对其内容求哈希;元数据未变时无需读取内容。Host 重启时若产物未变,revision 保持不变,因此图流重连不会替换浏览器插件。浏览器半侧将两种帧都交给 Client Modules,由它串行处理条目变更并等待浏览器资源清理。
### 浏览器侧替换
+7 -7
View File
@@ -50,12 +50,13 @@ type WatchedBundle = {
/** Snapshot the executable bundle metadata that drives reloads. */
function bundleStat(path: string): WatchedBundleStat {
const bundle = statSync(path)
return { mtimeMs: bundle.mtimeMs, size: bundle.size }
return { mtimeMs: bundle.mtimeMs, ctimeMs: bundle.ctimeMs, size: bundle.size }
}
/** Whether the executable bundle is unchanged since the last successful re-hash. */
/** Whether the executable bundle metadata is unchanged since its last publication. */
function sameBundleStat(left: WatchedBundleStat, right: WatchedBundleStat): boolean {
return left.mtimeMs === right.mtimeMs
&& left.ctimeMs === right.ctimeMs
&& left.size === right.size
}
@@ -71,10 +72,8 @@ export function apply(ctx: Context, config: Config): void {
// --- bundle watch: one HMR-owned stat poll ------------------------------
const watched = new Map<string, WatchedBundle>()
const rehash = (id: string, watch: WatchedBundle, current: WatchedBundleStat): void => {
const publish = (id: string, watch: WatchedBundle, current: WatchedBundleStat): void => {
try {
// rebuilt() replaces the opaque startup rev on its first call; later
// calls stay silent until the completed-build entry stamp changes.
ctx.clientModules.rebuilt(id)
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
@@ -85,6 +84,7 @@ export function apply(ctx: Context, config: Config): void {
ctx.logger.warn(error)
}
watch.mtimeMs = current.mtimeMs
watch.ctimeMs = current.ctimeMs
watch.size = current.size
watch.dirty = false
}
@@ -102,7 +102,7 @@ export function apply(ctx: Context, config: Config): void {
}
// The module host captured its baseline before reading the bytes in the
// startup batch. Only a mismatch crosses into generation publication.
if (!sameBundleStat(current, watch)) rehash(id, watch, current)
if (!sameBundleStat(current, watch)) publish(id, watch, current)
}
const pollWatches = (): void => {
@@ -119,7 +119,7 @@ export function apply(ctx: Context, config: Config): void {
// Stat-before-publication preserves a detectable older baseline for
// writes that land during the read. The preset stamps the entry after
// sibling chunks, so a completed build supplies the final stat change.
rehash(id, watch, current)
publish(id, watch, current)
}
}
@@ -34,7 +34,7 @@ interface FakeHostOptions {
function artifactBaseline(path: string): ClientArtifactBaseline {
const bundle = statSync(path)
return { path, mtimeMs: bundle.mtimeMs, size: bundle.size }
return { path, mtimeMs: bundle.mtimeMs, ctimeMs: bundle.ctimeMs, size: bundle.size }
}
function fakeClientModuleHost(rows: Map<string, string>, options: FakeHostOptions = {}): FakeHost {
@@ -163,7 +163,7 @@ describe('hmr node half', () => {
await fiber.dispose()
})
it('rehashes only a row changed between its startup snapshot and watch installation', async () => {
it('publishes only a row changed between its startup snapshot and watch installation', async () => {
const bundle = join(dir, 'construction.js')
writeFileSync(bundle, 'v1')
let rewrite = true
@@ -184,7 +184,26 @@ describe('hmr node half', () => {
await fiber.dispose()
})
it('marks a vanished bundle dirty so identical metadata still re-hashes after it reappears', async () => {
it('publishes a ctime change when a rewrite preserves mtime and size', async () => {
const bundle = join(dir, 'preserved-mtime.js')
writeFileSync(bundle, 'seed')
const fixedTime = new Date(1_600_000_000_000)
utimesSync(bundle, fixedTime, fixedTime)
const baseline = statSync(bundle)
const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]]))
const fiber = await mount(clientModuleHost, fakeHttpServer([]))
try {
await expect.poll(() => Date.now()).toBeGreaterThan(Math.ceil(baseline.ctimeMs))
writeFileSync(bundle, 'next')
utimesSync(bundle, fixedTime, fixedTime)
expect(statSync(bundle)).toMatchObject({ mtimeMs: baseline.mtimeMs, size: baseline.size })
await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toEqual(['pkg-a']) }, { timeout: 3_000 })
} finally {
await fiber.dispose()
}
})
it('publishes a reappearing bundle with restored mtime and size', async () => {
const bundle = join(dir, 'replace.js')
writeFileSync(bundle, 'seed')
const fixedTime = new Date(1_600_000_000_000)
@@ -207,7 +226,7 @@ describe('hmr node half', () => {
await fiber.dispose()
})
it('retains a dirty baseline when a catch-up re-hash races a rename', async () => {
it('retains a dirty baseline when catch-up publication races a rename', async () => {
const bundle = join(dir, 'rename.js')
writeFileSync(bundle, 'v1')
let first = true
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/modules/README.md
README.md: 88d9e7bf5fec1e8d12a2e5bd3431c6815ab4a9d3
README.zh.md: d90348004fe6d2e8c3af1dfca09022a6a5665847
README.md: c99293b1f7a26a86279c4d71d9d6f41c6452b13e
README.zh.md: f6057204e1a039cecb824a3c355f4d7b906abac9
+2 -1
View File
@@ -71,7 +71,7 @@ Executing a plugin bundle only registers its factory; every module-body side eff
The Node half scans incrementally per package — no full-rescan path. Every `internal/plugin` emission marks the fiber's entry name dirty; a microtask flush reconciles each dirty name against the live loader entries, and the activation pass seeds the same dirty set and flushes synchronously, so first scan and steady state share one implementation. Package metadata is cached per Loader specifier and owning-tree base URL until restart, while the resolved manifest package name identifies the browser module. Distinct active Loader sources resolving to one package name are rejected; removing the conflict promotes the remaining source without requiring its fiber to restart. Bundle content changes reach the graph only through `rebuilt()` (the HMR hook).
The Node half snapshots each `client.js` entry before publication and creates combo descriptors without building response bodies. It groups resources into combo route keys (`/plugins/??...&rev=...`), with one bootstrap combo for the modules row and one or more application combos for the other rows; each phase is partitioned before a URL exceeds 3 KiB. The graph and batch descriptors carry the app-directory-relative form of the same key (`plugins/??...&rev=...`), which a browser document resolves under its own mount, while the response table stays keyed by the absolute route; the source-map trailer is resolved against the combo script's own directory. A script body is combined once on its first `GET` and ends with its map reference. The corresponding map files are read, validated, and combined separately on the first map `GET`; `HEAD` materializes neither body. The Host does not scan or preload sibling chunks: an exact `/plugins/<package>/client.<name>.js?rev=<rev>` request reads and caches that script, and its map remains uncomputed until the map URL is requested. The chunk reference the browser derives from its owner row is document-relative as well (`plugins/<package>/client.<name>.js?rev=<rev>`), and a chunk script's map trailer is the bare `client.<name>.js.map?rev=<rev>` name, which resolves against the script's own directory. Every combo or chunk map is Indexed Source Map v3 and uses an authored section when available or an identity section for the packaged bundle. Initial per-plugin revisions use process nonces. During development, the shared preset stamps `client.js` after every package output is written; HMR derives the next revision from the entry bytes and that completed-build stamp, so a chunk-only rebuild changes the owner revision without a Host-side chunk scan. Combo revisions derive from the ordered row revisions. Advertised combo responses and requested chunk responses remain immutable across unrelated graph recomposition, and an unknown resource or revision returns 404.
The Node half snapshots each `client.js` entry before publication and creates combo descriptors without building response bodies. It groups resources into combo route keys (`/plugins/??...&rev=...`), with one bootstrap combo for the modules row and one or more application combos for the other rows; each phase is partitioned before a URL exceeds 3 KiB. The graph and batch descriptors carry the app-directory-relative form of the same key (`plugins/??...&rev=...`), which a browser document resolves under its own mount, while the response table stays keyed by the absolute route; the source-map trailer is resolved against the combo script's own directory. A script body is combined once on its first `GET` and ends with its map reference. The corresponding map files are read, validated, and combined separately on the first map `GET`; `HEAD` materializes neither body. The Host does not scan or preload sibling chunks: an exact `/plugins/<package>/client.<name>.js?rev=<rev>` request reads and caches that script, and its map remains uncomputed until the map URL is requested. The chunk reference the browser derives from its owner row is document-relative as well (`plugins/<package>/client.<name>.js?rev=<rev>`), and a chunk script's map trailer is the bare `client.<name>.js.map?rev=<rev>` name, which resolves against the script's own directory. Every combo or chunk map is Indexed Source Map v3 and uses an authored section when available or an identity section for the packaged bundle. Initial publication and HMR derive per-plugin revisions from the entry's `mtimeMs`, `ctimeMs`, and size, without hashing artifact contents. Status-change time distinguishes rewrites that preserve mtime and size. Unchanged artifacts therefore retain their revisions across Host restarts, so SSE reconnection does not replace their browser plugins. The shared preset stamps `client.js` after every package output is written, so a chunk-only rebuild changes the owner revision without a Host-side chunk scan. Combo revisions derive from the ordered row revisions. Advertised combo responses and requested chunk responses remain immutable across unrelated graph recomposition, and an unknown resource or revision returns 404.
### Boot manifest injection
@@ -127,6 +127,7 @@ None; this package neither assembles nor sends a provider request.
These limits define what the module system does not do. They are current package constraints, not a task backlog.
- **Metadata-based revisions** — revisions identify filesystem generations, not content equality. Metadata-only changes can reload a plugin; changes invisible in mtime, ctime, and size cannot be distinguished.
- **Flat module graph by design** — every bundle is one module node whose edges point only at table leaves; the interface (`loadCache`/`edges`/`invalidate`) already supports a general module graph, so the externalization granularity can change without an interface change.
- **Bootstrap and code replacement limits** — the page retains its modules bootstrap and static platform identities. Removing or replacing the bootstrap requires a page reload; live replacement requests report a page-local error while retaining its fiber and exports; replacing package code and all existing consumers is outside ordinary enable/disable synchronization.
- **Lazy delivery retains requested bodies** — the Host holds each bundle and lazy response plan; a script or map body remains cached after its first `GET`, and HMR additionally retains one prior startup generation. Memory grows only for response bodies that clients request while preserving one-generation race tolerance.
+2 -1
View File
@@ -71,7 +71,7 @@ application combo 脚本只携带每个插件的 `client.js` 入口,并在启
Node 半侧逐包增量扫描——没有全量重扫路径。每次发出 `internal/plugin` 事件时,系统都会把该 fiber 的 entry 名标脏;微任务 flush 会把每个脏名与当前 loader 条目对账,激活 pass 会初始化同一个脏集合并同步 flush,因此首次扫描与稳态共用同一实现。包元数据按 Loader specifier 与所属 tree base URL 缓存至重启,解析出的 manifest(元数据清单)包名作为浏览器模块身份。若不同的 active Loader source 解析到同一包名,组合会失败;移除冲突来源后,剩余来源无需重启 fiber 即可接替。bundle 内容变更只能通过 `rebuilt()`(HMR 钩子)进入图。
Node 半侧会在发布前快照每个 `client.js` 入口,并在不构建响应 body 的情况下创建 combo descriptor。它把资源分组为 combo 路由键(`/plugins/??...&rev=...`):modules row 使用一个 bootstrap combo,其余 row 使用一个或多个 application combo;每个阶段都会在 URL 超过 3 KiB 之前分区。启动图与批次描述符携带同一路由键的应用目录相对形式(`plugins/??...&rev=...`),浏览器文档会按其自身挂载解析它,而响应表仍以绝对路由为键;source-map trailer 则相对该 combo 脚本自身目录解析。脚本 body 在首次 `GET` 时只组合一次,并以对应的 map 引用结尾;map 文件则在首次 map `GET` 时单独读取、校验并组合,`HEAD` 不会物化任一 body。Host 不扫描也不预加载同级 chunk:精确的 `/plugins/<package>/client.<name>.js?rev=<rev>` 请求会读取并缓存该脚本,其 map 仍会等到 map URL 被请求后才计算。浏览器从所属 row 派生出该 chunk 引用,因此它同样是文档相对形式(`plugins/<package>/client.<name>.js?rev=<rev>`);chunk 脚本携带的 map trailer 则是裸文件名 `client.<name>.js.map?rev=<rev>`,由该脚本自身目录解析。每个 combo 或 chunk map 都是 Indexed Source Map v3,并在可用时使用作者提供的 section,否则为已打包 bundle 生成 identity section。初始逐插件 revision 使用进程 nonce。开发期间,共享预设会在该包所有输出写完后标记 `client.js`;HMR 从入口字节和该构建完成标记派生下一 revision,因此仅 chunk 发生重建也会更换 owner revision,无需 Host 扫描 chunk。combo revision 从有序 row revision 派生。已公告的 combo 响应与已请求的 chunk 响应会跨无关图重组保持不可变;未知资源或 revision 返回 404。
Node 半侧会在发布前快照每个 `client.js` 入口,并在不构建响应 body 的情况下创建 combo descriptor。它把资源分组为 combo 路由键(`/plugins/??...&rev=...`):modules row 使用一个 bootstrap combo,其余 row 使用一个或多个 application combo;每个阶段都会在 URL 超过 3 KiB 之前分区。启动图与批次描述符携带同一路由键的应用目录相对形式(`plugins/??...&rev=...`),浏览器文档会按其自身挂载解析它,而响应表仍以绝对路由为键;source-map trailer 则相对该 combo 脚本自身目录解析。脚本 body 在首次 `GET` 时只组合一次,并以对应的 map 引用结尾;map 文件则在首次 map `GET` 时单独读取、校验并组合,`HEAD` 不会物化任一 body。Host 不扫描也不预加载同级 chunk:精确的 `/plugins/<package>/client.<name>.js?rev=<rev>` 请求会读取并缓存该脚本,其 map 仍会等到 map URL 被请求后才计算。浏览器从所属 row 派生出该 chunk 引用,因此它同样是文档相对形式(`plugins/<package>/client.<name>.js?rev=<rev>`);chunk 脚本携带的 map trailer 则是裸文件名 `client.<name>.js.map?rev=<rev>`,由该脚本自身目录解析。每个 combo 或 chunk map 都是 Indexed Source Map v3,并在可用时使用作者提供的 section,否则为已打包 bundle 生成 identity section。首次发布与 HMR 都从入口的 `mtimeMs`、`ctimeMs` 和大小派生逐插件 revision,不对产物内容求哈希。状态变更时间用于区分保留 mtime 和大小的重写。未变化的产物因此会跨 Host 重启保持 revision,SSE 重连不会替换其浏览器插件。共享预设会在该包所有输出写完后标记 `client.js`,因此仅 chunk 发生重建也会更换 owner revision,无需 Host 扫描 chunk。combo revision 从有序 row revision 派生。已公告的 combo 响应与已请求的 chunk 响应会跨无关图重组保持不可变;未知资源或 revision 返回 404。
### 启动 manifest 注入
@@ -127,6 +127,7 @@ bundle 路由随注入的 `webServer` 生命周期注册:服务就绪时注册
这些限制说明模块系统不做什么。它们是当前包约束,不是任务积压。
- **基于元数据的 revision**——revision 标识文件系统代际,而非内容相等性。仅元数据变化也可能重载插件;mtime、ctime 和大小都无法反映的变化无法区分。
- **有意采用扁平模块图**——每个 bundle 是一个模块节点,其边只指向表中的叶节点;接口(`loadCache`/`edges`/`invalidate`)已经支持通用模块图,因此可以改变 externalization 粒度而不更改接口。
- **Bootstrap 与代码替换限制**——页面保留 modules bootstrap 和静态平台模块的身份。移除或替换 bootstrap 需要刷新页面;动态替换请求会报告页面本地错误,并保留其 fiber 与导出;替换包代码及其所有现有消费者不属于普通启停同步。
- **惰性提供会保留已请求的 body**——Host 在内存中保留每个 bundle 与惰性响应计划;脚本或 map body 在首次 `GET` 后保留缓存,HMR 还会保留上一代启动响应。内存仅随客户端实际请求的响应 body 增长,同时保留一代竞态容忍。
+21 -25
View File
@@ -23,7 +23,7 @@
* @module @deepseek-ai/dsh-client-modules
*/
import { createHash, randomBytes } from 'node:crypto'
import { createHash } from 'node:crypto'
import { existsSync, readFileSync, statSync } from 'node:fs'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { createRequire } from 'node:module'
@@ -62,6 +62,8 @@ export interface ClientArtifactBaseline {
readonly path: string
/** Bundle modification time in milliseconds. */
readonly mtimeMs: number
/** Bundle status-change time in milliseconds, including writes that preserve mtime. */
readonly ctimeMs: number
/** Bundle size in bytes. */
readonly size: number
}
@@ -202,21 +204,21 @@ function clientExportOf(pkgName: string, exportsField: unknown): string | undefi
throw new Error(`client-modules: ${pkgName} exports["./client"] must be a string or an object with a string default`)
}
/** sha1 content hash shortened to 12 hex chars (combo / graph / rebuilt-artifact rev). */
function shortHash(input: string | Buffer): string {
/** sha1 metadata hash shortened to 12 hex chars. */
function shortHash(input: string): string {
return createHash('sha1').update(input).digest('hex').slice(0, HASH_REVISION_LENGTH)
}
/** Hash several response fields without allowing bytes to move across field boundaries. */
function framedHash(domain: string, parts: readonly Buffer[]): string {
function framedHash(domain: string, parts: readonly string[]): string {
const hash = createHash('sha1').update(domain).update('\0')
for (const part of parts) hash.update(`${String(part.byteLength)}:`).update(part)
for (const part of parts) hash.update(`${String(Buffer.byteLength(part))}:`).update(part)
return hash.digest('hex').slice(0, HASH_REVISION_LENGTH)
}
/** Hash one completed build generation observed through its entry artifact. */
function artifactRevision(bundle: Buffer, baseline: ClientArtifactBaseline): string {
return framedHash('plugin-artifact', [bundle, Buffer.from(String(baseline.mtimeMs))])
/** Identify an entry's build from filesystem metadata without hashing its contents. */
function artifactRevision(baseline: ClientArtifactBaseline): string {
return framedHash('plugin-artifact', [String(baseline.mtimeMs), String(baseline.ctimeMs), String(baseline.size)])
}
/** Absolute route prefix serving every plugin resource. */
@@ -390,8 +392,8 @@ function lazyBody(produce: () => Buffer): () => Promise<Buffer> {
/** Derive one combo revision from the ordered immutable row revisions. */
function comboRevision(resources: readonly ComboResource[]): string {
return framedHash('combo', resources.flatMap(resource => [
Buffer.from(resource.id),
Buffer.from(resource.rev),
resource.id,
resource.rev,
]))
}
@@ -602,8 +604,6 @@ export class ClientModuleRegistry extends Service {
private readonly rebuildListeners = new Set<(id: string, rev: string) => void>()
private readonly graphListeners = new Set<() => void>()
private readonly dirty = new Set<string>()
private readonly initialRevisionNonce = randomBytes(8).toString('hex')
private nextInitialRevision = 0
private responses = new Map<string, LazyResponse>()
private batchResponses = new Map<string, LazyResponse>()
/** One prior graph generation covers a request racing the HMR recomposition that replaced its URL. */
@@ -706,17 +706,18 @@ export class ClientModuleRegistry extends Service {
/**
* Publish one completed bundle generation (the HMR watch's registration
* hook — the only entry point through which build changes reach the graph).
* Unchanged mtime, ctime and size preserve the graph without reading the bundle.
* @param id - entry id (package name).
* @returns the new rev, or undefined for an unknown id.
* @returns the current artifact rev, or undefined for an unknown id.
*/
rebuilt(id: string): string | undefined {
const record = this.table.get(id)
if (record === undefined) return undefined
const baseline = this.captureArtifactBaseline(record.meta.clientPath)
const bundle = readFileSync(record.meta.clientPath)
const rev = artifactRevision(bundle, baseline)
record.baseline = baseline
const rev = artifactRevision(baseline)
if (rev === record.entry.rev) return rev
const bundle = readFileSync(record.meta.clientPath)
record.baseline = baseline
record.entry = graphRow(id, rev, record.meta)
record.bundle = bundle
this.composed = this.compose()
@@ -734,7 +735,7 @@ export class ClientModuleRegistry extends Service {
}
/**
* Subscribe to bundle rebuilds; fires only when the re-hash changed the rev.
* Subscribe to bundle rebuilds; fires only when artifact metadata changes the rev.
* @param listener - receives the entry id and its new bundle rev.
* @returns the unsubscriber.
*/
@@ -940,15 +941,11 @@ export class ClientModuleRegistry extends Service {
return {
path: clientPath,
mtimeMs: bundle.mtimeMs,
ctimeMs: bundle.ctimeMs,
size: bundle.size,
}
}
/** Allocate an opaque initial row revision without inspecting artifact bytes. */
private allocateInitialRevision(): string {
return `${this.initialRevisionNonce}-${String(this.nextInitialRevision++)}`
}
/**
* Read the activation-time bundle snapshot.
* @param pkgName - package that declares the client bundle.
@@ -1037,10 +1034,9 @@ export class ClientModuleRegistry extends Service {
const source = sources[0]
if (source === undefined) return this.table.delete(packageName)
if (this.table.get(packageName)?.sourceKey === source.sourceKey) return false
// The opaque initial rev rides the row until HMR observes a file change;
// a fiber restart from the same source reuses the existing row.
// Startup and HMR share revisions so unchanged artifacts survive a server restart.
const snapshot = this.initialBundleSnapshot(packageName, source.meta.clientPath)
const rev = this.allocateInitialRevision()
const rev = artifactRevision(snapshot.baseline)
this.table.set(packageName, {
entry: graphRow(packageName, rev, source.meta),
loaderName: source.loaderName,
@@ -1,5 +1,6 @@
/** Node-half composition diagnostics for package metadata and built client bundles. */
import { Hash } from 'node:crypto'
import { mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { SourceMap } from 'node:module'
@@ -438,7 +439,7 @@ describe('client bundle activation', () => {
emitLoaderEntryChange(context, packageName)
await Promise.resolve()
expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName])
expect(service.graph().entries[0]!.rev).not.toBe(firstRevision)
expect(service.graph().entries[0]!.rev).toBe(firstRevision)
expect(service.clientPath(packageName)).toBe(clientPath)
})
@@ -674,28 +675,106 @@ describe('client bundle activation', () => {
expect((await routeRequest(route, third)).status).toBe(200)
})
it('assigns opaque startup revisions instead of deriving them from artifact content', () => {
it('preserves artifact revisions across registry restarts and scan order changes', () => {
const firstName = '@fixture/startup-revision-first'
const secondName = '@fixture/startup-revision-second'
writeBuiltPackage(firstName, {})
writeBuiltPackage(secondName, {})
const service = construct([firstName, secondName])
const [first, second] = service.graph().entries
const firstMatch = /^(?<nonce>[a-f\d]{16})-(?<sequence>\d+)$/.exec(first!.rev)
const secondMatch = /^(?<nonce>[a-f\d]{16})-(?<sequence>\d+)$/.exec(second!.rev)
expect(firstMatch?.groups).toMatchObject({ sequence: '0' })
expect(secondMatch?.groups).toMatchObject({ nonce: firstMatch?.groups?.nonce, sequence: '1' })
expect(construct([firstName, secondName]).graph()).toEqual(service.graph())
const reversed = construct([secondName, firstName]).graph()
for (const entry of service.graph().entries) {
expect(reversed.entries.find(row => row.id === entry.id)).toEqual(entry)
}
const firstPath = service.clientPath(firstName)!
const firstStat = statSync(firstPath)
expect(service.artifactBaseline(firstName)).toEqual({
path: firstPath,
mtimeMs: firstStat.mtimeMs,
ctimeMs: firstStat.ctimeMs,
size: firstStat.size,
})
expect(service.artifactBaseline('@fixture/unknown')).toBeUndefined()
})
it('keeps the initial graph and subscribers unchanged when a rebuild has identical artifacts', () => {
const packageName = '@fixture/unchanged-rebuild'
writeBuiltPackage(packageName, {})
const service = construct([packageName])
const graph = service.graph()
const onRebuilt = vi.fn()
const onGraphChanged = vi.fn()
service.onRebuilt(onRebuilt)
service.onGraphChanged(onGraphChanged)
expect(service.rebuilt(packageName)).toBe(graph.entries[0]!.rev)
expect(service.graph()).toBe(graph)
expect(onRebuilt).not.toHaveBeenCalled()
expect(onGraphChanged).not.toHaveBeenCalled()
})
it('hashes only metadata when publishing initial and rebuilt artifact revisions', () => {
const packageName = '@fixture/metadata-revision'
const clientPath = writePackage(packageName)
mkdirSync(dirname(clientPath), { recursive: true })
const bundle = `module.exports = ${JSON.stringify('x'.repeat(64 * 1024))}\n`
writeFileSync(clientPath, bundle)
const update = vi.spyOn(Hash.prototype, 'update')
try {
const service = construct([packageName])
writeFileSync(clientPath, `module.exports = ${JSON.stringify('y'.repeat(64 * 1024))}\n`)
service.rebuilt(packageName)
const inputBytes = update.mock.calls.map(([input]) => Buffer.byteLength(input))
expect(inputBytes.length).toBeGreaterThan(0)
expect(inputBytes.reduce((total, bytes) => total + bytes, 0)).toBeLessThan(4 * 1024)
} finally {
update.mockRestore()
}
})
it.each(['bytes', 'build stamp'])('agrees with a fresh registry after rebuilding changed %s', (change) => {
const packageName = '@fixture/changed-rebuild'
const clientPath = writePackage(packageName)
mkdirSync(dirname(clientPath), { recursive: true })
writeFileSync(clientPath, 'module.exports = {}\n')
const timestamp = new Date(1_700_000_000_000)
utimesSync(clientPath, timestamp, timestamp)
const service = construct([packageName])
const before = service.graph()
const initialStat = statSync(clientPath)
if (change === 'bytes') {
writeFileSync(clientPath, 'module.exports = { changed: true }\n')
utimesSync(clientPath, initialStat.atime, initialStat.mtime)
} else {
utimesSync(clientPath, initialStat.atime, new Date(initialStat.mtimeMs + 1_000))
}
const rev = service.rebuilt(packageName)
expect(rev).not.toBe(before.entries[0]!.rev)
expect(construct([packageName]).graph()).toEqual(service.graph())
})
it('changes revisions when a same-size rewrite preserves mtime', async () => {
const packageName = '@fixture/restored-mtime'
const clientPath = writePackage(packageName)
mkdirSync(dirname(clientPath), { recursive: true })
writeFileSync(clientPath, 'module.exports = { generation: 1 }\n')
const timestamp = new Date(1_700_000_000_000)
utimesSync(clientPath, timestamp, timestamp)
const service = construct([packageName])
const before = service.artifactBaseline(packageName)!
const first = service.graph()
// ctime is OS-owned; cross its millisecond before the write on every supported filesystem.
await expect.poll(() => Date.now()).toBeGreaterThan(Math.ceil(before.ctimeMs))
writeFileSync(clientPath, 'module.exports = { generation: 2 }\n')
utimesSync(clientPath, timestamp, timestamp)
expect(statSync(clientPath)).toMatchObject({ mtimeMs: before.mtimeMs, size: before.size })
expect(service.rebuilt(packageName)).not.toBe(first.entries[0]!.rev)
expect(construct([packageName]).graph()).toEqual(service.graph())
})
it('splits startup combos before the map-form URL exceeds 3 KiB', async () => {
const packageNames = Array.from({ length: 48 }, (_, index) => (
`@fixture/combo-url-${String(index).padStart(3, '0')}-${'x'.repeat(40)}`
@@ -787,6 +866,8 @@ describe('client bundle activation', () => {
expect((await routeRequest(route, `${row.url}&stale=1`.replace(`rev=${row.rev}`, 'rev=stale'))).status).toBe(404)
writeFileSync(`${clientPath}.map`, '{"version":3,"names":[],"mappings":"AAAA","sources":["src/changed.tsx"]}\n')
const entryStat = statSync(clientPath)
utimesSync(clientPath, entryStat.atime, new Date(entryStat.mtimeMs + 1_000))
const nextRev = service.rebuilt(packageName)
expect(nextRev).not.toBe(row.rev)
const nextRow = service.graph().entries[0]!
@@ -0,0 +1,27 @@
// @vitest-environment jsdom
import { act } from '@testing-library/react'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import { RemoteMock } from '@deepseek-ai/dsh-remote-mock'
import { TestClient, remoteDefaultResponses, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
describe('assembled renderer connection recovery', () => {
it('keeps the mounted root across a transport reconnect', async () => {
const mock = RemoteMock.create().load(remoteDefaultResponses)
const client = await TestClient.start({ roster: webApp }, mock, { mount: true })
onTestFinished(() => client.dispose())
const root = client.container!.querySelector('[data-slot="root"]')
const generation = client.connection.generation.getSnapshot()!.id
expect(root).not.toBeNull()
await act(async () => {
client.connection.reconnect()
await vi.waitFor(() => {
expect(client.connection.generation.getSnapshot()?.id).toBeGreaterThan(generation)
expect(client.connection.state.getSnapshot()).toBe('connected')
})
})
expect(client.container!.querySelector('[data-slot="root"]')).toBe(root)
}, 60_000)
})
@@ -586,13 +586,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: 'rebuilt(id: string): string | undefined',
description: 'Publish one completed bundle generation (the HMR watch\'s registration hook — the only entry point through which build changes reach the graph).',
description: 'Publish one completed bundle generation (the HMR watch\'s registration hook — the only entry point through which build changes reach the graph). Unchanged mtime, ctime and size preserve the graph without reading the bundle.',
parameters: [{ name: 'id', description: 'entry id (package name).' }],
returns: 'the new rev, or undefined for an unknown id.',
returns: 'the current artifact rev, or undefined for an unknown id.',
},
{
signature: 'onRebuilt(listener: (id: string, rev: string) => void): () => void',
description: 'Subscribe to bundle rebuilds; fires only when the re-hash changed the rev.',
description: 'Subscribe to bundle rebuilds; fires only when artifact metadata changes the rev.',
parameters: [{ name: 'listener', description: 'receives the entry id and its new bundle rev.' }],
returns: 'the unsubscriber.',
},
@@ -4485,7 +4485,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ClientArtifactBaseline',
declaration: 'export interface ClientArtifactBaseline {\n readonly path: string;\n readonly mtimeMs: number;\n readonly size: number;\n}',
declaration: 'export interface ClientArtifactBaseline {\n readonly path: string;\n readonly mtimeMs: number;\n readonly ctimeMs: number;\n readonly size: number;\n}',
},
{
name: 'CollectedOutput',
+1
View File
@@ -4,6 +4,7 @@ import { pnpmInvocation } from './pnpm-invocation.ts'
const serialFiles = [
'apps/web/tests/hmr-live.e2e.ts',
'apps/web/tests/client-plugin-live.e2e.ts',
]
const workerRaw = process.env.DSH_WEB_SNAPSHOT_WORKERS
const workers = Number.parseInt(workerRaw ?? '', 10)
+2
View File
@@ -10,6 +10,8 @@
"files": [],
"include": [
"apps/web/product-isolation.ts",
"apps/web/tests/connection-recovery.e2e.ts",
"apps/web/tests/server-restart.e2e.ts",
"apps/web/tests/default-product-isolation.e2e.ts",
"apps/web/tests/diff-context.e2e.ts",
"apps/web/tests/scaffold.ts",