Merge pull request #3227 from deepseek-harness/fix/inspector-client-bootstrap

fix(inspector): stabilize client bootstrap identity
This commit is contained in:
imccyu
2026-08-27 20:33:08 +08:00
committed by GitHub
31 changed files with 715 additions and 136 deletions
@@ -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: 0fe4e86410f3b313ec5a31099d5a6ed1f828585b
2026-07-23-client-plugin-loading-model.zh.md: 386b0edb722d8cedd9325c941f9b392b8cdc8ae2
2026-07-23-client-plugin-loading-model.md: 1d08aa71378e7e135b67428a1afca2499de7cc60
2026-07-23-client-plugin-loading-model.zh.md: 8aaa5d38f63d115fa89216d2b37682ba65a1d8cc
@@ -53,8 +53,8 @@ What happens between `dsh web` starting and the UI appearing? Three stages: the
**Host side — compose the graph.**
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. A roster row that fails to import is caught by `assertEntriesLoaded`; a row whose fiber rejects is reported with its original stack by `assertEntriesActivated` ([host boot decision](2026-07-24-web-config-tree-boot-and-transport-layering.md)).
2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) scans loader entries' package.json `dsh.client` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }], batches: [{ phase, url, rev, entries }] }`. 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 name forever 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.
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.
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.
@@ -53,8 +53,8 @@ Host 会快照每个已构建插件产物,并把每个调度阶段的有序 ro
**host 侧——组合这张图。**
1. 负责组合的 app`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,包括无条件挂载的 `client-hmr` 行。名册行 import 失败由 `assertEntriesLoaded` 捕获;fiber reject 的行则由 `assertEntriesActivated` 报告原始 stack[host boot 决策](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md))。
2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)扫描 loader entry package.json `dsh.client` 声明组合出 `window.__DSH_BOOT__``{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }], batches: [{ phase, url, rev, entries }] }`。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 包」的否定结论)按名永久缓存bundle 重哈希只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。初始 row 使用不透明的进程 nonce 加序号,不对其产物求哈希;启动 combo revision 对合并脚本输入及 indexed map 求哈希,row 与批次描述再共同哈希进 `graph.rev`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知;modules 会注册 combo 路由并贡献结构化 index 注入行。
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 注入行。
为什么名册是 yml 行而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个在仓库中声明了 dsh.client 的包,不代表这次部署要挂载它,扫描发现无从替人做这个决定;node 半只扫描配置树实际挂载了的东西。
@@ -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-08-24-cordis-runtime-tree-inspection.md
2026-08-24-cordis-runtime-tree-inspection.md: 9784018a050bdb27e791a44e1cd342a31cec42c0
2026-08-24-cordis-runtime-tree-inspection.zh.md: 05b9b0d4387447b7c8df05b7c7e771e96c767bd9
2026-08-24-cordis-runtime-tree-inspection.md: 2cd63aa41567adc19d52d584874583183aef3cdc
2026-08-24-cordis-runtime-tree-inspection.zh.md: 88cf2b344d4feef10f3632918539d3cb98750fc9
@@ -42,7 +42,7 @@ The identities are intentionally distinct:
- `NodeId` is assigned per DevTools connection when a node enters that frontend's document. It remains stable while the corresponding backend node is retained and is discarded when that node leaves the tree, on the rare full-document fallback, or when the connection closes.
- `RemoteObjectId` is assigned by the selected Runtime session when `DOM.resolveNode` exposes the live object. It remains scoped to that DevTools connection and object group.
`sourceId` identifies one Client runtime instance and remains stable across its automatic transport reconnects; `generation` identifies one WebSocket admission. Disconnect removes the synthetic context from the Console with `Runtime.executionContextDestroyed`. Reconnection announces a fresh CDP execution-context id because the destroyed id and its RemoteObjects cannot be reused, but this does not imply that the browser's underlying JavaScript realm was recreated.
`sourceId` identifies one browser-tab Client runtime and is retained in that tab's `sessionStorage`, so automatic transport reconnects and page refreshes reuse it. Before opening the transport, a Client with Web Locks claims that id for its page lifetime; a simultaneously live tab copied from the same storage state cannot claim it and persists a fresh id instead. Browsers without Web Locks retain storage-backed refresh identity but cannot arbitrate copied live tabs. `generation` identifies one WebSocket admission and always rotates. Disconnect removes the synthetic context from the Console with `Runtime.executionContextDestroyed`. Reconnection announces a fresh CDP execution-context id because the destroyed id and its RemoteObjects cannot be reused, but this does not imply that the browser's underlying JavaScript realm was recreated.
Standard CDP does not place a `RemoteObjectId` field on `DOM.Node`. `DOM.Node` carries `nodeId` and `backendNodeId`; `DOM.resolveNode` returns the corresponding `Runtime.RemoteObject`, and `DOM.requestNode` performs the reverse mapping. The implementation keeps these three CDP identities correlated without adding non-standard DOM fields.
@@ -42,7 +42,7 @@ collector 从 root、注册表中的每个 live Fiber,以及每个 event hook
- `NodeId` 在节点进入某个 frontend document 时按 DevTools 连接分配;对应 backend node 被保留期间保持稳定,并在节点离开树、少见的整 document fallback 或连接关闭时丢弃。
- `RemoteObjectId``DOM.resolveNode` 暴露实时对象时由选定的 Runtime session 分配;它只属于该 DevTools 连接和 object group。
`sourceId` 标识一个 Client runtime instance,并在自动重连 transport 时保持稳定;`generation` 标识一次 WebSocket 接纳。断联通过 `Runtime.executionContextDestroyed` 从 Console 移除 synthetic context。重连会发布新的 CDP execution-context id,因为已销毁的 id 及其 RemoteObject 不能复用;这并不表示浏览器底层 JavaScript realm 被重新创建。
`sourceId` 标识一个浏览器 tab 的 Client runtime,并保存在该 tab 的 `sessionStorage` 中,因此自动重连 transport 与页面刷新都会复用它。Client 在打开 transport 前会在 Web Locks 可用时独占该 id,直至页面结束;从同一存储状态复制出的另一个同时存活 tab 无法取得该锁,因而会持久化一个新 id。缺少 Web Locks 的浏览器仍保留基于存储的刷新身份,但无法仲裁复制出的 live tab。`generation` 标识一次 WebSocket 接纳且每次都会轮换。断联通过 `Runtime.executionContextDestroyed` 从 Console 移除 synthetic context。重连会发布新的 CDP execution-context id,因为已销毁的 id 及其 RemoteObject 不能复用;这并不表示浏览器底层 JavaScript realm 被重新创建。
标准 CDP 不会在 `DOM.Node` 上放置 `RemoteObjectId` 字段。`DOM.Node` 携带 `nodeId``backendNodeId``DOM.resolveNode` 返回对应的 `Runtime.RemoteObject``DOM.requestNode` 执行反向映射。实现会关联这三类 CDP 身份,而不添加非标准 DOM 字段。
@@ -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-08-27-inspector-development-mount.md
2026-08-27-inspector-development-mount.md: 0e5f0af52306ebb553e4fb911696cfc3fae087ee
2026-08-27-inspector-development-mount.zh.md: 252e52692df2ad983e6da9ee9640c5ae6603f20f
2026-08-27-inspector-development-mount.md: a8660c664f6fba49369af075bc998171a454a842
2026-08-27-inspector-development-mount.zh.md: 3dd82cc89e4c172c3ff9a4f8dc4cafd47c9fd847
@@ -10,21 +10,17 @@ English | [中文](2026-08-27-inspector-development-mount.zh.md)
## Decision
The inspector package owns a development overlay, `packages/experimental/inspector/cordis.patch.yml`, holding a single `insert` of the `experimental-inspector` row. A launch selects it through the generic overlay flag; `pnpm run demo:inspector` is the shorthand for `pnpm dsh web --patch ./packages/experimental/inspector/cordis.patch.yml`.
The inspector package owns two development overlays. `packages/experimental/inspector/cordis.source.patch.yml` inserts `./src/index.ts` for the tsx source launch behind `pnpm run demo:inspector`. `packages/experimental/inspector/cordis.patch.yml` inserts `./lib/index.js` for `node apps/cli/lib/bin.js web --patch ./packages/experimental/inspector/cordis.patch.yml` after `pnpm run build`.
The overlay contributes only the row; the row's module resolves from the profile plane at entry import:
- A source launch (`pnpm dsh`, tsx) resolves the workspace package through the tsconfig `paths` facade and needs no installation.
- A built launch (`node apps/cli/lib/bin.js`) needs the package importable from the profile first: `dsh plugin --profile web add link:<absolute package path>`, once per profile. `link:` keeps dependency resolution inside the real package directory; `file:` re-installs the package's `workspace:^` dependencies in the profile and fails with `ERR_PNPM_WORKSPACE_PKG_NOT_FOUND`.
A launch whose profile cannot import the package fails loud at entry import (`Cannot find package '@deepseek-ai/dsh-experimental-inspector' imported from <profile dir>`); nothing is skipped silently.
Each relative entry resolves from its overlay file's directory through the Loader's normal owning-tree `baseUrl`. The source launch therefore reaches TypeScript directly, while the built launch reaches the package artifact; neither path reads or modifies profile-installed plugin state. A missing source or built entry fails loud during Loader import rather than skipping the Inspector.
## Consequences
Published packages carry no trace of the inspector: no manifest entry, no composition row, no launcher flag. Mounting stays a per-launch choice — the same service without the overlay never loads the package — and every layer the launch composes is declared in a config file. The cost is launch-mode asymmetry: a built launch needs the one-time profile `link:` install, and the overlay must be named on every invocation, which `pnpm run demo:inspector` absorbs for the common case.
Published packages carry no trace of the inspector: no manifest entry, no composition row, no launcher flag. Mounting stays a per-launch choice — the same service without an overlay never loads the package — and every layer the launch composes is declared in a config file. The source shorthand names its overlay automatically; a built launch names the built overlay explicitly and requires current `lib/` artifacts.
## Alternatives considered
- A `disabled: !!js` row in the shipped web-app patch: the dependency gate and npm publication both force the private package into the published manifest.
- A `--inspector` launcher flag mounting the package as an extra bundle layer: the launcher owns neither app flags nor plugin package names.
- An optional `peerDependencies` entry on `dsh-web-app` plus a dynamic `ctx.loader.create` from its glue plugin: it writes a never-published name into a published manifest and mounts a row no config layer declares.
- One bare-package overlay for both launch modes: source resolution can use the workspace facade, but built resolution would require persistent profile installation state unrelated to the launch command.
@@ -10,21 +10,17 @@ Status: implemented
## Decision
inspector 包自有份开发 overlay`packages/experimental/inspector/cordis.patch.yml`,只含一个 `insert``experimental-inspector` 行。启动通过通用 overlay flag 选它;`pnpm run demo:inspector` `pnpm dsh web --patch ./packages/experimental/inspector/cordis.patch.yml` 的简写
inspector 包自有份开发 overlay`packages/experimental/inspector/cordis.source.patch.yml``pnpm run demo:inspector` 背后的 tsx 源码启动插入 `./src/index.ts``packages/experimental/inspector/cordis.patch.yml` 为执行过 `pnpm run build` 后的 `node apps/cli/lib/bin.js web --patch ./packages/experimental/inspector/cordis.patch.yml` 插入 `./lib/index.js`
overlay 只贡献这一行;行的模块在 entry import 时从 profile 平面解析:
- 源码启动(`pnpm dsh`tsx)经 tsconfig `paths` 门面解析 workspace 包,无需任何安装。
- built 启动(`node apps/cli/lib/bin.js`)需先让包可从 profile import`dsh plugin --profile web add link:<包目录绝对路径>`,每个 profile 一次。`link:` 让依赖解析留在真实包目录内;`file:` 会在 profile 里重装该包的 `workspace:^` 依赖并以 `ERR_PNPM_WORKSPACE_PKG_NOT_FOUND` 失败。
profile 无法 import 该包的启动会在 entry import 处响亮失败(`Cannot find package '@deepseek-ai/dsh-experimental-inspector' imported from <profile dir>`);不存在静默跳过。
两个相对 entry 都通过 Loader 常规的所属 tree `baseUrl` 从各自 overlay 文件目录解析。源码启动因此直接读取 TypeScript,built 启动读取包产物;两条路径都不读取或修改 profile 已安装插件状态。源码或 built entry 缺失时,Loader import 会响亮失败,不会跳过 Inspector。
## Consequences
已发布的包不携带 inspector 的任何痕迹:没有 manifest 条目、没有组合行、没有 launcher flag。挂载保持按次启动选择——不带 overlay 的同一服务永远不会加载该包——且启动组合的每一层都由 config 文件声明。代价是启动方式不对称:built 启动需要一次性 profile `link:` 安装,且每次调用都要点名 overlay,常见场景由 `pnpm run demo:inspector` 吸收
已发布的包不携带 inspector 的任何痕迹:没有 manifest 条目、没有组合行、没有 launcher flag。挂载保持按次启动选择——不带 overlay 的同一服务永远不会加载该包——且启动组合的每一层都由 config 文件声明。源码快捷命令会自动指定对应 overlay;built 启动需显式指定 built overlay,并要求 `lib/` 产物为当前版本
## Alternatives considered
- 随货 web-app patch 里放 `disabled: !!js` 行:依赖门禁与 npm 发布都会把 private 包逼进已发布 manifest。
- `--inspector` launcher flag 把包挂成额外 bundle 层:launcher 既不拥有 app flag 也不拥有插件包名。
- `dsh-web-app` 上加 optional `peerDependencies` 并由其 glue 插件动态 `ctx.loader.create`:向已发布 manifest 写入永不发布的名字,且挂载的行不在任何 config 层声明。
- 两种启动模式共用一份 bare-package overlay:源码解析可以使用 workspace 门面,但 built 解析会依赖与本次启动命令无关的持久 profile 安装状态。
+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: 6188880fcd682c7c3c105764f212e92395444b61
client-modules.zh.md: a732758492e1dbc9cbaef8d3effbd3310e4f10a1
client-modules.md: f58cb6592a009292ffc4f87a207fe4e103fd2365
client-modules.zh.md: 18c72dfba6f851776100a9b6200e7222d580db7c
+2 -2
View File
@@ -74,11 +74,11 @@ Each initial row's `rev` is an opaque process nonce plus sequence, so graph comp
## The scan
A package joins the table by declaring `dsh.client` (`platform: 'web'`, optional `inject` edges, optional `immediately`) in its package.json and exporting its built bundle at `exports["./client"]`. Package resolution anchors at the config tree's `ctx.baseUrl` — the cordis.yml directory, whose package declares every composed plugin as a dependency — and construction throws when that anchor is unset.
A package joins the table by declaring `dsh.client` (`platform: 'web'`, optional `inject` edges, optional `immediately`) in its package.json and exporting its built bundle at `exports["./client"]`. Each live row resolves from its own Loader specifier and owning-tree `baseUrl`, through the same `loader.internal.resolveSync` implementation that imports its Host face when available. The nearest owning package manifest supplies the browser module id, so relative source and built overlays retain the package identity. 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.
Scanning is incremental per package; there is no full-rescan code path. Every cordis `internal/plugin` emission (fiber construction or disposal) marks the fiber's entry name dirty, and a microtask flush reconciles each dirty name against the live loader entries. The activation pass seeds the same dirty set with all current entries and flushes synchronously, so first scan and steady state share one implementation — with opposite failure postures. At activation, a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud `AggregateError` listing every broken package: the fiber FAILS and the boot's fail-loud sweep reports it. In steady state, a broken package logs a warning and must not poison the others.
Package metadata — including the negative "not a client package" verdict — is cached per name and never expires: plugin-set changes take effect on restart. A fiber restart reuses its row and rev untouched; bundle content changes reach the graph only through `rebuilt()`.
Package metadata — including the negative "not a client package" verdict — is cached per Loader specifier and owning-tree base URL until restart. A fiber restart from the same source reuses its row and rev untouched; bundle content changes reach the graph only through `rebuilt()`.
## The bundle route and index injection
+2 -2
View File
@@ -74,11 +74,11 @@ interface WebBootGraph {
## 扫描
包加入这张表的方式,是在自己的 package.json 中声明 `dsh.client``platform: 'web'`、可选的 `inject` 边、可选的 `immediately`),并在 `exports["./client"]` 导出构建好的 bundle。包解析锚定在配置树的 `ctx.baseUrl`——即 cordis.yml 所在目录,该目录的包把每个被组合的插件声明为依赖——这一锚点未设置时,构造即抛错
包加入这张表的方式,是在自己的 package.json 中声明 `dsh.client``platform: 'web'`、可选的 `inject` 边、可选的 `immediately`),并在 `exports["./client"]` 导出构建好的 bundle。每个 live row 都从自己的 Loader specifier 与所属 tree `baseUrl` 解析;若 `loader.internal.resolveSync` 可用,则使用 Host face import 所用的同一个实现。最近归属的 package manifest 提供浏览器模块 id,因此相对 source 与 built overlay 仍保留包身份。若不同的 active Loader source 解析到同一包名,组合会失败;一个来源卸载后,仍存活的来源无需重启 fiber 即可提供该 row
扫描是单包增量的;不存在全量重扫代码路径。fiber 构造或 dispose(资源释放)时的每次 cordis `internal/plugin` 发射都把该 fiber 的 entry 名标脏,一次微任务 flush 把每个脏名与实时 loader entry 对账。激活趟以全部当前 entry 灌入同一个脏集合并同步 flush,因此初扫与稳态共享一条实现——但失败姿态相反。激活时,已加载 entry 中的畸形声明或缺失 bundle 会聚合为一个大声的 `AggregateError`,列出每个损坏的包:该 fiber 进入 FAILED,由启动的大声失败 sweep 上报。稳态下,损坏的包只记录一条警告,且不得殃及其他包。
包元数据——包括「非 client 包」这一否定结论——按名缓存且永不过期:插件集合的变更在重启后生效。fiber 重启原样复用其与 revbundle 内容变更只经 `rebuilt()` 到达图。
包元数据——包括「非 client 包」这一否定结论——按 Loader specifier 与所属 tree base URL 缓存至重启。同一来源的 fiber 重启原样复用其 row 与 revbundle 内容变更只经 `rebuilt()` 到达图。
## bundle 路由与 index 注入
+1 -1
View File
@@ -149,7 +149,7 @@
"release:publish": "tsx scripts/release/publish.ts",
"dsh": "node --import tsx/esm apps/cli/src/bin.ts",
"demo:code-mode": "node scripts/demo-code-mode.mjs",
"demo:inspector": "node --import tsx/esm apps/cli/src/bin.ts web --patch ./packages/experimental/inspector/cordis.patch.yml",
"demo:inspector": "node --import tsx/esm apps/cli/src/bin.ts web --patch ./packages/experimental/inspector/cordis.source.patch.yml",
"mock:llm": "node --import tsx packages/test-support/llm-mock-server/src/bin.ts",
"dev:web": "tsx scripts/dev-web.ts --poll",
"postinstall": "node scripts/install-lefthook.mjs"
+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: 4c81da74e5efbbbcdb35147431fc03252d51c94a
README.zh.md: 3e476c57606e64a4837ca97f4298faf124849e4c
README.md: 414d34af202ee4ab24e1b7570697ad63bd8cdbc8
README.zh.md: e6adc7b7d0265e52d5108e241a8f8a551976536d
+1 -1
View File
@@ -63,7 +63,7 @@ Executing a plugin bundle only registers its factory; every module-body side eff
### Incremental composition
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 name and never expires; bundle content changes reach the graph only through `rebuilt()` (the HMR hook).
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 bundle and available source map before publication. It groups resources into `/plugins/??...&rev=...` combo URLs, 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. Every combo 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, so startup does not hash every plugin; HMR hashes only an artifact reported as changed. Advertised responses are immutable, and an unknown combination or revision returns 404.
+1 -1
View File
@@ -63,7 +63,7 @@ application combo 脚本在启动时注册插件 factory;模块主体仍保持
### 增量组合
node 半侧逐包增量扫描——没有全量重扫路径。每次 `internal/plugin` 发出都会把该 fiber 的 entry 名标脏;一个微任务 flush 会把每个脏名与当前 loader 条目对账,激活 pass 播种同一脏集合并同步 flush,因此首次扫描与稳态共用同一实现。包元数据按名缓存且永不过期;bundle 内容变更只能通过 `rebuilt()`HMR 钩子)进入图。
node 半侧逐包增量扫描——没有全量重扫路径。每次 `internal/plugin` 发出都会把该 fiber 的 entry 名标脏;一个微任务 flush 会把每个脏名与当前 loader 条目对账,激活 pass 播种同一脏集合并同步 flush,因此首次扫描与稳态共用同一实现。包元数据按 Loader specifier 与所属 tree base URL 缓存至重启,解析出的 manifest 包名作为浏览器模块身份。若不同的 active Loader source 解析到同一包名,组合会失败;移除冲突来源后,剩余来源无需重启 fiber 即可接替。bundle 内容变更只能通过 `rebuilt()`HMR 钩子)进入图。
node 半侧会在发布前快照每个客户端 bundle 及其现有 source map。它把资源分组到 `/plugins/??...&rev=...` combo URLmodules row 使用一个 bootstrap combo,其余 row 使用一个或多个 application combo;每个阶段都会在 URL 超过 3 KiB 之前分区。每个 combo map 都是 Indexed Source Map v3,并在可用时使用作者提供的 section,否则为已打包 bundle 生成 identity section。初始逐插件 revision 使用进程 nonce,所以启动时不哈希每个插件;HMR 只哈希被报告为已变化的产物。已公告响应不可变;未知组合或 revision 返回 404。
+195 -51
View File
@@ -15,21 +15,23 @@
* against the live loader entries. The activation pass seeds the same dirty
* set with all current entries and flushes synchronously, so first scan and
* steady state share one implementation. Package metadata (including the
* negative "not a client package" verdict) is cached per name and never
* expires — plugin-set changes take effect on restart; bundle content
* changes reach the graph only through
* negative "not a client package" verdict) is cached per Loader specifier and
* owning-tree base URL until restart. The manifest package name identifies
* the browser module; distinct active Loader sources for that package are a
* composition error. Bundle content changes reach the graph only through
* {@link ClientModuleRegistry.rebuilt}.
* @module @deepseek-ai/dsh-client-modules
*/
import { createHash, randomBytes } from 'node:crypto'
import { readFileSync, statSync } from 'node:fs'
import { existsSync, readFileSync, statSync } from 'node:fs'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import { dirname, isAbsolute, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { Service } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/cordis-plugin-loader'
import type { Entry } from '@deepseek-ai/cordis-plugin-loader'
import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
import { optionalStringArray, stripClientSuffix } from './client/manifest.ts'
import type { WebBootBatch, WebBootBatchPhase, WebBootEntry, WebBootGraph } from './client/manifest.ts'
@@ -80,11 +82,26 @@ export interface ClientArtifactBaseline {
readonly size: number
}
/** Resolved package metadata for one `dsh.client` package (cached per name, never expires). */
/** Resolved metadata cached for one Loader specifier and owning-tree base URL until restart. */
interface PkgMeta extends WebBootRowFields {
clientPath: string
}
interface ResolvedPkgMeta {
packageName: string
meta: PkgMeta
}
/** One active Loader source and the browser package manifest it resolves to. */
interface ClientPackageSource extends ResolvedPkgMeta {
/** Loader specifier from the active row. */
loaderName: string
/** Resolution base of the config tree that owns the row. */
baseUrl: string
/** Stable cache and contribution key for this source. */
sourceKey: string
}
/** Recovery instruction shared by grouped startup and steady-state bundle diagnostics. */
const CLIENT_BUNDLE_BUILD_INSTRUCTION = 'run `pnpm run build` before launch'
@@ -129,6 +146,10 @@ class ClientPackageCompositionError extends AggregateError {
/** One composed table row: the wire entry plus the resolved package metadata behind it. */
interface WebPluginRecord {
entry: WebBootEntry
/** Loader specifier whose active row contributes this browser module. */
loaderName: string
/** Loader resolution input that selected this package instance. */
sourceKey: string
meta: PkgMeta
/** Exact build artifact included in the startup batches. */
bundle: Buffer
@@ -167,6 +188,15 @@ const SOURCE_MAP_TRAILER = /(?:\r?\n)?\/\/# sourceMappingURL=[^\r\n]*(?:\r?\n)?$
/** Debugger source name appended to page bundles in the WebWorker image. */
const SOURCE_URL_TRAILER = /(?:\r?\n)?\/\/# sourceURL=([^\r\n]+)(?:\r?\n)?$/
/** Return a bare package-root specifier, excluding package subpaths and path-like entries. */
function exactPackageSpecifier(specifier: string): string | undefined {
if (specifier.startsWith('@')) {
const parts = specifier.split('/')
return parts.length === 2 && parts.every(Boolean) ? specifier : undefined
}
return specifier.length > 0 && !specifier.includes('/') ? specifier : undefined
}
/** Narrow an unknown parsed JSON value to the `dsh.client` declaration, throwing on malformed fields. */
function parseDshClient(pkgName: string, value: unknown): DshClientDeclaration | undefined {
if (value === undefined) return undefined
@@ -504,14 +534,13 @@ export class ClientModuleRegistry extends Service {
static inject = ['webServer', 'loader']
private readonly table = new Map<string, WebPluginRecord>()
// Negative verdicts (unresolvable specifier — builtins like cordis:include,
// subpath rows — or a package without a web `dsh.client` declaration) are
// cached as null and never expire: plugin-set changes take effect on restart.
private readonly pkgMeta = new Map<string, PkgMeta | null>()
private readonly sources = new Map<string, ClientPackageSource>()
// Resolution is entry-local: the same specifier can resolve differently in
// separate config trees. Negative verdicts remain stable until restart.
private readonly pkgMeta = new Map<string, ResolvedPkgMeta | null>()
private readonly rebuildListeners = new Set<(id: string, rev: string) => void>()
private readonly graphListeners = new Set<() => void>()
private readonly dirty = new Set<string>()
private readonly resolvePkgJson: (spec: string) => string
private readonly initialRevisionNonce = randomBytes(8).toString('hex')
private nextInitialRevision = 0
private responses = new Map<string, { body: Buffer; contentType: string }>()
@@ -527,16 +556,6 @@ export class ClientModuleRegistry extends Service {
*/
constructor(ctx: Context) {
super(ctx, 'clientModules')
// Resolution anchor: the config tree's baseUrl (the cordis.yml directory,
// whose package declares every composed plugin as a dependency). The
// modules package's own URL would miss sibling packages under pnpm's
// isolated node_modules.
if (ctx.baseUrl === undefined) {
throw new Error('client-modules: ctx.baseUrl is unset — the node half needs the config-tree anchor to resolve plugin packages')
}
const require = createRequire(ctx.baseUrl)
this.resolvePkgJson = spec => require.resolve(`${spec}/package.json`)
// Subscribe before seeding so a fiber arriving mid-activation lands in the
// same dirty set (Set idempotence makes the overlap harmless). An entry-less
// fiber is a child plugin or a manual mount — never a loader row; O(1) drop.
@@ -716,31 +735,31 @@ export class ClientModuleRegistry extends Service {
}
}
private resolveMeta(pkgName: string): PkgMeta | null {
const cached = this.pkgMeta.get(pkgName)
private resolveMeta(loaderName: string, baseUrl: string): ResolvedPkgMeta | null {
const sourceKey = this.sourceKey(loaderName, baseUrl)
const cached = this.pkgMeta.get(sourceKey)
if (cached !== undefined) return cached
let pkgPath: string
try {
pkgPath = this.resolvePkgJson(pkgName)
} catch {
const located = this.locatePkgJson(loaderName, baseUrl)
if (located === undefined) {
// Not a resolvable package root: loader builtins (cordis:include) and
// subpath entries (…/gateway) land here — permanently not a client row.
this.pkgMeta.set(pkgName, null)
this.pkgMeta.set(sourceKey, null)
return null
}
const { packageName, path: pkgPath } = located
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as Record<string, unknown>
const dsh = pkg.dsh
const decl = parseDshClient(
pkgName,
packageName,
dsh !== null && typeof dsh === 'object' ? (dsh as Record<string, unknown>).client : undefined,
)
if (decl === undefined || decl.platform !== 'web') {
this.pkgMeta.set(pkgName, null)
this.pkgMeta.set(sourceKey, null)
return null
}
const clientRel = clientExportOf(pkgName, pkg.exports)
const clientRel = clientExportOf(packageName, pkg.exports)
if (clientRel === undefined) {
throw new Error(`client-modules: ${pkgName} declares dsh.client but exports no "./client" bundle`)
throw new Error(`client-modules: ${packageName} declares dsh.client but exports no "./client" bundle`)
}
const meta: PkgMeta = {
clientPath: join(dirname(pkgPath), clientRel),
@@ -748,8 +767,87 @@ export class ClientModuleRegistry extends Service {
external: decl.external ?? [],
immediately: decl.immediately === true,
}
this.pkgMeta.set(pkgName, meta)
return meta
const resolved = { packageName, meta }
this.pkgMeta.set(sourceKey, resolved)
return resolved
}
/**
* Locate the manifest of the package the Loader mounts for a row. The row's
* module location is authoritative: the specifier resolves through the same
* Loader resolution that imported the row's host half — including any
* active ESM hooks — and the nearest ancestor manifest declaring the name
* owns the module. Tree-anchored `require` resolution remains only for
* runtimes without Node internals.
* @param loaderName - module specifier of the loader row.
* @param baseUrl - resolution base of the tree that owns the row.
* @returns the manifest path, or `undefined` when the name resolves to no package root.
*/
private locatePkgJson(loaderName: string, baseUrl: string): { path: string; packageName: string } | undefined {
if (loaderName.startsWith('cordis:')) return undefined
const pathLike = loaderName.startsWith('.') || loaderName.startsWith('file:') || isAbsolute(loaderName)
const expectedPackageName = pathLike ? undefined : exactPackageSpecifier(loaderName)
if (!pathLike && expectedPackageName === undefined) return undefined
const internal = this.ctx.loader.internal
if (internal === undefined || typeof Reflect.get(internal, 'resolveSync') !== 'function') {
if (expectedPackageName === undefined) {
const moduleUrl = loaderName.startsWith('file:')
? loaderName
: isAbsolute(loaderName) ? pathToFileURL(loaderName).href : new URL(loaderName, baseUrl).href
return this.nearestPackage(moduleUrl)
}
try {
return {
path: createRequire(baseUrl).resolve(`${expectedPackageName}/package.json`),
packageName: expectedPackageName,
}
} catch {
// Without Node internals the owning tree is the only resolver; an
// unresolvable name is classified exactly as below.
return undefined
}
}
let moduleUrl: string
try {
moduleUrl = internal.version === 'v2'
? internal.resolveSync(baseUrl, { specifier: loaderName, attributes: {} }).url
: internal.resolveSync(loaderName, baseUrl, {}).url
} catch {
// The Loader cannot resolve the name: its row cannot have imported, so
// the name is permanently not a client row.
return undefined
}
return this.nearestPackage(moduleUrl, expectedPackageName)
}
private nearestPackage(
moduleUrl: string,
expectedPackageName?: string,
): { path: string; packageName: string } | undefined {
if (!moduleUrl.startsWith('file:')) return undefined
let dir = dirname(fileURLToPath(moduleUrl))
while (true) {
const candidate = join(dir, 'package.json')
if (existsSync(candidate)) {
try {
const name = (JSON.parse(readFileSync(candidate, 'utf8')) as { name?: unknown }).name
if (typeof name === 'string' && (expectedPackageName === undefined || name === expectedPackageName)) {
return { path: candidate, packageName: name }
}
} catch {
// An unreadable or malformed intermediate manifest cannot own the
// module; keep walking toward the declaring package root.
}
}
const parent = dirname(dir)
if (parent === dir) break
dir = parent
}
return undefined
}
private sourceKey(loaderName: string, baseUrl: string): string {
return `${baseUrl}\0${loaderName}`
}
/** Capture the bundle stats before reading its bytes. */
@@ -800,26 +898,72 @@ export class ClientModuleRegistry extends Service {
}
}
/** Reconcile one entry name against the live loader entries. @returns whether the table changed. */
private processOne(entryName: string): boolean {
let qualifies = false
/** Reconcile one entry name against the live Loader sources. @returns whether the table changed. */
private processOne(entryName: string, onError: (err: Error) => void): boolean {
const nextSources = new Map<string, ClientPackageSource>()
for (const entry of this.ctx.loader.entries()) {
if (entry.options.name === entryName && entry.fiber !== undefined && !entry.disabled) {
qualifies = true
break
if (entry.options.name !== entryName || entry.fiber === undefined || entry.disabled) continue
const source = this.resolveSource(entry)
if (source !== undefined) nextSources.set(source.sourceKey, source)
}
const affectedPackages = new Set<string>()
for (const [sourceKey, source] of this.sources) {
if (source.loaderName !== entryName) continue
affectedPackages.add(source.packageName)
if (!nextSources.has(sourceKey)) this.sources.delete(sourceKey)
}
for (const [sourceKey, source] of nextSources) {
affectedPackages.add(source.packageName)
this.sources.set(sourceKey, source)
}
let changed = false
for (const packageName of affectedPackages) {
try {
if (this.reconcilePackage(packageName)) changed = true
} catch (error) {
onError(error instanceof Error ? error : new Error(String(error)))
}
}
if (!qualifies) return this.table.delete(entryName)
if (this.table.has(entryName)) return false
const meta = this.resolveMeta(entryName)
if (meta === null) return false
return changed
}
private resolveSource(entry: Entry): ClientPackageSource | undefined {
const loaderName = entry.options.name
const baseUrl = entry.parent.tree.ctx.baseUrl
if (baseUrl === undefined) {
throw new Error(`client-modules: loader entry ${loaderName} has no resolution base URL`)
}
const resolved = this.resolveMeta(loaderName, baseUrl)
if (resolved === null) return undefined
return { ...resolved, loaderName, baseUrl, sourceKey: this.sourceKey(loaderName, baseUrl) }
}
private reconcilePackage(packageName: string): boolean {
const sources: ClientPackageSource[] = []
for (const source of this.sources.values()) {
if (source.packageName === packageName) sources.push(source)
}
if (sources.length > 1) {
const locations = sources
.map(source => `${JSON.stringify(source.loaderName)} from ${source.baseUrl}`)
.join(', ')
throw new Error(
`client-modules: package ${packageName} resolves from multiple active Loader sources: ${locations}; remove one entry`,
)
}
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 reuses the existing row without inspecting bytes.
const snapshot = this.initialBundleSnapshot(entryName, meta.clientPath)
// a fiber restart from the same source reuses the existing row.
const snapshot = this.initialBundleSnapshot(packageName, source.meta.clientPath)
const rev = this.allocateInitialRevision()
this.table.set(entryName, {
entry: graphRow(entryName, rev, meta),
meta,
this.table.set(packageName, {
entry: graphRow(packageName, rev, source.meta),
loaderName: source.loaderName,
sourceKey: source.sourceKey,
meta: source.meta,
bundle: snapshot.bundle,
baseline: snapshot.baseline,
...(snapshot.sourceMap === undefined ? {} : { sourceMap: snapshot.sourceMap }),
@@ -832,7 +976,7 @@ export class ClientModuleRegistry extends Service {
for (const entryName of [...this.dirty]) {
this.dirty.delete(entryName)
try {
if (this.processOne(entryName)) changed = true
if (this.processOne(entryName, onError)) changed = true
} catch (error) {
// Steady state: one broken package must not poison the others; the
// activation pass aggregates these into a loud throw instead.
@@ -7,8 +7,8 @@ import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { runInNewContext } from 'node:vm'
import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it } from 'vitest'
import { Context, type Fiber } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { renderIndexInjections, type WebServer, type WebRoute } from '@deepseek-ai/dsh-host-webserver'
import * as modulesClient from '../src/client/index.ts'
import { ClientModuleRegistry, bootInjections, orderByModuleGraph } from '../src/index.ts'
@@ -58,13 +58,26 @@ function writeBuiltPackage(packageName: string, client: Record<string, unknown>)
}
/** Construct the node-half service and capture its plugin-bundle route. */
function constructWithRoute(packageNames: string[]): { service: ClientModuleRegistry; route: WebRoute } {
function constructWithRoute(
packageNames: string[],
options: {
contextBaseUrl?: string
entryBaseUrl?: string
internal?: NonNullable<Context['loader']['internal']>
} = {},
): { context: Context; service: ClientModuleRegistry; route: WebRoute } {
const ctx = new Context()
ctx.baseUrl = pathToFileURL(root!).href + '/'
ctx.baseUrl = options.contextBaseUrl ?? pathToFileURL(root!).href + '/'
ctx.provide('loader', {
internal: options.internal,
*entries() {
for (const packageName of packageNames) {
yield { options: { name: packageName }, fiber: {}, disabled: false }
yield {
options: { name: packageName },
fiber: {},
disabled: false,
parent: { tree: { ctx: { baseUrl: options.entryBaseUrl ?? ctx.baseUrl } } },
}
}
},
})
@@ -80,7 +93,7 @@ function constructWithRoute(packageNames: string[]): { service: ClientModuleRegi
ctx.provide('webServer', webServer as WebServer)
const service = new ClientModuleRegistry(ctx)
if (route === undefined) throw new Error('client bundle route was not registered')
return { service, route }
return { context: ctx, service, route }
}
/** Construct the node-half service over the enabled fixture entries. */
@@ -227,6 +240,171 @@ describe('HTML bootstrap facade', () => {
})
describe('client bundle activation', () => {
it.each(['v1', 'v2'] as const)(
'resolves %s package metadata from the owning entry tree',
(version) => {
const packageName = `@fixture/entry-base-${version}`
const clientPath = writePackage(packageName)
const hostPath = join(dirname(clientPath), 'index.js')
mkdirSync(dirname(hostPath), { recursive: true })
writeFileSync(hostPath, 'export default {}\n')
writeFileSync(clientPath, 'module.exports = {}\n')
const contextBaseUrl = pathToFileURL(join(root!, 'profile')).href + '/'
const entryBaseUrl = pathToFileURL(join(root!, 'overlay')).href + '/'
const calls: unknown[][] = []
const resolveSync = (...args: unknown[]) => {
calls.push(args)
return { format: 'module' as const, url: pathToFileURL(hostPath).href }
}
const internal = { version, resolveSync }
const { service } = constructWithRoute([packageName], {
contextBaseUrl,
entryBaseUrl,
internal: internal as NonNullable<Context['loader']['internal']>,
})
expect(calls).toEqual(version === 'v2'
? [[entryBaseUrl, { specifier: packageName, attributes: {} }]]
: [[packageName, entryBaseUrl, {}]])
expect(service.clientPath(packageName)).toBe(clientPath)
expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName])
},
)
it('derives the browser module id from a file entry owning manifest', () => {
const packageName = '@fixture/file-entry'
const clientPath = writePackage(packageName)
const hostPath = join(dirname(clientPath), 'index.js')
mkdirSync(dirname(hostPath), { recursive: true })
writeFileSync(hostPath, 'export default {}\n')
writeFileSync(clientPath, 'module.exports = {}\n')
const service = construct([pathToFileURL(hostPath).href])
expect(service.clientPath(packageName)).toBe(clientPath)
expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName])
})
it.each(['relative', 'absolute'] as const)(
'finds the owning manifest through the %s-path fallback without Node loader internals',
(kind) => {
const packageName = `@fixture/${kind}-fallback-entry`
const clientPath = writePackage(packageName)
const packageRoot = dirname(dirname(clientPath))
const hostPath = join(packageRoot, 'index.js')
mkdirSync(dirname(clientPath), { recursive: true })
writeFileSync(hostPath, 'export default {}\n')
writeFileSync(clientPath, 'module.exports = {}\n')
const loaderName = kind === 'relative' ? './index.js' : hostPath
const { service } = constructWithRoute([loaderName], {
entryBaseUrl: pathToFileURL(packageRoot).href + '/',
})
expect(service.clientPath(packageName)).toBe(clientPath)
expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName])
},
)
it.each(['v1', 'v2', 'worker'] as const)(
'derives a file entry package id through the %s Loader resolver',
(version) => {
const packageName = `@fixture/file-entry-${version}`
const clientPath = writePackage(packageName)
const hostPath = join(dirname(clientPath), 'index.js')
mkdirSync(dirname(hostPath), { recursive: true })
writeFileSync(hostPath, 'export default {}\n')
writeFileSync(clientPath, 'module.exports = {}\n')
const loaderName = pathToFileURL(hostPath).href
const entryBaseUrl = pathToFileURL(join(root!, 'overlay')).href + '/'
const calls: unknown[][] = []
const resolveSync = (...args: unknown[]) => {
calls.push(args)
return { format: 'module' as const, url: loaderName }
}
const internal = { version, resolveSync }
const { service } = constructWithRoute([loaderName], {
entryBaseUrl,
internal: internal as NonNullable<Context['loader']['internal']>,
})
expect(calls).toEqual(version === 'v2'
? [[entryBaseUrl, { specifier: loaderName, attributes: {} }]]
: [[loaderName, entryBaseUrl, {}]])
expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName])
},
)
it('rejects distinct active Loader sources for one browser package', () => {
const packageName = '@fixture/duplicate-source'
const clientPath = writePackage(packageName)
const hostPath = join(dirname(clientPath), 'index.js')
mkdirSync(dirname(hostPath), { recursive: true })
writeFileSync(hostPath, 'export default {}\n')
writeFileSync(clientPath, 'module.exports = {}\n')
const alias = './duplicate-source.js'
const internal = {
version: 'v2' as const,
resolveSync: () => ({ format: 'module' as const, url: pathToFileURL(hostPath).href }),
}
expect(() => constructWithRoute([packageName, alias], {
internal: internal as unknown as NonNullable<Context['loader']['internal']>,
})).toThrow(
`client-modules: package ${packageName} resolves from multiple active Loader sources:`,
)
})
it('promotes the remaining Loader source after the selected alias unloads', async () => {
const packageName = '@fixture/duplicate-source-recovery'
const clientPath = writePackage(packageName)
const hostPath = join(dirname(clientPath), 'index.js')
mkdirSync(dirname(hostPath), { recursive: true })
writeFileSync(hostPath, 'export default {}\n')
writeFileSync(clientPath, 'module.exports = {}\n')
const alias = './duplicate-source-recovery.js'
const entries = [packageName]
const internal = {
version: 'v2' as const,
resolveSync: () => ({ format: 'module' as const, url: pathToFileURL(hostPath).href }),
}
const { context, service } = constructWithRoute(entries, {
internal: internal as unknown as NonNullable<Context['loader']['internal']>,
})
const firstRevision = service.graph().entries[0]!.rev
const warning = vi.spyOn(context.logger, 'warn').mockImplementation(() => undefined)
entries.push(alias)
emitLoaderEntryChange(context, alias)
await Promise.resolve()
expect(warning).toHaveBeenCalledWith(expect.objectContaining({
message: expect.stringContaining(`package ${packageName} resolves from multiple active Loader sources`) as string,
}))
expect(service.graph().entries[0]!.rev).toBe(firstRevision)
entries.splice(entries.indexOf(packageName), 1)
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.clientPath(packageName)).toBe(clientPath)
})
it('uses owning-tree package resolution for an import-only Worker module loader', () => {
const packageName = '@fixture/worker-loader'
writeBuiltPackage(packageName, {})
const internal = {
version: 'worker',
import: async () => ({}),
} as unknown as NonNullable<Context['loader']['internal']>
const { service } = constructWithRoute([packageName], { internal })
expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName])
})
it('allows sibling dsh roles', () => {
const currentName = '@fixture/current-client-field'
const clientPath = writePackage(currentName, {
@@ -564,6 +742,12 @@ describe('client bundle activation', () => {
})
})
function emitLoaderEntryChange(context: Context, name: string): void {
context.emit('internal/plugin', {
entry: { options: { name } },
} as unknown as Fiber)
}
describe('shared module declarations', () => {
it('accepts external requests and carries them onto the graph row', () => {
const packageName = '@fixture/shared-declared'
@@ -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/experimental/inspector/README.md
README.md: e10a68eed10c0bc71d26b5eacf9ee3eac4c6818d
README.zh.md: f6aceb5374730ff9879151980c60e54dad39fd89
README.md: d6c519723a8b9eab31048fb71f13b17004a6af52
README.zh.md: 0c2f58032a7c1f1a485f974da60c1d60e335eb51
+2 -1
View File
@@ -106,7 +106,7 @@ Node delivery is depth-limited per DevTools connection: `DOM.getDocument` serves
Sources publish complete snapshots, while the Worker compares stable backend node identities before notifying DevTools. Unchanged snapshots emit no DOM event; additions, removals, and attribute changes use node-level CDP events, inserted-node payloads withhold their subtree, and sibling reordering replaces only that parent's children. Existing `NodeId` values and unaffected Elements expansion remain stable.
When a Client disconnects, its Console execution context and live object ids are destroyed immediately. With disconnected-tree retention enabled, Elements keeps the last tree unchanged while connection state remains in the inspection model rather than becoming an unreviewed DOM attribute. Reconnection keeps the logical source id, creates a new synthetic CDP context id for the new transport generation, and replaces the stale tree after its complete snapshot arrives. The Worker retains at most `maxDisconnectedCordisTrees` such snapshots; zero removes them immediately.
When a Client disconnects, its Console execution context and live object ids are destroyed immediately. With disconnected-tree retention enabled, Elements keeps the last tree unchanged while connection state remains in the inspection model rather than becoming an unreviewed DOM attribute. Reconnection keeps the logical source id, creates a new synthetic CDP context id for the new transport generation, and replaces the stale tree after its complete snapshot arrives. The Client retains its logical id in `sessionStorage` and claims it through Web Locks for the page lifetime, so refresh reuses the id while a duplicated live tab receives a new one. The Worker retains at most `maxDisconnectedCordisTrees` such snapshots; zero removes them immediately.
<a id="host-fetch-capture"></a>
## Host fetch capture
@@ -138,6 +138,7 @@ None; this package neither assembles nor sends a provider request.
- **Client active debugging is unsupported** — Console events, Runtime evaluation, RemoteObject access, and read-only `lib/client.js` Sources work. Client-script debugger requests return explicit unsupported errors; target-wide pause and resume control the Host only.
- **Client Sources expose the Inspector bundle only** — other page scripts are not cataloged by this package.
- **Client evaluation uses page JavaScript** — page Content Security Policy can block dynamic evaluation, and the synthetic context does not provide DevTools command-line helpers or native REPL declaration semantics.
- **Client identity arbitration requires Web Locks** — browsers without that API retain reconnect and refresh identity through `sessionStorage`, but cannot distinguish two simultaneously live tabs copied from the same storage state.
- **Fetch interception covers `globalThis.fetch`** — direct Undici APIs and fetch references retained before activation are not observed.
- **Body cloning has cost** — full capture tees request and response streams up to the configured limits and can increase memory and I/O pressure. The retained-body limit does not include buffering inside the stream tee, including an oversized source chunk or data queued for a slower application reader.
- **No automatic Worker restart** — an unexpected Worker exit fails the current Inspector instance; lifecycle recovery belongs to a later change.
+2 -1
View File
@@ -106,7 +106,7 @@ Host 与 Client 发布同一种嵌套 `CordisTreeSnapshot` 类型。Context 与
source 仍发布完整 snapshotWorker 在通知 DevTools 前按稳定的 backend node identity 比较差异。无变化的 snapshot 不发送 DOM event;新增、移除和 attribute 变化使用节点级 CDP event,插入节点的载荷扣留其子树,兄弟节点重排只替换对应 parent 的 children。现有 `NodeId` 与未受影响的 Elements 展开状态保持稳定。
Client 断联时,其 Console execution context 与 live object id 会立即销毁。启用断联树保留后,Elements 会原样保留最后一棵树;连接状态留在 inspection model 中,不会未经设计就成为 DOM attribute。重连会沿用逻辑 source id,为新的 transport generation 创建新的 synthetic CDP context id,并在完整 snapshot 到达后替换旧树。Worker 最多保留 `maxDisconnectedCordisTrees` 棵此类 snapshot;设为零会立即移除。
Client 断联时,其 Console execution context 与 live object id 会立即销毁。启用断联树保留后,Elements 会原样保留最后一棵树;连接状态留在 inspection model 中,不会未经设计就成为 DOM attribute。重连会沿用逻辑 source id,为新的 transport generation 创建新的 synthetic CDP context id,并在完整 snapshot 到达后替换旧树。Client 把逻辑 id 保存在 `sessionStorage` 中,并通过 Web Locks 在页面存活期间独占该 id,因此刷新会复用 id,而复制出的另一个 live tab 会取得新 id。Worker 最多保留 `maxDisconnectedCordisTrees` 棵此类 snapshot;设为零会立即移除。
<a id="host-fetch-capture"></a>
## Host fetch 采集
@@ -138,6 +138,7 @@ CDP target 通过 `Runtime.evaluate` 提供 Host 和已连接 Client realm 中
- **Client active debugging 不受支持**——Console event、Runtime 求值、RemoteObject 访问和只读 `lib/client.js` Sources 可用。Client script debugger request 返回明确的 unsupported errortarget-wide pause 与 resume 只控制 Host。
- **Client Sources 只暴露 Inspector bundle**——本包不收录页面中的其他 script。
- **Client 求值使用页面 JavaScript**——页面 Content Security Policy 可能阻止动态求值;synthetic context 不提供 DevTools command-line helper 或原生 REPL 声明语义。
- **Client 身份仲裁依赖 Web Locks**——缺少该 API 的浏览器仍会通过 `sessionStorage` 保持重连与刷新身份,但无法区分从同一存储状态复制出的两个同时存活 tab。
- **fetch 拦截范围是 `globalThis.fetch`**——直接调用 Undici API,以及激活前保存的 fetch 引用不会被观察。
- **body clone 有运行成本**——完整采集会 tee 请求与响应 stream,直至达到配置上限,可能增加内存与 I/O 压力。保留 body 的上限不包含 stream tee 内部的缓冲,包括来源提供的超大 chunk,或为读取较慢的应用分支排队的数据。
- **不自动重启 Worker**——Worker 意外退出会使当前 Inspector 实例失败;生命周期恢复留待后续改动。
@@ -1,13 +1,8 @@
# Development overlay for the experimental inspector: mount it per launch with
# pnpm run demo:inspector (pnpm dsh web --patch ./packages/experimental/inspector/cordis.patch.yml)
# A source launch resolves this workspace package through the tsconfig paths
# facade and needs no installation. A built launch additionally needs the
# package importable from the profile:
# dsh plugin --profile web add link:<absolute path to this package directory>
# (`link:`, not `file:` — `file:` re-installs the workspace:^ dependencies
# inside the profile and fails). The package is private and ships with no
# published dsh installation; a missing package fails loud at entry import.
# Built-artifact overlay for the experimental inspector:
# node apps/cli/lib/bin.js web --patch ./packages/experimental/inspector/cordis.patch.yml
# The relative entry is anchored to this file, so the private package does not
# need to be installed into the selected profile. Run `pnpm run build` first.
- insert:
- id: experimental-inspector
name: '@deepseek-ai/dsh-experimental-inspector'
name: './lib/index.js'
@@ -0,0 +1,6 @@
# Source overlay for `pnpm run demo:inspector`. The relative entry is anchored
# to this file and runs through the CLI's tsx loader without a profile install.
- insert:
- id: experimental-inspector
name: './src/index.ts'
@@ -2,12 +2,20 @@
import type { InspectorClientBootstrap } from '../../shared/bridge/messages/control.ts'
import { ClientInspectorSource } from './transport.ts'
import { ClientRealmSource } from '../inspection/realm.ts'
/**
* Start the browser source transport for one validated Host bootstrap.
* @param bootstrap - Host-injected endpoint and resource limits.
* @returns The active reconnecting Client source.
* @returns The active reconnecting Client source after its tab identity is claimed.
*/
export function startInspectorClient(bootstrap: InspectorClientBootstrap): ClientInspectorSource {
return new ClientInspectorSource(bootstrap)
export async function startInspectorClient(bootstrap: InspectorClientBootstrap): Promise<ClientInspectorSource> {
const label = document.title || 'Client'
const realmSource = await ClientRealmSource.claim(label)
try {
return new ClientInspectorSource(bootstrap, label, undefined, realmSource)
} catch (error) {
realmSource.close()
throw error
}
}
@@ -50,9 +50,10 @@ export class ClientInspectorSource extends InspectorSourceConnection {
private readonly bootstrap: InspectorClientBootstrap,
label = document.title || 'Client',
private readonly sourceCatalog: ClientSourceCatalog | undefined = discoverInspectorClientSourceCatalog(),
realmSource = new ClientRealmSource(label),
) {
super()
this.realmSource = new ClientRealmSource(label)
this.realmSource = realmSource
this.lifecycle = new ClientBridgeLifecycle(bootstrap.reconnectBaseMs, bootstrap.reconnectMaxMs)
this.publisher = new ClientBridgePublisher({
topics: ['*'],
@@ -107,19 +108,23 @@ export class ClientInspectorSource extends InspectorSourceConnection {
this.publisher.close()
const socket = this.socket
const generation = this.generation
if (socket?.readyState === WebSocket.OPEN && generation !== undefined) {
const frame: SourceCloseFrame = {
v: INSPECTOR_PROTOCOL_VERSION,
t: 'source/close',
sourceId: this.realmSource.sourceId,
generation,
try {
if (socket?.readyState === WebSocket.OPEN && generation !== undefined) {
const frame: SourceCloseFrame = {
v: INSPECTOR_PROTOCOL_VERSION,
t: 'source/close',
sourceId: this.realmSource.sourceId,
generation,
}
socket.send(JSON.stringify(frame))
socket.close(1000, 'Client source closed')
} else {
socket?.close()
}
socket.send(JSON.stringify(frame))
socket.close(1000, 'Client source closed')
} else {
socket?.close()
} finally {
this.socket = undefined
this.realmSource.close()
}
this.socket = undefined
}
private connect(): void {
@@ -5,12 +5,42 @@ import { inspectorId } from '../../shared/identity.ts'
import type { InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts'
import { bridgeCapabilities } from '../cdp/index.ts'
const CLIENT_SOURCE_STORAGE_KEY = 'dsh.experimental-inspector.client-source-id.v0'
const CLIENT_SOURCE_LOCK_PREFIX = 'dsh.experimental-inspector.client-source:'
/** Owns one browser realm's stable source id across transport reconnects. */
export class ClientRealmSource {
/** Logical source id retained across reconnecting transport generations. */
readonly sourceId = inspectorId<'InspectorSourceId'>(`client-${randomUUID()}`, 'sourceId')
readonly sourceId: InspectorSourceDescriptor['sourceId']
constructor(private readonly label: string) {}
constructor(
private readonly label: string,
sourceId = sessionClientSourceId(),
private releaseClaim?: () => void,
) {
this.sourceId = sourceId
}
/**
* Claim the tab identity before opening its source transport. Browsers with
* Web Locks reject a copied `sessionStorage` identity while its original tab
* remains live; a fresh id is persisted and claimed instead.
* @param label - Human-readable Client label reported to the Worker.
* @returns The claimed realm source.
*/
static async claim(label: string): Promise<ClientRealmSource> {
let sourceId = sessionClientSourceId()
const locks = browserLockManager()
if (locks === undefined) return new ClientRealmSource(label, sourceId)
while (true) {
const release = await tryClaimSourceId(locks, sourceId)
if (release !== undefined) {
persistClientSourceId(sourceId)
return new ClientRealmSource(label, sourceId, release)
}
sourceId = generatedClientSourceId()
}
}
/**
* Create the descriptor for one newly admitted transport generation.
@@ -27,6 +57,65 @@ export class ClientRealmSource {
capabilities: bridgeCapabilities(clientOrigin(), hasSources),
}
}
/** Release this page's identity claim. */
close(): void {
this.releaseClaim?.()
this.releaseClaim = undefined
}
}
function sessionClientSourceId(): InspectorSourceDescriptor['sourceId'] {
const generated = generatedClientSourceId()
try {
const stored = sessionStorage.getItem(CLIENT_SOURCE_STORAGE_KEY)
if (stored !== null) {
try {
return inspectorId<'InspectorSourceId'>(stored, 'sourceId')
} catch {
// Invalid page-owned storage is replaced with a fresh protocol identity below.
}
}
sessionStorage.setItem(CLIENT_SOURCE_STORAGE_KEY, generated)
} catch {
// Disabled or unavailable session storage limits identity to this page lifetime.
}
return generated
}
function generatedClientSourceId(): InspectorSourceDescriptor['sourceId'] {
return inspectorId<'InspectorSourceId'>(`client-${randomUUID()}`, 'sourceId')
}
function persistClientSourceId(sourceId: InspectorSourceDescriptor['sourceId']): void {
try {
sessionStorage.setItem(CLIENT_SOURCE_STORAGE_KEY, sourceId)
} catch {
// Disabled or unavailable session storage limits identity to this page lifetime.
}
}
function browserLockManager(): LockManager | undefined {
if (typeof navigator === 'undefined') return undefined
return navigator.locks
}
function tryClaimSourceId(
locks: LockManager,
sourceId: InspectorSourceDescriptor['sourceId'],
): Promise<(() => void) | undefined> {
return new Promise((resolve, reject) => {
let release!: () => void
const held = new Promise<void>((released) => { release = released })
void locks.request(`${CLIENT_SOURCE_LOCK_PREFIX}${sourceId}`, { ifAvailable: true }, async (lock) => {
if (lock === null) {
resolve(undefined)
return
}
resolve(release)
await held
}).catch(reject)
})
}
function clientOrigin(): string {
@@ -38,15 +38,18 @@ export const name = 'experimental-inspector'
/** This transport root has no Client service dependencies. */
export const inject: string[] = []
/** Mount the Client source and shared `ctx.inspector` publishing API. */
export function apply(ctx: Context): void {
/**
* Mount the Client source and shared `ctx.inspector` publishing API.
* @param ctx - Client Cordis context whose page identity and lifecycle own the source.
*/
export async function apply(ctx: Context): Promise<void> {
const injected = globalThis.__DSH_INSPECTOR__
if (injected === undefined) {
throw new Error('experimental inspector: Host bootstrap is missing')
}
const bootstrap = parseInspectorClientBootstrap(injected)
ctx.effect(() => {
const source = startInspectorClient(bootstrap)
await ctx.effect(async () => {
const source = await startInspectorClient(bootstrap)
const disposers: Array<() => unknown> = []
try {
disposers.push(publishCordisTree(ctx, source, {
@@ -66,7 +69,10 @@ export function apply(ctx: Context): void {
}, 'experimental-inspector: Client source')
}
function disposeInspectorClient(source: ReturnType<typeof startInspectorClient>, disposers: readonly (() => unknown)[]): void {
function disposeInspectorClient(
source: Awaited<ReturnType<typeof startInspectorClient>>,
disposers: readonly (() => unknown)[],
): void {
const failures: unknown[] = []
for (const dispose of [...disposers].reverse()) {
try {
@@ -150,8 +150,10 @@ describe.skipIf(!built)('Inspector built Client in Chromium', () => {
const context = contextEvent.params?.context as Record<string, unknown>
const contextId = context.id
const uniqueContextId = context.uniqueId
const sourceId = asRecord(context.auxData).sourceId
expect(contextId).toBeTypeOf('number')
expect(uniqueContextId).toBeTypeOf('string')
expect(sourceId).toBeTypeOf('string')
const evaluated = await cdp.call('Runtime.evaluate', {
expression: 'globalThis.__inspectorConsoleEvaluation = { answer: 6 * 7 }',
@@ -211,6 +213,26 @@ describe.skipIf(!built)('Inspector built Client in Chromium', () => {
url: script.params?.url,
lineNumber: 0,
})).error?.message).toContain('Client native debugging is unavailable')
const duplicateContext = cdp.waitForEvent('Runtime.executionContextCreated', (event) => {
const candidate = event.params?.context as Record<string, unknown> | undefined
return String(candidate?.name).startsWith('Client —') && candidate?.id !== contextId
})
const popup = await Promise.all([
page.waitForEvent('popup'),
page.evaluate(() => {
if (window.open(location.href, '_blank') === null) throw new Error('duplicate tab was blocked')
}),
]).then(([opened]) => opened)
await popup.waitForFunction(() => Reflect.get(globalThis, '__INSPECTOR_BROWSER_TEST__') !== undefined)
const duplicate = (await duplicateContext).params?.context as Record<string, unknown>
expect(asRecord(duplicate.auxData).sourceId).not.toBe(sourceId)
await cdp.call('Debugger.disable')
await popup.evaluate(async () => {
const state = Reflect.get(globalThis, '__INSPECTOR_BROWSER_TEST__') as { dispose?: () => Promise<void> } | undefined
await state?.dispose?.()
})
await popup.close()
}, 20_000)
})
@@ -231,14 +253,14 @@ globalThis.__DSH_BOOT__ = ${JSON.stringify(boot)};
globalThis.__ModuleLoader__ = { load(registration) { globalThis.__INSPECTOR_REGISTRATION__ = registration; } };
</script>
<script src="/client.js?rev=browser-test"></script>
<script>
<script type="module">
const registration = globalThis.__INSPECTOR_REGISTRATION__;
const disposers = [];
const root = {
__inspectorContext: true,
registry: new Map(),
events: { _hooks: {} },
effect(callback) { const dispose = callback(); disposers.push(dispose); return dispose; },
async effect(callback) { const dispose = await callback(); disposers.push(dispose); return dispose; },
on() { return () => {}; },
provide(name, value) { this[name] = value; return () => { delete this[name]; }; },
};
@@ -248,9 +270,9 @@ const plugin = registration.factory(specifier => {
if (specifier === '@deepseek-ai/cordis') return cordis;
throw new Error('Unexpected Client bundle dependency ' + specifier);
});
plugin.apply(root);
await plugin.apply(root);
globalThis.__INSPECTOR_BROWSER_TEST__ = {
dispose() { for (const dispose of disposers.reverse()) dispose(); },
async dispose() { for (const dispose of disposers.reverse()) await dispose(); },
};
</script>`
}
@@ -3,6 +3,7 @@
import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { apply } from '../src/client/index.ts'
import { ClientRealmSource } from '../src/client/inspection/realm.ts'
import type { InspectorClientBootstrap } from '../src/shared/bridge/messages/control.ts'
class FakeWebSocket extends EventTarget {
@@ -66,9 +67,11 @@ describe('experimental Inspector Client plugin', () => {
const nativeFetch = globalThis.fetch
afterEach(() => {
vi.restoreAllMocks()
FakeWebSocket.sockets.length = 0
globalThis.WebSocket = nativeWebSocket
globalThis.fetch = nativeFetch
sessionStorage.clear()
delete globalThis.__DSH_INSPECTOR__
Reflect.deleteProperty(globalThis, '__DSH_BOOT__')
})
@@ -179,6 +182,92 @@ describe('experimental Inspector Client plugin', () => {
await fiber.dispose()
})
it('keeps the logical source id when the Client plugin is recreated after a page refresh', async () => {
globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket
globalThis.__DSH_INSPECTOR__ = bootstrap
const firstContext = new Context()
const firstFiber = firstContext.plugin({ apply })
await firstFiber.await()
const firstSocket = FakeWebSocket.sockets[0]!
firstSocket.open()
const firstOpen = JSON.parse(firstSocket.sent[0]!) as {
source: { sourceId: string; generation: string }
}
await firstFiber.dispose()
const secondContext = new Context()
const secondFiber = secondContext.plugin({ apply })
await secondFiber.await()
const secondSocket = FakeWebSocket.sockets[1]!
secondSocket.open()
const secondOpen = JSON.parse(secondSocket.sent[0]!) as {
source: { sourceId: string; generation: string }
}
expect(secondOpen.source.sourceId).toBe(firstOpen.source.sourceId)
expect(secondOpen.source.generation).not.toBe(firstOpen.source.generation)
await secondFiber.dispose()
})
it('rotates a copied session identity while its original page remains live', async () => {
const descriptor = Object.getOwnPropertyDescriptor(navigator, 'locks')
const held = new Set<string>()
const request = async (
name: string,
_options: LockOptions,
callback: (lock: Lock | null) => unknown,
): Promise<unknown> => {
const acquired = !held.has(name)
if (acquired) held.add(name)
try {
return await callback(acquired ? { name, mode: 'exclusive' } : null)
} finally {
if (acquired) held.delete(name)
}
}
Object.defineProperty(navigator, 'locks', {
configurable: true,
value: { request },
})
let first: ClientRealmSource | undefined
let duplicate: ClientRealmSource | undefined
let refreshed: ClientRealmSource | undefined
try {
first = await ClientRealmSource.claim('first')
duplicate = await ClientRealmSource.claim('duplicate')
expect(duplicate.sourceId).not.toBe(first.sourceId)
first.close()
await vi.waitFor(() => { expect(held.size).toBe(1) })
sessionStorage.setItem('dsh.experimental-inspector.client-source-id.v0', first.sourceId)
refreshed = await ClientRealmSource.claim('refreshed')
expect(refreshed.sourceId).toBe(first.sourceId)
} finally {
first?.close()
duplicate?.close()
refreshed?.close()
if (descriptor === undefined) Reflect.deleteProperty(navigator, 'locks')
else Object.defineProperty(navigator, 'locks', descriptor)
}
})
it('falls back to a page-lifetime source id when session storage is unavailable', async () => {
globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket
globalThis.__DSH_INSPECTOR__ = bootstrap
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new DOMException('storage disabled', 'SecurityError')
})
const ctx = new Context()
const fiber = ctx.plugin({ apply })
await fiber.await()
const socket = FakeWebSocket.sockets[0]!
socket.open()
const open = JSON.parse(socket.sent[0]!) as { source: { sourceId: string } }
expect(open.source.sourceId).toMatch(/^client-/u)
await fiber.dispose()
})
it('cancels an outstanding Client Runtime operation without sending a late response', async () => {
globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket
globalThis.__DSH_INSPECTOR__ = bootstrap
@@ -45,6 +45,13 @@ export type Resolution =
| { readonly kind: 'static'; readonly specifier: string; readonly factory: StaticModuleFactory }
| { readonly kind: 'file'; readonly path: string }
/** Node-loader-compatible resolution returned through the Cordis internal seam. */
export interface WorkerInternalResolution {
readonly format: 'builtin' | 'commonjs' | 'json'
/** File URL for VFS modules; the original bare specifier for builtins. */
readonly url: string
}
interface ModuleRecord {
readonly module: { exports: unknown }
}
@@ -121,6 +128,8 @@ export class WorkerModuleLoader {
readonly internal: {
readonly version: 'worker'
import(specifier: string, parentURL?: string, attributes?: unknown): Promise<unknown>
resolve(specifier: string, parentURL?: string, attributes?: unknown): Promise<WorkerInternalResolution>
resolveSync(specifier: string, parentURL?: string, attributes?: unknown): WorkerInternalResolution
}
constructor(options: WorkerModuleLoaderOptions) {
@@ -133,12 +142,23 @@ export class WorkerModuleLoader {
.sort(([left], [right]) => right.length - left.length)
this.conditions = new Set(options.conditions ?? DEFAULT_CONDITIONS)
this.als = createAlsRuntime(options.alsCausality)
const resolveInternal = (specifier: string, parentURL?: string): WorkerInternalResolution => {
const from = parentURL === undefined ? this.root : this.baseDirectoryOf(parentURL)
const resolution = this.resolve(specifier, from)
if (resolution.kind === 'static') return { format: 'builtin', url: resolution.specifier }
return {
format: resolution.path.endsWith('.json') ? 'json' : 'commonjs',
url: pathToFileUrl(resolution.path),
}
}
this.internal = {
version: 'worker',
import: async (specifier: string, parentURL?: string): Promise<unknown> => {
const from = parentURL === undefined ? this.root : this.baseDirectoryOf(parentURL)
return this.load(this.resolve(specifier, from))
},
resolve: async (specifier: string, parentURL?: string) => resolveInternal(specifier, parentURL),
resolveSync: resolveInternal,
}
}
@@ -60,6 +60,23 @@ describe('the replacement table', () => {
})
describe('module identity through the loader', () => {
it('exposes async and synchronous resolution through the Cordis internal seam', async () => {
const vfs = new MemoryVfs()
vfs.seedDirectory('/dsh/node_modules/example')
vfs.writeFileSync('/dsh/node_modules/example/package.json', JSON.stringify({ main: 'index.js' }))
vfs.writeFileSync('/dsh/node_modules/example/index.js', 'module.exports = {}\n')
const loader = new WorkerModuleLoader({ vfs, root: '/dsh', staticModules: createNodeBuiltins() })
expect(loader.internal.resolveSync('example', 'file:///dsh/app.js')).toEqual({
format: 'commonjs',
url: 'file:///dsh/node_modules/example/index.js',
})
await expect(loader.internal.resolve('node:fs', 'file:///dsh/app.js')).resolves.toEqual({
format: 'builtin',
url: 'node:fs',
})
})
it('hands the same instance to two requires of one specifier', () => {
const require = loaderRequire()
expect(require('node:events')).toBe(require('node:events'))