mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
fix(client): preserve combo source identities
This commit is contained in:
+2
-2
@@ -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: 027fd07ca9a46f13807802912c6cbd9e76cd8e6e
|
||||
2026-07-23-client-plugin-loading-model.zh.md: e4a6bdf3050987761475fad03b8999b0005568d4
|
||||
2026-07-23-client-plugin-loading-model.md: bd6f6e58c571102afc789ef57085db1e302158cc
|
||||
2026-07-23-client-plugin-loading-model.zh.md: 256b57102bbec6f793d48d0bdaf60445b194ecdf
|
||||
|
||||
@@ -42,7 +42,7 @@ The vendored Loader consumes the module system through its `internal` contract
|
||||
|
||||
The Host snapshots every built plugin artifact and partitions each scheduling phase's ordered rows into one or more same-origin classic scripts. It greedily fills each group while the longer map-form request URL remains within 3 KiB, preserving graph order and allowing another request instead of emitting an oversized URL. Each script is addressed by its package resources, for example `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>`. The `bootstrap` and `application` values are scheduling phases in the graph, not URL components: HTML preloads every application URL before executing every parser-blocking bootstrap URL. The module system keys in-flight transport by combo URL, so concurrent row arrivals within one group execute one script. Successful settlement still requires each requested row's factory id to exist in the module table, and registration does not run the factory, so the side-effect boundary remains first materialization.
|
||||
|
||||
The shared tsdown preset emits `client.js.map` for every plugin 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 `sourceMappingURL`, records its generated-line offset, resolves every source against the original per-plugin map URL, and emits an Indexed Source Map v3 whose sections embed the available plugin maps. A missing component map leaves that script range unmapped without suppressing the combined map. The absolute 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>`. One resource follows the same rule and still produces an indexed map with one section. The Vite shell also emits source maps, letting shell code and combo-loaded plugins map stacks and performance profiles back to TypeScript/TSX.
|
||||
The shared tsdown preset emits `client.js.map` for every plugin 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 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>`. One resource follows the same rule and still produces an indexed map with one section. The Vite shell also emits source maps, letting shell code and combo-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 content-addressed 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. After the watcher observes one artifact change, `rebuilt(id)` hashes only that bundle and map and publishes the resulting revision. Startup combo revisions cover the combined script inputs and indexed map. Versioned scripts and maps use immutable caching. 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.
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ vendored Loader 经其 `internal` 约定消费模块系统——唯一调用点
|
||||
|
||||
Host 会快照每个已构建插件产物,并把每个调度阶段的有序 row 划入一个或多个同源 classic script。它在更长的 map 形式请求 URL 保持在 3 KiB 以内时贪心填充每组,既保留 graph 顺序,也以增加请求代替超长 URL。每个脚本都由其中的 package 资源寻址,例如 `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>`。`bootstrap` 与 `application` 是图中的调度阶段,不是 URL 组成部分:HTML 先预加载所有 application URL,再执行所有阻塞 parser 的 bootstrap URL。模块系统按 combo URL 复用进行中的传输,因此同组 row 的并发到达只执行一个脚本。成功结算仍要求模块表中已经存在被请求 row 的 factory id;登记不会运行 factory,所以副作用边界依然是首次物化。
|
||||
|
||||
共享 tsdown 预设为每个插件产出 `client.js.map`,并把第一方源码路径重写成浏览器可识别的仓库形式 `/packages/<group>/<package>/src/...`。生产 Client 构建会消费 `lib/types`;预设把每份 tsc map 交给 Rolldown,并从原文件补齐 `sourcesContent`,使最终 map 回到 TypeScript/TSX,而不是停在编译后的 JavaScript。内联进 bundle 的其他 workspace 源码同样回到其 `packages/` 归属,依赖包路径保持原样。Combo 生成会移除每个局部 `sourceMappingURL`、记录其生成行偏移、以原插件 map URL 解析每个 source,再产出一份以 section 内嵌现有插件 map 的 Indexed Source Map v3。缺少组件 map 时,对应脚本范围保持未映射,不会抑制整份组合 map。绝对 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>`。单资源也采用相同规则,仍产出只有一个 section 的 indexed map。Vite 壳同样产出 sourcemap,使壳代码与经 combo 加载的插件都能从 stack 和性能 profile 回到 TypeScript/TSX。
|
||||
共享 tsdown 预设为每个插件产出 `client.js.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 名。绝对 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>`。单资源也采用相同规则,仍产出只有一个 section 的 indexed map。Vite 壳同样产出 sourcemap,使壳代码与经 combo 加载的插件都能从 stack 和性能 profile 回到 TypeScript/TSX。
|
||||
|
||||
图为 HMR 保留每个 row 带 revision 的单资源 combo URL,并为每个启动 combo 请求增加按内容寻址的描述;多条描述可以使用同一调度阶段。初始 row revision 是进程级不透明 nonce,而不是内容哈希;它无需在启动时哈希每个插件,也能保证已快照的单资源响应不可变。watcher 观察到某个产物变化后,`rebuilt(id)` 只哈希该 bundle 与 map,并发布所得 revision。启动 combo revision 覆盖合并脚本输入与 indexed map。版本化脚本与 map 使用 immutable 缓存。Host 只提供精确生成的 URL;陈旧 revision 与未发布资源列表返回 404,不会别名到其他字节。外部脚本的 `error` 事件不给响应状态与正文,因此失败诊断只报告 URL;同源 Host 与构建期写入的 registration id 是身份边界,`load` 后的 factory 存在性检查负责拒绝未登记预期 id 的产物。
|
||||
|
||||
|
||||
+2
-2
@@ -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-20-webworker-pack-lowering-and-preview.md
|
||||
2026-08-20-webworker-pack-lowering-and-preview.md: 2a05b24a3821ea905829e9da3e1edb1584135a5f
|
||||
2026-08-20-webworker-pack-lowering-and-preview.zh.md: d6c85d64038be5bc1a9ac77f1a4642c68a5d4606
|
||||
2026-08-20-webworker-pack-lowering-and-preview.md: 22aa6aae3aef57b0cf52f04cb741841e7e14691f
|
||||
2026-08-20-webworker-pack-lowering-and-preview.zh.md: 059a1ff55d687f8c02d1e8ad73b6bba57c839801
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ The browser worker can neither compile modules at load nor be served by the prod
|
||||
|
||||
**Lowering happens at pack time only.** `@deepseek-ai/dsh-experimental-webworker-packer` composes the profile, materializes the closure, and lowers every JavaScript body; `LOWERING_VERSION` and `WRAPPER_PARAMS` are the pack↔worker contract and live in `src/image-layout.ts` beside the rest of the image layout. The loader wraps bodies exactly as the image holds them: a body still carrying module syntax is a refusal naming the image, and `startWorkerHost` requires the manifest's `lowered` to equal this build's contract before it mounts a single module. `lowerModuleSource` is the transform's only face and the packer its only caller; inside the worker graph, imports name the module that owns the value — never the package barrel, which is the edge that smuggled the parser in. Source-directory exclusion applies only to workspace and vendored packages whose runtime plane is built `lib/`; installed third-party packages retain JavaScript under `src/` and `dist/` because their published entrypoints may resolve there.
|
||||
|
||||
**The preview is the served page plus one tag.** One Vite build emits `dist/index.html` and `dist/preview.html` sharing every chunk; the only difference is a prepended bootstrap entry whose module connects the worker host. Startup then converges on one protocol: whichever side applies the injection table settles the `__DSH_BOOT_READY__` deferred — the served renderer resolves it in a tail script after the rendered rows, the worker bootstrap installs it before its first await and settles it after the last row — and the client entry awaits it before reading any injected state, so the chain from the stock entry onward is the served chain verbatim. The build uses a relative base so the output mounts under any static directory; the served form anchors deep SPA-fallback paths by rendering `<base href="/">` at serve time, keeping the on-disk pages byte-shared.
|
||||
**The preview is the served page plus one tag.** One Vite build emits `dist/index.html` and `dist/preview.html` sharing every chunk; the only difference is a prepended bootstrap entry whose module connects the worker host. Startup then converges on one protocol: whichever side applies the injection table settles the `__DSH_BOOT_READY__` deferred — the served renderer resolves it in a tail script after the rendered rows, the worker bootstrap installs it before its first await and settles it after the last row — and the client entry awaits it before reading any injected state, so the chain from the stock entry onward is the served chain verbatim. Plugin combo scripts and maps travel through the tunnel; the page-side loader replaces each tunnel-only map reference with a Blob URL before executing its script Blob, preserving indexed-map component names in DevTools. The build uses a relative base so the output mounts under any static directory; the served form anchors deep SPA-fallback paths by rendering `<base href="/">` at serve time, keeping the on-disk pages byte-shared.
|
||||
|
||||
**The repository preview carries selectable filesystem sources.** The packer emits one base image and a small overlay archive for each named built-in fixture. Without a source query, `preview.html` waits at a chooser for an empty filesystem, the built-in fixtures, or the separately owned WebFS provider. A valid `preview-fixture=none|<built-in-id>` query selects directly and skips the chooser for deterministic browser runs; its distinct name avoids the Client's existing `fixture` transport switch. The Worker mounts the base and then applies the selected overlays in order, restricted to `home/` and `workspace/`, before it validates the base manifest or boots Cordis. `packages/experimental/webworker-runtime/tests/fixtures/vfs-example/` supplies one built-in overlay without giving the packer Session or Workspace knowledge. Its plaintext JSONL logs use the persistence backend's real project/session directory layout, so Session Persistence reads them cold and Workspace Registry derives the Workspace from their `/dsh/workspace` headers. The main Session exceeds the Client's 50-message page and keeps representative tool results at its tail; persisted one-shot and continuable children exercise the subagent catalog. WebFS authorization and user data remain a separate provider and never share this fixture tree.
|
||||
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
|
||||
**Lowering 只发生在 pack 期。** `@deepseek-ai/dsh-experimental-webworker-packer` 组合 profile、物化闭包、lower 每个 JavaScript 模块体;`LOWERING_VERSION` 与 `WRAPPER_PARAMS` 是 pack↔worker 的契约,与镜像布局的其余部分一起放在 `src/image-layout.ts`。装载器完全按镜像持有的形态包装模块体:仍带模块语法的模块体是一次点名镜像的拒绝,且 `startWorkerHost` 在挂载任何模块之前要求 manifest 的 `lowered` 等于本构建的契约。`lowerModuleSource` 是转换器唯一的面、packer 是它唯一的调用方;worker 图内部的 import 一律指向拥有该值的模块——绝不指向包 barrel,那正是把解析器偷运进来的那条边。源码目录排除只用于运行期使用已构建 `lib/` 的 workspace 与 vendored 包;已安装第三方包会保留 `src/` 和 `dist/` 下的 JavaScript,因为其发布入口可能解析到这些位置。
|
||||
|
||||
**preview 就是服务页面加一个标签。** 一次 Vite 构建产出共享全部 chunk 的 `dist/index.html` 与 `dist/preview.html`;唯一差异是前插的一个引导入口,其模块负责连接 worker host。启动随之汇于一个协议:应用注入表的一方 settle `__DSH_BOOT_READY__` deferred——served 渲染器在渲染完的行之后用尾部脚本 resolve,worker 引导段在首个 await 之前安装、末行生效后 settle——client 入口在读取任何注入状态前 await 它,因此从标准入口起的链路逐字就是 served 链路。构建使用相对 base,产物可挂载于任意静态目录;served 形态在 serve 期渲染 `<base href="/">` 锚定深层 SPA fallback 路径,磁盘上的两个页面保持字节共享。
|
||||
**preview 就是服务页面加一个标签。** 一次 Vite 构建产出共享全部 chunk 的 `dist/index.html` 与 `dist/preview.html`;唯一差异是前插的一个引导入口,其模块负责连接 worker host。启动随之汇于一个协议:应用注入表的一方 settle `__DSH_BOOT_READY__` deferred——served 渲染器在渲染完的行之后用尾部脚本 resolve,worker 引导段在首个 await 之前安装、末行生效后 settle——client 入口在读取任何注入状态前 await 它,因此从标准入口起的链路逐字就是 served 链路。插件 combo 脚本与 map 都通过 tunnel;页面侧 loader 会在执行脚本 Blob 前,把每个仅 tunnel 可达的 map 引用替换成 Blob URL,从而在 DevTools 中保留 indexed map 的组件名称。构建使用相对 base,产物可挂载于任意静态目录;served 形态在 serve 期渲染 `<base href="/">` 锚定深层 SPA fallback 路径,磁盘上的两个页面保持字节共享。
|
||||
|
||||
**仓库 preview 携带可选择的文件系统来源。** Packer 产出一份基础镜像,并为每套具名内置 fixture 产出一份小型 overlay 归档。没有来源 query 时,`preview.html` 会停在选择面板,可选择空文件系统、内置 fixtures,或归另一实现所有的 WebFS provider。合法的 `preview-fixture=none|<built-in-id>` query 会直接选择并跳过面板,供确定性的浏览器流程使用;该独立名称避开 Client 既有的 `fixture` transport 开关。Worker 先挂载基础镜像,再按顺序把所选 overlays 应用到仅限 `home/` 和 `workspace/` 的路径,随后才校验基础 manifest 并启动 Cordis。`packages/experimental/webworker-runtime/tests/fixtures/vfs-example/` 提供其中一套内置 overlay,Packer 无需理解 Session 或 Workspace。明文 JSONL 日志使用 persistence backend 的真实 project/session 目录布局,因此 Session Persistence 会冷读取它们,Workspace Registry 则根据其 `/dsh/workspace` header 派生 Workspace。主 Session 超过 Client 的 50-message page,并把代表性工具结果留在尾页;持久化的 one-shot 与 continuable child 用于验证 subagent catalog。WebFS 授权与用户数据仍属于独立 provider,绝不与该 fixture 共用目录。
|
||||
|
||||
|
||||
@@ -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: 770045112e1181f602da987b2c084c17c1b9fada
|
||||
client-modules.zh.md: 40c6af6d2bab6156a0986324e89f46203e260af9
|
||||
client-modules.md: e80329be63c957407df5c9e06fd94780b66459bc
|
||||
client-modules.zh.md: c42f04f3d8be53aa5e5d7cb3c57d06ccd5e21e72
|
||||
|
||||
@@ -82,7 +82,7 @@ Package metadata — including the negative "not a client package" verdict — i
|
||||
|
||||
## The bundle route and index injection
|
||||
|
||||
`GET`/`HEAD /plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` serves an exact generated combo script; a one-resource request uses the same form and is the HMR path. Its absolute `sourceMappingURL` changes every resource suffix in parallel, yielding `/plugins/??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`. The map is Indexed Source Map v3 even for one resource. It contains sections for available component maps and leaves components without maps unmapped. Every generated request URL is at most 3 KiB measured as UTF-8 bytes; partitioning uses the longer map form even when a group has no map. All application URLs are preloaded, and all bootstrap URLs execute before the graph global and Vite entry. All advertised responses use long-lived immutable caching. Unknown or altered resource lists, absent maps, missing revisions, and stale revisions answer 404 rather than serving different bytes or letting the SPA fallback return HTML as JavaScript; other methods are 405. The injection rows carry the current graph on every index render, so a reload always boots against the live composition.
|
||||
`GET`/`HEAD /plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` serves an exact generated combo script; a one-resource request uses the same form and is the HMR path. Its absolute `sourceMappingURL` changes every resource suffix in parallel, yielding `/plugins/??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`. The map is Indexed Source Map v3 even for one resource. An authored component map supplies its section; a component without one receives an identity section whose `sourcesContent` is the generated bundle and whose source name is its packaged `sourceURL` or plugin route. Every startup request URL is at most 3 KiB measured as UTF-8 bytes; partitioning uses the longer map form. All application URLs are preloaded, and all bootstrap URLs execute before the graph global and Vite entry. All advertised responses use long-lived immutable caching. Unknown or altered resource lists, missing revisions, and stale revisions answer 404 rather than serving different bytes or letting the SPA fallback return HTML as JavaScript; other methods are 405. The injection rows carry the current graph on every index render, so a reload always boots against the live composition.
|
||||
|
||||
## The service
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ interface WebBootGraph {
|
||||
|
||||
## bundle 路由与 index 注入
|
||||
|
||||
`GET`/`HEAD /plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` 提供精确生成的 combo 脚本;单资源请求采用同一形式,也是 HMR 路径。其绝对 `sourceMappingURL` 平行改写每个资源后缀,得到 `/plugins/??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`。即使只有一个资源,map 仍采用 Indexed Source Map v3;有组件 map 的资源形成 section,没有 map 的组件区间保持未映射。每条生成的请求 URL 按 UTF-8 字节计算都不超过 3 KiB;即使某组没有 map,切分仍按更长的 map 形式计算。所有 application URL 都会预加载,所有 bootstrap URL 都会在图全局量与 Vite entry 之前执行。所有已发布响应都使用长期 immutable 缓存。未知或被修改的资源列表、缺失 map、缺少 revision 及陈旧 revision 都返回 404,绝不提供其他字节,也不会让 SPA fallback 把 HTML 当作 JavaScript 返回;其他方法返回 405。注入行在每次 index 渲染时携带当前图,因此重新加载总是基于实时组合启动。
|
||||
`GET`/`HEAD /plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` 提供精确生成的 combo 脚本;单资源请求采用同一形式,也是 HMR 路径。其绝对 `sourceMappingURL` 平行改写每个资源后缀,得到 `/plugins/??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`。即使只有一个资源,map 仍采用 Indexed Source Map v3。组件有自带 map 时直接用于对应 section;没有时则获得 identity section,其 `sourcesContent` 是构建后 bundle,source 名取打包后的 `sourceURL` 或插件路由。每条启动请求 URL 按 UTF-8 字节计算都不超过 3 KiB;切分按更长的 map 形式计算。所有 application URL 都会预加载,所有 bootstrap URL 都会在图全局量与 Vite entry 之前执行。所有已发布响应都使用长期 immutable 缓存。未知或被修改的资源列表、缺少 revision 及陈旧 revision 都返回 404,绝不提供其他字节,也不会让 SPA fallback 把 HTML 当作 JavaScript 返回;其他方法返回 405。注入行在每次 index 渲染时携带当前图,因此重新加载总是基于实时组合启动。
|
||||
|
||||
## 服务
|
||||
|
||||
|
||||
@@ -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: daecf2f73e6c4d43d068e14053374d4ab0c674d9
|
||||
README.zh.md: 291cea6f570fe4c73635bdbe8a6793252962863d
|
||||
README.md: cb41158c29e96de722522a6fae2fa4d959671daa
|
||||
README.zh.md: 1c47b23430b39397e572f29c5d60f0210e4ad9e5
|
||||
|
||||
@@ -10,7 +10,7 @@ The Host installs `window.__ModuleLoader__`, preloads every application combo sc
|
||||
|
||||
Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → exports; graph row (`window.__DSH_BOOT__`) → register its initial-combo factory; registered factory → materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the asynchronous graph-row load and records observed edges into the module record. `prefetch` is the stage-one arrival hook; rows sharing an initial combo URL share one in-flight script task. `invalidate(id, rev)` drops a non-bootstrap factory and materialized record and switches that row to its revisioned one-resource combo URL, so HMR reloads one plugin without executing the initial multi-resource script again.
|
||||
|
||||
The Node half scans enabled Loader entries for web `dsh.client` packages, resolves and snapshots each `exports["./client"]` plus its available source map, carries package-specific `dsh.client.external` requests, and orders dynamic providers before consumers. It schedules the modules row as bootstrap and every other row as application. Both initial delivery and HMR use the same combo route: `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` for any non-empty resource list, including a one-resource HMR reload. Within each phase, graph composition greedily partitions the ordered resources before the longer map-form URL would exceed 3 KiB, so a large application uses several requests without changing the path model. A script's absolute `sourceMappingURL` mirrors its list as `/plugins/??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`. Every generated map is Indexed Source Map v3, with sections for the available plugin maps and unmapped ranges where a component map is absent. Initial per-plugin revisions are opaque process nonces, so startup does not hash every plugin; HMR hashes only an artifact whose watcher reports a change. Every advertised response is immutable, and an unadvertised combination or revision returns 404 instead of serving different bytes. Source launch maps host imports to TypeScript source but still consumes these built client exports; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures.
|
||||
The Node half scans enabled Loader entries for web `dsh.client` packages, resolves and snapshots each `exports["./client"]` plus its available source map, carries package-specific `dsh.client.external` requests, and orders dynamic providers before consumers. It schedules the modules row as bootstrap and every other row as application. Both initial delivery and HMR use the same combo route: `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` for any non-empty resource list, including a one-resource HMR reload. Within each phase, graph composition greedily partitions the ordered resources before the longer map-form URL would exceed 3 KiB, so a large application uses several requests without changing the path model. A script's absolute `sourceMappingURL` mirrors its list as `/plugins/??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`. Every generated map is Indexed Source Map v3. An authored plugin map supplies its section; otherwise an identity section embeds the generated bundle and names its packaged `lib/client.js` when available, so one combo remains separable into its component files. Initial per-plugin revisions are opaque process nonces, so startup does not hash every plugin; HMR hashes only an artifact whose watcher reports a change. Every advertised response is immutable, and an unadvertised combination or revision returns 404 instead of serving different bytes. Source launch maps host imports to TypeScript source but still consumes these built client exports; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures.
|
||||
|
||||
`dsh.client.external` is an optional exact-specifier request list beyond the implicit baseline of shell-seeded React, Cordis, and static UI libraries. A request is answered by the dynamic package row it names or an exact static-table key; only a trailing `/client` aliases a package row, and there is no provider-alias declaration. Type-only imports are erased and create no request. Composition rejects malformed requests, missing suppliers, self-requests, and synchronous request cycles; import and prefetch recursively register dynamic suppliers before their consumers materialize. See [shared modules and the module graph](../AGENTS.md#shared-modules-and-the-module-graph).
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ Host 会先安装 `window.__ModuleLoader__`、预加载所有 application combo
|
||||
|
||||
解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 导出;模块图记录(`window.__DSH_BOOT__`)→ 登记其初始 combo 中的 factory;已登记 factory → 物化;其他情况一律抛出异常。这是构建时 bundle 纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含异步 graph-row 加载分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段到达钩子;共享同一初始 combo URL 的 row 会共享一个进行中的脚本任务。`invalidate(id, rev)` 会丢弃非 bootstrap factory 与物化记录,并让该 row 改用带 revision 的单资源 combo URL,因此 HMR(热模块替换)只重载一个插件,不会再次执行初始多资源脚本。
|
||||
|
||||
Node 侧会扫描已启用的 Loader 配置项以发现 web `dsh.client` 包,解析并快照每个 `exports["./client"]` 及其可用 sourcemap,携带包专属 `dsh.client.external` 请求,并把动态提供方排在消费者之前。它把 modules row 调度为 bootstrap,把其余 row 调度为 application。初始传输与 HMR 使用同一条 combo 路由:任意非空资源列表都写成 `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>`,HMR 单资源重载也不例外。图组合会在更长的 map 形式 URL 超过 3 KiB 前,按顺序贪心切分每个阶段的资源,因此大型 application 会产生多次请求,但路径模型不变。脚本中的绝对 `sourceMappingURL` 把各自的资源列表平行改写成 `/plugins/??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`。每个生成的 map 都是 Indexed Source Map v3:有 map 的插件形成 section,缺失组件 map 的范围保持未映射。初始逐插件 revision 使用不透明的进程 nonce,因此启动时不会哈希每个插件;HMR 只哈希 watcher 报告发生变化的产物。每个已发布响应都不可变,未发布的组合或 revision 返回 404,绝不提供其他字节。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费这些构建后的客户端导出;缺失文件共享一条构建说明,随后以包/路径列表列出各项,而无关的文件系统错误仍是独立故障。
|
||||
Node 侧会扫描已启用的 Loader 配置项以发现 web `dsh.client` 包,解析并快照每个 `exports["./client"]` 及其可用 sourcemap,携带包专属 `dsh.client.external` 请求,并把动态提供方排在消费者之前。它把 modules row 调度为 bootstrap,把其余 row 调度为 application。初始传输与 HMR 使用同一条 combo 路由:任意非空资源列表都写成 `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>`,HMR 单资源重载也不例外。图组合会在更长的 map 形式 URL 超过 3 KiB 前,按顺序贪心切分每个阶段的资源,因此大型 application 会产生多次请求,但路径模型不变。脚本中的绝对 `sourceMappingURL` 把各自的资源列表平行改写成 `/plugins/??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`。每个生成的 map 都是 Indexed Source Map v3:插件有自带 map 时直接用于对应 section;没有时则生成 identity section,内嵌构建后 bundle,并在存在打包路径时命名为其 `lib/client.js`,因此一个 combo 仍能拆回各组件文件。初始逐插件 revision 使用不透明的进程 nonce,因此启动时不会哈希每个插件;HMR 只哈希 watcher 报告发生变化的产物。每个已发布响应都不可变,未发布的组合或 revision 返回 404,绝不提供其他字节。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费这些构建后的客户端导出;缺失文件共享一条构建说明,随后以包/路径列表列出各项,而无关的文件系统错误仍是独立故障。
|
||||
|
||||
`dsh.client.external` 是外壳播种的 React、Cordis 和静态 UI 库这一统一基座之外的可选精确 specifier 请求列表。请求由其命名的动态 package row 或精确静态表键回答;只有末尾 `/client` 会别名到 package row,并且不存在 provider 别名声明。纯类型 import 会被擦除,不产生请求。组合阶段会拒绝畸形请求、缺失提供方、自请求和同步请求环;import 与 prefetch 会在消费者物化前递归登记动态提供方。参见[共享模块与模块图](../AGENTS.md#shared-modules-and-the-module-graph)。
|
||||
|
||||
|
||||
@@ -138,7 +138,7 @@ interface WebPluginRecord {
|
||||
bundle: Buffer
|
||||
/** Pre-read filesystem baseline handed to the HMR watcher. */
|
||||
baseline: ClientArtifactBaseline
|
||||
/** Optional parsed and original source map snapshot for immutable delivery. */
|
||||
/** Optional authored source map snapshot; generated-file identity mapping is the fallback. */
|
||||
sourceMap?: { body: Buffer; parsed: Record<string, unknown> }
|
||||
}
|
||||
|
||||
@@ -151,10 +151,10 @@ interface ComboArtifactBase {
|
||||
}
|
||||
|
||||
/** One generated combo response over an ordered list of plugin resources. */
|
||||
type ComboArtifact = ComboArtifactBase & (
|
||||
| { sourceMap: Buffer; sourceMapUrl: string }
|
||||
| { sourceMap?: undefined; sourceMapUrl?: undefined }
|
||||
)
|
||||
interface ComboArtifact extends ComboArtifactBase {
|
||||
sourceMap: Buffer
|
||||
sourceMapUrl: string
|
||||
}
|
||||
|
||||
/** One generated initial-load response and its wire descriptor. */
|
||||
type BatchArtifact = ComboArtifact & { descriptor: WebBootBatch }
|
||||
@@ -168,6 +168,8 @@ const COMBO_REVISION_PLACEHOLDER = '0'.repeat(HASH_REVISION_LENGTH)
|
||||
|
||||
/** Source-map trailer emitted by tsdown at the end of every client bundle. */
|
||||
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)?$/
|
||||
|
||||
/** Narrow an unknown parsed JSON value to the `dsh.client` declaration, throwing on malformed fields. */
|
||||
function parseDshClient(pkgName: string, value: unknown): DshClientDeclaration | undefined {
|
||||
@@ -264,10 +266,22 @@ function partitionComboRecords(records: readonly WebPluginRecord[]): WebPluginRe
|
||||
return chunks
|
||||
}
|
||||
|
||||
/** Remove a bundle-local source-map trailer and retain one final newline. */
|
||||
function withoutSourceMapTrailer(input: Buffer): string {
|
||||
const stripped = input.toString('utf8').replace(SOURCE_MAP_TRAILER, '')
|
||||
return stripped.endsWith('\n') ? stripped : `${stripped}\n`
|
||||
/** Executable source plus the generated-file name used when no authored map exists. */
|
||||
interface ComboSource {
|
||||
source: string
|
||||
fallbackSource: string
|
||||
}
|
||||
|
||||
/** Remove bundle-local debug directives and retain their stable generated-file name. */
|
||||
function comboSource(record: WebPluginRecord): ComboSource {
|
||||
let source = record.bundle.toString('utf8')
|
||||
const sourceUrl = SOURCE_URL_TRAILER.exec(source)?.[1]
|
||||
source = source.replace(SOURCE_URL_TRAILER, '').replace(SOURCE_MAP_TRAILER, '')
|
||||
if (!source.endsWith('\n')) source += '\n'
|
||||
const fallbackSource = sourceUrl === undefined
|
||||
? `/plugins/${record.entry.id}/client.js`
|
||||
: /^(?:[A-Za-z][A-Za-z\d+.-]*:|\/)/.test(sourceUrl) ? sourceUrl : `/${sourceUrl}`
|
||||
return { source, fallbackSource }
|
||||
}
|
||||
|
||||
/** Stamp a combo script's absolute indexed-map URL onto its executable bytes. */
|
||||
@@ -327,27 +341,39 @@ function comboSectionMap(record: WebPluginRecord): Record<string, unknown> {
|
||||
return section
|
||||
}
|
||||
|
||||
/** Map each generated line to the same line in a bundled JavaScript source. */
|
||||
function identitySectionMap(source: string, sourceUrl: string): Record<string, unknown> {
|
||||
const mappings = Array.from({ length: newlineCount(source) }, (_, index) => index === 0 ? 'AAAA' : 'AACA')
|
||||
.join(';')
|
||||
return {
|
||||
version: 3,
|
||||
names: [],
|
||||
sources: [sourceUrl],
|
||||
sourcesContent: [source],
|
||||
mappings,
|
||||
}
|
||||
}
|
||||
|
||||
/** Concatenate one or more factory registrations and compose their maps as indexed sections. */
|
||||
function buildCombo(records: readonly WebPluginRecord[], revision?: string): ComboArtifact {
|
||||
let source = ''
|
||||
const sections: { offset: { line: number; column: 0 }; map: Record<string, unknown> }[] = []
|
||||
let line = 0
|
||||
for (const record of records) {
|
||||
if (record.sourceMap !== undefined) {
|
||||
sections.push({ offset: { line, column: 0 }, map: comboSectionMap(record) })
|
||||
}
|
||||
const bundle = `${withoutSourceMapTrailer(record.bundle)};\n`
|
||||
const prepared = comboSource(record)
|
||||
const section = record.sourceMap === undefined
|
||||
? identitySectionMap(prepared.source, prepared.fallbackSource)
|
||||
: comboSectionMap(record)
|
||||
sections.push({ offset: { line, column: 0 }, map: section })
|
||||
const bundle = `${prepared.source};\n`
|
||||
source += bundle
|
||||
line += newlineCount(bundle)
|
||||
}
|
||||
const sourceMap = sections.length === 0
|
||||
? undefined
|
||||
: Buffer.from(`${JSON.stringify({ version: 3, file: 'client.js', sections })}\n`)
|
||||
const sourceMap = Buffer.from(`${JSON.stringify({ version: 3, file: 'client.js', sections })}\n`)
|
||||
const sourceBytes = Buffer.from(source)
|
||||
const rev = revision ?? framedHash('combo', sourceMap === undefined ? [sourceBytes] : [sourceBytes, sourceMap])
|
||||
const rev = revision ?? framedHash('combo', [sourceBytes, sourceMap])
|
||||
const entries = records.map(record => record.entry.id)
|
||||
const url = comboUrl(entries, rev)
|
||||
if (sourceMap === undefined) return { url, rev, entries, script: comboScript(source) }
|
||||
const sourceMapUrl = comboUrl(entries, rev, true)
|
||||
return { url, rev, entries, script: comboScript(source, sourceMapUrl), sourceMap, sourceMapUrl }
|
||||
}
|
||||
@@ -658,12 +684,10 @@ export class ClientModuleRegistry extends Service {
|
||||
body: artifact.script,
|
||||
contentType: 'text/javascript; charset=utf-8',
|
||||
})
|
||||
if (artifact.sourceMap !== undefined) {
|
||||
batchResponses.set(artifact.sourceMapUrl, {
|
||||
body: artifact.sourceMap,
|
||||
contentType: 'application/json; charset=utf-8',
|
||||
})
|
||||
}
|
||||
batchResponses.set(artifact.sourceMapUrl, {
|
||||
body: artifact.sourceMap,
|
||||
contentType: 'application/json; charset=utf-8',
|
||||
})
|
||||
}
|
||||
const responses = new Map(batchResponses)
|
||||
for (const record of this.table.values()) {
|
||||
@@ -672,12 +696,10 @@ export class ClientModuleRegistry extends Service {
|
||||
body: artifact.script,
|
||||
contentType: 'text/javascript; charset=utf-8',
|
||||
})
|
||||
if (artifact.sourceMap !== undefined) {
|
||||
responses.set(artifact.sourceMapUrl, {
|
||||
body: artifact.sourceMap,
|
||||
contentType: 'application/json; charset=utf-8',
|
||||
})
|
||||
}
|
||||
responses.set(artifact.sourceMapUrl, {
|
||||
body: artifact.sourceMap,
|
||||
contentType: 'application/json; charset=utf-8',
|
||||
})
|
||||
}
|
||||
this.previousBatchResponses = this.batchResponses
|
||||
this.batchResponses = batchResponses
|
||||
@@ -781,7 +803,7 @@ export class ClientModuleRegistry extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
/** Treat a missing, torn, or malformed development map as an unmapped artifact revision. */
|
||||
/** Treat a missing, torn, or malformed development map as an identity-mapped artifact revision. */
|
||||
private readSourceMapSnapshot(clientPath: string): WebPluginRecord['sourceMap'] {
|
||||
try {
|
||||
return sourceMapSnapshot(clientPath)
|
||||
|
||||
@@ -272,7 +272,7 @@ describe('client bundle activation', () => {
|
||||
expect(String(thrown)).not.toContain('pnpm run build')
|
||||
})
|
||||
|
||||
it('omits a torn or malformed source map without blocking composition', async () => {
|
||||
it('falls back to a generated-file map when an authored map is malformed', async () => {
|
||||
const packageName = '@fixture/malformed-source-map'
|
||||
const clientPath = writePackage(packageName)
|
||||
mkdirSync(dirname(clientPath), { recursive: true })
|
||||
@@ -281,13 +281,60 @@ describe('client bundle activation', () => {
|
||||
const torn = constructWithRoute([packageName])
|
||||
const tornRow = torn.service.graph().entries[0]!
|
||||
expect((await routeRequest(torn.route, tornRow.url)).body.toString('utf8'))
|
||||
.not.toContain('sourceMappingURL')
|
||||
expect((await routeRequest(torn.route, mapUrl(torn.service.graph().batches[0]!.url))).status).toBe(404)
|
||||
.toContain(`sourceMappingURL=${mapUrl(tornRow.url)}`)
|
||||
const fallback = await routeRequest(torn.route, mapUrl(torn.service.graph().batches[0]!.url))
|
||||
expect(JSON.parse(fallback.body.toString('utf8'))).toMatchObject({
|
||||
sections: [{ map: { sources: [`/plugins/${packageName}/client.js`] } }],
|
||||
})
|
||||
|
||||
writeFileSync(`${clientPath}.map`, '{"version":3,"sources":[null]}\n')
|
||||
expect(() => construct([packageName])).not.toThrow()
|
||||
})
|
||||
|
||||
it('maps packed combo sections back to each generated client bundle', async () => {
|
||||
const names = ['@fixture/generated-first', '@fixture/generated-second']
|
||||
for (const [index, packageName] of names.entries()) {
|
||||
const clientPath = writePackage(packageName)
|
||||
mkdirSync(dirname(clientPath), { recursive: true })
|
||||
writeFileSync(
|
||||
clientPath,
|
||||
`window.generation = ${String(index)}\n//# sourceURL=packages/client/generated-${String(index)}/lib/client.js`,
|
||||
)
|
||||
}
|
||||
|
||||
const { service, route } = constructWithRoute(names)
|
||||
const batch = service.graph().batches[0]!
|
||||
const script = (await routeRequest(route, batch.url)).body.toString('utf8')
|
||||
expect(script).not.toContain('//# sourceURL=')
|
||||
expect(script).toContain(`//# sourceMappingURL=${mapUrl(batch.url)}`)
|
||||
const payload = JSON.parse((await routeRequest(route, mapUrl(batch.url))).body.toString('utf8')) as {
|
||||
sections: { map: { mappings: string; sources: string[]; sourcesContent: string[] } }[]
|
||||
}
|
||||
expect(payload.sections.map(section => section.map)).toEqual([
|
||||
{
|
||||
version: 3,
|
||||
names: [],
|
||||
mappings: 'AAAA',
|
||||
sources: ['/packages/client/generated-0/lib/client.js'],
|
||||
sourcesContent: ['window.generation = 0\n'],
|
||||
},
|
||||
{
|
||||
version: 3,
|
||||
names: [],
|
||||
mappings: 'AAAA',
|
||||
sources: ['/packages/client/generated-1/lib/client.js'],
|
||||
sourcesContent: ['window.generation = 1\n'],
|
||||
},
|
||||
])
|
||||
const consumer = new SourceMap(payload as unknown as ConstructorParameters<typeof SourceMap>[0])
|
||||
expect(consumer.findEntry(0, 0)).toMatchObject({
|
||||
originalSource: '/packages/client/generated-0/lib/client.js',
|
||||
})
|
||||
expect(consumer.findEntry(2, 0)).toMatchObject({
|
||||
originalSource: '/packages/client/generated-1/lib/client.js',
|
||||
})
|
||||
})
|
||||
|
||||
it('retains one prior immutable batch generation across rebuild recomposition', async () => {
|
||||
const packageName = '@fixture/batch-rebuild-race'
|
||||
const clientPath = writePackage(packageName)
|
||||
@@ -491,7 +538,7 @@ describe('client bundle activation', () => {
|
||||
expect(consumer.findEntry(3, 0)).toMatchObject({ originalSource: '/packages/demo/second.ts' })
|
||||
})
|
||||
|
||||
it('keeps a later source-map section usable when an earlier bundle has no map', async () => {
|
||||
it('combines a generated-file fallback with a later authored map', async () => {
|
||||
const unmappedName = '@fixture/unmapped-first'
|
||||
const mappedName = '@fixture/mapped-second'
|
||||
const unmappedPath = writePackage(unmappedName)
|
||||
@@ -512,6 +559,9 @@ describe('client bundle activation', () => {
|
||||
const response = await routeRequest(route, mapUrl(service.graph().batches[0]!.url))
|
||||
const payload = JSON.parse(response.body.toString('utf8')) as ConstructorParameters<typeof SourceMap>[0]
|
||||
const consumer = new SourceMap(payload)
|
||||
expect(consumer.findEntry(0, 0)).toMatchObject({
|
||||
originalSource: `/plugins/${unmappedName}/client.js`,
|
||||
})
|
||||
expect(consumer.findEntry(2, 0)).toMatchObject({ originalSource: '/packages/demo/mapped.ts' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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/webworker-runtime/README.md
|
||||
README.md: f8789f8e82005c15cd38cbe77832e486d4a44b9b
|
||||
README.zh.md: e89ae7fd1e6b5d9ad9aa68edecfd92a81dcaf9b9
|
||||
README.md: 4daa09d47af72ed3e98a3c35ee3674c55faf78fb
|
||||
README.zh.md: 495e835abcacab9323a2f1786e6155bf849245bb
|
||||
|
||||
@@ -9,7 +9,7 @@ Three artifacts from one tsdown pipeline:
|
||||
- **`lib/index.js` (assembly library)** — `createWorkerHost`/`startWorkerHost` mount the base image and any ordered data overlays (`storage/`), install the module loader (`module-system/`) and the `process` shim, boot the tree through the image's own `dsh-app-boot`, and hand the tunnel its serving seams. Overlays may replace files only under `home/` and `workspace/`; they cannot replace the base manifest, configuration, or modules. The image layout contract (`image-layout.ts`: virtual root, config/manifest paths, empty directories, the `lowered` wrapper-contract gate) is shared with the packer. Boot patches force the deployment-shaped rows: frontend serving off, JSONL session logs on the plaintext path, preset roots onto the image's `config/agent-presets`.
|
||||
- **`lib/worker.js` (worker bundle)** — the assembly plus this package's Node-compatibility layer as one self-contained ES module. The module proxy table (`module-proxies.ts`) is the only platform fork: `node:*` builtins over VFS/tunnel/browser primitives, structural stubs that fail loud on the console for what a browser cannot do, and native/binary package replacements. `node:module` supplies `createRequire().resolve` and `.resolve.paths()` over the image package root, so unchanged packages can discover manifests without evaluating their modules. VFS mutations drive `node:fs` callback, polling, and promise watchers; open descriptors retain file identity and access mode across rename, replacement, and unlink; `readable-stream` supplies the stream state machine used by file streams and unchanged image packages such as Chokidar and readdirp. AsyncLocalStorage carries sync-stack causality across `await` through the snapshot/restore faces the pack-time lowering injects. The worker holds no compiler: an image the packer did not lower is refused at mount ([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md)).
|
||||
- **`src/shell/` (the worker's own process layer)** — a browser worker cannot fork, so `node:child_process` is not a stub but an implementation: `spawn` starts the command in its own Web Worker — this same bundle, told by its first frame to be a shell process — and reports it through the `ChildProcess` surface the subprocess service consumes. The command runs off the host's thread, `SIGKILL` terminates it whatever it is doing, and it reaches the VFS only by message (the host serves those frames). Worker platform executables preserve native-package protocols such as Landlock without replacing their JavaScript packages or coupling their implementations to `node:child_process`; ordinary commands use the package's evaluator and coreutils command table. The grammar is `@yarnpkg/parsers`' `parseShell`, while `execSync`/`fork` still refuse because they need a real process.
|
||||
- **`lib/client.js` (page half)** — startup has two independent stages. `chooseWorkerHostSource({ image?, fixtureManifest? })` optionally owns the boot barrier and fixture manifest: without `preview-fixture` it waits at the source chooser, while a valid query selects directly; either path returns ordered overlays. `connectWorkerHost(worker, { image?, overlays? })` remains the public base-runtime connector; callers that skip the chooser get an empty overlay list. `apps/web` invokes both and supplies its statically bundled Worker. The opening `init` frame carries the base and ordered overlay URLs, the boot payload delivers the structured index-injection table, and `applyIndexInjections` executes it before the shell entry runs. Script preload rows are advisory and skipped because `/plugins` resources resolve only through the tunnel; `loadBundle` performs the actual fetch and execution on first demand. The tunnel also exposes fetch-shaped transport and the API client.
|
||||
- **`lib/client.js` (page half)** — startup has two independent stages. `chooseWorkerHostSource({ image?, fixtureManifest? })` optionally owns the boot barrier and fixture manifest: without `preview-fixture` it waits at the source chooser, while a valid query selects directly; either path returns ordered overlays. `connectWorkerHost(worker, { image?, overlays? })` remains the public base-runtime connector; callers that skip the chooser get an empty overlay list. `apps/web` invokes both and supplies its statically bundled Worker. The opening `init` frame carries the base and ordered overlay URLs, the boot payload delivers the structured index-injection table, and `applyIndexInjections` executes it before the shell entry runs. Script preload rows are advisory and skipped because `/plugins` resources resolve only through the tunnel; `loadBundle` fetches each combo on first demand, localizes its tunnel-only source map as a page Blob URL, and executes the script as a Blob. The tunnel also exposes fetch-shaped transport and the API client.
|
||||
|
||||
Acceptance lives in `apps/web/tests/preview-boot.e2e.ts`, which serves the real built pages and drives the pre-boot chooser plus Worker activation in headless Chromium. The empty selection exercises first-run startup. The `vfs-example` overlay supplies ordinary workspace files and plaintext persistence artifacts for cold Workspace/Session discovery, tool presentation, subagent navigation, and history paging without a model request. The chooser reserves WebFS as a separate user-authorized source; that provider does not read the built-in fixture.
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
- **`lib/index.js`(装配库)**——`createWorkerHost`/`startWorkerHost` 挂载基础镜像和按序排列的数据 overlays(`storage/`)、安装模块加载器(`module-system/`)与 `process` shim、经镜像自带的 `dsh-app-boot` 启动插件树,并把服务缝隙交给隧道。Overlay 只能替换 `home/` 与 `workspace/` 下的文件,不能替换基础 manifest、配置或模块。镜像布局契约(`image-layout.ts`:虚拟根、config/manifest 路径、空目录、`lowered` 包装契约门)与 packer 共享。boot patch 强制部署形态行:关前端静态服务、JSONL 会话日志走明文、preset 根指向镜像内 `config/agent-presets`。
|
||||
- **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS、隧道和浏览器原语,浏览器做不到的走结构化 stub(调用即在 console 报错并抛出),native/binary 包则替换执行后端。`node:module` 在镜像 package 根之上提供 `createRequire().resolve` 与 `.resolve.paths()`,使未修改的包无需执行目标模块即可发现 manifest。VFS mutation 驱动 `node:fs` 的 callback、polling 和 promise watcher;打开的 descriptor 在 rename、replacement 和 unlink 后仍保留文件身份与访问模式;`readable-stream` 提供文件流以及 Chokidar、readdirp 等未修改镜像包所用的流状态机。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md))。
|
||||
- **`src/shell/`(worker 自己的进程层)**——浏览器 worker 无法 fork,所以 `node:child_process` 不是 stub 而是实现:`spawn` 把命令放进它自己的 Web Worker——就是这同一个束,由首帧告诉它「你是 shell 进程」——并以 subprocess 服务消费的 `ChildProcess` 面报告结果。命令不占宿主线程,`SIGKILL` 不管它在干什么都能终止它,而它只能靠消息触达 VFS(由宿主应答这些帧)。Worker 平台 executable 在不替换 JavaScript 包、也不把具体实现耦合进 `node:child_process` 的情况下保持 Landlock 等 native 包协议;普通命令使用本包的求值器与 coreutils 命令表。语法来自 `@yarnpkg/parsers` 的 `parseShell`,而 `execSync`/`fork` 依然拒绝,因为它们需要真进程。
|
||||
- **`lib/client.js`(页面半)**——启动分为相互独立的两段。`chooseWorkerHostSource({ image?, fixtureManifest? })` 可选地拥有 boot barrier 与 fixture manifest:没有 `preview-fixture` 时停在来源选择面板,合法 query 则直接选择;两条路径都返回按序排列的 overlays。`connectWorkerHost(worker, { image?, overlays? })` 仍是公开的基础运行态连接器;调用方跳过选择器时 overlay 列表为空。`apps/web` 调用这两段并提供静态打包的 Worker。开局 `init` 帧携带基础镜像与按序排列的 overlay URL,boot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。脚本 preload 行只是提示,因此会被跳过:`/plugins` 资源只能经 tunnel 解析,`loadBundle` 会在首次需要时完成实际获取与执行。Tunnel 还暴露 fetch 形传输与 API 客户端。
|
||||
- **`lib/client.js`(页面半)**——启动分为相互独立的两段。`chooseWorkerHostSource({ image?, fixtureManifest? })` 可选地拥有 boot barrier 与 fixture manifest:没有 `preview-fixture` 时停在来源选择面板,合法 query 则直接选择;两条路径都返回按序排列的 overlays。`connectWorkerHost(worker, { image?, overlays? })` 仍是公开的基础运行态连接器;调用方跳过选择器时 overlay 列表为空。`apps/web` 调用这两段并提供静态打包的 Worker。开局 `init` 帧携带基础镜像与按序排列的 overlay URL,boot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。脚本 preload 行只是提示,因此会被跳过:`/plugins` 资源只能经 tunnel 解析,`loadBundle` 会在首次需要时获取 combo、把仅 tunnel 可达的 sourcemap 本地化为页面 Blob URL,再以 Blob 执行脚本。Tunnel 还暴露 fetch 形传输与 API 客户端。
|
||||
|
||||
验收在 `apps/web/tests/preview-boot.e2e.ts`:静态服务真实构建页面,在 headless Chromium 里驱动 pre-boot 选择面板与 Worker 激活。空白选择验证首次启动;`vfs-example` overlay 提供普通 workspace 文件与明文 persistence 产物,无需模型请求即可验证 Workspace/Session 冷发现、工具呈现、subagent 导航和历史分页。选择面板为 WebFS 保留独立的用户授权来源;该 provider 不读取内置 fixture。
|
||||
|
||||
|
||||
@@ -90,6 +90,25 @@ class LogicalStreamInbox {
|
||||
const REFUSAL_STATUS = 500
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const SOURCE_MAP_TRAILER = /\/\/# sourceMappingURL=([^\r\n]+)\s*$/
|
||||
|
||||
/** Replace a tunnel-only map reference with a browser-readable object URL. */
|
||||
async function localizeSourceMap(source: string, bundleUrl: string, fetch: TunnelFetch): Promise<string> {
|
||||
const match = SOURCE_MAP_TRAILER.exec(source)
|
||||
if (match?.[1] === undefined) return source
|
||||
try {
|
||||
const response = await fetch(new URL(match[1], new URL(bundleUrl, globalThis.location.origin)))
|
||||
if (!response.ok) return source.replace(SOURCE_MAP_TRAILER, '')
|
||||
const objectUrl = URL.createObjectURL(new Blob([await response.text()], { type: 'application/json' }))
|
||||
// The script retains this URL for DevTools' lazy map lookup; the document
|
||||
// releases its object URLs when the preview page unloads.
|
||||
return source.replace(SOURCE_MAP_TRAILER, `//# sourceMappingURL=${objectUrl}`)
|
||||
} catch {
|
||||
// A source map is diagnostic-only; its transport failure must not prevent
|
||||
// the plugin factory from registering.
|
||||
return source.replace(SOURCE_MAP_TRAILER, '')
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize a RequestInit body to a transferable ArrayBuffer. */
|
||||
function toBodyBuffer(body: RequestInit['body']): ArrayBuffer | undefined {
|
||||
@@ -269,7 +288,7 @@ export class WorkerTunnel {
|
||||
if (!response.ok) {
|
||||
throw new Error(`web-preview tunnel: bundle ${url} failed with HTTP ${String(response.status)}`)
|
||||
}
|
||||
const source = await response.text()
|
||||
const source = await localizeSourceMap(await response.text(), url, this.fetch)
|
||||
const blob = URL.createObjectURL(new Blob([source], { type: 'text/javascript' }))
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { WorkerTunnel } from '../../src/client/client.ts'
|
||||
|
||||
type StubListener = (event: { data?: unknown }) => void
|
||||
|
||||
function stubWorker(): {
|
||||
worker: Worker
|
||||
sent: { t: string; id: number; url: string }[]
|
||||
deliver: (frame: unknown) => void
|
||||
} {
|
||||
const listeners: StubListener[] = []
|
||||
const sent: { t: string; id: number; url: string }[] = []
|
||||
return {
|
||||
worker: {
|
||||
addEventListener: (type: string, listener: StubListener) => {
|
||||
if (type === 'message') listeners.push(listener)
|
||||
},
|
||||
postMessage: (frame: unknown) => { sent.push(frame as { t: string; id: number; url: string }) },
|
||||
} as unknown as Worker,
|
||||
sent,
|
||||
deliver: (frame) => { for (const listener of listeners) listener({ data: frame }) },
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
document.head.innerHTML = ''
|
||||
})
|
||||
|
||||
it('loads a combo map through the tunnel and gives the blob script a local map URL', async () => {
|
||||
const { worker, sent, deliver } = stubWorker()
|
||||
const tunnel = new WorkerTunnel(worker)
|
||||
const blobs: Blob[] = []
|
||||
const revoked: string[] = []
|
||||
const NativeURL = URL
|
||||
class StubURL extends NativeURL {
|
||||
static createObjectURL(blob: Blob): string {
|
||||
blobs.push(blob)
|
||||
return `blob:fixture-${String(blobs.length)}`
|
||||
}
|
||||
|
||||
static revokeObjectURL(url: string): void {
|
||||
revoked.push(url)
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('URL', StubURL)
|
||||
vi.spyOn(document.head, 'append').mockImplementation((...nodes) => {
|
||||
for (const node of nodes) {
|
||||
if (typeof node !== 'string') queueMicrotask(() => { node.dispatchEvent(new Event('load')) })
|
||||
}
|
||||
})
|
||||
|
||||
const scriptUrl = '/plugins/??a/client.js,b/client.js&rev=abc'
|
||||
const mapUrl = '/plugins/??a/client.js.map,b/client.js.map&rev=abc'
|
||||
const loading = tunnel.loadBundle(scriptUrl)
|
||||
expect(sent[0]?.url).toBe(`http://localhost:3000${scriptUrl}`)
|
||||
deliver({
|
||||
t: 'res',
|
||||
id: 1,
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/javascript' },
|
||||
body: new TextEncoder().encode(`factory();\n//# sourceMappingURL=${mapUrl}\n`).buffer,
|
||||
})
|
||||
await vi.waitFor(() => { expect(sent).toHaveLength(2) })
|
||||
expect(sent[1]?.url).toBe(`http://localhost:3000${mapUrl}`)
|
||||
const map = '{"version":3,"sections":[]}'
|
||||
deliver({
|
||||
t: 'res',
|
||||
id: 2,
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: new TextEncoder().encode(map).buffer,
|
||||
})
|
||||
await loading
|
||||
|
||||
expect(await blobs[0]?.text()).toBe(map)
|
||||
expect(await blobs[1]?.text()).toContain('//# sourceMappingURL=blob:fixture-1')
|
||||
expect(revoked).toEqual(['blob:fixture-2'])
|
||||
})
|
||||
Reference in New Issue
Block a user